feat(expense): 승인완료 메뉴 — 전 직원 월별 + 엑셀/첨부 zip
- /expense/approved: 승인자/관리자 전용, 월 선택 + 전 직원 승인완료 항목 - 직원별 승인 금액 합계표 + 총합 - 엑셀 다운(리스트 + 직원별합계 시트) - 첨부 일괄 zip: 파일명 yy년 mm월 dd일(요일)_이름.확장자 (업로드일 기준) - store: APPROVED_STATUSES(승인,정산완료) / db: list_approved, approved_attachments Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ from psycopg_pool import ConnectionPool
|
|||||||
|
|
||||||
from app.timezone import KST
|
from app.timezone import KST
|
||||||
|
|
||||||
from .store import CATEGORIES, METHODS, STATUSES
|
from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES
|
||||||
|
|
||||||
|
|
||||||
class ExpenseDBStore:
|
class ExpenseDBStore:
|
||||||
@@ -370,6 +370,46 @@ class ExpenseDBStore:
|
|||||||
out["size_bytes"] = int(out.get("size_bytes", 0))
|
out["size_bytes"] = int(out.get("size_bytes", 0))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
# ── 승인완료 (월별, 전 직원) ──
|
||||||
|
def list_approved(self, *, year: int, month: int) -> list[dict[str, Any]]:
|
||||||
|
"""해당 월(spent_at)의 승인완료 항목 — 전 직원."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM expense_items
|
||||||
|
WHERE status = ANY(%s)
|
||||||
|
AND EXTRACT(YEAR FROM spent_at) = %s
|
||||||
|
AND EXTRACT(MONTH FROM spent_at) = %s
|
||||||
|
ORDER BY owner ASC, spent_at ASC, created_at ASC
|
||||||
|
""",
|
||||||
|
(list(APPROVED_STATUSES), year, month),
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def approved_attachments(
|
||||||
|
self, *, year: int, month: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""해당 월 승인완료 항목의 첨부 — zip 다운로드용.
|
||||||
|
|
||||||
|
uploaded_at(datetime), spent_at(date) 를 가공 없이 반환(파일명 생성용).
|
||||||
|
"""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.id, a.item_id, a.owner, a.kind, a.filename,
|
||||||
|
a.stored_path, a.content_type, a.uploaded_at,
|
||||||
|
i.spent_at
|
||||||
|
FROM expense_attachments a
|
||||||
|
JOIN expense_items i ON i.id = a.item_id
|
||||||
|
WHERE i.status = ANY(%s)
|
||||||
|
AND EXTRACT(YEAR FROM i.spent_at) = %s
|
||||||
|
AND EXTRACT(MONTH FROM i.spent_at) = %s
|
||||||
|
ORDER BY a.owner ASC, a.uploaded_at ASC
|
||||||
|
""",
|
||||||
|
(list(APPROVED_STATUSES), year, month),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
# ── 집계 (월별) ──
|
# ── 집계 (월별) ──
|
||||||
def monthly_summary(
|
def monthly_summary(
|
||||||
self, *, email: str, year: int
|
self, *, email: str, year: int
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.timezone import now_kst
|
from app.timezone import KST, now_kst
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
@@ -69,6 +71,52 @@ def _categories(request: Request) -> list[str]:
|
|||||||
return _category_store(request).list()
|
return _category_store(request).list()
|
||||||
|
|
||||||
|
|
||||||
|
_WEEKDAYS_KR = ("월", "화", "수", "목", "금", "토", "일")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_month(request: Request) -> tuple[int, int, str]:
|
||||||
|
"""?month=YYYY-MM 파싱. 누락/오류 시 이번 달(KST)."""
|
||||||
|
raw = request.query_params.get("month") or ""
|
||||||
|
try:
|
||||||
|
y_str, m_str = raw.split("-")
|
||||||
|
year, mon = int(y_str), int(m_str)
|
||||||
|
if not (1 <= mon <= 12):
|
||||||
|
raise ValueError
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
n = now_kst()
|
||||||
|
year, mon = n.year, n.month
|
||||||
|
return year, mon, f"{year:04d}-{mon:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _name_map(request: Request) -> dict[str, str]:
|
||||||
|
"""email(소문자) → 표시 이름. 미등록 사용자는 이메일 로컬파트."""
|
||||||
|
from app.main import user_store # noqa: WPS433
|
||||||
|
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for u in user_store.list_all():
|
||||||
|
email = str(u.get("email", "")).lower().strip()
|
||||||
|
if email:
|
||||||
|
out[email] = u.get("name") or email.split("@")[0]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name(email: str, names: dict[str, str]) -> str:
|
||||||
|
email = (email or "").lower().strip()
|
||||||
|
return names.get(email) or (email.split("@")[0] if email else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _att_download_name(uploaded_at: Any, name: str, original: str) -> str:
|
||||||
|
"""첨부 다운로드 파일명: `yy년 mm월 dd일(요일)_이름{ext}`. 날짜=업로드일(KST)."""
|
||||||
|
if isinstance(uploaded_at, datetime):
|
||||||
|
d = uploaded_at.astimezone(KST)
|
||||||
|
else:
|
||||||
|
d = now_kst()
|
||||||
|
wd = _WEEKDAYS_KR[d.weekday()]
|
||||||
|
base = f"{d.strftime('%y')}년 {d.strftime('%m')}월 {d.strftime('%d')}일({wd})_{name}"
|
||||||
|
ext = os.path.splitext(original or "")[1].lower()
|
||||||
|
return f"{base}{ext}"
|
||||||
|
|
||||||
|
|
||||||
def _upload_dir(request: Request) -> Path:
|
def _upload_dir(request: Request) -> Path:
|
||||||
"""첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관."""
|
"""첨부 저장 루트. DATA_DIR/uploads/expense — DATA_DIR 와 같은 볼륨에 보관."""
|
||||||
base = getattr(request.app.state, "data_dir", None)
|
base = getattr(request.app.state, "data_dir", None)
|
||||||
@@ -288,6 +336,170 @@ async def reports_page(request: Request) -> HTMLResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 승인완료 — 전 직원 월별 (승인자/관리자)
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def _approved_aggregate(
|
||||||
|
items: list[dict[str, Any]], names: dict[str, str]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""직원별 승인 금액 합계."""
|
||||||
|
agg: dict[str, dict[str, Any]] = {}
|
||||||
|
for it in items:
|
||||||
|
owner = it.get("owner", "")
|
||||||
|
row = agg.setdefault(
|
||||||
|
owner,
|
||||||
|
{"owner": owner, "name": _display_name(owner, names), "count": 0, "total": 0},
|
||||||
|
)
|
||||||
|
row["count"] += 1
|
||||||
|
row["total"] += int(it.get("amount", 0))
|
||||||
|
return sorted(agg.values(), key=lambda x: -x["total"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/approved", response_class=HTMLResponse)
|
||||||
|
async def approved_page(request: Request) -> HTMLResponse:
|
||||||
|
"""전 직원 승인완료 항목 — 월별 + 직원별 합계."""
|
||||||
|
from app.main import ( # noqa: WPS433
|
||||||
|
build_erp_nav,
|
||||||
|
get_current_user_record,
|
||||||
|
render_template,
|
||||||
|
)
|
||||||
|
from app.store import has_module, is_admin # noqa: WPS433
|
||||||
|
|
||||||
|
user = get_current_user_record(request)
|
||||||
|
if user is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
if not (is_admin(user) or has_module(user, "expense_approver")):
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"denied.html",
|
||||||
|
{"reason": "개인경비 승인자만 접근할 수 있습니다."},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
year, mon, raw = _parse_month(request)
|
||||||
|
items = store.list_approved(year=year, month=mon)
|
||||||
|
names = _name_map(request)
|
||||||
|
for it in items:
|
||||||
|
it["owner_name"] = _display_name(it.get("owner", ""), names)
|
||||||
|
owner_summary = _approved_aggregate(items, names)
|
||||||
|
grand_total = sum(r["total"] for r in owner_summary)
|
||||||
|
return render_template(
|
||||||
|
request,
|
||||||
|
"expense/approved.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"is_admin": is_admin(user),
|
||||||
|
"items": items,
|
||||||
|
"owner_summary": owner_summary,
|
||||||
|
"grand_total": grand_total,
|
||||||
|
"month": raw,
|
||||||
|
"count": len(items),
|
||||||
|
"nav_items": build_erp_nav(user, active="expense"),
|
||||||
|
"page_title": "개인경비 — 승인완료",
|
||||||
|
"page_subtitle": f"{raw} · 전 직원 승인완료 {len(items)}건",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/approved/export.xlsx")
|
||||||
|
async def approved_export(
|
||||||
|
request: Request,
|
||||||
|
user: dict[str, Any] = Depends(_require_approver),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
from openpyxl import Workbook # 지연 import
|
||||||
|
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
year, mon, raw = _parse_month(request)
|
||||||
|
items = store.list_approved(year=year, month=mon)
|
||||||
|
names = _name_map(request)
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "승인완료"
|
||||||
|
header = [
|
||||||
|
"사용일", "이름", "이메일", "분류", "결제수단", "가맹점",
|
||||||
|
"금액", "메모", "상태", "승인자", "결재일시",
|
||||||
|
]
|
||||||
|
ws.append(header)
|
||||||
|
for r in items:
|
||||||
|
owner = r.get("owner", "")
|
||||||
|
ws.append([
|
||||||
|
r.get("spent_at", ""),
|
||||||
|
_display_name(owner, names),
|
||||||
|
owner,
|
||||||
|
r.get("category", ""),
|
||||||
|
r.get("method", ""),
|
||||||
|
r.get("merchant", ""),
|
||||||
|
int(r.get("amount", 0)),
|
||||||
|
r.get("memo", ""),
|
||||||
|
r.get("status", ""),
|
||||||
|
r.get("approver_email", "") or "",
|
||||||
|
r.get("decided_at", "") or "",
|
||||||
|
])
|
||||||
|
widths = [12, 14, 28, 10, 12, 24, 12, 30, 10, 24, 22]
|
||||||
|
for col, w in enumerate(widths, start=1):
|
||||||
|
ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w
|
||||||
|
|
||||||
|
# 직원별 합계 시트
|
||||||
|
ws2 = wb.create_sheet("직원별합계")
|
||||||
|
ws2.append(["이름", "이메일", "건수", "합계금액"])
|
||||||
|
for row in _approved_aggregate(items, names):
|
||||||
|
ws2.append([row["name"], row["owner"], row["count"], row["total"]])
|
||||||
|
for col, w in enumerate([14, 28, 8, 16], start=1):
|
||||||
|
ws2.column_dimensions[ws2.cell(row=1, column=col).column_letter].width = w
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
fname = f"expense_approved_{raw}.xlsx"
|
||||||
|
return StreamingResponse(
|
||||||
|
buf,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/approved/attachments.zip")
|
||||||
|
async def approved_attachments_zip(
|
||||||
|
request: Request,
|
||||||
|
user: dict[str, Any] = Depends(_require_approver),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""해당 월 승인완료 항목의 첨부 전체를 zip 한 개로. 파일명 규칙 적용."""
|
||||||
|
store = _store(request)
|
||||||
|
_require_db_store(store)
|
||||||
|
year, mon, raw = _parse_month(request)
|
||||||
|
atts = store.approved_attachments(year=year, month=mon)
|
||||||
|
names = _name_map(request)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
used: dict[str, int] = {}
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for a in atts:
|
||||||
|
path = Path(a.get("stored_path", ""))
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
nm = _display_name(a.get("owner", ""), names)
|
||||||
|
fname = _att_download_name(a.get("uploaded_at"), nm, a.get("filename", ""))
|
||||||
|
# 동일명 충돌 → 접미사
|
||||||
|
if fname in used:
|
||||||
|
used[fname] += 1
|
||||||
|
stem, ext = os.path.splitext(fname)
|
||||||
|
arcname = f"{stem}_{used[fname]}{ext}"
|
||||||
|
else:
|
||||||
|
used[fname] = 1
|
||||||
|
arcname = fname
|
||||||
|
zf.write(str(path), arcname=arcname)
|
||||||
|
buf.seek(0)
|
||||||
|
zipname = f"expense_approved_{raw}.zip"
|
||||||
|
return StreamingResponse(
|
||||||
|
buf,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{zipname}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
# 승인자 설정 — 분류 관리
|
# 승인자 설정 — 분류 관리
|
||||||
# ────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from app.timezone import now_kst_iso
|
|||||||
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
||||||
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
||||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
||||||
|
# 승인 완료(결재 승인 이후) 상태 — "승인완료" 집계/내보내기 대상.
|
||||||
|
APPROVED_STATUSES: tuple[str, ...] = ("승인", "정산완료")
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="erp-ex-approved">
|
||||||
|
|
||||||
|
<!-- ── 상단 액션 + 월 선택 ── -->
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||||
|
<form id="mon-form" method="get" action="/expense/approved" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||||
|
<input type="month" name="month" value="{{ month }}" />
|
||||||
|
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||||
|
</form>
|
||||||
|
<a class="erp-btn erp-btn-primary" href="/expense/api/approved/export.xlsx?month={{ month }}">엑셀 다운</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/api/approved/attachments.zip?month={{ month }}">첨부 일괄(zip)</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 직원별 합계 ── -->
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>직원별 승인 금액 합계</h2>
|
||||||
|
<span class="erp-muted">{{ month }} · 총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>이메일</th>
|
||||||
|
<th style="width:100px; text-align:right;">건수</th>
|
||||||
|
<th style="width:160px; text-align:right;">합계금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in owner_summary %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td class="erp-muted">{{ r.owner }}</td>
|
||||||
|
<td style="text-align:right;">{{ r.count }}</td>
|
||||||
|
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(r.total) }} 원</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
{% if owner_summary %}
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" style="text-align:right;">합계</th>
|
||||||
|
<th style="text-align:right;">{{ count }}</th>
|
||||||
|
<th style="text-align:right;">{{ "{:,}".format(grand_total) }} 원</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 승인완료 항목 목록 ── -->
|
||||||
|
<div class="erp-card-block">
|
||||||
|
<div class="erp-card-block-head">
|
||||||
|
<h2>승인완료 항목 ({{ count }}건)</h2>
|
||||||
|
<span class="erp-muted">전 직원 · 사용일 기준 {{ month }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table" id="appr-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:110px;">사용일</th>
|
||||||
|
<th style="width:120px;">이름</th>
|
||||||
|
<th style="width:90px;">분류</th>
|
||||||
|
<th style="width:100px;">수단</th>
|
||||||
|
<th>가맹점/메모</th>
|
||||||
|
<th style="width:120px; text-align:right;">금액</th>
|
||||||
|
<th style="width:90px;">상태</th>
|
||||||
|
<th style="width:60px; text-align:center;">첨부</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr data-id="{{ it.id }}">
|
||||||
|
<td>{{ it.spent_at }}</td>
|
||||||
|
<td>{{ it.owner_name }}<div class="erp-row-sub">{{ it.owner }}</div></td>
|
||||||
|
<td>{{ it.category }}</td>
|
||||||
|
<td>{{ it.method }}</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ it.merchant }}</div>
|
||||||
|
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(it.amount) }} 원</td>
|
||||||
|
<td>
|
||||||
|
{% if it.status == '정산완료' %}
|
||||||
|
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erp-page-actions { display:flex; gap:var(--sp-8); margin-bottom:var(--sp-16); flex-wrap:wrap; align-items:center; }
|
||||||
|
.erp-ex-approved .erp-row-sub { font-size:12px; color:var(--color-text-muted, #888); }
|
||||||
|
#appr-table td { vertical-align: middle; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// 월 선택 변경 시 자동 조회
|
||||||
|
const monInput = document.querySelector('#mon-form input[name="month"]');
|
||||||
|
if (monInput) monInput.addEventListener("change", () => monInput.form.submit());
|
||||||
|
|
||||||
|
// 첨부 보기 (공용 뷰어)
|
||||||
|
const tbody = document.querySelector("#appr-table tbody");
|
||||||
|
if (tbody) {
|
||||||
|
tbody.addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest("button.js-view-att");
|
||||||
|
if (!btn) return;
|
||||||
|
const tr = btn.closest("tr[data-id]");
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
const who = tr.children[1].textContent.trim();
|
||||||
|
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${who}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<a class="erp-btn erp-btn-outline" href="/expense/pending">
|
<a class="erp-btn erp-btn-outline" href="/expense/pending">
|
||||||
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
|
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
|
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
|
||||||
{% if is_approver %}
|
{% if is_approver %}
|
||||||
|
|||||||
Reference in New Issue
Block a user