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 main import engine
|
||||||
from wehago_compare import (
|
from wehago_compare import (
|
||||||
_build_db_state_signature,
|
_build_db_state_signature,
|
||||||
|
_ensure_year_export_row_cache,
|
||||||
_get_fast_year_export_row_cache_signature,
|
_get_fast_year_export_row_cache_signature,
|
||||||
_load_snapshot_status_map,
|
_load_snapshot_status_map,
|
||||||
_project_year_export_row_cache_from_latest_resolved,
|
_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)
|
projected = _project_year_export_row_cache_from_latest_resolved(conn, year, signature)
|
||||||
if not projected:
|
if not projected:
|
||||||
print({"step": "fast_year_projection_miss", "year": year}, flush=True)
|
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:
|
else:
|
||||||
_upsert_snapshot_status(
|
_upsert_snapshot_status(
|
||||||
conn,
|
conn,
|
||||||
@@ -62,7 +74,6 @@ def main() -> None:
|
|||||||
row_counts={},
|
row_counts={},
|
||||||
built_now=True,
|
built_now=True,
|
||||||
)
|
)
|
||||||
selected_signature = _get_fast_year_export_row_cache_signature(conn, year)
|
|
||||||
print(
|
print(
|
||||||
{
|
{
|
||||||
"step": "fast_year_projection_done",
|
"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);
|
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) {
|
@media (max-width: 1100px) {
|
||||||
.admin-user-main-row {
|
.admin-user-main-row {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -201,12 +299,14 @@
|
|||||||
<th>페이지 권한</th>
|
<th>페이지 권한</th>
|
||||||
<th>상태</th>
|
<th>상태</th>
|
||||||
<th>관리자</th>
|
<th>관리자</th>
|
||||||
|
<th>최종접속기록(KST)</th>
|
||||||
<th>수정일</th>
|
<th>수정일</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<tr
|
<tr
|
||||||
|
data-user-id="{{ user.id }}"
|
||||||
data-username="{{ user.username }}"
|
data-username="{{ user.username }}"
|
||||||
data-display-name="{{ user.display_name }}"
|
data-display-name="{{ user.display_name }}"
|
||||||
data-role="{{ 'admin' if user.is_admin else 'viewer' }}"
|
data-role="{{ 'admin' if user.is_admin else 'viewer' }}"
|
||||||
@@ -232,14 +332,35 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ '활성' if user.is_active else '비활성' }}</td>
|
<td>{{ '활성' if user.is_active else '비활성' }}</td>
|
||||||
<td>{{ '예' if user.is_admin 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>
|
<td>{{ user.updated_at }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" class="empty">사용자가 없습니다.</td></tr>
|
<tr><td colspan="8" class="empty">사용자가 없습니다.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
@@ -253,6 +374,79 @@
|
|||||||
const role = form.querySelector("[name='role']");
|
const role = form.querySelector("[name='role']");
|
||||||
const active = form.querySelector("[name='is_active']");
|
const active = form.querySelector("[name='is_active']");
|
||||||
const permissionInputs = [...form.querySelectorAll("[name='permissions']")];
|
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) => {
|
document.querySelectorAll(".user-edit-button").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
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", () => {
|
role.addEventListener("change", () => {
|
||||||
const isAdmin = role.value === "admin";
|
const isAdmin = role.value === "admin";
|
||||||
permissionInputs.forEach((input) => {
|
permissionInputs.forEach((input) => {
|
||||||
|
|||||||
+18
-10
@@ -82,28 +82,34 @@
|
|||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 7px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 8px;
|
padding: 7px 8px;
|
||||||
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.06);
|
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.06);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-spacer {
|
.nav-spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 12px;
|
min-width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
|
flex: 0 0 auto;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
padding: 9px 13px;
|
padding: 8px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-weight: 700;
|
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;
|
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,8 +525,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sync-status {
|
.sync-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
max-width: 320px;
|
max-width: 230px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(248, 250, 252, 0.92);
|
background: rgba(248, 250, 252, 0.92);
|
||||||
@@ -535,10 +542,11 @@
|
|||||||
|
|
||||||
.view-mode-switch {
|
.view-mode-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 80px;
|
width: 72px;
|
||||||
height: var(--status-widget-height);
|
height: var(--status-widget-height);
|
||||||
min-height: var(--status-widget-height);
|
min-height: var(--status-widget-height);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -589,11 +597,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-mode-switch[data-mode="dual"]::before {
|
.view-mode-switch[data-mode="dual"]::before {
|
||||||
transform: translateX(38px);
|
transform: translateX(30px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-mode-switch[data-mode="dual"] .label {
|
.view-mode-switch[data-mode="dual"] .label {
|
||||||
left: 41px;
|
left: 33px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-mode-switch[data-mode="single"]::before {
|
.view-mode-switch[data-mode="single"]::before {
|
||||||
|
|||||||
@@ -53,8 +53,8 @@
|
|||||||
|
|
||||||
.cost-kpis {
|
.cost-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
grid-template-columns: repeat(9, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cost-kpi {
|
.cost-kpi {
|
||||||
@@ -69,6 +69,9 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cost-kpi strong {
|
.cost-kpi strong {
|
||||||
@@ -77,6 +80,8 @@
|
|||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-accordion {
|
.summary-accordion {
|
||||||
@@ -174,7 +179,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cost-table {
|
.cost-table {
|
||||||
min-width: 2820px;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
@@ -182,8 +188,8 @@
|
|||||||
|
|
||||||
.cost-table th,
|
.cost-table th,
|
||||||
.cost-table td {
|
.cost-table td {
|
||||||
padding: 8px 9px;
|
padding: 7px 5px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
border-right: 1px solid #e6e8ec;
|
border-right: 1px solid #e6e8ec;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -238,29 +244,50 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
|
background: #ffffff;
|
||||||
|
background-clip: padding-box;
|
||||||
box-shadow: 1px 0 0 #d9dde3;
|
box-shadow: 1px 0 0 #d9dde3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-col-2 {
|
.sticky-col-2 {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 130px;
|
left: 94px;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
|
background: #ffffff;
|
||||||
|
background-clip: padding-box;
|
||||||
box-shadow: 1px 0 0 #d9dde3;
|
box-shadow: 1px 0 0 #d9dde3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-col-3 {
|
.sticky-col-3 {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 236px;
|
left: 172px;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
|
background: #ffffff;
|
||||||
|
background-clip: padding-box;
|
||||||
box-shadow: 1px 0 0 #d9dde3;
|
box-shadow: 1px 0 0 #d9dde3;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead .sticky-col,
|
thead .sticky-col,
|
||||||
thead .sticky-col-2,
|
thead .sticky-col-2,
|
||||||
thead .sticky-col-3 {
|
thead .sticky-col-3 {
|
||||||
|
background: #edf4f4;
|
||||||
z-index: 70 !important;
|
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 {
|
.summary-table-body td {
|
||||||
background: #fbfcfd;
|
background: #fbfcfd;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -308,12 +335,15 @@
|
|||||||
.summary-table-body .sticky-col-3 {
|
.summary-table-body .sticky-col-3 {
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
background: #fbfcfd;
|
background: #fbfcfd;
|
||||||
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-table-body.open .summary-data-row .sticky-col,
|
.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-2,
|
||||||
.summary-table-body.open .summary-data-row .sticky-col-3 {
|
.summary-table-body.open .summary-data-row .sticky-col-3 {
|
||||||
z-index: 58;
|
z-index: 58;
|
||||||
|
background: #fbfcfd;
|
||||||
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-row-toggle {
|
.summary-row-toggle {
|
||||||
@@ -450,13 +480,13 @@
|
|||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12);
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-pm { width: 130px; }
|
.col-pm { width: 94px; }
|
||||||
.col-code { width: 106px; }
|
.col-code { width: 78px; }
|
||||||
.col-name { width: 240px; }
|
.col-name { width: 148px; }
|
||||||
.col-small { width: 82px; }
|
.col-small { width: 54px; }
|
||||||
.col-date { width: 104px; }
|
.col-date { width: 72px; }
|
||||||
.col-money { width: 118px; text-align: right; }
|
.col-money { width: 72px; text-align: right; }
|
||||||
.col-rate { width: 86px; text-align: right; }
|
.col-rate { width: 62px; text-align: right; }
|
||||||
|
|
||||||
.group-pre { background: #f3f8ee !important; }
|
.group-pre { background: #f3f8ee !important; }
|
||||||
.group-during { background: #eef5fb !important; }
|
.group-during { background: #eef5fb !important; }
|
||||||
@@ -475,7 +505,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -520,7 +550,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: #0f766e;
|
color: #0f766e;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -588,7 +618,7 @@
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.cost-kpis {
|
.cost-kpis {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
grid-template-columns: repeat(10, minmax(96px, 1fr));
|
grid-template-columns: repeat(9, minmax(96px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
@@ -600,12 +630,6 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel cost-analysis-shell">
|
<section class="panel cost-analysis-shell">
|
||||||
<div class="section-title">
|
|
||||||
<div>
|
|
||||||
<h2>프로젝트 손익분석</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="cost-toolbar" id="costAnalysisForm">
|
<form class="cost-toolbar" id="costAnalysisForm">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>조회방식</label>
|
<label>조회방식</label>
|
||||||
@@ -681,7 +705,7 @@
|
|||||||
<th colspan="4" class="group-pre">사업전</th>
|
<th colspan="4" class="group-pre">사업전</th>
|
||||||
<th colspan="5" class="group-during">사업중</th>
|
<th colspan="5" class="group-during">사업중</th>
|
||||||
<th colspan="5" class="group-post">사업후</th>
|
<th colspan="5" class="group-post">사업후</th>
|
||||||
<th colspan="8" class="group-total">총계</th>
|
<th colspan="9" class="group-total">총계</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" class="group-pre sub-cost">원가</th>
|
<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-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="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="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>
|
||||||
<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>
|
<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>
|
</thead>
|
||||||
<tbody class="summary-table-body" id="summaryTableBody">
|
<tbody class="summary-table-body" id="summaryTableBody">
|
||||||
<tr class="summary-control-row">
|
<tr class="summary-control-row">
|
||||||
<td colspan="35">
|
<td colspan="36">
|
||||||
<button type="button" class="summary-row-toggle" id="summaryToggle" aria-expanded="false">
|
<button type="button" class="summary-row-toggle" id="summaryToggle" aria-expanded="false">
|
||||||
<span class="summary-row-arrow">▾</span>
|
<span class="summary-row-arrow">▾</span>
|
||||||
<span>합계</span>
|
<span>합계</span>
|
||||||
@@ -728,7 +753,7 @@
|
|||||||
<tr class="summary-data-row" id="summaryTableRow"></tr>
|
<tr class="summary-data-row" id="summaryTableRow"></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tbody id="costAnalysisBody">
|
<tbody id="costAnalysisBody">
|
||||||
<tr><td colspan="35" class="empty">조회 중입니다.</td></tr>
|
<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -897,14 +922,13 @@
|
|||||||
const items = [
|
const items = [
|
||||||
["프로젝트", `${formatNumber(summary.project_count)}건`],
|
["프로젝트", `${formatNumber(summary.project_count)}건`],
|
||||||
["도급금액", formatNumber(summary.contract_amount)],
|
["도급금액", formatNumber(summary.contract_amount)],
|
||||||
["청구금액", formatNumber(summary.billing_amount)],
|
["기간 청구금액", formatNumber(summary.billing_amount)],
|
||||||
["청구수익", formatNumber(summary.revenue_amount)],
|
["기간 수금금액", formatNumber(summary.collection_amount)],
|
||||||
["수금금액", formatNumber(summary.collection_amount)],
|
|
||||||
["잔여도급금액", formatBalance(summary.contract_balance_amount)],
|
["잔여도급금액", formatBalance(summary.contract_balance_amount)],
|
||||||
["총비용", formatNumber(summary.total_cost)],
|
["총비용", formatNumber(summary.total_cost)],
|
||||||
["이윤", formatNumber(summary.profit_amount)],
|
["이윤", formatNumber(summary.profit_amount)],
|
||||||
["청구수익률", formatRate(summary.revenue_profit_rate)],
|
|
||||||
["수금수익률", formatRate(summary.collection_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("");
|
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)}
|
${moneyCell(summary.profit_amount)}
|
||||||
${rateCell(summary.revenue_profit_rate)}
|
${rateCell(summary.revenue_profit_rate)}
|
||||||
${rateCell(summary.collection_profit_rate)}
|
${rateCell(summary.collection_profit_rate)}
|
||||||
|
${rateCell(summary.cumulative_profit_rate)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,7 +1049,7 @@
|
|||||||
function renderTable() {
|
function renderTable() {
|
||||||
const rows = latestDisplayRows.length ? latestDisplayRows : getFilteredRows();
|
const rows = latestDisplayRows.length ? latestDisplayRows : getFilteredRows();
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
body.innerHTML = `<tr><td colspan="35" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
|
body.innerHTML = `<tr><td colspan="36" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const viewportHeight = tableWrap?.clientHeight || 600;
|
const viewportHeight = tableWrap?.clientHeight || 600;
|
||||||
@@ -1035,8 +1060,8 @@
|
|||||||
const topHeight = startIndex * virtualRowHeight;
|
const topHeight = startIndex * virtualRowHeight;
|
||||||
const bottomHeight = Math.max(0, (rows.length - endIndex) * virtualRowHeight);
|
const bottomHeight = Math.max(0, (rows.length - endIndex) * virtualRowHeight);
|
||||||
const visibleRows = rows.slice(startIndex, endIndex);
|
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 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="35" style="height:${bottomHeight}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) => {
|
body.innerHTML = topSpacer + visibleRows.map((row, visibleOffset) => {
|
||||||
const rowIndex = startIndex + visibleOffset;
|
const rowIndex = startIndex + visibleOffset;
|
||||||
const pre = row.phases?.pre || {};
|
const pre = row.phases?.pre || {};
|
||||||
@@ -1083,6 +1108,7 @@
|
|||||||
<td class="col-money">${formatNumber(row.profit_amount)}</td>
|
<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.revenue_profit_rate)}</td>
|
||||||
<td class="ratio-cell">${formatRate(row.collection_profit_rate)}</td>
|
<td class="ratio-cell">${formatRate(row.collection_profit_rate)}</td>
|
||||||
|
<td class="ratio-cell">${formatRate(row.cumulative_profit_rate)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join("") + bottomSpacer;
|
}).join("") + bottomSpacer;
|
||||||
@@ -1091,10 +1117,20 @@
|
|||||||
function recalcVisibleSummary() {
|
function recalcVisibleSummary() {
|
||||||
const rows = getFilteredRows();
|
const rows = getFilteredRows();
|
||||||
latestDisplayRows = rows;
|
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"]) {
|
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);
|
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"]) {
|
for (const phase of ["pre", "during", "post"]) {
|
||||||
const bucket = row.phases?.[phase] || {};
|
const bucket = row.phases?.[phase] || {};
|
||||||
for (const item of ["labor", "outsource", "overhead", "sga", "sales"]) {
|
for (const item of ["labor", "outsource", "overhead", "sga", "sales"]) {
|
||||||
@@ -1115,17 +1151,26 @@
|
|||||||
sales_total: 0,
|
sales_total: 0,
|
||||||
total_cost: 0,
|
total_cost: 0,
|
||||||
profit_amount: 0,
|
profit_amount: 0,
|
||||||
|
cumulative_profit_amount: 0,
|
||||||
phases: {
|
phases: {
|
||||||
pre: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
|
pre: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
|
||||||
during: { 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 },
|
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.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.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.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;
|
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);
|
renderSummaryTable(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,7 +1181,9 @@
|
|||||||
const controller = activeLoadController;
|
const controller = activeLoadController;
|
||||||
searchButton.disabled = true;
|
searchButton.disabled = true;
|
||||||
searchButton.textContent = "조회 중";
|
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 {
|
try {
|
||||||
const params = new URLSearchParams({ start_date: lockedStartDate, end_date: lockedEndDate, mode: currentMode });
|
const params = new URLSearchParams({ start_date: lockedStartDate, end_date: lockedEndDate, mode: currentMode });
|
||||||
const response = await fetch(`/cost-analysis/data?${params.toString()}`, {
|
const response = await fetch(`/cost-analysis/data?${params.toString()}`, {
|
||||||
@@ -1365,7 +1412,7 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
rememberDateInputs();
|
rememberDateInputs();
|
||||||
loadData().catch((error) => {
|
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";
|
currentMode = button.dataset.mode || "individual";
|
||||||
modeToggle.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button));
|
modeToggle.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button));
|
||||||
loadData().catch((error) => {
|
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) => {
|
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 = "";
|
kpis.innerHTML = "";
|
||||||
summaryTableRow.innerHTML = "";
|
summaryTableRow.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<form class="login-card" method="post" action="/login">
|
<form class="login-card" method="post" action="/login">
|
||||||
<h1>한맥 인트라넷 로그인</h1>
|
<h1>한맥기술 ERP 점검</h1>
|
||||||
<input type="hidden" name="next" value="{{ next_url }}">
|
<input type="hidden" name="next" value="{{ next_url }}">
|
||||||
<label for="username">아이디</label>
|
<label for="username">아이디</label>
|
||||||
<input id="username" name="username" autocomplete="username" required autofocus>
|
<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;
|
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"] {
|
.status-card[data-status="matched"] {
|
||||||
background: #f7faf8;
|
background: #f7faf8;
|
||||||
border-color: #d2ddd6;
|
border-color: #d2ddd6;
|
||||||
@@ -1640,15 +1646,17 @@
|
|||||||
class="status-card"
|
class="status-card"
|
||||||
data-target="detail-{{ section.key }}"
|
data-target="detail-{{ section.key }}"
|
||||||
data-status="{{ section.key }}"
|
data-status="{{ section.key }}"
|
||||||
|
data-count-pending="{% if wehago_compare.pending and section.count == 0 %}true{% else %}false{% endif %}"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
>
|
>
|
||||||
<span class="status-label">{{ section.label }}</span>
|
<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>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-stack">
|
<div class="detail-stack">
|
||||||
{% for section in wehago_compare.metric_sections %}
|
{% 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'] %}
|
{% 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 getColumnClass = (field) => `col-${String(field || '').replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
||||||
|
|
||||||
const renderVoucherDetailTable = (group) => {
|
const getVoucherLineColumns = (statusKey = '') => {
|
||||||
if (!voucherDetailTableWrap) return;
|
if (statusKey === 'hanmac_unconnected') {
|
||||||
const rows = Array.isArray(group?.rows) ? group.rows : [];
|
return [
|
||||||
const lineColumns = [
|
['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', '연도'],
|
['fiscal_year', '연도'],
|
||||||
['status_label', '구분'],
|
|
||||||
['ledger_date', 'WEHAGO 일자'],
|
['ledger_date', 'WEHAGO 일자'],
|
||||||
['voucher_no', '전표번호'],
|
['voucher_no', '전표번호'],
|
||||||
['draft_no', '가전표번호'],
|
['draft_no', '가전표번호'],
|
||||||
@@ -2044,6 +2062,16 @@
|
|||||||
['ledger_desc', 'WEHAGO 적요'],
|
['ledger_desc', 'WEHAGO 적요'],
|
||||||
['voucher_desc', 'ERP 적요'],
|
['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) {
|
if (!rows.length) {
|
||||||
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
|
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
|
||||||
return;
|
return;
|
||||||
@@ -2335,22 +2363,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const lineColumns = [
|
const lineColumns = getVoucherLineColumns(statusKey);
|
||||||
['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 stripAccountCodePrefix = (value) => {
|
const stripAccountCodePrefix = (value) => {
|
||||||
const text = String(value ?? '');
|
const text = String(value ?? '');
|
||||||
return text
|
return text
|
||||||
@@ -2746,8 +2759,13 @@
|
|||||||
sections.forEach((section) => {
|
sections.forEach((section) => {
|
||||||
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
|
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
|
||||||
const valueNode = card?.querySelector('.status-value');
|
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) {
|
if (valueNode) {
|
||||||
valueNode.textContent = Number(section.count || 0).toLocaleString();
|
valueNode.textContent = isPendingCount ? '갱신 중' : countValue.toLocaleString();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const lastAction = payload?.last_action;
|
const lastAction = payload?.last_action;
|
||||||
|
|||||||
+3282
-306
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user