Preserve latest comparison functionality
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from runtime_config import DB_PATH
|
||||
|
||||
|
||||
DERIVED_SIGNATURE_TABLES = (
|
||||
("wehago_compare_query_groups", "signature"),
|
||||
("wehago_compare_query_rows", "signature"),
|
||||
("wehago_compare_query_metrics", "signature"),
|
||||
("wehago_compare_query_page_cache", "signature"),
|
||||
("wehago_compare_final_status_projection", "signature"),
|
||||
("wehago_metric_count_cache", "signature"),
|
||||
("wehago_summary_range_cache", "signature"),
|
||||
)
|
||||
|
||||
|
||||
def active_signature(conn: sqlite3.Connection, start_year: int, end_year: int) -> str:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT setting_json
|
||||
FROM wehago_compare_settings
|
||||
WHERE setting_key = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"wehago_active_query_projection:{start_year}:{end_year}",),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return ""
|
||||
try:
|
||||
payload = json.loads(row[0] or "{}")
|
||||
except Exception:
|
||||
return ""
|
||||
return str(payload.get("signature") or "").strip() if isinstance(payload, dict) else ""
|
||||
|
||||
|
||||
def table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
|
||||
return bool(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
|
||||
(table_name,),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Prune obsolete WEHAGO derived projection records.")
|
||||
parser.add_argument("--start-year", type=int, default=2025)
|
||||
parser.add_argument("--end-year", type=int, default=2025)
|
||||
parser.add_argument("--execute", action="store_true", help="Actually delete rows. Without this flag, only prints counts.")
|
||||
args = parser.parse_args()
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
signature = active_signature(conn, args.start_year, args.end_year)
|
||||
if not signature:
|
||||
raise SystemExit("No active projection signature was found. Refusing to prune.")
|
||||
|
||||
results: list[dict[str, object]] = []
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
for table_name, signature_column in DERIVED_SIGNATURE_TABLES:
|
||||
if not table_exists(conn, table_name):
|
||||
continue
|
||||
count = int(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM {table_name}
|
||||
WHERE start_year = ? AND end_year = ?
|
||||
AND {signature_column} <> ?
|
||||
""",
|
||||
(args.start_year, args.end_year, signature),
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
results.append({"table": table_name, "obsolete_rows": count})
|
||||
if args.execute and count:
|
||||
conn.execute(
|
||||
f"""
|
||||
DELETE FROM {table_name}
|
||||
WHERE start_year = ? AND end_year = ?
|
||||
AND {signature_column} <> ?
|
||||
""",
|
||||
(args.start_year, args.end_year, signature),
|
||||
)
|
||||
if args.execute:
|
||||
conn.commit()
|
||||
else:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"start_year": args.start_year,
|
||||
"end_year": args.end_year,
|
||||
"active_signature": signature,
|
||||
"execute": bool(args.execute),
|
||||
"tables": results,
|
||||
"obsolete_total": sum(int(row["obsolete_rows"]) for row in results),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,6 +11,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from main import engine
|
||||
from wehago_compare import (
|
||||
_build_db_state_signature,
|
||||
_ensure_year_export_row_cache,
|
||||
_get_fast_year_export_row_cache_signature,
|
||||
_load_snapshot_status_map,
|
||||
_project_year_export_row_cache_from_latest_resolved,
|
||||
@@ -52,7 +53,18 @@ def main() -> None:
|
||||
projected = _project_year_export_row_cache_from_latest_resolved(conn, year, signature)
|
||||
if not projected:
|
||||
print({"step": "fast_year_projection_miss", "year": year}, flush=True)
|
||||
_refresh_year_resolved_sections(conn, year)
|
||||
selected_signature = _ensure_year_export_row_cache(conn, year)
|
||||
else:
|
||||
selected_signature = _get_fast_year_export_row_cache_signature(conn, year)
|
||||
if selected_signature:
|
||||
_upsert_snapshot_status(
|
||||
conn,
|
||||
year,
|
||||
signature=selected_signature,
|
||||
state="ready",
|
||||
row_counts={},
|
||||
built_now=True,
|
||||
)
|
||||
else:
|
||||
_upsert_snapshot_status(
|
||||
conn,
|
||||
@@ -62,7 +74,6 @@ def main() -> None:
|
||||
row_counts={},
|
||||
built_now=True,
|
||||
)
|
||||
selected_signature = _get_fast_year_export_row_cache_signature(conn, year)
|
||||
print(
|
||||
{
|
||||
"step": "fast_year_projection_done",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from runtime_config import DB_PATH
|
||||
|
||||
|
||||
DEFAULT_SAMPLES = (
|
||||
"2025-02-25-00029",
|
||||
"2025-01-10-00006",
|
||||
"2025-01-15-50027",
|
||||
"2025-01-15-50028",
|
||||
"2025-01-21-50197",
|
||||
"2025-01-21-50198",
|
||||
)
|
||||
|
||||
|
||||
def run_command(command: list[str]) -> str:
|
||||
proc = subprocess.run(command, cwd=Path(__file__).resolve().parents[1], text=True, capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
payload = {
|
||||
"command": command,
|
||||
"returncode": proc.returncode,
|
||||
"stdout": proc.stdout,
|
||||
"stderr": proc.stderr,
|
||||
}
|
||||
raise SystemExit(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def parse_sample(value: str, default_year: int) -> tuple[int, str, str]:
|
||||
text = value.strip()
|
||||
parts = text.replace("/", "-").split("-")
|
||||
if len(parts) == 4:
|
||||
year, month, day, voucher = parts
|
||||
elif len(parts) == 3:
|
||||
year = str(default_year)
|
||||
month, day, voucher = parts
|
||||
else:
|
||||
raise ValueError(f"Invalid sample format: {value}")
|
||||
return int(year), f"{int(month):02d}-{int(day):02d}", f"{int(voucher):05d}" if voucher.isdigit() else voucher
|
||||
|
||||
|
||||
def active_signature(conn: sqlite3.Connection, year: int) -> str:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT setting_json
|
||||
FROM wehago_compare_settings
|
||||
WHERE setting_key = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"wehago_active_query_projection:{year}:{year}",),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return ""
|
||||
try:
|
||||
payload = json.loads(row[0] or "{}")
|
||||
except Exception:
|
||||
return ""
|
||||
return str(payload.get("signature") or "") if isinstance(payload, dict) else ""
|
||||
|
||||
|
||||
def validate_projection(conn: sqlite3.Connection, year: int, signature: str) -> dict[str, Any]:
|
||||
counts = {
|
||||
row[0]: int(row[1] or 0)
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT status_key, COUNT(*)
|
||||
FROM wehago_compare_query_groups
|
||||
WHERE start_year = ? AND end_year = ? AND signature = ?
|
||||
AND status_key IN ('voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted')
|
||||
GROUP BY status_key
|
||||
""",
|
||||
(year, year, signature),
|
||||
).fetchall()
|
||||
}
|
||||
final_counts = {
|
||||
row[0]: int(row[1] or 0)
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT final_status, COUNT(*)
|
||||
FROM wehago_compare_final_status_projection
|
||||
WHERE start_year = ? AND end_year = ? AND signature = ?
|
||||
GROUP BY final_status
|
||||
""",
|
||||
(year, year, signature),
|
||||
).fetchall()
|
||||
}
|
||||
raw_total = int(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT compare_voucher_no)
|
||||
FROM wehago_ledger_rows
|
||||
WHERE fiscal_year = ?
|
||||
AND COALESCE(compare_voucher_no, '') <> ''
|
||||
""",
|
||||
(year,),
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
recheck_without_erp = int(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM wehago_compare_query_groups g
|
||||
WHERE g.start_year = ? AND g.end_year = ? AND g.signature = ?
|
||||
AND g.status_key = 'voucher_recheck'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM wehago_compare_query_rows r
|
||||
WHERE r.start_year = g.start_year
|
||||
AND r.end_year = g.end_year
|
||||
AND r.signature = g.signature
|
||||
AND r.status_key = g.status_key
|
||||
AND r.group_index = g.group_index
|
||||
AND COALESCE(r.voucher_account_name, '') <> ''
|
||||
AND (
|
||||
ABS(COALESCE(r.voucher_debit, 0)) > 0.0001
|
||||
OR ABS(COALESCE(r.voucher_credit, 0)) > 0.0001
|
||||
)
|
||||
)
|
||||
""",
|
||||
(year, year, signature),
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
unmatched_with_erp = int(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM wehago_compare_query_groups g
|
||||
WHERE g.start_year = ? AND g.end_year = ? AND g.signature = ?
|
||||
AND g.status_key = 'voucher_unmatched'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM wehago_compare_query_rows r
|
||||
WHERE r.start_year = g.start_year
|
||||
AND r.end_year = g.end_year
|
||||
AND r.signature = g.signature
|
||||
AND r.status_key = g.status_key
|
||||
AND r.group_index = g.group_index
|
||||
AND COALESCE(r.voucher_account_name, '') <> ''
|
||||
AND (
|
||||
ABS(COALESCE(r.voucher_debit, 0)) > 0.0001
|
||||
OR ABS(COALESCE(r.voucher_credit, 0)) > 0.0001
|
||||
)
|
||||
)
|
||||
""",
|
||||
(year, year, signature),
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
return {
|
||||
"raw_total": raw_total,
|
||||
"counts": counts,
|
||||
"final_counts": final_counts,
|
||||
"classified_total": sum(counts.values()),
|
||||
"difference": raw_total - sum(counts.values()),
|
||||
"recheck_without_erp": recheck_without_erp,
|
||||
"unmatched_with_erp": unmatched_with_erp,
|
||||
}
|
||||
|
||||
|
||||
def sample_statuses(conn: sqlite3.Connection, year: int, signature: str, samples: list[str]) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for sample in samples:
|
||||
sample_year, ledger_date, voucher_no = parse_sample(sample, year)
|
||||
rows = [
|
||||
{
|
||||
"status_key": row[0],
|
||||
"ledger_date": row[1],
|
||||
"voucher_no": row[2],
|
||||
"draft_no": row[3],
|
||||
"review_reason": row[4],
|
||||
}
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT status_key, ledger_date, voucher_no, draft_no, review_reason
|
||||
FROM wehago_compare_query_groups
|
||||
WHERE start_year = ? AND end_year = ? AND signature = ?
|
||||
AND fiscal_year = ?
|
||||
AND ledger_date = ?
|
||||
AND voucher_no = ?
|
||||
ORDER BY status_key, group_index
|
||||
""",
|
||||
(year, year, signature, sample_year, ledger_date, voucher_no),
|
||||
).fetchall()
|
||||
]
|
||||
result.append({"sample": sample, "rows": rows})
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run a fast WEHAGO logic iteration: compile, reconcile, validate, sample-check, optionally prune.")
|
||||
parser.add_argument("--year", type=int, default=2025)
|
||||
parser.add_argument("--sample", action="append", default=[])
|
||||
parser.add_argument("--skip-compile", action="store_true")
|
||||
parser.add_argument("--skip-reconcile", action="store_true")
|
||||
parser.add_argument("--prune", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
python = str(repo_root / ".venv" / "bin" / "python")
|
||||
if not args.skip_compile:
|
||||
run_command(
|
||||
[
|
||||
python,
|
||||
"-m",
|
||||
"py_compile",
|
||||
"scripts/reconcile_wehago_projection_to_db.py",
|
||||
"scripts/prune_wehago_projection_history.py",
|
||||
"wehago_compare.py",
|
||||
"main.py",
|
||||
]
|
||||
)
|
||||
reconcile_output = ""
|
||||
if not args.skip_reconcile:
|
||||
reconcile_output = run_command([python, "scripts/reconcile_wehago_projection_to_db.py", "--year", str(args.year)])
|
||||
prune_output = ""
|
||||
if args.prune:
|
||||
prune_output = run_command(
|
||||
[
|
||||
python,
|
||||
"scripts/prune_wehago_projection_history.py",
|
||||
"--start-year",
|
||||
str(args.year),
|
||||
"--end-year",
|
||||
str(args.year),
|
||||
"--execute",
|
||||
]
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
signature = active_signature(conn, args.year)
|
||||
samples = args.sample or list(DEFAULT_SAMPLES)
|
||||
payload = {
|
||||
"year": args.year,
|
||||
"active_signature": signature,
|
||||
"validation": validate_projection(conn, args.year, signature) if signature else {},
|
||||
"samples": sample_statuses(conn, args.year, signature, samples) if signature else [],
|
||||
"reconcile_output_tail": reconcile_output.strip().splitlines()[-8:],
|
||||
"prune_output": json.loads(prune_output) if prune_output.strip().startswith("{") else prune_output,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+212
-1
@@ -121,6 +121,104 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.last-login-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.last-login-button:hover,
|
||||
.last-login-button:focus {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-history-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(17, 24, 39, 0.28);
|
||||
}
|
||||
|
||||
.login-history-modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.login-history-dialog {
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
max-height: min(760px, calc(100vh - 48px));
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-history-head strong {
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.login-history-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--ink);
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-history-close:hover,
|
||||
.login-history-close:focus {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-history-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.login-history-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.login-history-table th,
|
||||
.login-history-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.login-history-table .ua-cell {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-user-main-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -201,12 +299,14 @@
|
||||
<th>페이지 권한</th>
|
||||
<th>상태</th>
|
||||
<th>관리자</th>
|
||||
<th>최종접속기록(KST)</th>
|
||||
<th>수정일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr
|
||||
data-user-id="{{ user.id }}"
|
||||
data-username="{{ user.username }}"
|
||||
data-display-name="{{ user.display_name }}"
|
||||
data-role="{{ 'admin' if user.is_admin else 'viewer' }}"
|
||||
@@ -232,14 +332,35 @@
|
||||
</td>
|
||||
<td>{{ '활성' if user.is_active else '비활성' }}</td>
|
||||
<td>{{ '예' if user.is_admin else '-' }}</td>
|
||||
<td>
|
||||
{% if user.last_login_at %}
|
||||
<button type="button" class="last-login-button" data-login-history-user="{{ user.id }}">
|
||||
{{ user.last_login_at_kst }}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.updated_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="empty">사용자가 없습니다.</td></tr>
|
||||
<tr><td colspan="8" class="empty">사용자가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="login-history-modal" id="loginHistoryModal" aria-hidden="true">
|
||||
<div class="login-history-dialog" role="dialog" aria-modal="true" aria-labelledby="loginHistoryTitle">
|
||||
<div class="login-history-head">
|
||||
<strong id="loginHistoryTitle">접속이력</strong>
|
||||
<button type="button" class="login-history-close" id="loginHistoryClose">닫기</button>
|
||||
</div>
|
||||
<div id="loginHistoryBody" class="login-history-table-wrap">
|
||||
<div class="empty">접속이력을 불러오지 않았습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
@@ -253,6 +374,79 @@
|
||||
const role = form.querySelector("[name='role']");
|
||||
const active = form.querySelector("[name='is_active']");
|
||||
const permissionInputs = [...form.querySelectorAll("[name='permissions']")];
|
||||
const loginHistoryModal = document.getElementById("loginHistoryModal");
|
||||
const loginHistoryTitle = document.getElementById("loginHistoryTitle");
|
||||
const loginHistoryBody = document.getElementById("loginHistoryBody");
|
||||
const loginHistoryClose = document.getElementById("loginHistoryClose");
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function openLoginHistoryModal() {
|
||||
loginHistoryModal?.classList.add("open");
|
||||
loginHistoryModal?.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeLoginHistoryModal() {
|
||||
loginHistoryModal?.classList.remove("open");
|
||||
loginHistoryModal?.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function renderLoginHistory(payload) {
|
||||
const events = Array.isArray(payload?.events) ? payload.events : [];
|
||||
if (!events.length) {
|
||||
loginHistoryBody.innerHTML = '<div class="empty">접속이력이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
loginHistoryBody.innerHTML = `
|
||||
<table class="login-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시(KST)</th>
|
||||
<th>결과</th>
|
||||
<th>아이디</th>
|
||||
<th>IP</th>
|
||||
<th>실패사유</th>
|
||||
<th>사용환경</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${events.map((event) => `
|
||||
<tr>
|
||||
<td title="${escapeHtml(event.created_at_kst || event.created_at)}">${escapeHtml(event.created_at_kst || event.created_at)}</td>
|
||||
<td>${event.success ? "성공" : "실패"}</td>
|
||||
<td title="${escapeHtml(event.username)}">${escapeHtml(event.username || "-")}</td>
|
||||
<td title="${escapeHtml(event.ip_address)}">${escapeHtml(event.ip_address || "-")}</td>
|
||||
<td title="${escapeHtml(event.failure_reason)}">${escapeHtml(event.failure_reason || "-")}</td>
|
||||
<td class="ua-cell" title="${escapeHtml(event.user_agent)}">${escapeHtml(event.user_agent || "-")}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadLoginHistory(userId, label) {
|
||||
loginHistoryTitle.textContent = `${label || "사용자"} 접속이력`;
|
||||
loginHistoryBody.innerHTML = '<div class="empty">접속이력을 불러오는 중입니다.</div>';
|
||||
openLoginHistoryModal();
|
||||
try {
|
||||
const response = await fetch(`/admin/users/${encodeURIComponent(userId)}/login-events?limit=100`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload?.error || "접속이력을 불러오지 못했습니다.");
|
||||
renderLoginHistory(payload);
|
||||
} catch (error) {
|
||||
loginHistoryBody.innerHTML = `<div class="empty">${escapeHtml(error.message || "접속이력을 불러오지 못했습니다.")}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".user-edit-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
@@ -270,6 +464,23 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".last-login-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const row = button.closest("tr");
|
||||
loadLoginHistory(button.dataset.loginHistoryUser, row?.dataset.username || button.textContent.trim());
|
||||
});
|
||||
});
|
||||
|
||||
loginHistoryClose?.addEventListener("click", closeLoginHistoryModal);
|
||||
loginHistoryModal?.addEventListener("click", (event) => {
|
||||
if (event.target === loginHistoryModal) closeLoginHistoryModal();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && loginHistoryModal?.classList.contains("open")) {
|
||||
closeLoginHistoryModal();
|
||||
}
|
||||
});
|
||||
|
||||
role.addEventListener("change", () => {
|
||||
const isAdmin = role.value === "admin";
|
||||
permissionInputs.forEach((input) => {
|
||||
|
||||
+18
-10
@@ -82,28 +82,34 @@
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 8px;
|
||||
padding: 7px 8px;
|
||||
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.06);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.nav-spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 12px;
|
||||
min-width: 6px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
flex: 0 0 auto;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
padding: 9px 13px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -519,8 +525,9 @@
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
max-width: 320px;
|
||||
max-width: 230px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
@@ -535,10 +542,11 @@
|
||||
|
||||
.view-mode-switch {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
width: 72px;
|
||||
height: var(--status-widget-height);
|
||||
min-height: var(--status-widget-height);
|
||||
border-radius: 999px;
|
||||
@@ -589,11 +597,11 @@
|
||||
}
|
||||
|
||||
.view-mode-switch[data-mode="dual"]::before {
|
||||
transform: translateX(38px);
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.view-mode-switch[data-mode="dual"] .label {
|
||||
left: 41px;
|
||||
left: 33px;
|
||||
}
|
||||
|
||||
.view-mode-switch[data-mode="single"]::before {
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
|
||||
.cost-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(9, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cost-kpi {
|
||||
@@ -69,6 +69,9 @@
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cost-kpi strong {
|
||||
@@ -77,6 +80,8 @@
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.summary-accordion {
|
||||
@@ -174,7 +179,8 @@
|
||||
}
|
||||
|
||||
.cost-table {
|
||||
min-width: 2820px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
@@ -182,8 +188,8 @@
|
||||
|
||||
.cost-table th,
|
||||
.cost-table td {
|
||||
padding: 8px 9px;
|
||||
font-size: 12px;
|
||||
padding: 7px 5px;
|
||||
font-size: 11px;
|
||||
vertical-align: middle;
|
||||
border-right: 1px solid #e6e8ec;
|
||||
background: #ffffff;
|
||||
@@ -238,29 +244,50 @@
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 6;
|
||||
background: #ffffff;
|
||||
background-clip: padding-box;
|
||||
box-shadow: 1px 0 0 #d9dde3;
|
||||
}
|
||||
|
||||
.sticky-col-2 {
|
||||
position: sticky;
|
||||
left: 130px;
|
||||
left: 94px;
|
||||
z-index: 6;
|
||||
background: #ffffff;
|
||||
background-clip: padding-box;
|
||||
box-shadow: 1px 0 0 #d9dde3;
|
||||
}
|
||||
|
||||
.sticky-col-3 {
|
||||
position: sticky;
|
||||
left: 236px;
|
||||
left: 172px;
|
||||
z-index: 6;
|
||||
background: #ffffff;
|
||||
background-clip: padding-box;
|
||||
box-shadow: 1px 0 0 #d9dde3;
|
||||
}
|
||||
|
||||
thead .sticky-col,
|
||||
thead .sticky-col-2,
|
||||
thead .sticky-col-3 {
|
||||
background: #edf4f4;
|
||||
z-index: 70 !important;
|
||||
}
|
||||
|
||||
.cost-table tbody td.sticky-col,
|
||||
.cost-table tbody td.sticky-col-2,
|
||||
.cost-table tbody td.sticky-col-3 {
|
||||
background: #ffffff;
|
||||
background-clip: padding-box;
|
||||
z-index: 16;
|
||||
}
|
||||
|
||||
.cost-table tbody tr:hover td.sticky-col,
|
||||
.cost-table tbody tr:hover td.sticky-col-2,
|
||||
.cost-table tbody tr:hover td.sticky-col-3 {
|
||||
background: #f8fbfb;
|
||||
}
|
||||
|
||||
.summary-table-body td {
|
||||
background: #fbfcfd;
|
||||
font-weight: 900;
|
||||
@@ -308,12 +335,15 @@
|
||||
.summary-table-body .sticky-col-3 {
|
||||
z-index: 8;
|
||||
background: #fbfcfd;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.summary-table-body.open .summary-data-row .sticky-col,
|
||||
.summary-table-body.open .summary-data-row .sticky-col-2,
|
||||
.summary-table-body.open .summary-data-row .sticky-col-3 {
|
||||
z-index: 58;
|
||||
background: #fbfcfd;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.summary-row-toggle {
|
||||
@@ -450,13 +480,13 @@
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.col-pm { width: 130px; }
|
||||
.col-code { width: 106px; }
|
||||
.col-name { width: 240px; }
|
||||
.col-small { width: 82px; }
|
||||
.col-date { width: 104px; }
|
||||
.col-money { width: 118px; text-align: right; }
|
||||
.col-rate { width: 86px; text-align: right; }
|
||||
.col-pm { width: 94px; }
|
||||
.col-code { width: 78px; }
|
||||
.col-name { width: 148px; }
|
||||
.col-small { width: 54px; }
|
||||
.col-date { width: 72px; }
|
||||
.col-money { width: 72px; text-align: right; }
|
||||
.col-rate { width: 62px; text-align: right; }
|
||||
|
||||
.group-pre { background: #f3f8ee !important; }
|
||||
.group-during { background: #eef5fb !important; }
|
||||
@@ -475,7 +505,7 @@
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
box-shadow: none;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -520,7 +550,7 @@
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
box-shadow: none;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -588,7 +618,7 @@
|
||||
@media (max-width: 720px) {
|
||||
.cost-kpis {
|
||||
overflow-x: auto;
|
||||
grid-template-columns: repeat(10, minmax(96px, 1fr));
|
||||
grid-template-columns: repeat(9, minmax(96px, 1fr));
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
@@ -600,12 +630,6 @@
|
||||
|
||||
{% block content %}
|
||||
<section class="panel cost-analysis-shell">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>프로젝트 손익분석</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="cost-toolbar" id="costAnalysisForm">
|
||||
<div class="field">
|
||||
<label>조회방식</label>
|
||||
@@ -681,7 +705,7 @@
|
||||
<th colspan="4" class="group-pre">사업전</th>
|
||||
<th colspan="5" class="group-during">사업중</th>
|
||||
<th colspan="5" class="group-post">사업후</th>
|
||||
<th colspan="8" class="group-total">총계</th>
|
||||
<th colspan="9" class="group-total">총계</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="2" class="group-pre sub-cost">원가</th>
|
||||
@@ -698,6 +722,7 @@
|
||||
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="profit_amount" data-sort-type="number">이윤</button></th>
|
||||
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="revenue_profit_rate" data-sort-type="number">청구수익률</button></th>
|
||||
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="collection_profit_rate" data-sort-type="number">수금수익률</button></th>
|
||||
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="cumulative_profit_rate" data-sort-type="number">누적수익률</button></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="group-pre col-money"><button type="button" class="sort-button" data-sort-key="phases.pre.labor" data-sort-type="number">인건비</button></th>
|
||||
@@ -718,7 +743,7 @@
|
||||
</thead>
|
||||
<tbody class="summary-table-body" id="summaryTableBody">
|
||||
<tr class="summary-control-row">
|
||||
<td colspan="35">
|
||||
<td colspan="36">
|
||||
<button type="button" class="summary-row-toggle" id="summaryToggle" aria-expanded="false">
|
||||
<span class="summary-row-arrow">▾</span>
|
||||
<span>합계</span>
|
||||
@@ -728,7 +753,7 @@
|
||||
<tr class="summary-data-row" id="summaryTableRow"></tr>
|
||||
</tbody>
|
||||
<tbody id="costAnalysisBody">
|
||||
<tr><td colspan="35" class="empty">조회 중입니다.</td></tr>
|
||||
<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -897,14 +922,13 @@
|
||||
const items = [
|
||||
["프로젝트", `${formatNumber(summary.project_count)}건`],
|
||||
["도급금액", formatNumber(summary.contract_amount)],
|
||||
["청구금액", formatNumber(summary.billing_amount)],
|
||||
["청구수익", formatNumber(summary.revenue_amount)],
|
||||
["수금금액", formatNumber(summary.collection_amount)],
|
||||
["기간 청구금액", formatNumber(summary.billing_amount)],
|
||||
["기간 수금금액", formatNumber(summary.collection_amount)],
|
||||
["잔여도급금액", formatBalance(summary.contract_balance_amount)],
|
||||
["총비용", formatNumber(summary.total_cost)],
|
||||
["이윤", formatNumber(summary.profit_amount)],
|
||||
["청구수익률", formatRate(summary.revenue_profit_rate)],
|
||||
["수금수익률", formatRate(summary.collection_profit_rate)],
|
||||
["누적수익률", formatRate(summary.cumulative_profit_rate)],
|
||||
];
|
||||
kpis.innerHTML = items.map(([label, value]) => `<div class="cost-kpi"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`).join("");
|
||||
}
|
||||
@@ -963,6 +987,7 @@
|
||||
${moneyCell(summary.profit_amount)}
|
||||
${rateCell(summary.revenue_profit_rate)}
|
||||
${rateCell(summary.collection_profit_rate)}
|
||||
${rateCell(summary.cumulative_profit_rate)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1024,7 +1049,7 @@
|
||||
function renderTable() {
|
||||
const rows = latestDisplayRows.length ? latestDisplayRows : getFilteredRows();
|
||||
if (!rows.length) {
|
||||
body.innerHTML = `<tr><td colspan="35" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="36" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const viewportHeight = tableWrap?.clientHeight || 600;
|
||||
@@ -1035,8 +1060,8 @@
|
||||
const topHeight = startIndex * virtualRowHeight;
|
||||
const bottomHeight = Math.max(0, (rows.length - endIndex) * virtualRowHeight);
|
||||
const visibleRows = rows.slice(startIndex, endIndex);
|
||||
const topSpacer = topHeight ? `<tr class="virtual-spacer"><td colspan="35" style="height:${topHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
|
||||
const bottomSpacer = bottomHeight ? `<tr class="virtual-spacer"><td colspan="35" style="height:${bottomHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
|
||||
const topSpacer = topHeight ? `<tr class="virtual-spacer"><td colspan="36" style="height:${topHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
|
||||
const bottomSpacer = bottomHeight ? `<tr class="virtual-spacer"><td colspan="36" style="height:${bottomHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
|
||||
body.innerHTML = topSpacer + visibleRows.map((row, visibleOffset) => {
|
||||
const rowIndex = startIndex + visibleOffset;
|
||||
const pre = row.phases?.pre || {};
|
||||
@@ -1083,6 +1108,7 @@
|
||||
<td class="col-money">${formatNumber(row.profit_amount)}</td>
|
||||
<td class="ratio-cell">${formatRate(row.revenue_profit_rate)}</td>
|
||||
<td class="ratio-cell">${formatRate(row.collection_profit_rate)}</td>
|
||||
<td class="ratio-cell">${formatRate(row.cumulative_profit_rate)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("") + bottomSpacer;
|
||||
@@ -1091,10 +1117,20 @@
|
||||
function recalcVisibleSummary() {
|
||||
const rows = getFilteredRows();
|
||||
latestDisplayRows = rows;
|
||||
const summary = rows.reduce((acc, row) => {
|
||||
const buildSummary = (usePeriodValues = false) => rows.reduce((acc, row) => {
|
||||
for (const key of ["contract_amount", "billing_amount", "collection_amount", "contract_balance_amount", "revenue_amount", "cost_total", "sga_total", "sales_total", "total_cost", "profit_amount"]) {
|
||||
acc[key] += Number(row[key] || 0);
|
||||
}
|
||||
if (usePeriodValues) {
|
||||
acc.billing_amount += Number(row.period_billing_amount || 0) - Number(row.billing_amount || 0);
|
||||
acc.collection_amount += Number(row.period_collection_amount || 0) - Number(row.collection_amount || 0);
|
||||
acc.revenue_amount += Number(row.period_revenue_amount || 0) - Number(row.revenue_amount || 0);
|
||||
acc.cost_total += Number(row.period_cost_total || 0) - Number(row.cost_total || 0);
|
||||
acc.sga_total += Number(row.period_sga_total || 0) - Number(row.sga_total || 0);
|
||||
acc.sales_total += Number(row.period_sales_total || 0) - Number(row.sales_total || 0);
|
||||
acc.total_cost += Number(row.period_total_cost || 0) - Number(row.total_cost || 0);
|
||||
acc.profit_amount += Number(row.period_profit_amount || 0) - Number(row.profit_amount || 0);
|
||||
}
|
||||
for (const phase of ["pre", "during", "post"]) {
|
||||
const bucket = row.phases?.[phase] || {};
|
||||
for (const item of ["labor", "outsource", "overhead", "sga", "sales"]) {
|
||||
@@ -1115,17 +1151,26 @@
|
||||
sales_total: 0,
|
||||
total_cost: 0,
|
||||
profit_amount: 0,
|
||||
cumulative_profit_amount: 0,
|
||||
phases: {
|
||||
pre: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
|
||||
during: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
|
||||
post: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
|
||||
},
|
||||
});
|
||||
const summary = buildSummary(false);
|
||||
summary.collection_rate = summary.contract_amount ? summary.collection_amount / summary.contract_amount * 100 : 0;
|
||||
summary.contract_profit_rate = summary.contract_amount ? summary.profit_amount / summary.contract_amount * 100 : 0;
|
||||
summary.revenue_profit_rate = summary.revenue_amount ? summary.profit_amount / summary.revenue_amount * 100 : 0;
|
||||
summary.collection_profit_rate = summary.collection_amount ? summary.profit_amount / summary.collection_amount * 100 : 0;
|
||||
renderKpis(summary);
|
||||
summary.cumulative_profit_rate = summary.collection_amount ? (summary.collection_amount - summary.total_cost) / summary.collection_amount * 100 : 0;
|
||||
const kpiSummary = buildSummary(true);
|
||||
kpiSummary.collection_rate = kpiSummary.contract_amount ? kpiSummary.collection_amount / kpiSummary.contract_amount * 100 : 0;
|
||||
kpiSummary.contract_profit_rate = kpiSummary.contract_amount ? kpiSummary.profit_amount / kpiSummary.contract_amount * 100 : 0;
|
||||
kpiSummary.revenue_profit_rate = kpiSummary.revenue_amount ? kpiSummary.profit_amount / kpiSummary.revenue_amount * 100 : 0;
|
||||
kpiSummary.collection_profit_rate = kpiSummary.collection_amount ? kpiSummary.profit_amount / kpiSummary.collection_amount * 100 : 0;
|
||||
kpiSummary.cumulative_profit_rate = summary.cumulative_profit_rate;
|
||||
renderKpis(kpiSummary);
|
||||
renderSummaryTable(summary);
|
||||
}
|
||||
|
||||
@@ -1136,7 +1181,9 @@
|
||||
const controller = activeLoadController;
|
||||
searchButton.disabled = true;
|
||||
searchButton.textContent = "조회 중";
|
||||
body.innerHTML = `<tr><td colspan="35" class="empty">조회 중입니다.</td></tr>`;
|
||||
kpis.innerHTML = `<div class="cost-kpi"><span>조회</span><strong>조회 중</strong></div>`;
|
||||
summaryTableRow.innerHTML = "";
|
||||
body.innerHTML = `<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>`;
|
||||
try {
|
||||
const params = new URLSearchParams({ start_date: lockedStartDate, end_date: lockedEndDate, mode: currentMode });
|
||||
const response = await fetch(`/cost-analysis/data?${params.toString()}`, {
|
||||
@@ -1365,7 +1412,7 @@
|
||||
event.preventDefault();
|
||||
rememberDateInputs();
|
||||
loadData().catch((error) => {
|
||||
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1376,7 +1423,7 @@
|
||||
currentMode = button.dataset.mode || "individual";
|
||||
modeToggle.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button));
|
||||
loadData().catch((error) => {
|
||||
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1464,7 +1511,7 @@
|
||||
});
|
||||
|
||||
loadData().catch((error) => {
|
||||
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
|
||||
kpis.innerHTML = "";
|
||||
summaryTableRow.innerHTML = "";
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<form class="login-card" method="post" action="/login">
|
||||
<h1>한맥 인트라넷 로그인</h1>
|
||||
<h1>한맥기술 ERP 점검</h1>
|
||||
<input type="hidden" name="next" value="{{ next_url }}">
|
||||
<label for="username">아이디</label>
|
||||
<input id="username" name="username" autocomplete="username" required autofocus>
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}복리/접대비 분류{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set filters = benefit_report.filters %}
|
||||
<style>
|
||||
.report-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.report-head h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.report-head p {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.report-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #111;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-filter {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 82px 136px 164px 150px 180px 62px;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-filter .field {
|
||||
min-width: 0;
|
||||
}
|
||||
.report-filter input,
|
||||
.report-filter select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.report-filter button {
|
||||
min-height: 38px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #111;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.report-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 16px 0 4px;
|
||||
}
|
||||
.report-kpi {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.report-kpi span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.report-kpi strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.report-toggle-kpi {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.report-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: #111;
|
||||
border-radius: 0;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-toggle.is-on {
|
||||
border-color: #111;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
.report-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.88fr) minmax(520px, 1.12fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.report-summary-band {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.report-section h2 {
|
||||
font-size: 17px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.report-table-wrap {
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
line-height: 1.15;
|
||||
min-width: 860px;
|
||||
}
|
||||
.report-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f5f6f8;
|
||||
color: #4b5563;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
padding: 4px 5px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-table td {
|
||||
padding: 2px 5px;
|
||||
border-bottom: 1px solid #eceff3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.report-table .number {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.report-table .desc {
|
||||
min-width: 260px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-table .counterpart {
|
||||
min-width: 300px;
|
||||
max-width: 420px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
.counterpart-text {
|
||||
display: block;
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.counterpart-hover-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
z-index: 1400;
|
||||
min-width: 520px;
|
||||
max-width: min(820px, 72vw);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: #111;
|
||||
white-space: pre-line;
|
||||
line-height: 1.45;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.counterpart-hover-tooltip.is-open {
|
||||
display: block;
|
||||
}
|
||||
#voucherDetailBody td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-table button.link-cell {
|
||||
all: unset;
|
||||
display: inline;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: #111;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
padding: 0 !important;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
line-height: inherit;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.report-table button.link-cell:hover,
|
||||
.report-table button.link-cell:focus,
|
||||
.report-table button.link-cell:active {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
text-decoration: none !important;
|
||||
outline: none;
|
||||
}
|
||||
.summary-account-group {
|
||||
display: -webkit-box;
|
||||
max-width: 58px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.15;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.summary-person-cell,
|
||||
.summary-person-cell .link-cell {
|
||||
display: -webkit-box !important;
|
||||
max-width: 128px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.15;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.category-save-cell {
|
||||
display: block;
|
||||
min-width: 138px;
|
||||
}
|
||||
.category-save-cell select {
|
||||
min-height: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
padding: 0 3px;
|
||||
width: 100%;
|
||||
}
|
||||
.report-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.detail-more-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.detail-more-row button {
|
||||
min-height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.modal-detail-toggle {
|
||||
margin-top: 12px;
|
||||
min-height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.modal-detail-panel {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.modal-detail-panel.is-open {
|
||||
display: block;
|
||||
}
|
||||
.report-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(17, 24, 39, 0.42);
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
.report-modal-backdrop.is-open {
|
||||
display: flex;
|
||||
}
|
||||
.report-modal {
|
||||
width: min(1680px, calc(100vw - 24px));
|
||||
max-height: min(860px, calc(100vh - 24px));
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: none;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.report-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.report-modal-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.report-modal-head button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.report-modal-body {
|
||||
overflow: auto;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
.report-modal .report-table {
|
||||
min-width: 1680px;
|
||||
}
|
||||
.report-modal .report-table th,
|
||||
.report-modal .report-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-modal .report-table .desc {
|
||||
min-width: 460px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-modal .report-table .counterpart {
|
||||
min-width: 360px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.report-modal .counterpart-text {
|
||||
max-width: 560px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.report-head,
|
||||
.report-grid {
|
||||
display: grid;
|
||||
}
|
||||
.report-kpis {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="panel">
|
||||
<div class="report-head">
|
||||
<div>
|
||||
<h1>복리후생비/접대비 분류</h1>
|
||||
<p>{{ filters.start_year }}년부터 {{ filters.end_year }}년까지 WEHAGO 전표 기준</p>
|
||||
</div>
|
||||
<div class="report-actions">
|
||||
<a class="report-button" href="/wehago-benefit-entertainment/export?start_year={{ filters.start_year }}&end_year={{ filters.end_year }}&account_group={{ filters.account_group|urlencode }}&category={{ filters.category|urlencode }}&person_keyword={{ filters.person_keyword|urlencode }}&desc_keyword={{ filters.desc_keyword|urlencode }}&include_adjustments={{ '1' if filters.include_adjustments else '0' }}">엑셀 다운로드</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="report-filter" method="get" action="/wehago-benefit-entertainment">
|
||||
<div class="field">
|
||||
<label for="start_year">시작연도</label>
|
||||
<input id="start_year" name="start_year" value="{{ filters.start_year }}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="end_year">종료연도</label>
|
||||
<input id="end_year" name="end_year" value="{{ filters.end_year }}" inputmode="numeric">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="account_group">구분</label>
|
||||
<select id="account_group" name="account_group">
|
||||
<option value="all" {% if filters.account_group in ['all', ''] %}selected{% endif %}>전체</option>
|
||||
{% for option in benefit_report.account_group_options %}
|
||||
<option value="{{ option }}" {% if filters.account_group == option %}selected{% endif %}>{{ option }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="category">분류</label>
|
||||
<select id="category" name="category">
|
||||
<option value="all" {% if filters.category in ['all', ''] %}selected{% endif %}>전체</option>
|
||||
{% for option in benefit_report.category_options %}
|
||||
<option value="{{ option }}" {% if filters.category == option %}selected{% endif %}>{{ option }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="person_keyword">개인/거래처</label>
|
||||
<input id="person_keyword" name="person_keyword" value="{{ filters.person_keyword }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="desc_keyword">적요</label>
|
||||
<input id="desc_keyword" name="desc_keyword" value="{{ filters.desc_keyword }}" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" name="include_adjustments" value="{{ '1' if filters.include_adjustments else '0' }}">
|
||||
<button type="submit">검색</button>
|
||||
</form>
|
||||
|
||||
<div class="report-kpis">
|
||||
<div class="report-kpi"><span>총 금액</span><strong>{{ "{:,.0f}".format(benefit_report.total_amount) }}</strong></div>
|
||||
<div class="report-kpi"><span>전표 행</span><strong>{{ "{:,}".format(benefit_report.row_count) }}</strong></div>
|
||||
<div class="report-kpi"><span>거래처 요약</span><strong>{{ "{:,}".format(benefit_report.vendor_summary_rows|length) }}</strong></div>
|
||||
<div class="report-kpi"><span>직급 확인</span><strong>{{ "{:,}".format(benefit_report.hanmac_grade_match_count) }}</strong></div>
|
||||
<div class="report-kpi"><span>미리보기</span><strong>{{ "{:,}".format(benefit_report.shown_count) }}</strong></div>
|
||||
<div class="report-toggle-kpi">
|
||||
<a class="report-toggle {% if filters.include_adjustments %}is-on{% endif %}" href="/wehago-benefit-entertainment?start_year={{ filters.start_year }}&end_year={{ filters.end_year }}&account_group={{ filters.account_group|urlencode }}&category={{ filters.category|urlencode }}&person_keyword={{ filters.person_keyword|urlencode }}&desc_keyword={{ filters.desc_keyword|urlencode }}&include_adjustments={{ '0' if filters.include_adjustments else '1' }}">
|
||||
대체/원가/손익 {{ '포함' if filters.include_adjustments else '제외' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="report-section report-summary-band">
|
||||
<h2>분류/연도별 합계</h2>
|
||||
<div class="report-table-wrap">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<th>분류</th>
|
||||
{% for year in benefit_report.summary_years %}
|
||||
<th class="number">{{ year }}</th>
|
||||
{% endfor %}
|
||||
<th class="number">합계</th>
|
||||
<th class="number">건수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in benefit_report.category_year_summary_rows %}
|
||||
{% set is_drill_category = (row.account_group == '복리후생비' and row.category in ['임원 개인성 비용', '기타', '경조사']) or (row.account_group == '접대비' and row.category in ['기타', '경조사', '운동비']) %}
|
||||
<tr>
|
||||
<td>{{ row.account_group }}</td>
|
||||
<td><span class="report-pill">{{ row.category }}</span></td>
|
||||
{% for year in benefit_report.summary_years %}
|
||||
<td class="number">
|
||||
{% if is_drill_category and row.year_amounts.get(year, 0) %}
|
||||
<button type="button" class="link-cell" data-category-year-detail data-account-group="{{ row.account_group }}" data-category="{{ row.category }}" data-year="{{ year }}">{{ "{:,.0f}".format(row.year_amounts.get(year, 0)) }}</button>
|
||||
{% else %}
|
||||
{{ "{:,.0f}".format(row.year_amounts.get(year, 0)) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td class="number">
|
||||
{% if is_drill_category and row.total_amount %}
|
||||
<button type="button" class="link-cell" data-category-year-detail data-account-group="{{ row.account_group }}" data-category="{{ row.category }}" data-year="all">{{ "{:,.0f}".format(row.total_amount) }}</button>
|
||||
{% else %}
|
||||
{{ "{:,.0f}".format(row.total_amount) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="number">
|
||||
{% if is_drill_category and row.row_count %}
|
||||
<button type="button" class="link-cell" data-category-year-detail data-account-group="{{ row.account_group }}" data-category="{{ row.category }}" data-year="all">{{ "{:,}".format(row.row_count) }}</button>
|
||||
{% else %}
|
||||
{{ "{:,}".format(row.row_count) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ benefit_report.summary_years|length + 4 }}">조회된 자료가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="report-grid">
|
||||
<section class="report-section">
|
||||
<h2>거래처별 요약</h2>
|
||||
<div class="report-table-wrap">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<th>분류</th>
|
||||
<th>거래처</th>
|
||||
<th>개인/귀속</th>
|
||||
<th>직급</th>
|
||||
<th class="number">금액</th>
|
||||
<th class="number">건수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in benefit_report.vendor_summary_rows %}
|
||||
<tr>
|
||||
<td><span class="summary-account-group">{{ row.account_group }}</span></td>
|
||||
<td><span class="report-pill">{{ row.category }}</span></td>
|
||||
<td>{{ row.vendor_name }}</td>
|
||||
<td class="summary-person-cell"><button type="button" class="link-cell" data-summary-detail data-field="person" data-vendor="{{ row.vendor_name }}" data-category="{{ row.category }}" data-account-group="{{ row.account_group }}">{{ row.person_name or "-" }}</button></td>
|
||||
<td>{{ row.hanmac_member_grade or "" }}</td>
|
||||
<td class="number"><button type="button" class="link-cell" data-summary-detail data-field="amount" data-vendor="{{ row.vendor_name }}" data-category="{{ row.category }}" data-account-group="{{ row.account_group }}">{{ "{:,.0f}".format(row.amount) }}</button></td>
|
||||
<td class="number"><button type="button" class="link-cell" data-summary-detail data-field="count" data-vendor="{{ row.vendor_name }}" data-category="{{ row.category }}" data-account-group="{{ row.account_group }}">{{ "{:,}".format(row.row_count) }}</button></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7">조회된 자료가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<h2>전표 상세</h2>
|
||||
<div class="report-table-wrap">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>연도</th>
|
||||
<th>일자</th>
|
||||
<th>전표번호</th>
|
||||
<th>계정</th>
|
||||
<th>거래처</th>
|
||||
<th>직급</th>
|
||||
<th>분류</th>
|
||||
<th class="number">차변금액</th>
|
||||
<th class="number">대변금액</th>
|
||||
<th>적요</th>
|
||||
<th>상대계정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="voucherDetailBody">
|
||||
{% for row in benefit_report.detail_rows %}
|
||||
<tr data-detail-row>
|
||||
<td>{{ row.year }}</td>
|
||||
<td>{{ row.ledger_date }}</td>
|
||||
<td>{{ row.voucher_no }}</td>
|
||||
<td>{{ row.account_name }}</td>
|
||||
<td>{{ row.vendor_name }}</td>
|
||||
<td>{{ row.hanmac_member_grade or "" }}</td>
|
||||
<td>
|
||||
<div class="category-save-cell">
|
||||
<select data-category-select data-row-id="{{ row.ledger_row_id }}">
|
||||
{% for option in benefit_report.category_options %}
|
||||
<option value="{{ option }}" {% if row.category == option %}selected{% endif %}>{{ option }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td class="number">{{ "{:,.0f}".format(row.debit) }}</td>
|
||||
<td class="number">{{ "{:,.0f}".format(row.credit) }}</td>
|
||||
<td class="desc">{{ row.description }}</td>
|
||||
<td class="counterpart" data-tooltip="{{ row.counterpart_tooltip }}"><span class="counterpart-text">{{ row.counterpart_details }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="11">조회된 자료가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="detail-more-row" {% if benefit_report.row_count <= benefit_report.shown_count %}style="display:none"{% endif %}>
|
||||
<button type="button" id="voucherDetailMore">더 보기</button>
|
||||
</div>
|
||||
<p class="report-muted" id="voucherDetailMeta">화면에는 {{ "{:,}".format(benefit_report.shown_count) }}행이 표시됩니다. 엑셀에는 현재 필터의 전체 {{ "{:,}".format(benefit_report.row_count) }}행이 포함됩니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="report-modal-backdrop" id="summaryDetailModal" aria-hidden="true">
|
||||
<div class="report-modal" role="dialog" aria-modal="true" aria-labelledby="summaryDetailTitle">
|
||||
<div class="report-modal-head">
|
||||
<strong id="summaryDetailTitle">상세</strong>
|
||||
<button type="button" id="summaryDetailClose">닫기</button>
|
||||
</div>
|
||||
<div class="report-modal-body" id="summaryDetailBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="counterpart-hover-tooltip" id="counterpartHoverTooltip" aria-hidden="true"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
(() => {
|
||||
const reportData = {{ benefit_report_json|tojson }};
|
||||
const modal = document.getElementById("summaryDetailModal");
|
||||
const modalTitle = document.getElementById("summaryDetailTitle");
|
||||
const modalBody = document.getElementById("summaryDetailBody");
|
||||
const modalClose = document.getElementById("summaryDetailClose");
|
||||
const detailBody = document.getElementById("voucherDetailBody");
|
||||
const detailMore = document.getElementById("voucherDetailMore");
|
||||
const detailMeta = document.getElementById("voucherDetailMeta");
|
||||
const counterpartTooltip = document.getElementById("counterpartHoverTooltip");
|
||||
const detailPageSize = 300;
|
||||
const detailRows = reportData.detail_rows || [];
|
||||
const categoryOptions = reportData.category_options || Array.from(document.querySelectorAll("[data-category-select] option")).map((option) => option.value);
|
||||
let detailVisibleCount = document.querySelectorAll("[data-detail-row]").length;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function displayVendor(value) {
|
||||
const text = String(value || "").trim();
|
||||
return text || "미지정";
|
||||
}
|
||||
|
||||
function renderCategoryOptions(selectedCategory) {
|
||||
return categoryOptions.map((option) => `
|
||||
<option value="${escapeHtml(option)}" ${String(option) === String(selectedCategory || "") ? "selected" : ""}>${escapeHtml(option)}</option>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderDetailRow(row) {
|
||||
return `
|
||||
<tr data-detail-row>
|
||||
<td>${escapeHtml(row.year)}</td>
|
||||
<td>${escapeHtml(row.ledger_date)}</td>
|
||||
<td>${escapeHtml(row.voucher_no)}</td>
|
||||
<td>${escapeHtml(row.account_name)}</td>
|
||||
<td>${escapeHtml(displayVendor(row.vendor_name))}</td>
|
||||
<td>${escapeHtml(row.hanmac_member_grade || "")}</td>
|
||||
<td>
|
||||
<div class="category-save-cell">
|
||||
<select data-category-select data-row-id="${escapeHtml(row.ledger_row_id)}">
|
||||
${renderCategoryOptions(row.category)}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td class="number">${formatNumber(row.debit)}</td>
|
||||
<td class="number">${formatNumber(row.credit)}</td>
|
||||
<td class="desc">${escapeHtml(row.description)}</td>
|
||||
<td class="counterpart" data-tooltip="${escapeHtml(row.counterpart_tooltip || row.counterpart_details)}"><span class="counterpart-text">${escapeHtml(row.counterpart_details)}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateDetailControls() {
|
||||
const totalRows = Number(reportData.row_count || detailRows.length || 0);
|
||||
if (detailMeta) {
|
||||
detailMeta.textContent = `화면에는 ${detailVisibleCount.toLocaleString("ko-KR")}행이 표시됩니다. 엑셀에는 현재 필터의 전체 ${totalRows.toLocaleString("ko-KR")}행이 포함됩니다.`;
|
||||
}
|
||||
if (detailMore?.parentElement) {
|
||||
detailMore.parentElement.style.display = detailVisibleCount < detailRows.length ? "flex" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
function appendDetailRows() {
|
||||
if (!detailBody) return;
|
||||
const nextRows = detailRows.slice(detailVisibleCount, detailVisibleCount + detailPageSize);
|
||||
if (!nextRows.length) {
|
||||
updateDetailControls();
|
||||
return;
|
||||
}
|
||||
detailBody.insertAdjacentHTML("beforeend", nextRows.map(renderDetailRow).join(""));
|
||||
detailVisibleCount += nextRows.length;
|
||||
updateDetailControls();
|
||||
}
|
||||
|
||||
function buildVendorUsageRows(rows) {
|
||||
const map = new Map();
|
||||
rows.forEach((row) => {
|
||||
const vendor = displayVendor(row.vendor_name);
|
||||
const item = map.get(vendor) || { vendor, amount: 0, count: 0, people: new Set() };
|
||||
item.amount += Number(row.amount || 0);
|
||||
item.count += 1;
|
||||
const person = String(row.person_name || "").trim();
|
||||
if (person) item.people.add(person);
|
||||
map.set(vendor, item);
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) => b.amount - a.amount || a.vendor.localeCompare(b.vendor, "ko"));
|
||||
}
|
||||
|
||||
function renderDetailTable(rows, options = {}) {
|
||||
const showCategory = options.showCategory !== false;
|
||||
return `
|
||||
<div class="report-table-wrap">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일자</th>
|
||||
<th>전표번호</th>
|
||||
<th>계정</th>
|
||||
<th>거래처</th>
|
||||
<th>개인/귀속</th>
|
||||
${showCategory ? "<th>분류</th>" : ""}
|
||||
<th class="number">금액</th>
|
||||
<th>적요</th>
|
||||
<th>상대계정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map((row) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(row.ledger_date)}</td>
|
||||
<td>${escapeHtml(row.voucher_no)}</td>
|
||||
<td>${escapeHtml(row.account_name)}</td>
|
||||
<td>${escapeHtml(displayVendor(row.vendor_name))}</td>
|
||||
<td>${escapeHtml(row.person_name)}</td>
|
||||
${showCategory ? `<td>${escapeHtml(row.category)}</td>` : ""}
|
||||
<td class="number">${formatNumber(row.amount)}</td>
|
||||
<td class="desc">${escapeHtml(row.description)}</td>
|
||||
<td class="counterpart" data-tooltip="${escapeHtml(row.counterpart_tooltip || row.counterpart_details)}"><span class="counterpart-text">${escapeHtml(row.counterpart_details)}</span></td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function openModal(rows, meta) {
|
||||
modalTitle.textContent = meta.title || `${meta.vendor || "-"} / ${meta.category || "-"} 상세`;
|
||||
if (!rows.length) {
|
||||
modalBody.innerHTML = '<div class="report-muted">상세 값이 없습니다.</div>';
|
||||
} else {
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.amount || 0), 0);
|
||||
if (meta.mode === "categoryYear") {
|
||||
const vendorRows = buildVendorUsageRows(rows);
|
||||
modalBody.innerHTML = `
|
||||
<p class="report-muted">총 ${rows.length.toLocaleString("ko-KR")}건 / ${formatNumber(total)}원</p>
|
||||
<div class="report-table-wrap">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>거래처</th>
|
||||
<th>개인/귀속</th>
|
||||
<th class="number">금액</th>
|
||||
<th class="number">건수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${vendorRows.map((row) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(row.vendor)}</td>
|
||||
<td>${escapeHtml(Array.from(row.people).slice(0, 4).join(", ") || "-")}</td>
|
||||
<td class="number">${formatNumber(row.amount)}</td>
|
||||
<td class="number">${formatNumber(row.count)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="modal-detail-toggle" id="modalDetailToggle">상세 내역 열기</button>
|
||||
<div class="modal-detail-panel" id="modalDetailPanel">
|
||||
${renderDetailTable(rows, { showCategory: false })}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
modalBody.innerHTML = `
|
||||
<p class="report-muted">총 ${rows.length.toLocaleString("ko-KR")}건 / ${formatNumber(total)}원</p>
|
||||
${renderDetailTable(rows)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
modal.classList.add("is-open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove("is-open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function positionCounterpartTooltip(event) {
|
||||
if (!counterpartTooltip || !counterpartTooltip.classList.contains("is-open")) return;
|
||||
const margin = 14;
|
||||
const rect = counterpartTooltip.getBoundingClientRect();
|
||||
let left = event.clientX + margin;
|
||||
let top = event.clientY + margin;
|
||||
if (left + rect.width > window.innerWidth - margin) {
|
||||
left = Math.max(margin, event.clientX - rect.width - margin);
|
||||
}
|
||||
if (top + rect.height > window.innerHeight - margin) {
|
||||
top = Math.max(margin, event.clientY - rect.height - margin);
|
||||
}
|
||||
counterpartTooltip.style.left = `${left}px`;
|
||||
counterpartTooltip.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
document.addEventListener("mouseover", (event) => {
|
||||
const cell = event.target.closest(".counterpart[data-tooltip]");
|
||||
if (!cell || !counterpartTooltip) return;
|
||||
const text = cell.dataset.tooltip || "";
|
||||
if (!text.trim()) return;
|
||||
counterpartTooltip.textContent = text;
|
||||
counterpartTooltip.classList.add("is-open");
|
||||
counterpartTooltip.setAttribute("aria-hidden", "false");
|
||||
positionCounterpartTooltip(event);
|
||||
});
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (event.target.closest(".counterpart[data-tooltip]")) {
|
||||
positionCounterpartTooltip(event);
|
||||
}
|
||||
});
|
||||
document.addEventListener("mouseout", (event) => {
|
||||
const cell = event.target.closest(".counterpart[data-tooltip]");
|
||||
if (!cell || !counterpartTooltip) return;
|
||||
if (event.relatedTarget && cell.contains(event.relatedTarget)) return;
|
||||
counterpartTooltip.classList.remove("is-open");
|
||||
counterpartTooltip.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
modalBody?.addEventListener("click", (event) => {
|
||||
const toggle = event.target.closest("#modalDetailToggle");
|
||||
if (!toggle) return;
|
||||
const panel = document.getElementById("modalDetailPanel");
|
||||
const isOpen = panel?.classList.toggle("is-open");
|
||||
toggle.textContent = isOpen ? "상세 내역 접기" : "상세 내역 열기";
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-summary-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const vendor = button.dataset.vendor || "";
|
||||
const category = button.dataset.category || "";
|
||||
const accountGroup = button.dataset.accountGroup || "";
|
||||
const rows = (reportData.detail_rows || []).filter((row) =>
|
||||
displayVendor(row.vendor_name) === vendor &&
|
||||
String(row.category || "") === category &&
|
||||
String(row.account_group || "") === accountGroup
|
||||
);
|
||||
openModal(rows, { vendor, category, accountGroup });
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-category-year-detail]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const accountGroup = button.dataset.accountGroup || "";
|
||||
const category = button.dataset.category || "";
|
||||
const year = button.dataset.year || "all";
|
||||
const rows = (reportData.detail_rows || []).filter((row) =>
|
||||
String(row.account_group || "") === accountGroup &&
|
||||
String(row.category || "") === category &&
|
||||
(year === "all" || String(row.year || "") === year)
|
||||
);
|
||||
const titleYear = year === "all" ? "전체연도" : `${year}년`;
|
||||
openModal(rows, {
|
||||
title: `${accountGroup} / ${category} / ${titleYear} 상세`,
|
||||
vendor: accountGroup,
|
||||
category,
|
||||
accountGroup,
|
||||
mode: "categoryYear",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
detailBody?.addEventListener("change", async (event) => {
|
||||
const select = event.target.closest("[data-category-select]");
|
||||
if (!select) return;
|
||||
const rowId = select.dataset.rowId || "";
|
||||
const category = select.value || "";
|
||||
select.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/wehago-benefit-entertainment/api/category", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ledger_row_id: rowId, category }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.error || "분류 저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
alert(error.message || "분류 저장 중 오류가 발생했습니다.");
|
||||
select.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
detailMore?.addEventListener("click", appendDetailRows);
|
||||
updateDetailControls();
|
||||
|
||||
modalClose?.addEventListener("click", closeModal);
|
||||
modal?.addEventListener("click", (event) => {
|
||||
if (event.target === modal) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -466,6 +466,12 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.status-card[data-count-pending="true"] .status-value {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.status-card[data-status="matched"] {
|
||||
background: #f7faf8;
|
||||
border-color: #d2ddd6;
|
||||
@@ -1640,15 +1646,17 @@
|
||||
class="status-card"
|
||||
data-target="detail-{{ section.key }}"
|
||||
data-status="{{ section.key }}"
|
||||
data-count-pending="{% if wehago_compare.pending and section.count == 0 %}true{% else %}false{% endif %}"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="status-label">{{ section.label }}</span>
|
||||
<span class="status-value">{{ "{:,}".format(section.count) }}</span>
|
||||
<span class="status-value">
|
||||
{% if wehago_compare.pending and section.count == 0 %}갱신 중{% else %}{{ "{:,}".format(section.count) }}{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="detail-stack">
|
||||
{% for section in wehago_compare.metric_sections %}
|
||||
{% if section.key in ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'] %}
|
||||
@@ -2024,12 +2032,22 @@
|
||||
|
||||
const getColumnClass = (field) => `col-${String(field || '').replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
||||
|
||||
const renderVoucherDetailTable = (group) => {
|
||||
if (!voucherDetailTableWrap) return;
|
||||
const rows = Array.isArray(group?.rows) ? group.rows : [];
|
||||
const lineColumns = [
|
||||
const getVoucherLineColumns = (statusKey = '') => {
|
||||
if (statusKey === 'hanmac_unconnected') {
|
||||
return [
|
||||
['fiscal_year', '연도'],
|
||||
['proof_date', 'Hanmac 일자'],
|
||||
['voucher_no', '확정전표번호'],
|
||||
['draft_no', '가전표번호'],
|
||||
['voucher_account_name', 'Hanmac 계정'],
|
||||
['voucher_vendor', 'Hanmac 거래처'],
|
||||
['voucher_debit', 'Hanmac 차변'],
|
||||
['voucher_credit', 'Hanmac 대변'],
|
||||
['voucher_desc', 'Hanmac 적요'],
|
||||
];
|
||||
}
|
||||
return [
|
||||
['fiscal_year', '연도'],
|
||||
['status_label', '구분'],
|
||||
['ledger_date', 'WEHAGO 일자'],
|
||||
['voucher_no', '전표번호'],
|
||||
['draft_no', '가전표번호'],
|
||||
@@ -2044,6 +2062,16 @@
|
||||
['ledger_desc', 'WEHAGO 적요'],
|
||||
['voucher_desc', 'ERP 적요'],
|
||||
];
|
||||
};
|
||||
|
||||
const renderVoucherDetailTable = (group) => {
|
||||
if (!voucherDetailTableWrap) return;
|
||||
const rows = Array.isArray(group?.rows) ? group.rows : [];
|
||||
const isHanmacUnconnected = String(group?.summary?.status_label || '').toLowerCase() === 'hanmac unconnected';
|
||||
const lineColumns = [
|
||||
['status_label', '구분'],
|
||||
...getVoucherLineColumns(isHanmacUnconnected ? 'hanmac_unconnected' : ''),
|
||||
];
|
||||
if (!rows.length) {
|
||||
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
|
||||
return;
|
||||
@@ -2335,22 +2363,7 @@
|
||||
});
|
||||
});
|
||||
};
|
||||
const lineColumns = [
|
||||
['fiscal_year', '연도'],
|
||||
['ledger_date', 'WEHAGO 일자'],
|
||||
['voucher_no', '전표번호'],
|
||||
['draft_no', '가전표번호'],
|
||||
['ledger_account_name', 'WEHAGO 계정'],
|
||||
['voucher_account_name', 'ERP 계정'],
|
||||
['ledger_vendor', 'WEHAGO 거래처'],
|
||||
['voucher_vendor', 'ERP 거래처'],
|
||||
['ledger_debit', 'WEHAGO 차변'],
|
||||
['ledger_credit', 'WEHAGO 대변'],
|
||||
['voucher_debit', 'ERP 차변'],
|
||||
['voucher_credit', 'ERP 대변'],
|
||||
['ledger_desc', 'WEHAGO 적요'],
|
||||
['voucher_desc', 'ERP 적요'],
|
||||
];
|
||||
const lineColumns = getVoucherLineColumns(statusKey);
|
||||
const stripAccountCodePrefix = (value) => {
|
||||
const text = String(value ?? '');
|
||||
return text
|
||||
@@ -2746,8 +2759,13 @@
|
||||
sections.forEach((section) => {
|
||||
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
|
||||
const valueNode = card?.querySelector('.status-value');
|
||||
const countValue = Number(section.count || 0);
|
||||
const isPendingCount = Boolean(payload?.pending) && countValue === 0;
|
||||
if (card) {
|
||||
card.dataset.countPending = isPendingCount ? 'true' : 'false';
|
||||
}
|
||||
if (valueNode) {
|
||||
valueNode.textContent = Number(section.count || 0).toLocaleString();
|
||||
valueNode.textContent = isPendingCount ? '갱신 중' : countValue.toLocaleString();
|
||||
}
|
||||
});
|
||||
const lastAction = payload?.last_action;
|
||||
|
||||
+3282
-306
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user