Compare commits
2 Commits
hyunho
...
1e82572e15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e82572e15 | ||
|
|
e58e584a15 |
@@ -281,6 +281,19 @@ CREATE TABLE IF NOT EXISTS integration_vouchers (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_binary_sources (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source_key TEXT NOT NULL UNIQUE,
|
||||
source_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
content BYTEA NOT NULL,
|
||||
content_sha256 TEXT NOT NULL DEFAULT '',
|
||||
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS history_revisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scope TEXT NOT NULL DEFAULT 'organization',
|
||||
@@ -329,18 +342,6 @@ CREATE TABLE IF NOT EXISTS seat_assignment_versions (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entity_change_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
patch_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
@@ -534,6 +535,9 @@ ON integration_vouchers (project_code, project_name);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
|
||||
ON integration_project_category_mappings (source_key, normalized_project_key);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_binary_sources_source_key_idx
|
||||
ON integration_binary_sources (source_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
|
||||
ON member_versions (member_id, valid_from, valid_to);
|
||||
|
||||
@@ -543,9 +547,6 @@ ON seat_assignment_versions (member_id, valid_from, valid_to);
|
||||
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
|
||||
ON history_revisions (scope, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_change_events_entity_idx
|
||||
ON entity_change_events (entity_type, entity_id, changed_at DESC);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
@@ -611,6 +612,9 @@ ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL R
|
||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
|
||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
|
||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
||||
|
||||
DROP INDEX IF EXISTS entity_change_events_entity_idx;
|
||||
DROP TABLE IF EXISTS entity_change_events;
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -44,9 +44,10 @@ LEGACY_STATIC_DIR = LEGACY_DIR / "static"
|
||||
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
|
||||
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
|
||||
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
|
||||
DB_STATUS_SERVED_DIR = INCOMING_SERVED_DIR / "db-status"
|
||||
BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장"
|
||||
BUSINESS_DASHBOARD_WRAPPER_PATH = BUSINESS_DASHBOARD_DIR / "MH 통합 대시보드_260320.html"
|
||||
BUSINESS_DASHBOARD_THEME_CSS = BUSINESS_DASHBOARD_DIR / "MH 통합 대시보드_260320.css"
|
||||
BUSINESS_LEDGER_SERVED_DIR = INCOMING_SERVED_DIR / "ledger"
|
||||
BUSINESS_LEDGER_INDEX_PATH = BUSINESS_LEDGER_SERVED_DIR / "index.html"
|
||||
FIXED_OFFICE_SOURCE_KEY = "technical-development-center"
|
||||
FIXED_OFFICE_CONFIGS = {
|
||||
"technical-development-center": {
|
||||
@@ -66,7 +67,6 @@ FIXED_OFFICE_CONFIGS = {
|
||||
},
|
||||
}
|
||||
_fixed_office_cache: dict[str, dict[str, object]] = {}
|
||||
_business_ledger_html_cache: str | None = None
|
||||
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
|
||||
AUTH_DEFAULT_PASSWORD = "1111"
|
||||
AUTH_PASSWORD_ITERATIONS = 390000
|
||||
@@ -89,34 +89,159 @@ MH_HEADER_ORDER = [
|
||||
"사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)"
|
||||
]
|
||||
|
||||
DB_STATUS_TABLES = [
|
||||
{
|
||||
"table_ref": "public.members",
|
||||
"label": "구성원 마스터",
|
||||
"domain": "organization",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["조직 현황", "자리배치도"],
|
||||
"description": "조직/구성원 화면의 기준이 되는 현재 인원 마스터",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.member_versions",
|
||||
"label": "구성원 이력",
|
||||
"domain": "history",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["조직 현황", "이력 비교"],
|
||||
"description": "as-of 조회와 변경 이력을 위한 시점 버전",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.seat_maps",
|
||||
"label": "자리배치도 도면",
|
||||
"domain": "seatmap",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["자리배치도"],
|
||||
"description": "오피스별 도면 메타데이터와 활성 상태",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.seat_positions",
|
||||
"label": "현재 좌석 배치",
|
||||
"domain": "seatmap",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["자리배치도"],
|
||||
"description": "현재 인원의 실제 배치 좌표/슬롯 연결",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.seat_assignment_versions",
|
||||
"label": "좌석 배치 이력",
|
||||
"domain": "history",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["자리배치도", "이력 비교"],
|
||||
"description": "자리 이동 이력과 시점 조회용 배치 버전",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_import_batches",
|
||||
"label": "원본 업로드 배치",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "imported_at",
|
||||
"related_views": ["프로젝트별 분석", "팀/개인별 분석", "조직 현황"],
|
||||
"description": "원본 파일 적재 단위와 최근 import 기록",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_projects",
|
||||
"label": "통합 프로젝트 표준화",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["프로젝트별 분석", "팀/개인별 분석"],
|
||||
"description": "프로젝트 코드/이름/카테고리 정규화 결과",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_work_logs",
|
||||
"label": "근무 로그 표준화",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["팀/개인별 분석"],
|
||||
"description": "MH workbook 기준 일자별 근무 로그 본체",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_work_log_segments",
|
||||
"label": "근무 로그 세그먼트",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["팀/개인별 분석"],
|
||||
"description": "근무 로그를 프로젝트/활동 기준으로 분해한 상세 세그먼트",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_vouchers",
|
||||
"label": "전표 표준화",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["프로젝트별 분석"],
|
||||
"description": "payment CSV 기준 프로젝트별 수입/지출 전표",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_binary_sources",
|
||||
"label": "바이너리 원본 보관",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "imported_at",
|
||||
"related_views": ["사업관리대장"],
|
||||
"description": "엑셀/바이너리 원본을 DB에 보관하는 저장소",
|
||||
},
|
||||
{
|
||||
"table_ref": "auth.users",
|
||||
"label": "인증 사용자",
|
||||
"domain": "auth",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["로그인", "권한"],
|
||||
"description": "로그인 계정, role, 활성 상태",
|
||||
},
|
||||
{
|
||||
"table_ref": "auth.sessions",
|
||||
"label": "인증 세션",
|
||||
"domain": "auth",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["로그인", "권한"],
|
||||
"description": "현재/과거 로그인 세션과 만료 상태",
|
||||
},
|
||||
{
|
||||
"table_ref": "auth.login_audit_logs",
|
||||
"label": "로그인 감사 로그",
|
||||
"domain": "auth",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["로그인", "권한"],
|
||||
"description": "로그인 성공/실패 기록",
|
||||
},
|
||||
]
|
||||
|
||||
def build_business_ledger_html() -> str:
|
||||
global _business_ledger_html_cache
|
||||
if _business_ledger_html_cache is not None:
|
||||
return _business_ledger_html_cache
|
||||
if not BUSINESS_DASHBOARD_WRAPPER_PATH.exists():
|
||||
raise FileNotFoundError("Business dashboard wrapper file not found.")
|
||||
source = BUSINESS_DASHBOARD_WRAPPER_PATH.read_text(encoding="utf-8-sig")
|
||||
match = re.search(r"const BUSINESS_HTML_B64='([^']+)';", source)
|
||||
if not match:
|
||||
raise ValueError("Embedded business ledger source was not found.")
|
||||
decoded = base64.b64decode(match.group(1)).decode("utf-8")
|
||||
head_injection = (
|
||||
'<base href="/integrations/ledger-assets/">'
|
||||
'<link rel="stylesheet" href="/integrations/ledger-assets/MH%20통합%20대시보드_260320.css">'
|
||||
'<link rel="stylesheet" href="/integrations/ledger-assets/ledger-override.css?v=20260401-03">'
|
||||
)
|
||||
html = decoded.replace("</head>", f"{head_injection}</head>", 1)
|
||||
html = html.replace("<body>", '<body class="mh-business-theme">', 1)
|
||||
html = html.replace("</body>", '<script src="/integrations/ledger-assets/ledger-override.js?v=20260401-03"></script></body>', 1)
|
||||
_business_ledger_html_cache = html
|
||||
return html
|
||||
|
||||
DB_STATUS_TABLE_META = {str(item["table_ref"]): item for item in DB_STATUS_TABLES}
|
||||
DB_STATUS_TABLE_GROUPS = {
|
||||
"public.members": "유지",
|
||||
"public.member_versions": "유지",
|
||||
"public.seat_maps": "유지",
|
||||
"public.seat_positions": "유지",
|
||||
"public.seat_slots": "유지",
|
||||
"public.seat_assignment_versions": "유지",
|
||||
"public.history_revisions": "유지",
|
||||
"public.integration_import_batches": "유지",
|
||||
"public.integration_projects": "유지",
|
||||
"public.integration_work_logs": "유지",
|
||||
"public.integration_work_log_segments": "유지",
|
||||
"public.integration_vouchers": "유지",
|
||||
"public.integration_binary_sources": "유지",
|
||||
"auth.users": "유지",
|
||||
"auth.sessions": "유지",
|
||||
"auth.login_audit_logs": "유지",
|
||||
"public.member_overrides": "주의",
|
||||
"public.member_retirements": "주의",
|
||||
"public.member_aliases": "주의",
|
||||
"public.integration_project_aliases": "주의",
|
||||
"public.integration_project_category_mappings": "주의",
|
||||
"public.integration_project_pm_assignments": "주의",
|
||||
"public.integration_raw_organization_rows": "원본·추적",
|
||||
"public.integration_raw_mh_rows": "원본·추적",
|
||||
"public.integration_raw_mh_pm_rows": "원본·추적",
|
||||
"public.integration_raw_payment_rows": "원본·추적",
|
||||
}
|
||||
|
||||
def sync_default_business_ledger_source(cur) -> None:
|
||||
if not BUSINESS_DASHBOARD_DIR.exists():
|
||||
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
|
||||
row = cur.fetchone()
|
||||
table_exists = bool(row["table_exists"]) if row is not None else False
|
||||
if not table_exists:
|
||||
return
|
||||
candidates = [
|
||||
BUSINESS_LEDGER_SERVED_DIR / "사업관리대장-1.xlsx",
|
||||
BUSINESS_DASHBOARD_DIR / "사업관리대장-1.xlsx",
|
||||
BUSINESS_DASHBOARD_DIR / "사업관리 대장-1.xlsx",
|
||||
BUSINESS_DASHBOARD_DIR / "사업관리대장.xlsx",
|
||||
@@ -163,9 +288,253 @@ def sync_default_business_ledger_source(cur) -> None:
|
||||
)
|
||||
|
||||
|
||||
def make_json_safe(value: object) -> object:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, bytes):
|
||||
return f"<{len(value)} bytes>"
|
||||
if isinstance(value, dict):
|
||||
return {str(key): make_json_safe(val) for key, val in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [make_json_safe(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def fetch_db_status_snapshot() -> dict[str, object]:
|
||||
table_items: list[dict[str, object]] = []
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT schemaname, tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname IN ('public', 'auth')
|
||||
ORDER BY schemaname, tablename
|
||||
"""
|
||||
)
|
||||
all_tables = cur.fetchall()
|
||||
for row in all_tables:
|
||||
schema_name = str(row["schemaname"])
|
||||
table_name = str(row["tablename"])
|
||||
table_ref = f"{schema_name}.{table_name}"
|
||||
spec = DB_STATUS_TABLE_META.get(table_ref, {})
|
||||
cur.execute("SELECT to_regclass(%s) IS NOT NULL AS table_exists", (table_ref,))
|
||||
exists_row = cur.fetchone()
|
||||
exists = bool(exists_row["table_exists"]) if exists_row is not None else False
|
||||
row_count = 0
|
||||
last_event_at = None
|
||||
if exists:
|
||||
timestamp_column = str(spec.get("timestamp_column") or "")
|
||||
query = f"SELECT COUNT(*)::bigint AS row_count"
|
||||
if timestamp_column:
|
||||
query += f", MAX({timestamp_column}) AS last_event_at"
|
||||
else:
|
||||
query += ", NULL::timestamptz AS last_event_at"
|
||||
query += f" FROM {schema_name}.{table_name}"
|
||||
cur.execute(query)
|
||||
metric_row = cur.fetchone() or {}
|
||||
row_count = int(metric_row.get("row_count") or 0)
|
||||
last_event_at = metric_row.get("last_event_at")
|
||||
table_items.append(
|
||||
{
|
||||
"table_ref": table_ref,
|
||||
"schema": schema_name,
|
||||
"table_name": table_name,
|
||||
"label": str(spec.get("label") or table_name),
|
||||
"domain": str(spec.get("domain") or "other"),
|
||||
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
|
||||
"related_views": spec.get("related_views") or [],
|
||||
"group": DB_STATUS_TABLE_GROUPS.get(table_ref, "주의"),
|
||||
"exists": exists,
|
||||
"row_count": row_count,
|
||||
"last_event_at": last_event_at.isoformat() if last_event_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_key, source_name, row_count, source_path, imported_at
|
||||
FROM integration_import_batches
|
||||
ORDER BY imported_at DESC, id DESC
|
||||
"""
|
||||
)
|
||||
import_batches = [
|
||||
{
|
||||
"source_key": str(row["source_key"] or ""),
|
||||
"source_name": str(row["source_name"] or ""),
|
||||
"row_count": int(row["row_count"] or 0),
|
||||
"source_path": str(row["source_path"] or ""),
|
||||
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
|
||||
binary_sources: list[dict[str, object]] = []
|
||||
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
|
||||
binary_exists_row = cur.fetchone()
|
||||
binary_exists = bool(binary_exists_row["table_exists"]) if binary_exists_row is not None else False
|
||||
if binary_exists:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_key, source_name, filename, mime_type, OCTET_LENGTH(content) AS byte_size,
|
||||
content_sha256, imported_at
|
||||
FROM integration_binary_sources
|
||||
ORDER BY imported_at DESC, id DESC
|
||||
"""
|
||||
)
|
||||
binary_sources = [
|
||||
{
|
||||
"source_key": str(row["source_key"] or ""),
|
||||
"source_name": str(row["source_name"] or ""),
|
||||
"filename": str(row["filename"] or ""),
|
||||
"mime_type": str(row["mime_type"] or ""),
|
||||
"byte_size": int(row["byte_size"] or 0),
|
||||
"content_sha256": str(row["content_sha256"] or ""),
|
||||
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::bigint AS total_members,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(BTRIM(work_status), '') NOT IN ('퇴직', '휴직')
|
||||
)::bigint AS active_members
|
||||
FROM members
|
||||
"""
|
||||
)
|
||||
member_row = cur.fetchone() or {}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::bigint AS active_seat_maps
|
||||
FROM seat_maps
|
||||
WHERE is_active = TRUE
|
||||
"""
|
||||
)
|
||||
seat_map_row = cur.fetchone() or {}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::bigint AS fixed_office_maps
|
||||
FROM seat_maps
|
||||
WHERE source_type = 'fixed_html'
|
||||
"""
|
||||
)
|
||||
fixed_office_row = cur.fetchone() or {}
|
||||
|
||||
overview = {
|
||||
"visible_tables": len(DB_STATUS_TABLES),
|
||||
"total_tables": len(table_items),
|
||||
"existing_tables": sum(1 for item in table_items if item["exists"]),
|
||||
"registered_members": int(member_row.get("total_members") or 0),
|
||||
"active_members": int(member_row.get("active_members") or 0),
|
||||
"active_seat_maps": int(seat_map_row.get("active_seat_maps") or 0),
|
||||
"fixed_office_maps": int(fixed_office_row.get("fixed_office_maps") or 0),
|
||||
"import_batches": len(import_batches),
|
||||
"binary_sources": len(binary_sources),
|
||||
}
|
||||
group_summary = {
|
||||
"유지": [item["table_ref"] for item in table_items if item["group"] == "유지"],
|
||||
"주의": [item["table_ref"] for item in table_items if item["group"] == "주의"],
|
||||
"원본·추적": [item["table_ref"] for item in table_items if item["group"] == "원본·추적"],
|
||||
"정리 후보": [item["table_ref"] for item in table_items if item["group"] == "정리 후보"],
|
||||
}
|
||||
return {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"overview": overview,
|
||||
"tables": table_items,
|
||||
"import_batches": import_batches,
|
||||
"binary_sources": binary_sources,
|
||||
"group_summary": group_summary,
|
||||
"notes": [
|
||||
"members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.",
|
||||
"member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.",
|
||||
"integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.",
|
||||
"integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.",
|
||||
"재직 인원은 work_status 값이 '퇴직' 또는 '휴직'이 아닌 구성원 기준입니다.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def fetch_db_table_preview(schema_name: str, table_name: str, limit: int = 50) -> dict[str, object]:
|
||||
if schema_name not in {"public", "auth"}:
|
||||
raise HTTPException(status_code=404, detail="Unknown schema.")
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = %s
|
||||
AND tablename = %s
|
||||
""",
|
||||
(schema_name, table_name),
|
||||
)
|
||||
exists_row = cur.fetchone()
|
||||
if exists_row is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown table.")
|
||||
|
||||
table_ref = f"{schema_name}.{table_name}"
|
||||
spec = DB_STATUS_TABLE_META.get(table_ref, {})
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = %s
|
||||
AND table_name = %s
|
||||
ORDER BY ordinal_position
|
||||
""",
|
||||
(schema_name, table_name),
|
||||
)
|
||||
columns = [{"name": str(row["column_name"]), "type": str(row["data_type"])} for row in cur.fetchall()]
|
||||
|
||||
cur.execute(f"SELECT COUNT(*)::bigint AS row_count FROM {schema_name}.{table_name}")
|
||||
row_count = int((cur.fetchone() or {}).get("row_count") or 0)
|
||||
|
||||
safe_limit = max(1, min(int(limit), 50))
|
||||
cur.execute(f"SELECT * FROM {schema_name}.{table_name} LIMIT {safe_limit}")
|
||||
rows = [make_json_safe(dict(row)) for row in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"table_ref": table_ref,
|
||||
"schema": schema_name,
|
||||
"table_name": table_name,
|
||||
"label": str(spec.get("label") or table_name),
|
||||
"domain": str(spec.get("domain") or "other"),
|
||||
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
|
||||
"related_views": spec.get("related_views") or [],
|
||||
"row_count": row_count,
|
||||
"limit": safe_limit,
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
}
|
||||
return {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"overview": overview,
|
||||
"tables": table_items,
|
||||
"import_batches": import_batches,
|
||||
"binary_sources": binary_sources,
|
||||
"notes": [
|
||||
"members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.",
|
||||
"member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.",
|
||||
"integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.",
|
||||
"integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.",
|
||||
"재직 인원은 work_status 값이 '퇴직' 또는 '휴직'이 아닌 구성원 기준입니다.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
app.mount(
|
||||
"/integrations/ledger-assets",
|
||||
StaticFiles(directory=str(BUSINESS_DASHBOARD_DIR), check_dir=False),
|
||||
StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False),
|
||||
name="integration-ledger-assets",
|
||||
)
|
||||
|
||||
@@ -4027,6 +4396,27 @@ def health() -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/db-status")
|
||||
def admin_db_status() -> dict[str, object]:
|
||||
return fetch_db_status_snapshot()
|
||||
|
||||
|
||||
@app.get("/api/admin/db-status/table")
|
||||
def admin_db_status_table(schema: str, table: str, limit: int = 50) -> dict[str, object]:
|
||||
return fetch_db_table_preview(schema, table, limit)
|
||||
|
||||
|
||||
@app.get("/admin/db-status")
|
||||
def admin_db_status_view() -> FileResponse:
|
||||
target = DB_STATUS_SERVED_DIR / "index.html"
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="DB status dashboard file not found.")
|
||||
response = FileResponse(target)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/integration/business-ledger-default")
|
||||
def integration_business_ledger_default() -> Response:
|
||||
with get_conn() as conn:
|
||||
@@ -4627,20 +5017,16 @@ def integration_payment() -> FileResponse:
|
||||
|
||||
|
||||
@app.get("/integrations/ledger")
|
||||
def integration_ledger() -> HTMLResponse:
|
||||
try:
|
||||
html = build_business_ledger_html()
|
||||
except FileNotFoundError:
|
||||
def integration_ledger() -> FileResponse:
|
||||
# #21 phase-1: runtime no longer decodes reference wrapper HTML. Serve the promoted
|
||||
# ledger entry file from incoming-files/served/ledger only.
|
||||
target = BUSINESS_LEDGER_INDEX_PATH
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=500, detail="Business ledger integration source is invalid.")
|
||||
return HTMLResponse(
|
||||
html,
|
||||
headers={
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
)
|
||||
response = FileResponse(target)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/integrations/mh")
|
||||
|
||||
@@ -187,7 +187,7 @@ docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compos
|
||||
- 로컬 전용 디자인 참고 자산 복사
|
||||
- `incoming-files/sample style.css`
|
||||
- `incoming-files/260320.html`
|
||||
- `incoming-files/사업관리대장/`
|
||||
- `incoming-files/reference/ledger/`
|
||||
- `incoming-files/1.png`
|
||||
- `incoming-files/seat/center_chair_people_map(2).html`
|
||||
|
||||
|
||||
@@ -53,11 +53,18 @@
|
||||
- [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
|
||||
- 디자인 기준 문서:
|
||||
- [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md)
|
||||
- DB 테이블 분류 기준 문서:
|
||||
- [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md)
|
||||
- 로그인 기본 스타일은 [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css) 기준으로 유지
|
||||
- `8081` 허브 전용 디자인은 [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)에서만 덮어씀
|
||||
- 조직현황은 [legacy/static/common.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/common.css), [legacy/static/organization.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.css), [legacy/static/organization.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.js)를 사용
|
||||
- 프로젝트별 분석 디자인은 [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html) 내부에서 `design-tokens.css` + `design-patterns.css`를 참조
|
||||
- 사업관리대장 상세 팝업 디자인은 [incoming-files/사업관리대장/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/사업관리대장/ledger-override.js)에서 `design-tokens.css` + `design-patterns.css`를 직접 링크
|
||||
- 프로젝트별 분석 수정 원본은 [frontend/apps/payment/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/payment/index.html) 이고, 반영은 [scripts/publish_payment_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_payment_app.sh)로 한다.
|
||||
- 팀/개인별 분석 수정 원본은 [frontend/apps/team/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/team/index.html) 이고, 반영은 [scripts/publish_team_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_team_app.sh)로 한다.
|
||||
- DB 상태 화면 수정 원본은 [frontend/apps/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/db-status/index.html) 이고, 반영은 [scripts/publish_db_status_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_db_status_app.sh)로 한다.
|
||||
- 사업관리대장 실제 서비스 코드는 [incoming-files/served/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger) 기준으로 본다.
|
||||
- 사업관리대장 앱 소스 기준은 [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger) 이고, 반영은 [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)로 한다.
|
||||
- 사업관리대장 상세 팝업 디자인 수정 원본은 [frontend/apps/ledger/assets/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger/assets/ledger-override.js) 기준으로 본다.
|
||||
|
||||
디자인 수정 우선순위:
|
||||
|
||||
@@ -98,8 +105,12 @@
|
||||
- [legacy/static/DashBoard-organization.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/DashBoard-organization.html)
|
||||
- `/integrations/payment`:
|
||||
- [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html)
|
||||
- `/integrations/ledger`:
|
||||
- [incoming-files/served/ledger/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger/index.html)
|
||||
- `/integrations/mh`:
|
||||
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
|
||||
- `/admin/db-status`:
|
||||
- [incoming-files/served/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/db-status/index.html)
|
||||
|
||||
## Cross Checks Last Confirmed
|
||||
|
||||
@@ -109,6 +120,7 @@
|
||||
- `/api/health` 200
|
||||
- `/legacy/organization` 200
|
||||
- `/integrations/payment` 200
|
||||
- `/integrations/ledger` 200
|
||||
- `/integrations/mh` 200
|
||||
- `incoming-files/served` 내 실제 서빙 파일 존재 확인
|
||||
|
||||
@@ -119,19 +131,18 @@
|
||||
|
||||
## Open Issues Relevant Now
|
||||
|
||||
- `#2` 백엔드 영속 저장 구조 운영 마무리
|
||||
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
|
||||
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
|
||||
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
|
||||
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
|
||||
- `#19` 8081 백엔드 라우터/서빙 책임 분리
|
||||
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
|
||||
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석
|
||||
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
|
||||
- `#21` organization 레거시 구조 승격 및 장기 고도화
|
||||
|
||||
## Recommended Next Work Order
|
||||
|
||||
1. `#18` 범위에서 실제 서빙 파일과 비교용 파일 경계를 더 명확히 정리
|
||||
2. 사업관리대장 탭 기능 추가 전에 수정 대상 파일을 고정
|
||||
3. 그 다음 `#19`로 backend 라우터/서빙 책임 분리
|
||||
4. 마지막으로 `#20`에서 스크립트/문서/운영 규칙 정리
|
||||
1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화
|
||||
2. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
|
||||
3. 사업관리대장 세부 데이터 정합성 보정은 원본 규칙 분석 후 진행
|
||||
4. 필요 시 `#19` 잔여 정리 항목 재평가
|
||||
|
||||
## Quick Resume Prompt
|
||||
|
||||
@@ -141,5 +152,7 @@
|
||||
- `8081` 작업은 `work-8081` + `.dev-worktree-8081`
|
||||
- 먼저 [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md), [NEXT_SESSION_CHECKPOINT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/NEXT_SESSION_CHECKPOINT.md), [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md) 확인
|
||||
- 디자인 수정이면 [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css), [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css), [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) 먼저 확인
|
||||
- 현재 1차 구조 정리 기준 이슈는 `#18`
|
||||
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/mh`를 먼저 확인
|
||||
- 현재 구조 독립화 기준 이슈는 `#21`
|
||||
- `#2` 기준 DB 상태 확인은 `/api/admin/db-status` 또는 허브의 `DB 상태` 탭을 먼저 본다.
|
||||
- DB 테이블 유지/주의/원본·추적/정리 후보 분류는 [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md) 기준으로 본다.
|
||||
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`, `/api/admin/db-status`를 먼저 확인
|
||||
|
||||
@@ -36,14 +36,29 @@
|
||||
|
||||
- URL: `/integrations/payment`
|
||||
- 현재 실제 서빙 파일: `incoming-files/served/payment.html`
|
||||
- 앱 소스 기준: `frontend/apps/payment/index.html`
|
||||
- publish 규칙: `scripts/publish_payment_app.sh`
|
||||
- URL: `/integrations/ledger`
|
||||
- 현재 실제 서빙 파일: `incoming-files/served/ledger/index.html`
|
||||
- 현재 실제 runtime asset 경로: `incoming-files/served/ledger/*`
|
||||
- 앱 소스 기준: `frontend/apps/ledger/*`
|
||||
- publish 규칙: `frontend/apps/ledger/index.html` placeholder를 `scripts/publish_ledger_app.sh`가 runtime asset 경로로 치환
|
||||
- URL: `/integrations/mh`
|
||||
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
|
||||
- 앱 소스 기준: `frontend/apps/team/index.html`
|
||||
- publish 규칙: `scripts/publish_team_app.sh`
|
||||
- URL: `/admin/db-status`
|
||||
- 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html`
|
||||
- 앱 소스 기준: `frontend/apps/db-status/index.html`
|
||||
- publish 규칙: `scripts/publish_db_status_app.sh`
|
||||
|
||||
정리 원칙:
|
||||
|
||||
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
|
||||
- `reference/`는 원본 참고 파일, 복구 참고 파일, 비교용 자산만 둔다.
|
||||
- 1차 정리에서는 기존 실제 서빙 파일을 `served/`에 복사하고, backend 서빙 경로를 먼저 `served/`로 갱신한다.
|
||||
- `사업관리대장`은 `#21`부터 wrapper decode 방식 대신 `served/ledger/index.html`과 `served/ledger/*`를 직접 서빙한다.
|
||||
- `사업관리대장` 수정 원본은 `#21` 다음 단계부터 `frontend/apps/ledger/*`를 먼저 보고, `scripts/publish_ledger_app.sh`로 runtime served 파일에 반영한다.
|
||||
- 기존 루트 `incoming-files/payment.html`, `incoming-files/mh.html`는 안전한 비교/복구를 위해 당분간 남겨둔다.
|
||||
|
||||
## Seat Map
|
||||
@@ -80,7 +95,8 @@
|
||||
- `sample style.css`
|
||||
- `opayment.html`
|
||||
- `omh.html`
|
||||
- `사업관리대장/*`
|
||||
- `reference/ledger/MH 통합 대시보드_260320.html`
|
||||
- `reference/ledger/MH 통합 대시보드_260320.css`
|
||||
- 원본 xlsx/csv
|
||||
|
||||
## Out Of Scope For Phase 1
|
||||
@@ -97,4 +113,5 @@
|
||||
- 로그인은 `styles.css`만 본다.
|
||||
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
||||
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
||||
- `/admin/db-status`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
|
||||
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||
|
||||
160
docs/architecture/DB_TABLE_CATALOG.md
Normal file
160
docs/architecture/DB_TABLE_CATALOG.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# DB Table Catalog
|
||||
|
||||
## Purpose
|
||||
|
||||
이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 27개를 역할별로 분류한 운영 기준 문서다.
|
||||
|
||||
핵심 원칙:
|
||||
|
||||
- 테이블 수가 많다고 바로 줄이지 않는다.
|
||||
- 먼저 `유지 / 주의 / 원본·추적 / 정리 후보`로 나눈다.
|
||||
- 실제 운영 화면과 저장 흐름에 필요한 것은 유지한다.
|
||||
- 의미가 불분명하거나 중복 역할만 하는 것은 후보로 남겨두고, 실제 삭제는 별도 검증 후 진행한다.
|
||||
|
||||
## Summary
|
||||
|
||||
- 전체 테이블: `26`
|
||||
- 유지: `16`
|
||||
- 주의: `6`
|
||||
- 원본·추적: `4`
|
||||
- 정리 후보: `0`
|
||||
|
||||
## 1. 유지
|
||||
|
||||
현재 운영 화면, 인증, 이력, 적재 흐름에서 계속 필요하다.
|
||||
|
||||
- `auth.users`
|
||||
- `auth.sessions`
|
||||
- `auth.login_audit_logs`
|
||||
- `public.members`
|
||||
- `public.member_versions`
|
||||
- `public.history_revisions`
|
||||
- `public.seat_maps`
|
||||
- `public.seat_slots`
|
||||
- `public.seat_positions`
|
||||
- `public.seat_assignment_versions`
|
||||
- `public.integration_import_batches`
|
||||
- `public.integration_projects`
|
||||
- `public.integration_work_logs`
|
||||
- `public.integration_work_log_segments`
|
||||
- `public.integration_vouchers`
|
||||
- `public.integration_binary_sources`
|
||||
|
||||
설명:
|
||||
|
||||
- `members`, `seat_*`는 조직현황/자리배치도 핵심
|
||||
- `member_versions`, `seat_assignment_versions`, `history_revisions`는 as-of 조회와 이력 비교 핵심
|
||||
- `integration_*` 표준화 결과는 프로젝트별 분석 / 팀·개인별 분석 핵심
|
||||
- `integration_binary_sources`는 사업관리대장 같은 바이너리 원본 보관용
|
||||
- `auth.*`는 로그인과 권한 운영 핵심
|
||||
|
||||
## 2. 주의
|
||||
|
||||
현재도 역할은 있지만, 실제 운영에서 얼마나 계속 필요한지 주기적으로 점검해야 한다.
|
||||
|
||||
- `public.member_overrides`
|
||||
- `public.member_retirements`
|
||||
- `public.member_aliases`
|
||||
- `public.integration_project_aliases`
|
||||
- `public.integration_project_category_mappings`
|
||||
- `public.integration_project_pm_assignments`
|
||||
|
||||
설명:
|
||||
|
||||
- 이 테이블들은 핵심 마스터라기보다 “보정/매핑/예외 처리” 성격이 강하다.
|
||||
- 운영상 필요할 수 있지만, 남용되면 기준 데이터가 흐려진다.
|
||||
- 사용 규칙과 관리 책임을 분명히 해야 한다.
|
||||
|
||||
## 3. 원본·추적
|
||||
|
||||
원본 적재와 검증을 위해 필요하다. 직접 서비스 화면의 주 출력원이 아니라, 적재 근거와 추적용이다.
|
||||
|
||||
- `public.integration_raw_organization_rows`
|
||||
- `public.integration_raw_mh_rows`
|
||||
- `public.integration_raw_mh_pm_rows`
|
||||
- `public.integration_raw_payment_rows`
|
||||
|
||||
설명:
|
||||
|
||||
- 원본 파일을 바로 표준화 테이블에만 넣으면, 나중에 적재 오류를 추적하기 어렵다.
|
||||
- raw row 보관은 import 검증과 재현성 측면에서 의미가 있다.
|
||||
- 단, 장기 보관 정책과 용량 관리는 별도 필요하다.
|
||||
|
||||
## 4. 정리 후보
|
||||
|
||||
현재 기준 정리 후보 테이블은 없다.
|
||||
|
||||
## Domain Map
|
||||
|
||||
### 인증
|
||||
|
||||
- `auth.users`
|
||||
- `auth.sessions`
|
||||
- `auth.login_audit_logs`
|
||||
|
||||
### 조직 / 구성원
|
||||
|
||||
- `public.members`
|
||||
- `public.member_overrides`
|
||||
- `public.member_retirements`
|
||||
- `public.member_aliases`
|
||||
|
||||
### 자리배치도
|
||||
|
||||
- `public.seat_maps`
|
||||
- `public.seat_slots`
|
||||
- `public.seat_positions`
|
||||
|
||||
### 이력
|
||||
|
||||
- `public.history_revisions`
|
||||
- `public.member_versions`
|
||||
- `public.seat_assignment_versions`
|
||||
|
||||
### integration 표준화
|
||||
|
||||
- `public.integration_import_batches`
|
||||
- `public.integration_projects`
|
||||
- `public.integration_project_aliases`
|
||||
- `public.integration_project_category_mappings`
|
||||
- `public.integration_project_pm_assignments`
|
||||
- `public.integration_work_logs`
|
||||
- `public.integration_work_log_segments`
|
||||
- `public.integration_vouchers`
|
||||
- `public.integration_binary_sources`
|
||||
|
||||
### integration raw
|
||||
|
||||
- `public.integration_raw_organization_rows`
|
||||
- `public.integration_raw_mh_rows`
|
||||
- `public.integration_raw_mh_pm_rows`
|
||||
- `public.integration_raw_payment_rows`
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
### 바로 줄이지 말아야 하는 것
|
||||
|
||||
- `integration_raw_*`
|
||||
- `member_versions`
|
||||
- `seat_assignment_versions`
|
||||
- `auth.*`
|
||||
|
||||
이건 지금 구조상 “많아 보여도 필요한 층”이다.
|
||||
|
||||
### 먼저 점검할 것
|
||||
|
||||
- `member_overrides`, `member_aliases`, `project_aliases`의 실제 운영 빈도
|
||||
- `seat_maps`의 과거 실험 도면 정리 기준
|
||||
|
||||
### 정리 원칙
|
||||
|
||||
1. 테이블을 없애기 전에 실제 읽는 API/화면/스크립트를 확인한다.
|
||||
2. 원본 추적용 테이블은 운영 출력용 테이블과 구분해서 판단한다.
|
||||
3. 테이블 삭제보다 먼저 “사용 안 함” 상태를 문서화한다.
|
||||
4. 삭제는 백업과 검증 절차가 준비된 뒤에만 한다.
|
||||
|
||||
## Recommended Next Checks
|
||||
|
||||
1. `seat_maps` 과거 DXF 시도본 정리 기준 수립
|
||||
2. `주의` 그룹 테이블의 입력/수정 주체 명확화
|
||||
3. `DB 상태` 화면에서 이 분류를 기준으로 계속 설명 유지
|
||||
7
frontend/apps/db-status/README.md
Normal file
7
frontend/apps/db-status/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## DB Status App
|
||||
|
||||
- 수정 원본: `frontend/apps/db-status/index.html`
|
||||
- 실제 서빙: `incoming-files/served/db-status/index.html`
|
||||
- publish: `./scripts/publish_db_status_app.sh`
|
||||
|
||||
`#2` 이슈용 관리자 화면으로, 현재 DB 저장 구조와 적재 상태를 사람이 읽을 수 있게 보여준다.
|
||||
720
frontend/apps/db-status/index.html
Normal file
720
frontend/apps/db-status/index.html
Normal file
@@ -0,0 +1,720 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DB 상태</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||
color: var(--ds-ink, #2f2419);
|
||||
}
|
||||
.page {
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: rgba(76, 58, 35, 0.82);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.kpi {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 252, 247, 0.92);
|
||||
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||
}
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(112, 84, 41, 0.72);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #3d2e1d;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 251, 245, 0.96);
|
||||
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.panel-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.panel-body {
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.panel-body.tight {
|
||||
padding-top: 0;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 236, 196, 0.8);
|
||||
color: #7a5923;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 10px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: rgba(104, 79, 40, 0.76);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: rgba(250, 240, 213, 0.34);
|
||||
}
|
||||
.domain-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(90, 122, 94, 0.14);
|
||||
color: #456b4c;
|
||||
}
|
||||
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||
.group-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(240, 231, 214, 0.95);
|
||||
color: #674d27;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||
.table-title {
|
||||
font-weight: 800;
|
||||
color: #2f2419;
|
||||
}
|
||||
.table-trigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: #2f2419;
|
||||
font-weight: 800;
|
||||
}
|
||||
.table-trigger:hover {
|
||||
color: #80591f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.table-desc {
|
||||
margin-top: 5px;
|
||||
color: rgba(98, 75, 42, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.view-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.view-pill {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(86, 119, 93, 0.12);
|
||||
color: #456b4c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.notes {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: rgba(84, 65, 38, 0.84);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.preview-meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px 18px 0;
|
||||
}
|
||||
.preview-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 231, 214, 0.9);
|
||||
color: #634a25;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.column-pill em {
|
||||
font-style: normal;
|
||||
color: rgba(99, 74, 37, 0.68);
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.sticky-head th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(255, 248, 236, 0.98);
|
||||
z-index: 1;
|
||||
}
|
||||
.muted {
|
||||
color: rgba(110, 86, 50, 0.72);
|
||||
}
|
||||
.empty {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(44, 31, 16, 0.42);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal-card {
|
||||
width: min(1600px, 100%);
|
||||
max-height: min(88vh, 980px);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 250, 243, 0.98);
|
||||
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 16px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.modal-close {
|
||||
border: 0;
|
||||
background: rgba(240, 229, 206, 0.9);
|
||||
color: #6d5127;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(225, 208, 174, 0.96);
|
||||
}
|
||||
.modal-body {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 16px; }
|
||||
.hero { padding: 22px 20px; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
th, td { padding: 10px 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||
<p>
|
||||
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||
</p>
|
||||
<p>
|
||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="overview" class="overview"></section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>전체 테이블 현황</h2>
|
||||
<p>전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도메인</th>
|
||||
<th>테이블</th>
|
||||
<th>Rows</th>
|
||||
<th>최근 갱신</th>
|
||||
<th>연결 화면</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div style="display:grid; gap:20px;">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>원본 import 배치</h2>
|
||||
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Rows</th>
|
||||
<th>Imported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batch-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>바이너리 원본 보관</h2>
|
||||
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>파일</th>
|
||||
<th>크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="binary-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 메모</h2>
|
||||
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ol id="notes" class="notes"></ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>테이블 분류</h2>
|
||||
<p>유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="group-summary" class="panel-body"></div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||
</div>
|
||||
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="preview-meta" class="preview-meta"></div>
|
||||
<div class="panel-body tight">
|
||||
<div class="preview-table-wrap">
|
||||
<table>
|
||||
<thead id="preview-head" class="sticky-head"></thead>
|
||||
<tbody id="preview-body">
|
||||
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||
<td>
|
||||
<div style="margin-bottom:8px;">
|
||||
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||
</div>
|
||||
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.last_event_at)}</td>
|
||||
<td>
|
||||
<div class="view-list">
|
||||
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatches(items) {
|
||||
const target = document.getElementById("batch-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.imported_at)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderBinarySources(items) {
|
||||
const target = document.getElementById("binary-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(item.filename)}</td>
|
||||
<td>${formatBytes(item.byte_size)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
const target = document.getElementById("notes");
|
||||
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||
}
|
||||
|
||||
function renderGroupSummary(summary) {
|
||||
const target = document.getElementById("group-summary");
|
||||
const groups = [
|
||||
["유지", "keep"],
|
||||
["주의", "caution"],
|
||||
["원본·추적", "trace"],
|
||||
["정리 후보", "cleanup"],
|
||||
];
|
||||
target.innerHTML = groups.map(([label, klass]) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTablePreview(payload) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
|
||||
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||
previewMeta.innerHTML = `
|
||||
<div>
|
||||
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||
</div>
|
||||
<div class="preview-columns">
|
||||
${(payload.columns || []).map((column) => `
|
||||
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const columns = payload.columns || [];
|
||||
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||
if (!payload.rows || !payload.rows.length) {
|
||||
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
return;
|
||||
}
|
||||
previewBody.innerHTML = payload.rows.map((row) => `
|
||||
<tr>
|
||||
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||
</tr>
|
||||
`).join("");
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
async function loadTablePreview(schema, table) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
previewTitle.textContent = `${table}`;
|
||||
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||
previewMeta.innerHTML = "";
|
||||
previewHead.innerHTML = "";
|
||||
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
renderTablePreview(payload);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
document.getElementById("generated-at").textContent = payload.generated_at
|
||||
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||
: "갱신 시각 없음";
|
||||
renderOverview(payload.overview || {});
|
||||
renderTables(payload.tables || []);
|
||||
renderBatches(payload.import_batches || []);
|
||||
renderBinarySources(payload.binary_sources || []);
|
||||
renderNotes(payload.notes || []);
|
||||
renderGroupSummary(payload.group_summary || {});
|
||||
}
|
||||
|
||||
document.getElementById("preview-close").addEventListener("click", () => {
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||
if (event.target.id !== "preview-modal") return;
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/apps/ledger/README.md
Normal file
26
frontend/apps/ledger/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Ledger App Source
|
||||
|
||||
`사업관리대장` 화면의 앱 구조 source-of-truth 디렉터리다.
|
||||
|
||||
현재 원칙:
|
||||
|
||||
- 실제 runtime 응답은 여전히 `incoming-files/served/ledger/`를 사용한다.
|
||||
- 하지만 HTML/CSS/JS 수정 원본은 이 디렉터리에서 먼저 관리한다.
|
||||
- 변경 후에는 `scripts/publish_ledger_app.sh`로 `served/ledger/`에 반영한다.
|
||||
|
||||
구성:
|
||||
|
||||
- `index.html`: ledger 엔트리 HTML 원본 템플릿
|
||||
- `assets/MH 통합 대시보드_260320.css`: ledger base stylesheet
|
||||
- `assets/ledger-override.css`: 8081 ledger 스타일 확장
|
||||
- `assets/ledger-override.js`: 8081 ledger UI/상호작용 확장
|
||||
|
||||
주의:
|
||||
|
||||
- `index.html`은 runtime 경로를 직접 하드코딩하지 않는다.
|
||||
- `__LEDGER_HEAD_ASSETS__`, `__LEDGER_BODY_SCRIPTS__` placeholder는 publish 시 실제 `/integrations/ledger-assets/*` 경로로 치환된다.
|
||||
|
||||
범위:
|
||||
|
||||
- 이 디렉터리는 `#21` 이후 `사업관리대장`을 화면별 앱 구조로 승격하기 위한 첫 단계다.
|
||||
- 아직 프레임워크 앱은 아니고, 독립 관리되는 정식 화면 소스 디렉터리다.
|
||||
954
frontend/apps/ledger/index.html
Normal file
954
frontend/apps/ledger/index.html
Normal file
@@ -0,0 +1,954 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>사업관리대장 Dashboard</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f8fafc;color:#0f172a;font-family:'Pretendard','Noto Sans KR','Malgun Gothic',sans-serif}
|
||||
.wrap{max-width:1600px;margin:0 auto;padding:20px}
|
||||
.top{display:grid;grid-template-columns:1fr minmax(260px,520px);gap:12px;align-items:end}
|
||||
.title{font-size:34px;font-weight:900;letter-spacing:-.03em;margin:0}
|
||||
.sub{font-size:12px;color:#64748b;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
|
||||
.controls{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
|
||||
.btn{border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:12px;padding:10px 14px;font-size:13px;font-weight:800;cursor:pointer}
|
||||
.search{flex:1;min-width:250px;border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;font-size:13px;font-weight:700}
|
||||
.status{margin:10px 0 14px;font-size:12px;font-weight:700;color:#64748b}
|
||||
.cards{display:grid;grid-template-columns:repeat(5,minmax(150px,1fr));gap:10px;margin-bottom:12px}
|
||||
.card{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:10px 12px}
|
||||
.card .k{font-size:11px;font-weight:800;color:#64748b}
|
||||
.card .v{font-size:19px;font-weight:900;white-space:nowrap}
|
||||
.panel{background:#fff;border:1px solid #e2e8f0;border-radius:20px;overflow:hidden}
|
||||
.table-wrap{overflow:auto}
|
||||
table{width:100%;min-width:1250px;border-collapse:collapse}
|
||||
thead th{background:#0f172a;color:#ffffffd1;font-size:11px;text-transform:uppercase;letter-spacing:.12em;padding:12px 10px;text-align:left;white-space:nowrap;vertical-align:middle}
|
||||
.th-head{position:relative;display:flex;align-items:center}
|
||||
.th-head.end{justify-content:flex-end}
|
||||
.th-trigger{display:inline-flex;align-items:center;gap:6px;border:0;background:none;padding:0;color:#ffffffd1;font:inherit;font-weight:900;letter-spacing:inherit;text-transform:inherit;cursor:pointer}
|
||||
.th-trigger:hover,.th-trigger.active,.th-trigger.open{color:#fff}
|
||||
.th-title{display:inline-block}
|
||||
.th-meta{font-size:10px;color:#93c5fd;font-weight:800;letter-spacing:0;text-transform:none}
|
||||
.th-mark{display:inline-flex;align-items:center;justify-content:center;min-width:8px;color:#60a5fa;font-size:12px;line-height:1}
|
||||
.th-caret{font-size:10px;color:#93c5fd;transition:transform .15s ease}
|
||||
.th-trigger.open .th-caret{transform:rotate(180deg)}
|
||||
.th-menu{position:absolute;top:calc(100% + 8px);left:0;display:none;min-width:180px;max-width:320px;max-height:280px;overflow:auto;padding:6px;background:#fff;border:1px solid #cbd5e1;border-radius:12px;box-shadow:0 16px 40px #0f172a26;z-index:15}
|
||||
.th-head.end .th-menu{left:auto;right:0}
|
||||
.th-menu.open{display:block}
|
||||
.th-option{display:block;width:100%;border:0;background:none;border-radius:8px;padding:9px 10px;text-align:left;font-size:12px;font-weight:700;color:#0f172a;cursor:pointer;white-space:normal;word-break:break-word}
|
||||
.th-option:hover{background:#eff6ff}
|
||||
.th-option.active{background:#dbeafe;color:#1d4ed8}
|
||||
tbody td{padding:12px;border-bottom:1px solid #f1f5f9;font-size:13px;white-space:nowrap;vertical-align:middle}
|
||||
tbody tr:hover{background:#eff6ff}
|
||||
tbody tr.settled{background:#f8fafc;color:#94a3b8}
|
||||
tbody tr.settled:hover{background:#f1f5f9}
|
||||
tbody tr.settled .name,tbody tr.settled strong{color:#64748b}
|
||||
tbody tr.settled .badge{border-color:#cbd5e1;background:#f8fafc;color:#64748b}
|
||||
.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.name{font-weight:800;max-width:460px;overflow:hidden;text-overflow:ellipsis}
|
||||
.subline{font-size:11px;color:#94a3b8;font-weight:700;margin-top:3px}
|
||||
.badge{display:inline-flex;padding:3px 9px;border-radius:999px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;font-size:11px;font-weight:900}
|
||||
.badge.ok{border-color:#bbf7d0;background:#f0fdf4;color:#047857}
|
||||
.empty{display:none;padding:32px;text-align:center;color:#94a3b8;font-weight:800}
|
||||
.hidden{display:none}
|
||||
.modal{position:fixed;inset:0;background:#020617bf;backdrop-filter:blur(4px);display:none;align-items:center;justify-content:center;padding:16px;z-index:30}
|
||||
.modal.show{display:flex}
|
||||
.modal-card{width:min(1200px,100%);max-height:90vh;overflow:auto;background:#fff;border-radius:24px;border:1px solid #e2e8f0}
|
||||
.m-top{padding:20px;border-bottom:1px solid #f1f5f9;background:#f8fafc;display:flex;justify-content:space-between;gap:10px}
|
||||
.x{width:42px;height:42px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;font-size:22px;font-weight:900;color:#64748b;cursor:pointer}
|
||||
.m-body{padding:18px;display:grid;grid-template-columns:1.5fr 1fr;gap:12px}
|
||||
.sec{border:1px solid #e2e8f0;border-radius:16px;padding:12px}
|
||||
.sec.dark{background:#0f172a;color:#fff;border-color:#0f172a}
|
||||
.grid3{display:grid;grid-template-columns:repeat(3,minmax(100px,1fr));gap:8px}
|
||||
.grid4{display:grid;grid-template-columns:repeat(4,minmax(100px,1fr));gap:8px}
|
||||
.kv{border:1px solid #e2e8f0;border-radius:12px;padding:9px}
|
||||
.kvk{font-size:10px;color:#94a3b8;font-weight:900;text-transform:uppercase}
|
||||
.kvv{font-size:13px;font-weight:800;margin-top:3px;word-break:break-word}
|
||||
.line{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px dashed #e2e8f0;font-size:13px;font-weight:700}
|
||||
.line:last-child{border-bottom:0}
|
||||
.money{font-size:28px;font-weight:900}
|
||||
.progress{height:11px;background:#94a3b833;border-radius:999px;overflow:hidden;margin-top:7px}
|
||||
.bar{height:100%;background:#3b82f6;width:0%}
|
||||
.pay-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
||||
.pay-item{border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;background:#f8fafc}
|
||||
.pay-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
|
||||
.pay-name{font-size:13px;font-weight:900;word-break:break-word}
|
||||
.pay-meta{margin-top:6px;display:grid;grid-template-columns:repeat(2,minmax(120px,1fr));gap:6px 10px;font-size:12px;color:#475569;font-weight:700}
|
||||
.pay-empty{margin-top:10px;border:1px dashed #cbd5e1;border-radius:12px;padding:12px;color:#94a3b8;font-size:12px;font-weight:800;text-align:center}
|
||||
.pay-note{margin-top:8px;border-top:1px dashed #fecaca;padding-top:8px;font-size:12px;color:#b91c1c;font-weight:800;white-space:pre-wrap}
|
||||
.metric-btn{display:inline-flex;flex-direction:column;align-items:flex-end;gap:2px;border:0;background:none;padding:0;color:inherit;font:inherit;cursor:pointer}
|
||||
.metric-btn strong{color:#0f172a;text-decoration:underline;text-decoration-color:#bfdbfe;text-underline-offset:3px}
|
||||
tbody tr.settled .metric-btn strong{color:#64748b}
|
||||
.metric-btn:hover strong{color:#1d4ed8;text-decoration-color:#1d4ed8}
|
||||
.detail-row td{padding:0;border-bottom:1px solid #e2e8f0;background:#f8fafc}
|
||||
.detail-row:hover{background:#f8fafc}
|
||||
.detail-cell{padding:0}
|
||||
.inline-panel{padding:16px 18px}
|
||||
.inline-grid{display:grid;grid-template-columns:1.35fr 1fr;gap:12px}
|
||||
.inline-stack{display:flex;flex-direction:column;gap:10px}
|
||||
.inline-card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:12px}
|
||||
.inline-hero{background:#0f172a;color:#fff;border-color:#0f172a}
|
||||
.inline-hero-note{font-size:12px;color:#94a3b8;margin-top:6px}
|
||||
.inline-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:end}
|
||||
.inline-hero-col{min-width:0}
|
||||
.inline-hero-col.right{padding-left:14px;border-left:1px solid #334155}
|
||||
.out-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
||||
.out-item{border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;background:#f8fafc}
|
||||
.out-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
|
||||
.out-vendor{font-size:13px;font-weight:900}
|
||||
.out-name{margin-top:6px;font-size:13px;font-weight:800;word-break:break-word}
|
||||
.out-meta{margin-top:8px;display:grid;grid-template-columns:repeat(2,minmax(140px,1fr));gap:6px 10px;font-size:12px;color:#475569;font-weight:700}
|
||||
.out-payments{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding-top:8px;border-top:1px dashed #cbd5e1}
|
||||
.out-payment{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:8px}
|
||||
.out-payment-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;font-size:12px;font-weight:800}
|
||||
.out-payment-meta{margin-top:6px;display:grid;grid-template-columns:repeat(3,minmax(120px,1fr));gap:4px 8px;font-size:12px;color:#475569;font-weight:700}
|
||||
.out-note{margin-top:8px;border-top:1px dashed #fecaca;padding-top:8px;font-size:12px;color:#b91c1c;font-weight:800;white-space:pre-wrap}
|
||||
.project-head{display:grid;grid-template-columns:1.2fr .8fr;gap:12px;margin-bottom:12px}
|
||||
.project-meta-grid{display:grid;grid-template-columns:repeat(4,minmax(110px,1fr));gap:8px}
|
||||
.project-sections{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.section-card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:14px}
|
||||
.section-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:10px}
|
||||
.section-title{font-size:16px;font-weight:900}
|
||||
.section-sub{margin-top:4px;font-size:12px;color:#64748b;font-weight:800}
|
||||
.section-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:999px;padding:5px 10px;font-size:11px;font-weight:900;white-space:nowrap}
|
||||
.section-chip.out{border-color:#fecdd3;background:#fff1f2;color:#be123c}
|
||||
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px}
|
||||
.summary-card{background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:12px;min-width:0}
|
||||
.summary-label{font-size:11px;color:#64748b;font-weight:900;text-transform:uppercase}
|
||||
.summary-value{margin-top:6px;font-size:clamp(12px,0.95vw,22px);font-weight:900;line-height:1.15;white-space:nowrap;max-width:100%;letter-spacing:-.03em}
|
||||
.summary-note{margin-top:4px;font-size:12px;color:#94a3b8;font-weight:800}
|
||||
.ledger-stack{display:flex;flex-direction:column;gap:14px}
|
||||
.ledger-block{background:#fff;border:1px solid #e2e8f0;border-radius:18px;overflow:hidden}
|
||||
.ledger-block.outsource{border-color:#fecdd3;background:#fff}
|
||||
.ledger-block.collect{border-color:#c7d2fe;background:#fff}
|
||||
.ledger-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px}
|
||||
.ledger-head-left{display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.ledger-icon{width:20px;height:20px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:900;color:#fff;flex:0 0 auto}
|
||||
.ledger-block.outsource .ledger-icon{background:#f43f5e}
|
||||
.ledger-block.collect .ledger-icon{background:#6366f1}
|
||||
.ledger-name{font-size:13px;font-weight:900}
|
||||
.ledger-sub{margin-top:2px;font-size:11px;color:#64748b;font-weight:800}
|
||||
.ledger-pill{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;font-size:11px;font-weight:900;white-space:nowrap}
|
||||
.ledger-block.outsource .ledger-pill{border:1px solid #fecdd3;background:#fff1f2;color:#e11d48}
|
||||
.ledger-block.collect .ledger-pill{border:1px solid #c7d2fe;background:#eef2ff;color:#4f46e5}
|
||||
.ledger-table-wrap{padding:0 12px 12px}
|
||||
.ledger-table{width:100%;min-width:0;border-collapse:collapse}
|
||||
.ledger-table thead th{background:transparent;color:#94a3b8;font-size:11px;font-weight:900;letter-spacing:0;text-transform:none;padding:8px 10px;border-bottom:1px solid #e2e8f0}
|
||||
.ledger-table tbody td{padding:10px;border-bottom:1px solid #eef2f7;font-size:12px;color:#334155;white-space:normal;background:#fff}
|
||||
.ledger-table tbody tr:last-child td{border-bottom:0}
|
||||
.ledger-main{font-weight:800;color:#0f172a}
|
||||
.ledger-muted{display:block;margin-top:3px;font-size:11px;color:#94a3b8;font-weight:700}
|
||||
.ledger-amount{font-weight:900;text-align:right;color:#0f172a}
|
||||
.ledger-note{font-size:11px;color:#64748b;font-weight:700}
|
||||
.ledger-empty{padding:14px 12px;color:#94a3b8;font-size:12px;font-weight:800;text-align:center}
|
||||
.ledger-block.outsource .ledger-head{background:#fff1f2;border-bottom:1px solid #fecdd3}
|
||||
.ledger-block.collect .ledger-head{background:#eef2ff;border-bottom:1px solid #c7d2fe}
|
||||
.ledger-block.outsource .ledger-table thead th{background:#fff7f8}
|
||||
.ledger-block.collect .ledger-table thead th{background:#f5f7ff}
|
||||
@media(max-width:1280px){.top{grid-template-columns:1fr}.controls{justify-content:flex-start}.cards{grid-template-columns:repeat(2,minmax(140px,1fr))}.m-body{grid-template-columns:1fr}.inline-grid{grid-template-columns:1fr}.grid4{grid-template-columns:repeat(2,minmax(100px,1fr))}.inline-hero-split{grid-template-columns:1fr}.inline-hero-col.right{padding-left:0;border-left:0;border-top:1px solid #334155;padding-top:12px}.project-head{grid-template-columns:1fr}.project-meta-grid{grid-template-columns:repeat(2,minmax(110px,1fr))}.project-sections{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,minmax(120px,1fr))}.ledger-head{align-items:flex-start;flex-direction:column}.ledger-pill{align-self:flex-start}}
|
||||
</style>
|
||||
__LEDGER_HEAD_ASSETS__</head>
|
||||
<body class="mh-business-theme">
|
||||
<input id="file" type="file" accept=".csv,.xlsx,.xls" class="hidden" />
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<div><div class="sub">Live Management</div><h1 class="title">사업관리대장 <span style="font-weight:300;color:#94a3b8">| Dashboard</span></h1></div>
|
||||
<div class="controls"><button id="btnUpload" class="btn" type="button">파일 업로드</button><input id="search" class="search" placeholder="전체 검색" /></div>
|
||||
</div>
|
||||
<div id="status" class="status">CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.</div>
|
||||
<div id="cards" class="cards"></div>
|
||||
<div class="panel">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="code" data-label="구분 / 코드">
|
||||
<span class="th-title">구분 / 코드</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterCodeMenu" class="th-menu" data-filter="code"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="name" data-label="사업명">
|
||||
<span class="th-title">사업명</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterNameMenu" class="th-menu" data-filter="name"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="corp" data-label="계약법인">
|
||||
<span class="th-title">계약법인</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterCorpMenu" class="th-menu" data-filter="corp"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="status" data-label="진행상태">
|
||||
<span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterStatusMenu" class="th-menu" data-filter="status"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="outsource" data-label="외주비">
|
||||
<span class="th-title">외주비</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th class="num">
|
||||
<div class="th-head end">
|
||||
<button type="button" class="th-trigger" data-filter="amount" data-label="계약금">
|
||||
<span class="th-title">계약금</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterAmountMenu" class="th-menu" data-filter="amount"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th class="num">
|
||||
<div class="th-head end">
|
||||
<button type="button" class="th-trigger" data-filter="collected" data-label="수금액">
|
||||
<span class="th-title">수금액</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th class="num">
|
||||
<div class="th-head end">
|
||||
<button type="button" class="th-trigger" data-filter="rate" data-label="수금률">
|
||||
<span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterRateMenu" class="th-menu" data-filter="rate"></div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="empty">표시할 데이터가 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collectModal" class="modal">
|
||||
<div class="modal-card">
|
||||
<div class="m-top"><div><div id="mCat" class="badge">미분류</div><div id="mTitle" style="font-size:28px;font-weight:900;margin-top:6px"></div><div id="mSub" style="font-size:13px;color:#64748b;font-weight:700;margin-top:4px"></div></div><button id="btnCollectClose" class="x" type="button">×</button></div>
|
||||
<div class="m-body">
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="sec"><div class="grid3"><div class="kv"><div class="kvk">발주처</div><div id="mClient" class="kvv"></div></div><div class="kv"><div class="kvk">발주방법</div><div id="mOrder" class="kvv"></div></div><div class="kv"><div class="kvk">분담율</div><div id="mSplit" class="kvv"></div></div></div></div>
|
||||
<div class="sec"><div class="line"><span>착수일</span><strong id="mStartDate"></strong></div><div class="line"><span>준공일</span><strong id="mEndDate"></strong></div><div class="line"><span>대금구분</span><strong id="mPayType"></strong></div><div id="mPayItems" class="pay-list"></div></div>
|
||||
<div class="sec dark"><div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-end"><div><div style="font-size:11px;color:#94a3b8;font-weight:900">총 계약 합계(VAT 포함)</div><div id="mContractTotal" class="money"></div><div id="mContractSupply" style="font-size:12px;color:#94a3b8"></div></div><div style="text-align:right"><div style="font-size:11px;color:#60a5fa;font-weight:900">수금금액</div><div id="mCollected" class="money" style="color:#60a5fa"></div><div id="mCollectDate" style="font-size:12px;color:#94a3b8"></div></div></div><div style="margin-top:10px;display:flex;justify-content:space-between"><span style="font-size:12px;color:#94a3b8;font-weight:900">수금 진행률</span><strong id="mRate" style="font-size:28px"></strong></div><div class="progress"><div id="mRateBar" class="bar"></div></div><div style="display:flex;justify-content:space-between;margin-top:7px"><span style="color:#fda4af;font-size:12px;font-weight:900">미수 금액</span><strong id="mReceivable" style="color:#fb7185"></strong></div></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="sec"><div style="font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase">계약 / 청구 담당자</div><div style="margin-top:8px"><div id="mCmName" style="font-size:20px;font-weight:900"></div><div id="mCmOrg" style="font-size:13px;color:#0f172a;font-weight:800;margin-top:4px"></div><div id="mCmPhone" style="font-size:13px;font-weight:700;margin-top:8px"></div><div id="mCmEmail" style="font-size:13px;font-weight:700;margin-top:4px"></div></div></div>
|
||||
<div class="sec"><div style="font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase">부서 담당자</div><div style="margin-top:8px"><div id="mDmName" style="font-size:20px;font-weight:900"></div><div id="mDmOrg" style="font-size:13px;color:#334155;font-weight:800;margin-top:4px"></div><div id="mDmPhone" style="font-size:13px;font-weight:700;margin-top:8px"></div><div id="mDmEmail" style="font-size:13px;font-weight:700;margin-top:4px"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="outsourceModal" class="modal">
|
||||
<div class="modal-card">
|
||||
<div class="m-top"><div><div class="badge">외주비 상세</div><div id="oTitle" style="font-size:28px;font-weight:900;margin-top:6px"></div><div id="oSub" style="font-size:13px;color:#64748b;font-weight:700;margin-top:4px"></div></div><button id="btnOutsourceClose" class="x" type="button">×</button></div>
|
||||
<div class="m-body">
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="sec">
|
||||
<div class="grid3">
|
||||
<div class="kv"><div class="kvk">계약법인</div><div id="oCorp" class="kvv"></div></div>
|
||||
<div class="kv"><div class="kvk">발주처</div><div id="oClient" class="kvv"></div></div>
|
||||
<div class="kv"><div class="kvk">외주처 요약</div><div id="oVendors" class="kvv"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec">
|
||||
<div class="line"><span>외주 총액</span><strong id="oTotal"></strong></div>
|
||||
<div class="line"><span>외주 건수</span><strong id="oCount"></strong></div>
|
||||
<div class="line"><span>계약기간</span><strong id="oPeriod"></strong></div>
|
||||
<div id="oItems" class="out-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="sec dark">
|
||||
<div style="font-size:11px;color:#94a3b8;font-weight:900">총 외주비(공급가액 기준)</div>
|
||||
<div id="oTotalHero" class="money"></div>
|
||||
<div id="oTotalHint" style="font-size:12px;color:#94a3b8;margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||
<script>
|
||||
const FILTER_KEYS=["code","name","corp","status","outsource","amount","collected","rate"];
|
||||
const S={all:[],rows:[],viewRows:[],file:"",filters:{},totals:null,expanded:{key:""}};
|
||||
const E={file:document.getElementById("file"),btnUpload:document.getElementById("btnUpload"),search:document.getElementById("search"),status:document.getElementById("status"),cards:document.getElementById("cards"),tbody:document.getElementById("tbody"),empty:document.getElementById("empty"),collectModal:document.getElementById("collectModal"),btnCollectClose:document.getElementById("btnCollectClose"),outsourceModal:document.getElementById("outsourceModal"),btnOutsourceClose:document.getElementById("btnOutsourceClose"),filterButtons:Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(el=>[el.dataset.filter,el])),filterMenus:Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(el=>[el.dataset.filter,el]))};
|
||||
const G=id=>document.getElementById(id);
|
||||
const esc=v=>String(v||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
const escAttr=v=>esc(v).replace(/"/g,""");
|
||||
const n=v=>String(v||"").replace(/[\s\r\n]+/g,"").toLowerCase();
|
||||
const num=v=>{v=String(v||"").trim();if(!v||v.startsWith("="))return 0;return parseFloat(v.replace(/[^0-9.\-]/g,""))||0;};
|
||||
const won=v=>Math.round(v||0).toLocaleString("ko-KR")+" 원";
|
||||
const d=v=>{v=String(v||"").trim();return !v||v==="~"?"-":v;};
|
||||
const rate=(raw,col,sales)=>{const x=parseFloat(String(raw||"").replace(/[^0-9.\-]/g,""));if(Number.isFinite(x))return Math.max(0,Math.min(100,x));return sales>0?Math.max(0,Math.min(100,col/sales*100)):0;};
|
||||
const score=t=>{t=String(t||"");let s=0,m=t.replace(/\s+/g,"");if(m.includes("사업관리대장"))s+=8;if(m.includes("총괄사업코드"))s+=8;if(m.includes("사업명(계약명)"))s+=7;s+=(t.match(/[가-힣]/g)||[]).length*0.01;s-=(t.match(/<2F>/g)||[]).length*0.5;return s;};
|
||||
const rowKey=r=>[r.code||"",r.name||"",r.corp||"",r.client||""].join("|");
|
||||
function parseCsv(txt){const out=[];let row=[],f="",q=false;for(let i=0;i<txt.length;i++){const c=txt[i];if(c==='"'){if(q&&txt[i+1]==='"'){f+='"';i++;}else q=!q;continue;}if(c===","&&!q){row.push(f);f="";continue;}if((c==="\n"||c==="\r")&&!q){if(c==="\r"&&txt[i+1]==="\n")i++;row.push(f);out.push(row);row=[];f="";continue;}f+=c;}row.push(f);out.push(row);if(out.length&&out[0].length)out[0][0]=String(out[0][0]||"").replace(/^\uFEFF/,"");return out;}
|
||||
function hs(rows){
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const a=(rows[i]||[]).map(n);
|
||||
const hasName=a.some(v=>v.includes("사업명(계약명)")||v==="사업명"||v.includes("사업명"));
|
||||
const hasCode=a.some(v=>v.includes("총괄사업코드")||v.includes("사업코드"));
|
||||
const hasClient=a.some(v=>v.includes("발주처(매출처)")||v.includes("발주처"));
|
||||
if(hasName&&(hasCode||hasClient)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function ch(a,b){a=a||[];b=b||[];const m=Math.max(a.length,b.length),o=[];let carry="";for(let i=0;i<m;i++){const t=String(a[i]||"").replace(/\s+/g," ").trim(),s=String(b[i]||"").replace(/\s+/g," ").trim();if(t)carry=t;const top=t||carry;o.push(top&&s?(top+" "+s).trim():(top||s||""));}return o;}
|
||||
function hi(headers,cands){const C=(cands||[]).map(n).filter(Boolean);for(const c of C){for(let i=0;i<headers.length;i++)if(n(headers[i])===c)return i;}return -1;}
|
||||
function parseLedgerRows(R){
|
||||
if(R.length&&R[0].length)R[0][0]=String(R[0][0]||"").replace(/^\uFEFF/,"");
|
||||
const h=hs(R);if(h<0)throw new Error("헤더를 찾지 못했습니다.");
|
||||
const H=ch(R[h],R[h+1]||[]),I={cat:hi(H,["사업구분","사업 구분"]),corp:hi(H,["계약법인","계약 법인"]),code:hi(H,["총괄사업코드","총괄 사업코드","사업코드"]),name:hi(H,["사업명 (계약명)","사업명(계약명)","사업명"]),pay:hi(H,["대금구분","대금 구분"]),yn:hi(H,["계약여부"]),order:hi(H,["발주방법"]),pm:hi(H,["pm"]),status:hi(H,["진행상태"]),client:hi(H,["발주처 (매출처)","발주처(매출처)","발주처"]),split:hi(H,["분담율"]),cDate:hi(H,["계약기간 계약일","계약일","발행일"]),sDate:hi(H,["계약기간 착수일","착수일"]),eDate:hi(H,["계약기간 준공일","준공일"]),cSup:hi(H,["계약금 공급가액","매출금액 공급가액","공급가액"]),cVat:hi(H,["계약금 부가세","매출금액 부가세","부가세"]),cTot:hi(H,["계약금 합계","매출금액 합계","합계","계약금","매출금액"]),colDate:hi(H,["매출금액 수금일","수금일"]),sSup:hi(H,["매출금액 공급가액","공급가액"]),sVat:hi(H,["매출금액 부가세","부가세"]),sTot:hi(H,["매출금액 합계","합계","매출금액"]),col:hi(H,["매출금액 수금금액","수금금액","수금액"]),recv:hi(H,["매출금액 미수금액","미수금액"]),r:hi(H,["매출금액 수금율","수금율"]),note:hi(H,["비고"]),cmCo:hi(H,["계약/청구담당자 회사"]),cmNm:hi(H,["계약/청구담당자 이름"]),cmDp:hi(H,["계약/청구담당자 부서"]),cmPh:hi(H,["계약/청구담당자 연락처"]),cmEm:hi(H,["계약/청구담당자 이메일"]),dmCo:hi(H,["부서담당자 회사"]),dmNm:hi(H,["부서담당자 이름"]),dmDp:hi(H,["부서담당자 부서"]),dmPh:hi(H,["부서담당자 연락처"]),dmEm:hi(H,["부서담당자 이메일"])};
|
||||
const out=[];for(const row of R.slice(h+2)){const x={cat:I.cat>=0?String(row[I.cat]||"").trim():"",corp:I.corp>=0?String(row[I.corp]||"").trim():"",code:I.code>=0?String(row[I.code]||"").trim():"",name:I.name>=0?String(row[I.name]||"").trim():"",pay:I.pay>=0?String(row[I.pay]||"").trim():"",yn:I.yn>=0?String(row[I.yn]||"").trim():"",order:I.order>=0?String(row[I.order]||"").trim():"",pm:I.pm>=0?String(row[I.pm]||"").trim():"",status:I.status>=0?String(row[I.status]||"").trim():"",client:I.client>=0?String(row[I.client]||"").trim():"",split:I.split>=0?String(row[I.split]||"").trim():"",cDate:I.cDate>=0?String(row[I.cDate]||"").trim():"",sDate:I.sDate>=0?String(row[I.sDate]||"").trim():"",eDate:I.eDate>=0?String(row[I.eDate]||"").trim():"",cSup:I.cSup>=0?num(row[I.cSup]):0,cVat:I.cVat>=0?num(row[I.cVat]):0,cTot:I.cTot>=0?num(row[I.cTot]):0,colDate:I.colDate>=0?String(row[I.colDate]||"").trim():"",sSup:I.sSup>=0?num(row[I.sSup]):0,sVat:I.sVat>=0?num(row[I.sVat]):0,sTot:I.sTot>=0?num(row[I.sTot]):0,col:I.col>=0?num(row[I.col]):0,recv:I.recv>=0?num(row[I.recv]):0,rateRaw:I.r>=0?String(row[I.r]||"").trim():"",note:I.note>=0?String(row[I.note]||"").trim():"",cmCo:I.cmCo>=0?String(row[I.cmCo]||"").trim():"",cmNm:I.cmNm>=0?String(row[I.cmNm]||"").trim():"",cmDp:I.cmDp>=0?String(row[I.cmDp]||"").trim():"",cmPh:I.cmPh>=0?String(row[I.cmPh]||"").trim():"",cmEm:I.cmEm>=0?String(row[I.cmEm]||"").trim():"",dmCo:I.dmCo>=0?String(row[I.dmCo]||"").trim():"",dmNm:I.dmNm>=0?String(row[I.dmNm]||"").trim():"",dmDp:I.dmDp>=0?String(row[I.dmDp]||"").trim():"",dmPh:I.dmPh>=0?String(row[I.dmPh]||"").trim():"",dmEm:I.dmEm>=0?String(row[I.dmEm]||"").trim():""};
|
||||
if(!x.name&&!x.code)continue;if(!x.code&&!x.corp&&!x.client&&!x.pm)continue;if(!x.cTot)x.cTot=x.cSup+x.cVat;if(!x.sTot)x.sTot=x.sSup+x.sVat;if(!x.recv)x.recv=Math.max(0,x.sTot-x.col);x.rate=rate(x.rateRaw,x.col,x.sTot);out.push(x);}
|
||||
return out;
|
||||
}
|
||||
const hk=v=>String(v||"").normalize("NFKC").toLowerCase().replace(/[^0-9a-z가-힣]+/g,"");
|
||||
function findHeaderIndex(headers,cands){
|
||||
const normalized=(headers||[]).map(hk);
|
||||
const candidates=(cands||[]).map(hk).filter(Boolean);
|
||||
for(const c of candidates){
|
||||
for(let i=0;i<normalized.length;i++){
|
||||
if(!normalized[i]) continue;
|
||||
if(normalized[i]===c||normalized[i].includes(c)||c.includes(normalized[i])) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function textAt(row,idx){return idx>=0?String(row[idx]??"").replace(/\u00a0/g," ").replace(/\s+/g," ").trim():"";}
|
||||
function moneyAt(row,idx){return idx>=0?num(row[idx]):0;}
|
||||
function lastText(values){for(let i=values.length-1;i>=0;i--){const v=d(values[i]);if(v!=="-")return v;}return "-";}
|
||||
function paymentSummary(payments){
|
||||
const labels=[...new Set((payments||[]).map(p=>String(p.pay||"").trim()).filter(Boolean))];
|
||||
if(!labels.length) return "-";
|
||||
if(labels.length<=2) return labels.join(", ");
|
||||
return `${labels.slice(0,2).join(", ")} 외 ${labels.length-2}건`;
|
||||
}
|
||||
function paymentRecord(x,fallbackPay){
|
||||
const supply=x.sSup||0,vat=x.sVat||0,total=x.sTot||supply+vat,collected=x.col||0;
|
||||
return {pay:String(x.pay||x.name||fallbackPay||"미입력").trim(),status:x.status||"",issueDate:x.issueDate||x.cDate||"",collectDate:x.colDate||"",supply,vat,total,collected,receivable:x.recv||Math.max(0,total-collected),rate:rate(x.rateRaw,collected,total),note:String(x.note||"").trim()};
|
||||
}
|
||||
function finalizeProject(project){
|
||||
const payments=(project.payments||[]).filter(p=>p.pay||p.issueDate||p.collectDate||p.total||p.collected||p.receivable);
|
||||
if(!payments.length&&(project.issueDate||project.colDate||project.sSup||project.sVat||project.sTot||project.col||project.recv)) payments.push(paymentRecord(project,project.pay||"일괄"));
|
||||
project.payments=payments;
|
||||
project.pay=paymentSummary(payments);
|
||||
project.periodText=(d(project.sDate)==="-"&&d(project.eDate)==="-")?"-":`${d(project.sDate)} ~ ${d(project.eDate)}`;
|
||||
project.issueDateSummary=lastText(payments.map(p=>p.issueDate));
|
||||
project.collectDateSummary=lastText(payments.map(p=>p.collectDate));
|
||||
return project;
|
||||
}
|
||||
function normalizeProjectKey(v){return hk(v);}
|
||||
function normalizeProjectBase(v){
|
||||
return hk(String(v||"").replace(/\([^)]*\)/g," ").replace(/\[[^\]]*\]/g," "));
|
||||
}
|
||||
function summarizeOutsourceVendors(vendors){
|
||||
const list=(vendors||[]).filter(Boolean);
|
||||
if(!list.length) return "";
|
||||
if(list.length<=2) return list.join(", ");
|
||||
return `${list.slice(0,2).join(", ")} \uC678 ${list.length-2}\uACF3`;
|
||||
}
|
||||
function calcVatExcluded(total){return total>0?Math.round(total/1.1):0;}
|
||||
function outsourceTotalLabel(item){
|
||||
const ex=Math.round(item&&item.contractEx||0);
|
||||
const total=Math.round(item&&item.contractIn||0);
|
||||
if(ex>0) return won(ex);
|
||||
if(total>0) return won(calcVatExcluded(total));
|
||||
return "-";
|
||||
}
|
||||
function cleanVendorName(value,sheetName){
|
||||
const raw=String(value||sheetName||"").trim();
|
||||
return raw.replace(/^\(\uC8FC\)\s*/,"").replace(/^\uC8FC\uC2DD\uD68C\uC0AC\s*/,"").replace(/^\uC678\uC8FC/,"").trim()||String(sheetName||"\uC678\uC8FC").replace(/^\uC678\uC8FC/,"").trim()||"\uC678\uC8FC";
|
||||
}
|
||||
function getOutsourceLayout(rows){
|
||||
const header=rows[3]||[];
|
||||
const hasVatContract=String(header[9]??"").includes("VAT\uD3EC\uD568");
|
||||
if(hasVatContract){
|
||||
return {hasVatContract:true,contractEx:8,contractIn:9,invoiceDate:10,paymentDate:11,paymentAmount:12,remainingAmount:13,progress:14,label:15,note:16};
|
||||
}
|
||||
return {hasVatContract:false,contractEx:8,contractIn:-1,invoiceDate:9,paymentDate:10,paymentAmount:11,remainingAmount:12,progress:13,label:-1,note:14};
|
||||
}
|
||||
function shouldStopOutsourceRows(row){
|
||||
const first=String(row[0]??"").trim();
|
||||
const project=String(row[2]??"").trim();
|
||||
const detail=String(row[3]??"").trim();
|
||||
const joined=[row[0],row[2],row[3],row[13],row[14],row[15],row[16]].map(v=>String(v??"").trim()).join(" ");
|
||||
return first==="\uB0A0\uC9DC"||first.startsWith("*\uC790\uB8CC\uCD9C\uCC98")||project==="\uC801\uC694"||detail==="\uC801\uC694"||project.includes("\uC790\uB8CC\uCD9C\uCC98")||joined.includes("\uC6D0\uACC4\uC57D\uAE08")||joined.includes("\uC218\uAE08/\uC9C0\uAE09\uCC98");
|
||||
}
|
||||
function getOutsourceEntry(map,key,name){
|
||||
const current=map.get(key);
|
||||
if(current) return current;
|
||||
const next={name,key,baseKey:normalizeProjectBase(name),vendors:new Set(),items:[],contract:0,contractIn:0,paid:0,paidIn:0,remaining:0,remainingIn:0};
|
||||
map.set(key,next);
|
||||
return next;
|
||||
}
|
||||
function createOutsourceItem(entry,vendor,projectName,detail,row,layout){
|
||||
const contractEx=num(row[layout.contractEx]);
|
||||
const contractIn=layout.contractIn>=0?num(row[layout.contractIn]):0;
|
||||
const next={
|
||||
vendor,
|
||||
projectName,
|
||||
detail:String(detail||"-").trim()||"-",
|
||||
contractDate:String(row[4]??"").trim(),
|
||||
startDate:String(row[5]??"").trim(),
|
||||
endDate:String(row[7]??"").trim(),
|
||||
contractEx,
|
||||
contractIn,
|
||||
invoiceDate:String(row[layout.invoiceDate]??"").trim(),
|
||||
progress:String(row[layout.progress]??"").trim(),
|
||||
note:"",
|
||||
payments:[]
|
||||
};
|
||||
entry.items.push(next);
|
||||
return next;
|
||||
}
|
||||
function buildOutsourcePayment(item,row,layout){
|
||||
const invoiceDate=String(row[layout.invoiceDate]??"").trim();
|
||||
const paymentDate=String(row[layout.paymentDate]??"").trim();
|
||||
const paymentCell=String(row[layout.paymentAmount]??"").trim();
|
||||
const remainingCell=String(row[layout.remainingAmount]??"").trim();
|
||||
const paymentRaw=num(row[layout.paymentAmount]);
|
||||
const remainingRaw=num(row[layout.remainingAmount]);
|
||||
const label=layout.label>=0?String(row[layout.label]??"").trim():"";
|
||||
const note=layout.note>=0?String(row[layout.note]??"").trim():String(row[14]??"").trim();
|
||||
if(!(invoiceDate||paymentDate||paymentRaw||remainingRaw||label||note)) return null;
|
||||
if(note&&!label&&!paymentDate&&!paymentRaw&&!remainingRaw&&!invoiceDate){
|
||||
item.note=note;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
note,
|
||||
invoiceDate,
|
||||
paymentDate,
|
||||
paymentKnown:paymentCell!=="",
|
||||
remainingKnown:remainingCell!=="",
|
||||
paymentEx:paymentRaw?(layout.hasVatContract?calcVatExcluded(paymentRaw):paymentRaw):0,
|
||||
paymentIn:layout.hasVatContract?paymentRaw:0,
|
||||
remainingEx:remainingRaw?(layout.hasVatContract?calcVatExcluded(remainingRaw):remainingRaw):0,
|
||||
remainingIn:layout.hasVatContract?remainingRaw:0
|
||||
};
|
||||
}
|
||||
function finalizeOutsourceItem(item){
|
||||
const payments=Array.isArray(item.payments)?item.payments.filter(Boolean):[];
|
||||
const paidEx=Math.round(payments.reduce((sum,p)=>sum+(p.paymentEx||0),0));
|
||||
const paidIn=Math.round(payments.reduce((sum,p)=>sum+(p.paymentIn||0),0));
|
||||
let remainingEx=0;
|
||||
let remainingIn=0;
|
||||
for(let i=payments.length-1;i>=0;i--){
|
||||
const payment=payments[i];
|
||||
if(payment.remainingKnown){
|
||||
remainingEx=Math.round(payment.remainingEx||0);
|
||||
remainingIn=Math.round(payment.remainingIn||0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!remainingEx&&item.contractEx>0) remainingEx=Math.max(0,Math.round(item.contractEx-paidEx));
|
||||
if(!remainingIn&&item.contractIn>0) remainingIn=Math.max(0,Math.round(item.contractIn-paidIn));
|
||||
return {...item,payments,paidEx,paidIn,remainingEx,remainingIn};
|
||||
}
|
||||
function parseOutsourceRows(rows,sheetName,map){
|
||||
if(!rows||rows.length<6) return;
|
||||
const vendor=cleanVendorName((rows[1]||[])[0],sheetName);
|
||||
const layout=getOutsourceLayout(rows);
|
||||
let currentKey="",currentName="",currentItem=null;
|
||||
for(const row of rows.slice(5)){
|
||||
if(shouldStopOutsourceRows(row)) break;
|
||||
const projectName=String(row[2]??"").trim();
|
||||
const projectKey=normalizeProjectKey(projectName);
|
||||
const detail=String(row[3]??"").trim();
|
||||
const validProject=projectKey&&projectKey!=="ref";
|
||||
if(validProject){
|
||||
currentKey=projectKey;
|
||||
currentName=projectName;
|
||||
const entry=getOutsourceEntry(map,currentKey,currentName);
|
||||
entry.vendors.add(vendor);
|
||||
currentItem=createOutsourceItem(entry,vendor,currentName,detail,row,layout);
|
||||
const firstPayment=buildOutsourcePayment(currentItem,row,layout);
|
||||
if(firstPayment) currentItem.payments.push(firstPayment);
|
||||
continue;
|
||||
}
|
||||
if(!currentKey) continue;
|
||||
const entry=getOutsourceEntry(map,currentKey,currentName);
|
||||
entry.vendors.add(vendor);
|
||||
const contractEx=num(row[layout.contractEx]);
|
||||
const contractIn=layout.contractIn>=0?num(row[layout.contractIn]):0;
|
||||
const hasFinancialRow=!!(contractEx||contractIn||num(row[layout.paymentAmount])||num(row[layout.remainingAmount]));
|
||||
const hasMetaRow=!!(String(row[layout.invoiceDate]??"").trim()||String(row[layout.paymentDate]??"").trim()||String(row[layout.progress]??"").trim()||detail);
|
||||
if(detail&&hasMetaRow){
|
||||
currentItem=createOutsourceItem(entry,vendor,currentName,detail,row,layout);
|
||||
const payment=buildOutsourcePayment(currentItem,row,layout);
|
||||
if(payment) currentItem.payments.push(payment);
|
||||
continue;
|
||||
}
|
||||
if(!currentItem){
|
||||
if(!(hasFinancialRow||hasMetaRow)) continue;
|
||||
currentItem=createOutsourceItem(entry,vendor,currentName,detail||"\uC678\uC8FC \uACC4\uC57D",row,layout);
|
||||
}else{
|
||||
if(contractEx>0) currentItem.contractEx+=contractEx;
|
||||
if(contractIn>0) currentItem.contractIn+=contractIn;
|
||||
if(!currentItem.progress) currentItem.progress=String(row[layout.progress]??"").trim();
|
||||
}
|
||||
const payment=buildOutsourcePayment(currentItem,row,layout);
|
||||
if(payment) currentItem.payments.push(payment);
|
||||
}
|
||||
}
|
||||
function parseOutsourceSheets(workbook){
|
||||
const map=new Map();
|
||||
const names=(workbook&&workbook.SheetNames)||[];
|
||||
for(const sheetName of names){
|
||||
if(!String(sheetName||"").startsWith("\uC678\uC8FC")) continue;
|
||||
const sheet=workbook.Sheets[sheetName];
|
||||
if(!sheet) continue;
|
||||
const rows=XLSX.utils.sheet_to_json(sheet,{header:1,raw:false,defval:""});
|
||||
parseOutsourceRows(rows,sheetName,map);
|
||||
}
|
||||
for(const entry of map.values()){
|
||||
entry.items=entry.items.map(finalizeOutsourceItem).filter(item=>item.contractEx||item.contractIn||item.paidEx||item.paidIn||item.remainingEx||item.remainingIn||item.detail||item.payments.length);
|
||||
entry.contract=Math.round(entry.items.reduce((sum,item)=>sum+(item.contractEx||0),0));
|
||||
entry.contractIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.contractIn||0),0));
|
||||
entry.paid=Math.round(entry.items.reduce((sum,item)=>sum+(item.paidEx||0),0));
|
||||
entry.paidIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.paidIn||0),0));
|
||||
entry.remaining=Math.round(entry.items.reduce((sum,item)=>sum+(item.remainingEx||0),0));
|
||||
entry.remainingIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.remainingIn||0),0));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
function resolveOutsourceEntry(record,outsourceMap){
|
||||
const fullKey=normalizeProjectKey(record.name||"");
|
||||
const baseKey=normalizeProjectBase(record.name||"");
|
||||
if(fullKey&&outsourceMap.has(fullKey)) return outsourceMap.get(fullKey);
|
||||
if(baseKey&&outsourceMap.has(baseKey)) return outsourceMap.get(baseKey);
|
||||
let best=null,bestScore=0;
|
||||
for(const entry of outsourceMap.values()){
|
||||
const entryFull=String(entry&&entry.key||"");
|
||||
const entryBase=String(entry&&entry.baseKey||normalizeProjectBase(entry&&entry.name||""));
|
||||
for(const candidate of [entryFull,entryBase]){
|
||||
if(!candidate) continue;
|
||||
const matched=(fullKey&&fullKey.includes(candidate))||(candidate&&fullKey&&candidate.includes(fullKey))||(baseKey&&baseKey.includes(candidate))||(candidate&&baseKey&&candidate.includes(baseKey));
|
||||
if(matched&&candidate.length>bestScore){
|
||||
best=entry;
|
||||
bestScore=candidate.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function attachOutsourceCosts(records,outsourceMap){
|
||||
return (records||[]).map(record=>{
|
||||
const entry=resolveOutsourceEntry(record,outsourceMap);
|
||||
const outsourceCost=entry?Math.round(entry.contract||0):0;
|
||||
const outsourcePaid=entry?Math.round(entry.paid||0):0;
|
||||
const outsourceRemaining=entry?Math.round(entry.remaining||0):0;
|
||||
const outsourceCostIn=entry?Math.round(entry.contractIn||0):0;
|
||||
const outsourcePaidIn=entry?Math.round(entry.paidIn||0):0;
|
||||
const outsourceRemainingIn=entry?Math.round(entry.remainingIn||0):0;
|
||||
const outsourceVendors=entry?Array.from(entry.vendors):[];
|
||||
const outsourceItems=entry&&Array.isArray(entry.items)?entry.items.slice():[];
|
||||
return {
|
||||
...record,
|
||||
outsourceCost,
|
||||
outsourcePaid,
|
||||
outsourceRemaining,
|
||||
outsourceCostIn,
|
||||
outsourcePaidIn,
|
||||
outsourceRemainingIn,
|
||||
outsourceVendors,
|
||||
outsourceVendorText:summarizeOutsourceVendors(outsourceVendors),
|
||||
outsourceItems
|
||||
};
|
||||
});
|
||||
}
|
||||
function parseLedgerRecords(R){
|
||||
if(R.length&&R[0].length)R[0][0]=String(R[0][0]||"").replace(/^\uFEFF/,"");
|
||||
const h=hs(R);if(h<0)throw new Error("헤더를 찾지 못했습니다.");
|
||||
ch(R[h],R[h+1]||[]);
|
||||
const I={cat:1,corp:4,code:5,name:6,pay:7,yn:8,order:9,pm:10,status:11,client:12,split:13,cDate:14,sDate:15,eDate:17,cSup:18,cVat:19,cTot:20,issueDate:21,colDate:22,sSup:23,sVat:24,sTot:25,col:26,recv:27,r:28,note:29,cmCo:30,cmNm:31,cmDp:32,cmPh:33,cmEm:34,dmCo:35,dmNm:36,dmDp:37,dmPh:38,dmEm:39};
|
||||
const out=[];let current=null;
|
||||
for(const row of R.slice(h+2)){
|
||||
const x={
|
||||
cat:textAt(row,I.cat),corp:textAt(row,I.corp),code:textAt(row,I.code),name:textAt(row,I.name),pay:textAt(row,I.pay),
|
||||
yn:textAt(row,I.yn),order:textAt(row,I.order),pm:textAt(row,I.pm),status:textAt(row,I.status),client:textAt(row,I.client),
|
||||
split:textAt(row,I.split),cDate:textAt(row,I.cDate),sDate:textAt(row,I.sDate),eDate:textAt(row,I.eDate),
|
||||
cSup:moneyAt(row,I.cSup),cVat:moneyAt(row,I.cVat),cTot:moneyAt(row,I.cTot),issueDate:textAt(row,I.issueDate),colDate:textAt(row,I.colDate),
|
||||
sSup:moneyAt(row,I.sSup),sVat:moneyAt(row,I.sVat),sTot:moneyAt(row,I.sTot),col:moneyAt(row,I.col),recv:moneyAt(row,I.recv),rateRaw:textAt(row,I.r),
|
||||
note:textAt(row,I.note),cmCo:textAt(row,I.cmCo),cmNm:textAt(row,I.cmNm),cmDp:textAt(row,I.cmDp),cmPh:textAt(row,I.cmPh),cmEm:textAt(row,I.cmEm),
|
||||
dmCo:textAt(row,I.dmCo),dmNm:textAt(row,I.dmNm),dmDp:textAt(row,I.dmDp),dmPh:textAt(row,I.dmPh),dmEm:textAt(row,I.dmEm)
|
||||
};
|
||||
if(!x.cTot) x.cTot=x.cSup+x.cVat;
|
||||
if(!x.sTot) x.sTot=x.sSup+x.sVat;
|
||||
if(!x.recv) x.recv=Math.max(0,x.sTot-x.col);
|
||||
x.rate=rate(x.rateRaw,x.col,x.sTot);
|
||||
const isProject=!!(x.code||(x.name&&(x.cat||x.corp||x.client||x.yn||x.order||x.pm)));
|
||||
const isPayment=!isProject&&!!(x.pay||x.name||x.issueDate||x.colDate||x.sSup||x.sVat||x.sTot||x.col||x.recv);
|
||||
if(isProject){
|
||||
if(!x.name&&!x.code) continue;
|
||||
if(current) out.push(finalizeProject(current));
|
||||
current={...x,payments:[]};
|
||||
continue;
|
||||
}
|
||||
if(isPayment&¤t) current.payments.push(paymentRecord(x,x.pay));
|
||||
}
|
||||
if(current) out.push(finalizeProject(current));
|
||||
return out;
|
||||
}
|
||||
function extractLedgerTotals(rows){
|
||||
const indexes={contract:20,collected:26,receivable:27,rate:28};
|
||||
let summaryRow=null;
|
||||
for(let i=(rows||[]).length-1;i>=0;i--){
|
||||
const row=rows[i]||[];
|
||||
const hasSummaryLabel=row.some(cell=>String(cell??"").replace(/\s+/g,"").includes("합계"));
|
||||
if(hasSummaryLabel){summaryRow=row;break;}
|
||||
}
|
||||
if(!summaryRow) return null;
|
||||
const contract=num(summaryRow[indexes.contract]);
|
||||
const collected=num(summaryRow[indexes.collected]);
|
||||
const receivable=num(summaryRow[indexes.receivable]);
|
||||
const rateRaw=String(summaryRow[indexes.rate]??"").trim();
|
||||
if(!(contract||collected||receivable||rateRaw)) return null;
|
||||
const totalBase=collected+receivable;
|
||||
return {contract,collected,receivable,rate:rate(rateRaw,collected,totalBase)};
|
||||
}
|
||||
function parseLedger(txt){
|
||||
const rows=parseCsv(txt);
|
||||
return {records:parseLedgerRecords(rows),totals:extractLedgerTotals(rows)};
|
||||
}
|
||||
function parseLedgerExcel(buf){
|
||||
if(typeof XLSX==="undefined")throw new Error("XLSX 라이브러리를 불러오지 못했습니다.");
|
||||
const wb=XLSX.read(buf,{type:"array",cellDates:false});
|
||||
const outsourceMap=parseOutsourceSheets(wb);
|
||||
const names=wb.SheetNames||[];
|
||||
const preferredNames=names.filter(name=>String(name||"").includes("공유사업관리대장"));
|
||||
const candidateNames=preferredNames.length?preferredNames:[...names];
|
||||
let bestRecords=null;
|
||||
let bestSheet="";
|
||||
let bestScore=-1;
|
||||
let bestTotals=null;
|
||||
for(const name of candidateNames){
|
||||
try{
|
||||
const sheet=wb.Sheets[name];
|
||||
const rows=XLSX.utils.sheet_to_json(sheet,{header:1,raw:false,defval:""});
|
||||
const normalized=(rows||[]).map(r=>Array.isArray(r)?r.map(v=>String(v??"")):[]);
|
||||
const records=attachOutsourceCosts(parseLedgerRecords(normalized),outsourceMap);
|
||||
if(!records.length) continue;
|
||||
const totals=extractLedgerTotals(normalized);
|
||||
const bonus=String(name||"").includes("공유사업관리대장")?1000000:/사업관리대장/i.test(String(name||""))?10000:0;
|
||||
const score=records.length+bonus;
|
||||
if(score>bestScore){
|
||||
bestScore=score;
|
||||
bestRecords=records;
|
||||
bestSheet=name;
|
||||
bestTotals=totals;
|
||||
}
|
||||
}catch(_){
|
||||
// try next sheet
|
||||
}
|
||||
}
|
||||
if(!bestRecords) throw new Error("엑셀에서 사업관리대장 헤더를 찾지 못했습니다.");
|
||||
return { records: bestRecords, sheetName: bestSheet, totals: bestTotals };
|
||||
}
|
||||
function decode(buf){const u=new TextDecoder("utf-8").decode(buf);let e="";try{e=new TextDecoder("euc-kr").decode(buf);}catch(_){e=u;}return score(e)>score(u)?e:u;}
|
||||
function sumRows(rows){return rows.reduce((a,r)=>(a.c+=r.cTot||0,a.s+=r.sTot||0,a.col+=r.col||0,a.recv+=r.recv||0,a),{c:0,s:0,col:0,recv:0});}
|
||||
function isSettledRow(r){
|
||||
const noSales=(r.sTot||0)<=0&&(r.col||0)<=0&&(r.recv||0)<=0;
|
||||
const statusDone=String(r.status||"").includes("완료");
|
||||
const coopDone=String(r.yn||"").includes("업무협조")&&statusDone&&noSales;
|
||||
return coopDone||(statusDone&&Math.round(r.recv||0)<=0&&(r.rate||0)>=100);
|
||||
}
|
||||
function hasActiveDashboardFilters(){
|
||||
return !!String(E.search.value||"").trim()||FILTER_KEYS.some(key=>!!S.filters[key]);
|
||||
}
|
||||
function codeFilterLabel(r){return r.cat||"-";}
|
||||
function periodFilterLabel(r){return `${d(r.sDate)} ~ ${d(r.eDate)}`;}
|
||||
function outsourceFilterLabel(r){return r.outsourceCost?won(r.outsourceCost):"-";}
|
||||
function amountFilterLabel(r){return won(r.cSup);}
|
||||
function collectedFilterLabel(r){return won(r.col);}
|
||||
function rateFilterLabel(r){return r.rate.toFixed(2)+"%";}
|
||||
function uniqueFilterValues(rows,mapFn){
|
||||
const seen=new Set(),out=[];
|
||||
for(const row of rows){
|
||||
const value=String(mapFn(row)||"").trim();
|
||||
if(!value||seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function filterDefinitions(){
|
||||
return [
|
||||
{key:"code",map:codeFilterLabel},
|
||||
{key:"name",map:r=>r.name||"-"},
|
||||
{key:"corp",map:r=>r.corp||"-"},
|
||||
{key:"status",map:r=>r.status||"-"},
|
||||
{key:"outsource",map:outsourceFilterLabel},
|
||||
{key:"amount",map:amountFilterLabel},
|
||||
{key:"collected",map:collectedFilterLabel},
|
||||
{key:"rate",map:rateFilterLabel}
|
||||
];
|
||||
}
|
||||
function closeFilterMenus(){
|
||||
Object.values(E.filterMenus).forEach(menu=>menu.classList.remove("open"));
|
||||
Object.values(E.filterButtons).forEach(btn=>btn.classList.remove("open"));
|
||||
}
|
||||
function updateFilterButtons(){
|
||||
FILTER_KEYS.forEach(key=>{
|
||||
const btn=E.filterButtons[key];
|
||||
if(!btn) return;
|
||||
const active=!!S.filters[key];
|
||||
btn.classList.toggle("active",active);
|
||||
btn.title=active?`${btn.dataset.label}: ${S.filters[key]}`:btn.dataset.label||"";
|
||||
const mark=btn.querySelector(".th-mark");
|
||||
if(mark) mark.textContent=active?"•":"";
|
||||
});
|
||||
}
|
||||
function renderFilterMenu(key,values){
|
||||
const menu=E.filterMenus[key];
|
||||
if(!menu) return;
|
||||
const current=String(S.filters[key]||"");
|
||||
menu.innerHTML=`<button type="button" class="th-option${!current?" active":""}" data-filter-value="">전체</button>`+values.map(v=>`<button type="button" class="th-option${current===v?" active":""}" data-filter-value="${escAttr(v)}">${esc(v)}</button>`).join("");
|
||||
}
|
||||
function syncColumnFilters(rows){
|
||||
filterDefinitions().forEach(def=>{
|
||||
const values=uniqueFilterValues(rows,def.map);
|
||||
if(S.filters[def.key]&&!values.includes(S.filters[def.key])) delete S.filters[def.key];
|
||||
renderFilterMenu(def.key,values);
|
||||
});
|
||||
updateFilterButtons();
|
||||
}
|
||||
function toggleFilterMenu(key){
|
||||
const menu=E.filterMenus[key],btn=E.filterButtons[key];
|
||||
if(!menu||!btn) return;
|
||||
const willOpen=!menu.classList.contains("open");
|
||||
closeFilterMenus();
|
||||
if(willOpen){
|
||||
menu.classList.add("open");
|
||||
btn.classList.add("open");
|
||||
}
|
||||
}
|
||||
function setFilterValue(key,value){
|
||||
if(value) S.filters[key]=value;
|
||||
else delete S.filters[key];
|
||||
syncColumnFilters(S.all);
|
||||
closeFilterMenus();
|
||||
filter();
|
||||
}
|
||||
function matchesColumnFilters(r){
|
||||
if(S.filters.code&&codeFilterLabel(r)!==S.filters.code) return false;
|
||||
if(S.filters.name&&(r.name||"-")!==S.filters.name) return false;
|
||||
if(S.filters.corp&&(r.corp||"-")!==S.filters.corp) return false;
|
||||
if(S.filters.status&&(r.status||"-")!==S.filters.status) return false;
|
||||
if(S.filters.outsource&&outsourceFilterLabel(r)!==S.filters.outsource) return false;
|
||||
if(S.filters.amount&&amountFilterLabel(r)!==S.filters.amount) return false;
|
||||
if(S.filters.collected&&collectedFilterLabel(r)!==S.filters.collected) return false;
|
||||
if(S.filters.rate&&rateFilterLabel(r)!==S.filters.rate) return false;
|
||||
return true;
|
||||
}
|
||||
function setText(id,v){const el=G(id);if(el)el.textContent=v||"-";}
|
||||
function renderPaymentsHtml(payments){
|
||||
if(!payments||!payments.length) return '<div class="pay-empty">대금 차수 정보가 없습니다.</div>';
|
||||
return payments.map(p=>`<div class="pay-item"><div class="pay-head"><div class="pay-name">${esc(p.pay||"미입력")}</div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap">${esc(p.status||"-")}</div></div><div class="pay-meta"><span>발행일 ${esc(d(p.issueDate))}</span><span>수금일 ${esc(d(p.collectDate))}</span><span>공급가액 ${esc(won(p.supply))}</span><span>수금금액 ${esc(won(p.collected))}</span></div>${p.note?`<div class="pay-note">비고: ${esc(p.note)}</div>`:""}</div>`).join("");
|
||||
}
|
||||
function renderOutsourcePayments(payments){
|
||||
const list=(payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn));
|
||||
if(!list.length) return "";
|
||||
return `<div class="out-payments">${list.map((payment,index)=>`<div class="out-payment"><div class="out-payment-head"><span>${esc(payment.label||`\uC9C0\uAE09 ${index+1}`)}</span><span>${esc(payment.paymentDate?d(payment.paymentDate):"-")}</span></div><div class="out-payment-meta"><span>\uACC4\uC0B0\uC11C\uC77C\uC790 ${esc(payment.invoiceDate?d(payment.invoiceDate):"-")}</span><span>\uC9C0\uAE09\uAE08\uC561 ${esc(payment.paymentEx?won(payment.paymentEx):"-")}</span><span>\uC794\uC5EC\uAE08\uC561 ${esc(payment.remainingEx||payment.remainingEx===0?won(payment.remainingEx):"-")}</span></div>${payment.note?`<div class="out-note">\uBE44\uACE0: ${esc(payment.note)}</div>`:""}</div>`).join("")}</div>`;
|
||||
}
|
||||
function countOutsourceStages(r){
|
||||
return (r.outsourceItems||[]).reduce((sum,item)=>{
|
||||
const stages=(item.payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn));
|
||||
return sum+(stages.length||1);
|
||||
},0);
|
||||
}
|
||||
function summarizeOutsourceCounts(r){
|
||||
const vendors=(r.outsourceVendors||[]).length;
|
||||
const contracts=(r.outsourceItems||[]).length;
|
||||
const stages=countOutsourceStages(r);
|
||||
const parts=[];
|
||||
if(vendors) parts.push(`외주처 ${vendors.toLocaleString("ko-KR")}곳`);
|
||||
if(contracts) parts.push(`계약 ${contracts.toLocaleString("ko-KR")}건`);
|
||||
if(stages) parts.push(`지급단계 ${stages.toLocaleString("ko-KR")}건`);
|
||||
return parts.join(" · ")||"외주 내역 없음";
|
||||
}
|
||||
function renderOutsourceHtml(items){
|
||||
if(!items||!items.length) return '<div class="pay-empty">외주 상세 정보가 없습니다.</div>';
|
||||
return items.map(item=>{
|
||||
const stageCount=(item.payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn)).length;
|
||||
const stageText=stageCount?`지급단계 ${stageCount.toLocaleString("ko-KR")}건`:"지급내역 없음";
|
||||
const periodText=(d(item.startDate)==="-"&&d(item.endDate)==="-")?"-":`${d(item.startDate)} ~ ${d(item.endDate)}`;
|
||||
return `<div class="out-item"><div class="out-head"><div><div class="out-vendor">${esc(item.vendor||"외주")}</div><div class="out-name">${esc(item.detail||"-")}</div></div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap">${esc(item.progress||stageText)}</div></div><div class="out-meta"><span>계약기간 ${esc(periodText)}</span><span>계약금액 ${esc(item.contractEx?won(item.contractEx):"-")}</span><span>지급금액 ${esc(item.paidEx||item.paidEx===0?won(item.paidEx):"-")}</span><span>잔여금액 ${esc(item.remainingEx||item.remainingEx===0?won(item.remainingEx):"-")}</span><span>계산서일자 ${esc(item.invoiceDate?d(item.invoiceDate):"-")}</span><span>${esc(stageText)}</span></div>${item.note?`<div class="out-note">비고: ${esc(item.note)}</div>`:""}${renderOutsourcePayments(item.payments||[])}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
function renderContactCompact(label,name,company,dept,phone,email){
|
||||
return `<div class="summary-card"><div class="summary-label">${esc(label)}</div><div style="margin-top:6px;font-size:16px;font-weight:900">${esc(name||"-")}</div><div class="summary-note">${esc([company||"-",dept||"-"].join(" · "))}</div><div class="summary-note">${esc(`전화 ${phone||"-"} / 메일 ${email||"-"}`)}</div></div>`;
|
||||
}
|
||||
function renderOutsourceBoard(r){
|
||||
const items=r.outsourceItems||[];
|
||||
if(!items.length){
|
||||
return `<div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">등록된 외주 데이터 없음</div></div></div><div class="ledger-pill">총 계약 0원</div></div><div class="ledger-empty">외주 상세 정보가 없습니다.</div></div>`;
|
||||
}
|
||||
return `<div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 계약 ${esc(r.outsourceCost?won(r.outsourceCost):"-")}</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>외주처 / 계약명</th><th>계약기간</th><th style="text-align:right">계약금액</th><th style="text-align:right">지급금액</th><th style="text-align:right">잔여금액</th><th>진행현황</th><th>비고</th></tr></thead><tbody>${items.map(item=>{const periodText=(d(item.startDate)==="-"&&d(item.endDate)==="-")?"-":`${d(item.startDate)} ~ ${d(item.endDate)}`;const noteLines=(item.payments||[]).map(payment=>{const label=String(payment.label||"").trim();const note=String(payment.note||"").trim();if(!label&&!note) return "";if(label&¬e) return `${label}: ${note}`;return label||note;}).filter(Boolean);if(item.note) noteLines.unshift(item.note);return `<tr><td><span class="ledger-main">${esc(item.vendor||"외주")}</span><span class="ledger-muted">${esc(item.detail||"-")}</span></td><td><span class="ledger-main">${esc(periodText)}</span></td><td class="ledger-amount">${esc(item.contractEx?won(item.contractEx):"-")}</td><td class="ledger-amount">${esc(item.paidEx||item.paidEx===0?won(item.paidEx):"-")}</td><td class="ledger-amount">${esc(item.remainingEx||item.remainingEx===0?won(item.remainingEx):"-")}</td><td><span class="ledger-note">${esc(item.progress||"-")}</span></td><td><span class="ledger-note">${esc(noteLines.join(" / ")||"-")}</span></td></tr>`;}).join("")}</tbody></table></div></div>`;
|
||||
}
|
||||
function renderCollectionBoard(r){
|
||||
const payments=r.payments&&r.payments.length?r.payments:[{pay:r.pay||"-",issueDate:r.issueDate||"",collectDate:r.collectDateSummary||r.colDate||"",supply:r.sSup||0,collected:r.col||0,receivable:r.recv||Math.max(0,(r.sTot||0)-(r.col||0)),rate:r.rate||0,note:r.note||"",status:r.status||"-"}];
|
||||
return `<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 수금 ${esc(won(r.col))}</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>발행 / 수금일</th><th>구분</th><th style="text-align:right">공급가액</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th style="text-align:right">수금율</th><th>비고</th></tr></thead><tbody>${payments.map(payment=>{const dateParts=[payment.issueDate?`발행 ${d(payment.issueDate)}`:"",payment.collectDate?`수금 ${d(payment.collectDate)}`:""].filter(Boolean);const noteParts=[];if(payment.status) noteParts.push(payment.status);if(payment.note) noteParts.push(payment.note);return `<tr><td><span class="ledger-main">${esc(dateParts[0]||"-")}</span><span class="ledger-muted">${esc(dateParts[1]||"수금일 없음")}</span></td><td><span class="ledger-main">${esc(payment.pay||"미입력")}</span></td><td class="ledger-amount">${esc(won(payment.supply||0))}</td><td class="ledger-amount">${esc(won(payment.collected||0))}</td><td class="ledger-amount">${esc(won(payment.receivable||0))}</td><td class="ledger-amount">${esc(((payment.rate||0).toFixed?payment.rate.toFixed(2):Number(payment.rate||0).toFixed(2))+"%")}</td><td><span class="ledger-note">${esc(noteParts.join(" / ")||"-")}</span></td></tr>`;}).join("")}</tbody></table></div></div>`;
|
||||
}
|
||||
function renderProjectInline(r){
|
||||
const payments=r.payments||[];
|
||||
const latestCollect=d(r.collectDateSummary||r.colDate);
|
||||
const collectCountText=payments.length?`차수 ${payments.length.toLocaleString("ko-KR")}건`:"수금 내역 없음";
|
||||
const outsourceCountText=summarizeOutsourceCounts(r);
|
||||
const hasOutsource=(r.outsourceItems||[]).length>0||(r.outsourceCost||0)>0||(r.outsourcePaid||0)>0||(r.outsourceRemaining||0)>0;
|
||||
const summaryCards=[
|
||||
`<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">${esc(won(r.cSup))}</div><div class="summary-note">VAT 별도</div></div>`,
|
||||
`<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">${esc(won(r.col))}</div><div class="summary-note">${esc(latestCollect==="-"?"수금일 없음":`최종 수금일 ${latestCollect}`)}</div></div>`,
|
||||
`<div class="summary-card"><div class="summary-label">수금율</div><div class="summary-value">${esc(r.rate.toFixed(2)+"%")}</div><div class="summary-note">${esc(collectCountText)}</div></div>`
|
||||
].filter(Boolean).join("");
|
||||
const bottomNotes=[
|
||||
`<div class="summary-note">미수금액 ${esc(won(r.recv))}</div>`
|
||||
].join("");
|
||||
const boards=[
|
||||
hasOutsource?renderOutsourceBoard(r):"",
|
||||
renderCollectionBoard(r)
|
||||
].filter(Boolean).join("");
|
||||
return `<div class="inline-panel"><div class="project-head"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">${esc(r.corp||"-")}</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">${esc(r.client||"-")}</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">${esc(r.order||"-")}</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">${esc(r.pm||"-")}</div></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px">${renderContactCompact("계약 / 청구 담당자",r.cmNm,r.cmCo,r.cmDp,r.cmPh,r.cmEm)}${renderContactCompact("부서 담당자",r.dmNm,r.dmCo,r.dmDp,r.dmPh,r.dmEm)}</div></div><div class="inline-card"><div class="summary-grid">${summaryCards}</div><div style="margin-top:10px" class="progress"><div class="bar" style="width:${Math.max(0,Math.min(100,r.rate||0))}%"></div></div><div style="display:flex;justify-content:space-between;gap:10px;margin-top:10px">${bottomNotes}</div></div></div><div class="ledger-stack">${boards}</div></div>`;
|
||||
}
|
||||
function closeAllModals(){
|
||||
E.collectModal.classList.remove("show");
|
||||
E.outsourceModal.classList.remove("show");
|
||||
}
|
||||
function toggleInlineDetail(r){
|
||||
const key=rowKey(r);
|
||||
S.expanded.key=S.expanded.key===key?"":key;
|
||||
render();
|
||||
}
|
||||
function openCollectionModal(r){
|
||||
setText("mCat",r.cat||"미분류");G("mCat").classList.toggle("ok",(r.status||"").includes("완료"));setText("mTitle",r.name||"-");setText("mSub","Project Code: "+(r.code||"-")+" · 계약법인: "+(r.corp||"-"));
|
||||
setText("mClient",r.client||"-");setText("mOrder",r.order||"-");setText("mSplit",r.split||"-");setText("mStartDate",d(r.sDate));setText("mEndDate",d(r.eDate));setText("mPayType",r.pay||"-");G("mPayItems").innerHTML=renderPaymentsHtml(r.payments||[]);
|
||||
setText("mContractTotal",won(r.cTot));setText("mContractSupply","공급가액: "+won(r.cSup));setText("mCollected",won(r.col));setText("mCollectDate",(r.payments&&r.payments.length>1?"최근 수금일: ":"수금일: ")+d(r.collectDateSummary||r.colDate));setText("mRate",r.rate.toFixed(2)+"%");setText("mReceivable",won(r.recv));G("mRateBar").style.width=Math.max(0,Math.min(100,r.rate||0))+"%";
|
||||
setText("mCmName",r.cmNm||"-");setText("mCmOrg",(r.cmCo||"-")+" · "+(r.cmDp||"-"));setText("mCmPhone","전화: "+(r.cmPh||"-"));setText("mCmEmail","메일: "+(r.cmEm||"-"));
|
||||
setText("mDmName",r.dmNm||"-");setText("mDmOrg",(r.dmCo||"-")+" · "+(r.dmDp||"-"));setText("mDmPhone","전화: "+(r.dmPh||"-"));setText("mDmEmail","메일: "+(r.dmEm||"-"));
|
||||
closeAllModals();
|
||||
E.collectModal.classList.add("show");
|
||||
}
|
||||
function openOutsourceModal(r){
|
||||
setText("oTitle",r.name||"-");
|
||||
setText("oSub","Project Code: "+(r.code||"-")+" · PM: "+(r.pm||"-"));
|
||||
setText("oCorp",r.corp||"-");
|
||||
setText("oClient",r.client||"-");
|
||||
setText("oVendors",r.outsourceVendorText||"-");
|
||||
setText("oTotal",r.outsourceCost?won(r.outsourceCost):"-");
|
||||
setText("oCount",(r.outsourceItems||[]).length?`${(r.outsourceItems||[]).length.toLocaleString("ko-KR")}건`:"0건");
|
||||
setText("oPeriod",r.periodText||"-");
|
||||
setText("oTotalHero",r.outsourceCost?won(r.outsourceCost):"-");
|
||||
setText("oTotalHint",(r.outsourceItems||[]).length?"시트별 외주 상세 내역 합산":"외주 상세 정보가 없습니다.");
|
||||
G("oItems").innerHTML=renderOutsourceHtml(r.outsourceItems||[]);
|
||||
closeAllModals();
|
||||
E.outsourceModal.classList.add("show");
|
||||
}
|
||||
function outsourceSummaryText(r){
|
||||
const contracts=(r.outsourceItems||[]).length;
|
||||
const stages=countOutsourceStages(r);
|
||||
const parts=[];
|
||||
if(contracts) parts.push(`계약 ${contracts.toLocaleString("ko-KR")}건`);
|
||||
if(stages) parts.push(`지급단계 ${stages.toLocaleString("ko-KR")}건`);
|
||||
if(parts.length) return parts.join(" · ");
|
||||
return "-";
|
||||
}
|
||||
function render(){
|
||||
const rows=S.rows,t=sumRows(rows),viewRows=rows.slice().sort((a,b)=>{const as=isSettledRow(a),bs=isSettledRow(b);if(as!==bs)return as?1:-1;return (b.recv||0)-(a.recv||0);});
|
||||
const useSheetTotals=!!(S.totals&&!hasActiveDashboardFilters());
|
||||
const totalContract=useSheetTotals?S.totals.contract:t.c;
|
||||
const totalCollected=useSheetTotals?S.totals.collected:t.col;
|
||||
const totalReceivable=useSheetTotals?S.totals.receivable:t.recv;
|
||||
const totalRate=useSheetTotals?S.totals.rate:rate("",totalCollected,totalCollected+totalReceivable);
|
||||
S.viewRows=viewRows;
|
||||
E.cards.innerHTML=[["총 프로젝트수",rows.length.toLocaleString("ko-KR")+" 건"],["총 계약금",won(totalContract)],["총 수금금액",won(totalCollected)],["총 미수금액",won(totalReceivable)],["총 수금율",totalRate.toFixed(2)+"%"]].map(c=>`<div class="card"><div class="k">${esc(c[0])}</div><div class="v">${esc(c[1])}</div></div>`).join("");
|
||||
E.tbody.innerHTML=viewRows.map((r,i)=>{
|
||||
const key=rowKey(r);
|
||||
const detailOpen=S.expanded.key===key;
|
||||
const detailHtml=detailOpen?renderProjectInline(r):"";
|
||||
return `<tr data-i="${i}" class="${isSettledRow(r)?"settled":""}"><td><div class="badge">${esc(r.cat||"-")}</div><div class="subline">ID: ${esc(r.code||"-")}</div></td><td><div class="name">${esc(r.name||"-")}</div><div class="subline">${esc(r.periodText||"-")}</div></td><td><div>${esc(r.corp||"-")}</div></td><td><div class="badge ${(r.status||"").includes("완료")?"ok":""}">${esc(r.status||"-")}</div><div class="subline">${esc(r.yn||"-")}</div></td><td class="num"><strong>${esc(r.outsourceCost?won(r.outsourceCost):"-")}</strong></td><td class="num"><strong>${esc(won(r.cSup))}</strong></td><td class="num"><strong>${esc(won(r.col))}</strong></td><td class="num"><strong style="color:${isSettledRow(r)?"#94a3b8":"#2563eb"}">${esc(r.rate.toFixed(2)+"%")}</strong></td></tr>${detailHtml?`<tr class="detail-row"><td class="detail-cell" colspan="8">${detailHtml}</td></tr>`:""}`;
|
||||
}).join("");
|
||||
E.empty.style.display=rows.length?"none":"block";
|
||||
const settledCount=S.all.filter(isSettledRow).length;
|
||||
E.status.textContent=S.all.length?`로드 완료: ${S.all.length.toLocaleString("ko-KR")}건${S.file?` · 파일: ${S.file}`:""}${settledCount?` · 완납 ${settledCount.toLocaleString("ko-KR")}건 하단 정렬`:""}`:"CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.";
|
||||
}
|
||||
function filter(){const q=String(E.search.value||"").trim().toLowerCase();const searched=!q?S.all.slice():S.all.filter(r=>[r.code,r.name,r.client,r.pm,r.status,r.cat,r.corp,r.pay,(r.payments||[]).map(p=>p.pay).join(" "),r.periodText,r.outsourceVendorText,(r.outsourceItems||[]).map(item=>[item.vendor,item.detail,item.progress,item.note,(item.payments||[]).map(payment=>[payment.label,payment.note,payment.invoiceDate,payment.paymentDate].join(" ")).join(" ")].join(" ")).join(" "),outsourceFilterLabel(r),amountFilterLabel(r),collectedFilterLabel(r)].join(" ").toLowerCase().includes(q));S.rows=searched.filter(matchesColumnFilters);render();}
|
||||
function applyParsedLedgerResult(fileName,parsed,sheetName){
|
||||
S.all=parsed.records;
|
||||
S.totals=parsed.totals||null;
|
||||
S.file=(fileName||"")+(sheetName?` [${sheetName}]`:"");
|
||||
syncColumnFilters(S.all);
|
||||
filter();
|
||||
}
|
||||
async function loadLedgerFile(buffer,fileName){
|
||||
const isExcel=/\.(xlsx|xls)$/i.test(String(fileName||""));
|
||||
if(isExcel){
|
||||
const parsed=parseLedgerExcel(buffer);
|
||||
applyParsedLedgerResult(fileName,parsed,parsed.sheetName||"");
|
||||
return;
|
||||
}
|
||||
const parsed=parseLedger(decode(buffer));
|
||||
applyParsedLedgerResult(fileName,parsed,"");
|
||||
}
|
||||
E.btnUpload.addEventListener("click",()=>E.file.click());
|
||||
E.file.addEventListener("change",async e=>{
|
||||
const f=e.target.files&&e.target.files[0];
|
||||
try{
|
||||
if(f){
|
||||
const buf=await f.arrayBuffer();
|
||||
await loadLedgerFile(buf,f.name||"");
|
||||
}
|
||||
}catch(err){
|
||||
S.all=[];S.rows=[];S.totals=null;syncColumnFilters([]);closeAllModals();render();E.status.textContent="업로드 실패: "+(err&&err.message?err.message:String(err));
|
||||
}
|
||||
e.target.value="";
|
||||
});
|
||||
E.search.addEventListener("input",filter);
|
||||
Object.values(E.filterButtons).forEach(btn=>btn.addEventListener("click",e=>{e.stopPropagation();toggleFilterMenu(btn.dataset.filter);}));
|
||||
Object.values(E.filterMenus).forEach(menu=>menu.addEventListener("click",e=>{
|
||||
e.stopPropagation();
|
||||
const option=e.target&&e.target.closest?e.target.closest("button[data-filter-value]"):null;
|
||||
if(!option) return;
|
||||
setFilterValue(menu.dataset.filter,option.getAttribute("data-filter-value")||"");
|
||||
}));
|
||||
E.tbody.addEventListener("click",e=>{
|
||||
const rowEl=e.target&&e.target.closest?e.target.closest("tr[data-i]"):null;
|
||||
if(!rowEl) return;
|
||||
const r=S.viewRows[parseInt(rowEl.getAttribute("data-i"),10)];
|
||||
if(!r) return;
|
||||
toggleInlineDetail(r);
|
||||
});
|
||||
E.btnCollectClose.addEventListener("click",closeAllModals);
|
||||
E.btnOutsourceClose.addEventListener("click",closeAllModals);
|
||||
E.collectModal.addEventListener("click",e=>{if(e.target===E.collectModal)closeAllModals();});
|
||||
E.outsourceModal.addEventListener("click",e=>{if(e.target===E.outsourceModal)closeAllModals();});
|
||||
document.addEventListener("click",e=>{if(!(e.target&&e.target.closest&&e.target.closest(".th-head")))closeFilterMenus();});
|
||||
document.addEventListener("keydown",e=>{if(e.key==="Escape"){closeFilterMenus();closeAllModals();}});
|
||||
window.addEventListener("message",async e=>{
|
||||
const data=e.data||{};
|
||||
if(data.source==="total-control"&&data.type==="embedded-host") E.btnUpload.style.display="none";
|
||||
if(data.source!=="total-upload"||data.type!=="business") return;
|
||||
try{
|
||||
const buffer=data.buffer instanceof ArrayBuffer?data.buffer:(data.buffer&&data.buffer.buffer instanceof ArrayBuffer?data.buffer.buffer:null);
|
||||
if(!buffer) throw new Error("업로드 데이터가 비어 있습니다.");
|
||||
await loadLedgerFile(buffer,data.fileName||"사업관리대장.xlsx");
|
||||
}catch(err){
|
||||
S.all=[];S.rows=[];S.totals=null;syncColumnFilters([]);closeAllModals();render();E.status.textContent="업로드 실패: "+(err&&err.message?err.message:String(err));
|
||||
}
|
||||
});
|
||||
syncColumnFilters([]);
|
||||
render();
|
||||
</script>
|
||||
__LEDGER_BODY_SCRIPTS__</body>
|
||||
</html>
|
||||
18
frontend/apps/payment/README.md
Normal file
18
frontend/apps/payment/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Payment App Source
|
||||
|
||||
`프로젝트별 분석` 화면의 앱 구조 source-of-truth 디렉터리다.
|
||||
|
||||
원칙:
|
||||
|
||||
- 실제 runtime 응답은 여전히 `incoming-files/served/payment.html`을 사용한다.
|
||||
- 수정 원본은 이 디렉터리의 `index.html`만 본다.
|
||||
- 반영은 `scripts/publish_payment_app.sh`로 수행한다.
|
||||
|
||||
구성:
|
||||
|
||||
- `index.html`: 프로젝트별 분석 standalone app 원본
|
||||
|
||||
주의:
|
||||
|
||||
- runtime을 수정할 때 `incoming-files/served/payment.html`부터 고치지 않는다.
|
||||
- 먼저 `frontend/apps/payment/index.html`을 수정한 뒤 publish 한다.
|
||||
1622
frontend/apps/payment/index.html
Normal file
1622
frontend/apps/payment/index.html
Normal file
File diff suppressed because it is too large
Load Diff
18
frontend/apps/team/README.md
Normal file
18
frontend/apps/team/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Team App Source
|
||||
|
||||
`팀/개인별 분석` 화면의 앱 구조 source-of-truth 디렉터리다.
|
||||
|
||||
원칙:
|
||||
|
||||
- 실제 runtime 응답은 `incoming-files/served/mh.html`을 사용한다.
|
||||
- 수정 원본은 이 디렉터리의 `index.html`만 본다.
|
||||
- 반영은 `scripts/publish_team_app.sh`로 수행한다.
|
||||
|
||||
구성:
|
||||
|
||||
- `index.html`: 팀/개인별 분석 standalone app 원본
|
||||
|
||||
주의:
|
||||
|
||||
- runtime을 수정할 때 `incoming-files/served/mh.html`부터 고치지 않는다.
|
||||
- 먼저 `frontend/apps/team/index.html`을 수정한 뒤 publish 한다.
|
||||
3472
frontend/apps/team/index.html
Normal file
3472
frontend/apps/team/index.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ const projectFrame = document.getElementById("project-frame");
|
||||
const projectStage = document.getElementById("project-stage");
|
||||
const teamFrame = document.getElementById("team-frame");
|
||||
const teamStage = document.getElementById("team-stage");
|
||||
const dbStatusFrame = document.getElementById("db-status-frame");
|
||||
const dbStatusStage = document.getElementById("db-status-stage");
|
||||
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
|
||||
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
|
||||
const emptyStage = document.getElementById("empty-stage");
|
||||
@@ -115,6 +117,7 @@ const viewLabels = {
|
||||
project: "프로젝트별 분석",
|
||||
team: "팀/개인별 분석",
|
||||
organization: "조직 현황",
|
||||
"db-status": "DB 상태",
|
||||
"seatmap-admin": "자리배치도",
|
||||
"seatmap-readonly": "자리배치도",
|
||||
};
|
||||
@@ -1623,6 +1626,7 @@ function setActiveView(view) {
|
||||
const isLedger = currentView === "ledger";
|
||||
const isProject = currentView === "project";
|
||||
const isTeam = currentView === "team";
|
||||
const isDbStatus = currentView === "db-status";
|
||||
const isSeatMapAdmin = currentView === "seatmap-admin";
|
||||
const isSeatMapReadonly = currentView === "seatmap-readonly";
|
||||
if (ledgerStage) {
|
||||
@@ -1641,6 +1645,10 @@ function setActiveView(view) {
|
||||
teamStage.hidden = !isTeam;
|
||||
teamStage.style.display = isTeam ? "flex" : "none";
|
||||
}
|
||||
if (dbStatusStage) {
|
||||
dbStatusStage.hidden = !isDbStatus;
|
||||
dbStatusStage.style.display = isDbStatus ? "flex" : "none";
|
||||
}
|
||||
if (seatMapAdminStage) {
|
||||
seatMapAdminStage.hidden = !isSeatMapAdmin;
|
||||
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
|
||||
@@ -1650,7 +1658,7 @@ function setActiveView(view) {
|
||||
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
|
||||
}
|
||||
if (emptyStage) {
|
||||
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
|
||||
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly;
|
||||
emptyStage.hidden = !showEmpty;
|
||||
emptyStage.style.display = showEmpty ? "flex" : "none";
|
||||
}
|
||||
@@ -1677,6 +1685,10 @@ function setActiveView(view) {
|
||||
} else if (isTeam) {
|
||||
postGlobalDateRangeToFrame(teamFrame);
|
||||
}
|
||||
if (isDbStatus && previousView !== "db-status" && dbStatusFrame) {
|
||||
const frameSrc = dbStatusFrame.dataset.src || dbStatusFrame.src;
|
||||
dbStatusFrame.src = resolveAppUrl(frameSrc);
|
||||
}
|
||||
if (isSeatMapAdmin || isSeatMapReadonly) {
|
||||
loadSeatMapData();
|
||||
}
|
||||
|
||||
720
frontend/public/db-status.html
Normal file
720
frontend/public/db-status.html
Normal file
@@ -0,0 +1,720 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DB 상태</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||
color: var(--ds-ink, #2f2419);
|
||||
}
|
||||
.page {
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: rgba(76, 58, 35, 0.82);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.kpi {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 252, 247, 0.92);
|
||||
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||
}
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(112, 84, 41, 0.72);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #3d2e1d;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 251, 245, 0.96);
|
||||
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.panel-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.panel-body {
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.panel-body.tight {
|
||||
padding-top: 0;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 236, 196, 0.8);
|
||||
color: #7a5923;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 10px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: rgba(104, 79, 40, 0.76);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: rgba(250, 240, 213, 0.34);
|
||||
}
|
||||
.domain-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(90, 122, 94, 0.14);
|
||||
color: #456b4c;
|
||||
}
|
||||
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||
.group-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(240, 231, 214, 0.95);
|
||||
color: #674d27;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||
.table-title {
|
||||
font-weight: 800;
|
||||
color: #2f2419;
|
||||
}
|
||||
.table-trigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: #2f2419;
|
||||
font-weight: 800;
|
||||
}
|
||||
.table-trigger:hover {
|
||||
color: #80591f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.table-desc {
|
||||
margin-top: 5px;
|
||||
color: rgba(98, 75, 42, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.view-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.view-pill {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(86, 119, 93, 0.12);
|
||||
color: #456b4c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.notes {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: rgba(84, 65, 38, 0.84);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.preview-meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px 18px 0;
|
||||
}
|
||||
.preview-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 231, 214, 0.9);
|
||||
color: #634a25;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.column-pill em {
|
||||
font-style: normal;
|
||||
color: rgba(99, 74, 37, 0.68);
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.sticky-head th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(255, 248, 236, 0.98);
|
||||
z-index: 1;
|
||||
}
|
||||
.muted {
|
||||
color: rgba(110, 86, 50, 0.72);
|
||||
}
|
||||
.empty {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(44, 31, 16, 0.42);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal-card {
|
||||
width: min(1600px, 100%);
|
||||
max-height: min(88vh, 980px);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 250, 243, 0.98);
|
||||
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 16px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.modal-close {
|
||||
border: 0;
|
||||
background: rgba(240, 229, 206, 0.9);
|
||||
color: #6d5127;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(225, 208, 174, 0.96);
|
||||
}
|
||||
.modal-body {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 16px; }
|
||||
.hero { padding: 22px 20px; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
th, td { padding: 10px 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||
<p>
|
||||
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||
</p>
|
||||
<p>
|
||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="overview" class="overview"></section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>전체 테이블 현황</h2>
|
||||
<p>전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도메인</th>
|
||||
<th>테이블</th>
|
||||
<th>Rows</th>
|
||||
<th>최근 갱신</th>
|
||||
<th>연결 화면</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div style="display:grid; gap:20px;">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>원본 import 배치</h2>
|
||||
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Rows</th>
|
||||
<th>Imported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batch-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>바이너리 원본 보관</h2>
|
||||
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>파일</th>
|
||||
<th>크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="binary-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 메모</h2>
|
||||
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ol id="notes" class="notes"></ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>테이블 분류</h2>
|
||||
<p>유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="group-summary" class="panel-body"></div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||
</div>
|
||||
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="preview-meta" class="preview-meta"></div>
|
||||
<div class="panel-body tight">
|
||||
<div class="preview-table-wrap">
|
||||
<table>
|
||||
<thead id="preview-head" class="sticky-head"></thead>
|
||||
<tbody id="preview-body">
|
||||
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||
<td>
|
||||
<div style="margin-bottom:8px;">
|
||||
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||
</div>
|
||||
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.last_event_at)}</td>
|
||||
<td>
|
||||
<div class="view-list">
|
||||
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatches(items) {
|
||||
const target = document.getElementById("batch-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.imported_at)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderBinarySources(items) {
|
||||
const target = document.getElementById("binary-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(item.filename)}</td>
|
||||
<td>${formatBytes(item.byte_size)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
const target = document.getElementById("notes");
|
||||
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||
}
|
||||
|
||||
function renderGroupSummary(summary) {
|
||||
const target = document.getElementById("group-summary");
|
||||
const groups = [
|
||||
["유지", "keep"],
|
||||
["주의", "caution"],
|
||||
["원본·추적", "trace"],
|
||||
["정리 후보", "cleanup"],
|
||||
];
|
||||
target.innerHTML = groups.map(([label, klass]) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTablePreview(payload) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
|
||||
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||
previewMeta.innerHTML = `
|
||||
<div>
|
||||
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||
</div>
|
||||
<div class="preview-columns">
|
||||
${(payload.columns || []).map((column) => `
|
||||
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const columns = payload.columns || [];
|
||||
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||
if (!payload.rows || !payload.rows.length) {
|
||||
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
return;
|
||||
}
|
||||
previewBody.innerHTML = payload.rows.map((row) => `
|
||||
<tr>
|
||||
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||
</tr>
|
||||
`).join("");
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
async function loadTablePreview(schema, table) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
previewTitle.textContent = `${table}`;
|
||||
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||
previewMeta.innerHTML = "";
|
||||
previewHead.innerHTML = "";
|
||||
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
renderTablePreview(payload);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
document.getElementById("generated-at").textContent = payload.generated_at
|
||||
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||
: "갱신 시각 없음";
|
||||
renderOverview(payload.overview || {});
|
||||
renderTables(payload.tables || []);
|
||||
renderBatches(payload.import_batches || []);
|
||||
renderBinarySources(payload.binary_sources || []);
|
||||
renderNotes(payload.notes || []);
|
||||
renderGroupSummary(payload.group_summary || {});
|
||||
}
|
||||
|
||||
document.getElementById("preview-close").addEventListener("click", () => {
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||
if (event.target.id !== "preview-modal") return;
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -79,6 +79,7 @@
|
||||
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
|
||||
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
|
||||
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
|
||||
<button class="nav-pill" type="button" data-view="db-status">DB 상태</button>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
@@ -119,6 +120,11 @@
|
||||
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section id="db-status-stage" class="main-stage" hidden>
|
||||
<div class="stage-frame">
|
||||
<iframe id="db-status-frame" src="/db-status.html?v=20260401-02" data-src="/db-status.html?v=20260401-02" title="DB 상태 화면"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section id="seatmap-admin-stage" class="main-stage" hidden>
|
||||
<div class="seatmap-layout">
|
||||
<div class="seatmap-topbar">
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
- 현재 사용 파일:
|
||||
- `served/payment.html`
|
||||
- `served/mh.html`
|
||||
- `served/ledger/index.html`
|
||||
|
||||
주의:
|
||||
|
||||
- backend `/integrations/payment`, `/integrations/mh`는 위 `served/*`만 읽는다.
|
||||
- backend `/integrations/ledger`와 `/integrations/ledger-assets/*`도 `served/ledger/*`만 읽는다.
|
||||
- 새 기능을 붙일 때도 실제 서비스 파일은 `served/` 기준으로 수정한다.
|
||||
|
||||
## Reference
|
||||
@@ -26,6 +28,8 @@
|
||||
- 샘플 스타일 파일
|
||||
- 원본/백업 HTML
|
||||
- 디자인 비교용 파일
|
||||
- `reference/ledger/MH 통합 대시보드_260320.html`
|
||||
- `reference/ledger/MH 통합 대시보드_260320.css`
|
||||
|
||||
## Temporary Comparison Copies
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
# Reference Assets
|
||||
|
||||
이 디렉터리는 앞으로 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으기 위한 공간이다.
|
||||
이 디렉터리는 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으는 공간이다.
|
||||
|
||||
1차 정리에서는 위험한 대량 이동을 피하기 위해 기존 참고 파일을 즉시 옮기지 않는다.
|
||||
대신 실제 서빙 파일은 `incoming-files/served/`로 고정하고, 다음 차수에서 참고 자산을 단계적으로 재배치한다.
|
||||
`#21` 2차부터 실제 reference 재배치를 시작했다.
|
||||
|
||||
현재 포함:
|
||||
|
||||
- `ledger/`
|
||||
- 사업관리대장 원본 wrapper/html/css/xlsx
|
||||
- 이전 override 복사본
|
||||
- 중첩 백업 디렉터리
|
||||
|
||||
규칙:
|
||||
|
||||
- runtime은 이 디렉터리를 직접 서빙하지 않는다.
|
||||
- 실제 서비스 수정은 `incoming-files/served/` 기준으로 먼저 반영한다.
|
||||
- reference는 비교, 복구, 출처 확인이 필요할 때만 본다.
|
||||
|
||||
예상 대상:
|
||||
|
||||
|
||||
328
incoming-files/reference/ledger/ledger-override.css
Normal file
328
incoming-files/reference/ledger/ledger-override.css
Normal file
@@ -0,0 +1,328 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body.mh-business-theme {
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(214, 138, 58, 0.16), transparent 24%),
|
||||
radial-gradient(circle at top right, rgba(47, 153, 115, 0.10), transparent 20%),
|
||||
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
|
||||
}
|
||||
|
||||
body.mh-business-theme .wrap {
|
||||
width: min(100%, 2000px);
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 18px 26px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.mh-business-theme .top,
|
||||
body.mh-business-theme .status {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .business-shell {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 2px;
|
||||
padding: 18px;
|
||||
border-radius: 32px;
|
||||
background:
|
||||
radial-gradient(circle at 16% 14%, rgba(255,255,255,0.05), transparent 18%),
|
||||
radial-gradient(circle at 88% 8%, rgba(255,255,255,0.04), transparent 16%),
|
||||
linear-gradient(145deg, #0b352b 0%, #174e41 52%, #245f50 100%);
|
||||
box-shadow: 0 26px 54px rgba(15, 58, 47, 0.16);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 10px 0 2px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-search {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: min(360px, 100%);
|
||||
flex: 1 1 320px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-search .search {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(255,255,255,0.10);
|
||||
color: #f4efe6;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-search .search::placeholder {
|
||||
color: rgba(244, 239, 230, 0.74);
|
||||
}
|
||||
|
||||
body.mh-business-theme #btnUpload {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-year-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 60px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #f4efe6;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-year-chip.active {
|
||||
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
|
||||
color: #0a2a22;
|
||||
border-color: rgba(242, 196, 132, 0.58);
|
||||
box-shadow: 0 12px 28px rgba(10, 42, 34, 0.18);
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 98px;
|
||||
padding: 18px 22px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.07) 100%);
|
||||
color: #f4efe6;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 16px 30px rgba(7, 28, 22, 0.14);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip.active {
|
||||
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
|
||||
color: #0a2a22;
|
||||
border-color: rgba(242, 196, 132, 0.58);
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip .label {
|
||||
color: rgba(244, 239, 230, 0.78);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip.active .label {
|
||||
color: rgba(10, 42, 34, 0.78);
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip .count {
|
||||
color: #fff7e6;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip.active .count {
|
||||
color: #b86b1f;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip .meta {
|
||||
color: #f2c484;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip.active .meta {
|
||||
color: #7c5a20;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card {
|
||||
grid-column: span 2;
|
||||
min-height: 110px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(217, 197, 168, 0.55);
|
||||
background: linear-gradient(180deg, rgba(255,250,243,0.96) 0%, rgba(248,242,232,0.96) 100%);
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
|
||||
}
|
||||
|
||||
body.mh-business-theme .card.management {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card .k {
|
||||
color: #5b6d63;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card .v {
|
||||
margin-top: 8px;
|
||||
color: #17392f;
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card .n {
|
||||
margin-top: 8px;
|
||||
color: #7b6953;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body.mh-business-theme .panel {
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(217, 197, 168, 0.55);
|
||||
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
|
||||
}
|
||||
|
||||
body.mh-business-theme .table-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 28px;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
body.mh-business-theme .table-vat-note {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mh-business-theme table {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
table-layout: fixed;
|
||||
background: rgba(255, 250, 243, 0.96);
|
||||
}
|
||||
|
||||
body.mh-business-theme thead th {
|
||||
background: #0f352b;
|
||||
color: #fff5e6;
|
||||
border-right: 1px solid rgba(242, 196, 132, 0.2);
|
||||
}
|
||||
|
||||
body.mh-business-theme tbody td {
|
||||
background: rgba(255, 250, 243, 0.96);
|
||||
}
|
||||
|
||||
body.mh-business-theme .group-row td {
|
||||
padding: 12px 14px 10px;
|
||||
background: linear-gradient(180deg, rgba(255, 248, 238, 0.98) 0%, rgba(242, 222, 192, 0.78) 100%);
|
||||
border-top: 1px solid rgba(214, 138, 58, 0.26);
|
||||
border-bottom: 1px solid rgba(217, 197, 168, 0.54);
|
||||
}
|
||||
|
||||
body.mh-business-theme .group-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 250, 243, 0.98);
|
||||
border: 1px solid rgba(214, 138, 58, 0.3);
|
||||
color: #17392f;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 8px 18px rgba(15, 58, 47, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.mh-business-theme .group-chip .group-toggle {
|
||||
margin-left: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(242, 196, 132, 0.18);
|
||||
color: #b66e22;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
body.mh-business-theme .project-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #17392f;
|
||||
font: inherit;
|
||||
font-weight: 900;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.mh-business-theme .project-link:hover {
|
||||
color: #0f6a55;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
body.mh-business-theme .cards-toolbar-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card {
|
||||
grid-column: span 4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
body.mh-business-theme .wrap {
|
||||
padding: 12px 12px 20px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-search {
|
||||
margin-left: 0;
|
||||
max-width: none;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
498
incoming-files/reference/ledger/ledger-override.js
Normal file
498
incoming-files/reference/ledger/ledger-override.js
Normal file
@@ -0,0 +1,498 @@
|
||||
(function () {
|
||||
window.__mhLedgerEnhancementLoaded = false;
|
||||
if (typeof S === "undefined" || typeof E === "undefined" || typeof render !== "function") return;
|
||||
window.__mhLedgerEnhancementLoaded = true;
|
||||
if (!S.dashboard) S.dashboard = { year: "", section: "active" };
|
||||
if (!S.collapsedGroups) S.collapsedGroups = {};
|
||||
|
||||
function bgToday() {
|
||||
var now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
function bgParseDate(value) {
|
||||
var text = String(value || "").trim();
|
||||
if (!text) return null;
|
||||
var match = text.match(/(20\d{2})\D?(\d{1,2})\D?(\d{1,2})/);
|
||||
if (match) {
|
||||
var parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
var fallback = new Date(text);
|
||||
if (isNaN(fallback.getTime())) return null;
|
||||
return new Date(fallback.getFullYear(), fallback.getMonth(), fallback.getDate());
|
||||
}
|
||||
|
||||
function bgYearFromText(value) {
|
||||
var match = String(value || "").trim().match(/(20\d{2})/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function bgStartYear(row) {
|
||||
return bgYearFromText(row && row.sDate);
|
||||
}
|
||||
|
||||
function bgEndYear(row) {
|
||||
return bgYearFromText(row && row.eDate);
|
||||
}
|
||||
|
||||
function bgDisplayYear(row) {
|
||||
var start = bgStartYear(row);
|
||||
if (start) return start;
|
||||
var contractMatch = String((row && row.cDate) || "").trim().match(/(20\d{2})/);
|
||||
if (contractMatch) return contractMatch[1];
|
||||
var nameMatch = String((row && row.name) || "").trim().match(/^(20\d{2})/);
|
||||
if (nameMatch) return nameMatch[1];
|
||||
return bgEndYear(row) || "미지정";
|
||||
}
|
||||
|
||||
function bgCompletionYear(row) {
|
||||
return bgEndYear(row) || bgDisplayYear(row);
|
||||
}
|
||||
|
||||
function bgDateOrYearStart(row) {
|
||||
var yearText = bgDisplayYear(row);
|
||||
return bgParseDate(row && row.sDate) || bgParseDate(row && row.cDate) || (/^20\d{2}$/.test(yearText) ? new Date(Number(yearText), 0, 1) : null);
|
||||
}
|
||||
|
||||
function bgDateOrYearEnd(row) {
|
||||
var completionYear = bgCompletionYear(row);
|
||||
return bgParseDate(row && row.eDate) || (/^20\d{2}$/.test(completionYear) ? new Date(Number(completionYear), 11, 31) : null);
|
||||
}
|
||||
|
||||
function bgYearCutoff(year) {
|
||||
var targetYear = Number(year || 0);
|
||||
if (!targetYear) return null;
|
||||
var today = bgToday();
|
||||
if (targetYear < today.getFullYear()) return new Date(targetYear, 11, 31);
|
||||
if (targetYear === today.getFullYear()) return today;
|
||||
return null;
|
||||
}
|
||||
|
||||
function bgYearStartDate(year) {
|
||||
var targetYear = Number(year || 0);
|
||||
return targetYear ? new Date(targetYear, 0, 1) : null;
|
||||
}
|
||||
|
||||
function bgActiveInYear(row, year) {
|
||||
var cutoff = bgYearCutoff(year);
|
||||
var yearStart = bgYearStartDate(year);
|
||||
var startDate = bgDateOrYearStart(row);
|
||||
var endDate = bgDateOrYearEnd(row);
|
||||
if (!(cutoff && yearStart && startDate)) return false;
|
||||
if (startDate > cutoff) return false;
|
||||
if (endDate && endDate < yearStart) return false;
|
||||
return !(endDate && endDate <= cutoff);
|
||||
}
|
||||
|
||||
function bgStartedInYear(row, year) {
|
||||
var cutoff = bgYearCutoff(year);
|
||||
var startDate = bgDateOrYearStart(row);
|
||||
if (!(cutoff && startDate)) return false;
|
||||
return startDate.getFullYear() === Number(year || 0) && startDate <= cutoff;
|
||||
}
|
||||
|
||||
function bgCompletedInYear(row, year) {
|
||||
var cutoff = bgYearCutoff(year);
|
||||
var endDate = bgDateOrYearEnd(row);
|
||||
if (!(cutoff && endDate)) return false;
|
||||
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
||||
}
|
||||
|
||||
function bgYearRange(row) {
|
||||
var years = [];
|
||||
var startYear = Number(bgDisplayYear(row) || 0);
|
||||
var endYear = Number(bgCompletionYear(row) || 0);
|
||||
if (startYear && endYear && endYear >= startYear) {
|
||||
for (var year = startYear; year <= endYear; year += 1) years.push(String(year));
|
||||
} else if (startYear) {
|
||||
years.push(String(startYear));
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
function bgYears(rows) {
|
||||
var currentYear = new Date().getFullYear();
|
||||
var years = Array.from(new Set((Array.isArray(rows) ? rows : []).flatMap(bgYearRange).filter(function (year) {
|
||||
return /^20\d{2}$/.test(year);
|
||||
}))).sort(function (a, b) {
|
||||
return Number(b) - Number(a);
|
||||
});
|
||||
years = years.filter(function (year) {
|
||||
var numericYear = Number(year);
|
||||
return numericYear >= 2018 && numericYear <= currentYear;
|
||||
});
|
||||
return years.length ? years : [String(currentYear)];
|
||||
}
|
||||
|
||||
function bgEnsureYear(rows) {
|
||||
var years = bgYears(rows);
|
||||
if (!years.includes(S.dashboard.year)) S.dashboard.year = years[0];
|
||||
return years;
|
||||
}
|
||||
|
||||
function bgTotals(targetRows) {
|
||||
return (Array.isArray(targetRows) ? targetRows : []).reduce(function (acc, row) {
|
||||
acc.c += Number((row && row.cSup) || 0);
|
||||
acc.col += Number((row && row.col) || 0);
|
||||
acc.recv += Number((row && row.recv) || 0);
|
||||
return acc;
|
||||
}, { c: 0, col: 0, recv: 0 });
|
||||
}
|
||||
|
||||
function isSupportServiceRow(row) {
|
||||
var category = String((row && row.cat) || "").trim();
|
||||
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
|
||||
}
|
||||
|
||||
function isBaronProjectRow(row) {
|
||||
var category = String((row && row.cat) || "").trim();
|
||||
if (category.indexOf("바론") < 0) return false;
|
||||
if (isSupportServiceRow(row)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function bgSummarize(rows, selectedYear) {
|
||||
var items = Array.isArray(rows) ? rows : [];
|
||||
var targetYear = selectedYear || bgEnsureYear(items)[0];
|
||||
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
||||
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
||||
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
||||
var managementRows = newProjectRows.filter(isSupportServiceRow);
|
||||
return {
|
||||
targetYear: targetYear,
|
||||
activeRows: activeRows,
|
||||
newProjectRows: newProjectRows,
|
||||
completedRows: completedRows,
|
||||
managementRows: managementRows,
|
||||
managementTotals: bgTotals(managementRows)
|
||||
};
|
||||
}
|
||||
|
||||
function bgMatches(row) {
|
||||
var section = S.dashboard.section || "active";
|
||||
var selectedYear = S.dashboard.year || bgEnsureYear(S.all)[0];
|
||||
if (section === "new") return bgStartedInYear(row, selectedYear);
|
||||
if (section === "completed") return bgCompletedInYear(row, selectedYear);
|
||||
return bgActiveInYear(row, selectedYear);
|
||||
}
|
||||
|
||||
function normalizeStatusLabel(status) {
|
||||
var value = String(status || "").trim();
|
||||
if (!value) return "-";
|
||||
if (value.indexOf("진행") >= 0) return "과업 진행중";
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatSplitPercent(split) {
|
||||
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
||||
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
||||
return "분담율 " + numeric.toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function projectYear(row) {
|
||||
var start = String((row && row.sDate) || "").trim();
|
||||
var startMatch = start.match(/(20\d{2})/);
|
||||
if (startMatch) return startMatch[1];
|
||||
var name = String((row && row.name) || "").trim();
|
||||
var nameMatch = name.match(/^(20\d{2})/);
|
||||
if (nameMatch) return nameMatch[1];
|
||||
var end = String((row && row.eDate) || "").trim();
|
||||
var endMatch = end.match(/(20\d{2})/);
|
||||
if (endMatch) return endMatch[1];
|
||||
return "미지정";
|
||||
}
|
||||
|
||||
function groupSortRank(row) {
|
||||
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
|
||||
var startYear = Number(projectYear(row) || 0);
|
||||
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
|
||||
if (!startYear) return 9998;
|
||||
return startYear;
|
||||
}
|
||||
|
||||
function tableGroupLabel(row) {
|
||||
var startYear = projectYear(row);
|
||||
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
|
||||
return "미지정";
|
||||
}
|
||||
|
||||
function renderLedgerTable() {
|
||||
var table = document.querySelector(".panel table");
|
||||
if (!table || !E.tbody) return;
|
||||
var thead = table.querySelector("thead");
|
||||
if (thead) {
|
||||
thead.innerHTML = '<tr>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="cat" data-label="구분"><span class="th-title">구분</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCatMenu" class="th-menu" data-filter="cat"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="code" data-label="사업코드"><span class="th-title">사업코드</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCodeMenu" class="th-menu" data-filter="code"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="name" data-label="사업명(계약명)"><span class="th-title">사업명(계약명)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterNameMenu" class="th-menu" data-filter="name"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="client" data-label="발주처(계약처)"><span class="th-title">발주처(계약처)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterClientMenu" class="th-menu" data-filter="client"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="order" data-label="발주방법"><span class="th-title">발주방법</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOrderMenu" class="th-menu" data-filter="order"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="status" data-label="진행상태"><span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterStatusMenu" class="th-menu" data-filter="status"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="amount" data-label="계약금"><span class="th-title">계약금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterAmountMenu" class="th-menu" data-filter="amount"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="outsource" data-label="외주비"><span class="th-title">외주비</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="receivable" data-label="미수금"><span class="th-title">미수금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterReceivableMenu" class="th-menu" data-filter="receivable"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="collected" data-label="수금액"><span class="th-title">수금액</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
|
||||
+ "</tr>";
|
||||
}
|
||||
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
|
||||
var ar = groupSortRank(a);
|
||||
var br = groupSortRank(b);
|
||||
if (ar !== br) return ar - br;
|
||||
return Number(b.recv || 0) - Number(a.recv || 0);
|
||||
});
|
||||
S.viewRows = rows;
|
||||
var lastGroupLabel = "";
|
||||
E.tbody.innerHTML = rows.map(function (r) {
|
||||
var groupLabel = tableGroupLabel(r);
|
||||
var isCollapsed = !!S.collapsedGroups[groupLabel];
|
||||
var groupRow = "";
|
||||
if (groupLabel !== lastGroupLabel) {
|
||||
groupRow = '<tr class="group-row"><td colspan="11"><button type="button" class="group-chip" data-group-label="' + escAttr(groupLabel) + '"><span>' + esc(groupLabel) + '</span><span class="group-toggle" aria-hidden="true">' + (isCollapsed ? "+" : "-") + "</span></button></td></tr>";
|
||||
lastGroupLabel = groupLabel;
|
||||
}
|
||||
if (isCollapsed) return groupRow;
|
||||
return groupRow + '<tr class="' + (isSettledRow(r) ? 'settled' : '') + '">'
|
||||
+ '<td><div class="badge ' + esc(String(r.cat || "").indexOf("바론") >= 0 ? 'badge-baron' : 'badge-family') + '">' + esc(r.cat || "-") + '</div></td>'
|
||||
+ '<td><div class="subline" style="margin-top:0;font-size:12px;color:#66756d">' + esc(r.code || "-") + '</div></td>'
|
||||
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
|
||||
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
|
||||
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
|
||||
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.col || 0)) + '</strong></td>'
|
||||
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
||||
+ '</tr>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderCollectionBoard(r) {
|
||||
var payments = Array.isArray(r.payments) && r.payments.length ? r.payments : [{
|
||||
pay: r.pay || "-",
|
||||
issueDate: r.issueDate || "",
|
||||
collectDate: r.collectDateSummary || r.colDate || "",
|
||||
collected: r.col || 0,
|
||||
receivable: r.recv || Math.max(0, Number(r.sTot || 0) - Number(r.col || 0)),
|
||||
note: r.note || "",
|
||||
status: r.status || ""
|
||||
}];
|
||||
return '<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">기성 차수별 세금계산서 발행 및 수금 내역</div></div></div><div class="ledger-pill">총 수금 ' + esc(won(r.col || 0)) + '</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>기성 차수</th><th>세금계산서 발행일</th><th>수금일</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th>비고</th></tr></thead><tbody>'
|
||||
+ payments.map(function (payment, index) {
|
||||
var noteParts = [];
|
||||
if (payment.status) noteParts.push(payment.status);
|
||||
if (payment.note) noteParts.push(payment.note);
|
||||
return '<tr><td><span class="ledger-main">' + esc((index + 1) + "차") + '</span><span class="ledger-muted">' + esc(payment.pay || "-") + '</span></td><td><span class="ledger-main">' + esc(payment.issueDate ? d(payment.issueDate) : "-") + '</span></td><td><span class="ledger-main">' + esc(payment.collectDate ? d(payment.collectDate) : "-") + '</span></td><td class="ledger-amount">' + esc(won(payment.collected || 0)) + '</td><td class="ledger-amount" style="color:#a94832">' + esc(won(payment.receivable || 0)) + '</td><td><span class="ledger-note">' + esc(noteParts.join(" / ") || "-") + '</span></td></tr>';
|
||||
}).join("")
|
||||
+ "</tbody></table></div></div>";
|
||||
}
|
||||
|
||||
function renderContactCard(label, name, company, department, phone, email) {
|
||||
var hasValue = [name, company, department, phone, email].some(function (value) {
|
||||
return String(value || "").trim() !== "";
|
||||
});
|
||||
if (!hasValue) {
|
||||
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="summary-note">등록된 담당자 정보가 없습니다.</div></div>';
|
||||
}
|
||||
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="project-meta-grid">'
|
||||
+ '<div class="kv"><div class="kvk">이름</div><div class="kvv">' + esc(name || "-") + '</div></div>'
|
||||
+ '<div class="kv"><div class="kvk">소속</div><div class="kvv">' + esc(company || "-") + '</div><div class="summary-note">' + esc(department || "-") + '</div></div>'
|
||||
+ '<div class="kv"><div class="kvk">연락처</div><div class="kvv">' + esc(phone || "-") + '</div></div>'
|
||||
+ '<div class="kv"><div class="kvk">이메일</div><div class="kvv">' + esc(email || "-") + '</div></div>'
|
||||
+ "</div></div>";
|
||||
}
|
||||
|
||||
function renderProjectInline(r) {
|
||||
var payments = Array.isArray(r.payments) ? r.payments : [];
|
||||
var latestCollect = d(r.collectDateSummary || r.colDate);
|
||||
var hasOutsource = (Array.isArray(r.outsourceItems) && r.outsourceItems.length > 0) || Number(r.outsourceCost || 0) > 0 || Number(r.outsourcePaid || 0) > 0 || Number(r.outsourceRemaining || 0) > 0;
|
||||
var clientDisplay = typeof normalizeClientDisplay === "function" ? normalizeClientDisplay(r.client) : (String(r.client || "").trim() || "-");
|
||||
var splitDisplay = typeof formatSplitDisplay === "function" ? formatSplitDisplay(r.split) : formatSplitPercent(r.split).replace("분담율 ", "");
|
||||
var summaryCards = [
|
||||
'<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">' + esc(won(r.cSup || 0)) + '</div><div class="summary-note"></div></div>',
|
||||
'<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">' + esc(won(r.col || 0)) + '</div><div class="summary-note">' + esc(latestCollect === "-" ? "수금일 없음" : "최종 수금일 " + latestCollect) + '</div></div>',
|
||||
'<div class="summary-card"><div class="summary-label">수금률</div><div class="summary-value">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</div><div class="summary-note">' + esc(payments.length ? "기성 " + payments.length + "차까지 반영" : "차수 정보 없음") + '</div></div>',
|
||||
'<div class="summary-card receivable"><div class="summary-label">미수금액</div><div class="summary-value">' + esc(won(r.recv || 0)) + '</div><div class="summary-note">잔여 수금 필요 금액</div></div>'
|
||||
].join("");
|
||||
var boards = [
|
||||
hasOutsource && typeof renderOutsourceBoard === "function" ? renderOutsourceBoard(r) : "",
|
||||
renderCollectionBoard(r)
|
||||
].filter(Boolean).join("");
|
||||
return '<div class="inline-panel"><div class="project-head project-head-grid"><div class="project-head-main"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">' + esc(r.corp || "-") + '</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">' + esc(clientDisplay) + '</div><div class="summary-note">' + esc(splitDisplay ? "분담율 " + splitDisplay : "분담율 -") + '</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">' + esc(r.order || "-") + '</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">' + esc(r.pm || "-") + '</div></div></div></div><div class="inline-card"><div class="summary-grid">' + summaryCards + '</div><div class="project-progress progress"><div class="bar" style="width:' + esc(String(Math.max(0, Math.min(100, Number(r.rate || 0))))) + '%"></div></div></div></div><div class="project-contact-stack">' + renderContactCard("계약 / 청구 담당자", r.cmNm, r.cmCo, r.cmDp, r.cmPh, r.cmEm) + renderContactCard("부서 담당자", r.dmNm, r.dmCo, r.dmDp, r.dmPh, r.dmEm) + '</div></div><div class="ledger-stack">' + boards + '</div></div>';
|
||||
}
|
||||
|
||||
function openProjectWindow(r) {
|
||||
var popupKey = typeof rowKey === "function"
|
||||
? rowKey(r).replace(/[^0-9a-zA-Z]/g, "_")
|
||||
: String((r.code || "project") + "_" + (r.name || "")).replace(/[^0-9a-zA-Z_]/g, "_");
|
||||
var popup = window.open("", "business_project_" + popupKey, "width=1600,height=980,resizable=yes,scrollbars=yes");
|
||||
if (!popup) return;
|
||||
var styleText = Array.from(document.querySelectorAll("style")).map(function (el) {
|
||||
return el.textContent || "";
|
||||
}).join("\n");
|
||||
var detailHtml = renderProjectInline(r);
|
||||
var pageHtml = '<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>'
|
||||
+ esc(r.name || "사업 상세")
|
||||
+ '</title><link rel="stylesheet" href="/design-tokens.css?v=20260401-01"><link rel="stylesheet" href="/design-patterns.css?v=20260401-01"><style>' + styleText
|
||||
+ 'body{margin:0;background:#f1eadf;color:#10251d;font-family:"Pretendard","Noto Sans KR","Malgun Gothic",sans-serif;}'
|
||||
+ '.popup-wrap{max-width:1680px;margin:0 auto;padding:20px;}'
|
||||
+ '@media (max-width: 1180px){.project-head-grid{grid-template-columns:1fr;}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.project-meta-grid{grid-template-columns:1fr;}}'
|
||||
+ '@media (max-width: 760px){.popup-wrap{padding:14px;}.summary-grid{grid-template-columns:1fr;}.ledger-head{flex-direction:column;align-items:flex-start;}.ledger-pill{white-space:normal;}.ledger-table-wrap{padding:0 10px 12px;overflow-x:auto;}}'
|
||||
+ '</style></head><body><div class="popup-wrap"><div class="popup-head"><div class="popup-title">' + esc(r.name || "-") + '</div><div class="popup-sub">사업코드 ' + esc(r.code || "-") + ' · 계약법인 ' + esc(r.corp || "-") + '</div></div>' + detailHtml + "</div></body></html>";
|
||||
popup.document.open();
|
||||
popup.document.write(pageHtml);
|
||||
popup.document.close();
|
||||
popup.focus();
|
||||
}
|
||||
|
||||
async function tryLoadDbDefaultBusinessLedger() {
|
||||
if (window.__mhBusinessDefaultLoaded) return;
|
||||
window.__mhBusinessDefaultLoaded = true;
|
||||
try {
|
||||
var response = await fetch("/api/integration/business-ledger-default");
|
||||
if (!response.ok) throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
|
||||
var fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
|
||||
var buffer = await response.arrayBuffer();
|
||||
if (!buffer || !buffer.byteLength) throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
|
||||
await loadLedgerFile(buffer, fileName);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDashboardChrome() {
|
||||
if (!E.cards) return;
|
||||
document.body.setAttribute("data-mh-ledger-enhanced", "true");
|
||||
var wrap = document.querySelector(".wrap");
|
||||
var panel = document.querySelector(".panel");
|
||||
if (wrap && panel) {
|
||||
var shell = wrap.querySelector(".business-shell");
|
||||
if (!shell) {
|
||||
shell = document.createElement("div");
|
||||
shell.className = "business-shell";
|
||||
wrap.insertBefore(shell, E.cards);
|
||||
}
|
||||
if (E.cards.parentNode !== shell) shell.appendChild(E.cards);
|
||||
if (panel.parentNode !== shell) shell.appendChild(panel);
|
||||
}
|
||||
var years = bgEnsureYear(S.all);
|
||||
var summary = bgSummarize(S.all, S.dashboard.year);
|
||||
var rows = Array.isArray(S.rows) ? S.rows : [];
|
||||
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
|
||||
var totals = bgTotals(visibleBaronProjectRows);
|
||||
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
|
||||
var toolbarHtml = '<div class="cards-toolbar">'
|
||||
+ '<div class="cards-toolbar-row">'
|
||||
+ years.map(function (year) {
|
||||
return '<button type="button" class="summary-year-chip ' + (S.dashboard.year === year ? "active" : "") + '" data-dashboard-year="' + escAttr(year) + '">' + esc(year) + "</button>";
|
||||
}).join("")
|
||||
+ '<div class="cards-toolbar-search"></div>'
|
||||
+ "</div>"
|
||||
+ '<div class="cards-toolbar-metrics">'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
|
||||
+ "</div></div>";
|
||||
var cards = [
|
||||
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
|
||||
{ label: "계약금", value: won(totals.c), note: "" },
|
||||
{ label: "수금액", value: won(totals.col), note: "" },
|
||||
{ label: "미수금", value: won(totals.recv), note: "" },
|
||||
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
|
||||
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
||||
];
|
||||
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
||||
return '<div class="card ' + esc(card.className || "") + '"><div class="k">' + esc(card.label) + '</div><div class="v">' + esc(card.value) + '</div><div class="n">' + esc(card.note || "") + "</div></div>";
|
||||
}).join("");
|
||||
var searchWrap = E.cards.querySelector(".cards-toolbar-search");
|
||||
if (searchWrap && E.search) {
|
||||
searchWrap.appendChild(E.search);
|
||||
E.search.placeholder = "전체 검색";
|
||||
}
|
||||
}
|
||||
|
||||
var originalRender = render;
|
||||
render = function () {
|
||||
originalRender();
|
||||
applyDashboardChrome();
|
||||
renderLedgerTable();
|
||||
};
|
||||
|
||||
filter = function () {
|
||||
bgEnsureYear(S.all);
|
||||
var q = String(E.search.value || "").trim().toLowerCase();
|
||||
var searched = !q ? S.all.slice() : S.all.filter(function (r) {
|
||||
return [r.code, r.name, r.client, r.pm, r.status, r.cat, r.corp, r.pay, (r.payments || []).map(function (p) { return p.pay; }).join(" "), r.periodText].join(" ").toLowerCase().includes(q);
|
||||
});
|
||||
S.rows = searched.filter(function (r) {
|
||||
return bgMatches(r) && matchesColumnFilters(r);
|
||||
});
|
||||
render();
|
||||
};
|
||||
|
||||
if (E.cards && !E.cards.dataset.dashboardBound) {
|
||||
E.cards.dataset.dashboardBound = "true";
|
||||
E.cards.addEventListener("click", function (event) {
|
||||
var yearButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-year]") : null;
|
||||
if (yearButton) {
|
||||
S.dashboard.year = yearButton.getAttribute("data-dashboard-year") || S.dashboard.year;
|
||||
filter();
|
||||
return;
|
||||
}
|
||||
var sectionButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-section]") : null;
|
||||
if (sectionButton) {
|
||||
S.dashboard.section = sectionButton.getAttribute("data-dashboard-section") || "active";
|
||||
filter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (E.tbody && !E.tbody.dataset.projectBound) {
|
||||
E.tbody.dataset.projectBound = "true";
|
||||
E.tbody.addEventListener("click", function (event) {
|
||||
var groupButton = event.target && event.target.closest ? event.target.closest("[data-group-label]") : null;
|
||||
if (groupButton) {
|
||||
var label = groupButton.getAttribute("data-group-label") || "";
|
||||
if (label) {
|
||||
S.collapsedGroups[label] = !S.collapsedGroups[label];
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var trigger = event.target && event.target.closest ? event.target.closest(".project-link") : null;
|
||||
if (!trigger) return;
|
||||
var key = trigger.getAttribute("data-project-key") || "";
|
||||
var rows = Array.isArray(S.viewRows) ? S.viewRows : [];
|
||||
var row = rows.find(function (item) {
|
||||
return (String(item.code || "") + "|" + String(item.name || "")) === key;
|
||||
});
|
||||
if (row) openProjectWindow(row);
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
try {
|
||||
filter();
|
||||
if (typeof loadLedgerFile === "function") {
|
||||
tryLoadDbDefaultBusinessLedger();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
var data = event.data || {};
|
||||
if (data.source !== "total-upload" || data.type !== "business") return;
|
||||
setTimeout(function () {
|
||||
try {
|
||||
applyDashboardChrome();
|
||||
renderLedgerTable();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
})();
|
||||
1377
incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.css
Normal file
1377
incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,18 @@
|
||||
|
||||
- `payment.html`
|
||||
- `mh.html`
|
||||
- `ledger/index.html`
|
||||
- `ledger/ledger-override.css`
|
||||
- `ledger/ledger-override.js`
|
||||
- `ledger/MH 통합 대시보드_260320.css`
|
||||
- `ledger/사업관리대장-1.xlsx`
|
||||
|
||||
규칙:
|
||||
|
||||
- `/integrations/payment` 는 이 디렉터리의 `payment.html`을 읽는다.
|
||||
- `/integrations/mh` 는 이 디렉터리의 `mh.html`을 읽는다.
|
||||
- `/integrations/ledger` 는 `ledger/index.html`을 읽는다.
|
||||
- `/integrations/ledger-assets/*` 는 `ledger/` 하위 파일만 읽는다.
|
||||
- `payment.html` 수정 원본은 `frontend/apps/payment/index.html`이고, `scripts/publish_payment_app.sh`로 반영한다.
|
||||
- `mh.html` 수정 원본은 `frontend/apps/team/index.html`이고, `scripts/publish_team_app.sh`로 반영한다.
|
||||
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.
|
||||
|
||||
5
incoming-files/served/db-status/README.md
Normal file
5
incoming-files/served/db-status/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## DB Status Served Output
|
||||
|
||||
- 이 디렉터리는 `frontend/apps/db-status` publish 결과물만 둔다.
|
||||
- backend `/admin/db-status`는 여기의 `index.html`만 서빙한다.
|
||||
- 수정은 직접 여기서 하지 말고 `./scripts/publish_db_status_app.sh`를 사용한다.
|
||||
720
incoming-files/served/db-status/index.html
Normal file
720
incoming-files/served/db-status/index.html
Normal file
@@ -0,0 +1,720 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DB 상태</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||
color: var(--ds-ink, #2f2419);
|
||||
}
|
||||
.page {
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: rgba(76, 58, 35, 0.82);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.kpi {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 252, 247, 0.92);
|
||||
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||
}
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(112, 84, 41, 0.72);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #3d2e1d;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 251, 245, 0.96);
|
||||
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.panel-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.panel-body {
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.panel-body.tight {
|
||||
padding-top: 0;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 236, 196, 0.8);
|
||||
color: #7a5923;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 10px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: rgba(104, 79, 40, 0.76);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: rgba(250, 240, 213, 0.34);
|
||||
}
|
||||
.domain-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(90, 122, 94, 0.14);
|
||||
color: #456b4c;
|
||||
}
|
||||
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||
.group-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(240, 231, 214, 0.95);
|
||||
color: #674d27;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||
.table-title {
|
||||
font-weight: 800;
|
||||
color: #2f2419;
|
||||
}
|
||||
.table-trigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: #2f2419;
|
||||
font-weight: 800;
|
||||
}
|
||||
.table-trigger:hover {
|
||||
color: #80591f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.table-desc {
|
||||
margin-top: 5px;
|
||||
color: rgba(98, 75, 42, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.view-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.view-pill {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(86, 119, 93, 0.12);
|
||||
color: #456b4c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.notes {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: rgba(84, 65, 38, 0.84);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.preview-meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px 18px 0;
|
||||
}
|
||||
.preview-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 231, 214, 0.9);
|
||||
color: #634a25;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.column-pill em {
|
||||
font-style: normal;
|
||||
color: rgba(99, 74, 37, 0.68);
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.sticky-head th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(255, 248, 236, 0.98);
|
||||
z-index: 1;
|
||||
}
|
||||
.muted {
|
||||
color: rgba(110, 86, 50, 0.72);
|
||||
}
|
||||
.empty {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(44, 31, 16, 0.42);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal-card {
|
||||
width: min(1600px, 100%);
|
||||
max-height: min(88vh, 980px);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 250, 243, 0.98);
|
||||
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 16px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.modal-close {
|
||||
border: 0;
|
||||
background: rgba(240, 229, 206, 0.9);
|
||||
color: #6d5127;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(225, 208, 174, 0.96);
|
||||
}
|
||||
.modal-body {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 16px; }
|
||||
.hero { padding: 22px 20px; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
th, td { padding: 10px 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||
<p>
|
||||
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||
</p>
|
||||
<p>
|
||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="overview" class="overview"></section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>전체 테이블 현황</h2>
|
||||
<p>전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도메인</th>
|
||||
<th>테이블</th>
|
||||
<th>Rows</th>
|
||||
<th>최근 갱신</th>
|
||||
<th>연결 화면</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div style="display:grid; gap:20px;">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>원본 import 배치</h2>
|
||||
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Rows</th>
|
||||
<th>Imported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batch-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>바이너리 원본 보관</h2>
|
||||
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>파일</th>
|
||||
<th>크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="binary-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 메모</h2>
|
||||
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ol id="notes" class="notes"></ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>테이블 분류</h2>
|
||||
<p>유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="group-summary" class="panel-body"></div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||
</div>
|
||||
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="preview-meta" class="preview-meta"></div>
|
||||
<div class="panel-body tight">
|
||||
<div class="preview-table-wrap">
|
||||
<table>
|
||||
<thead id="preview-head" class="sticky-head"></thead>
|
||||
<tbody id="preview-body">
|
||||
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||
<td>
|
||||
<div style="margin-bottom:8px;">
|
||||
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||
</div>
|
||||
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.last_event_at)}</td>
|
||||
<td>
|
||||
<div class="view-list">
|
||||
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatches(items) {
|
||||
const target = document.getElementById("batch-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.imported_at)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderBinarySources(items) {
|
||||
const target = document.getElementById("binary-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(item.filename)}</td>
|
||||
<td>${formatBytes(item.byte_size)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
const target = document.getElementById("notes");
|
||||
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||
}
|
||||
|
||||
function renderGroupSummary(summary) {
|
||||
const target = document.getElementById("group-summary");
|
||||
const groups = [
|
||||
["유지", "keep"],
|
||||
["주의", "caution"],
|
||||
["원본·추적", "trace"],
|
||||
["정리 후보", "cleanup"],
|
||||
];
|
||||
target.innerHTML = groups.map(([label, klass]) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTablePreview(payload) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
|
||||
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||
previewMeta.innerHTML = `
|
||||
<div>
|
||||
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||
</div>
|
||||
<div class="preview-columns">
|
||||
${(payload.columns || []).map((column) => `
|
||||
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const columns = payload.columns || [];
|
||||
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||
if (!payload.rows || !payload.rows.length) {
|
||||
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
return;
|
||||
}
|
||||
previewBody.innerHTML = payload.rows.map((row) => `
|
||||
<tr>
|
||||
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||
</tr>
|
||||
`).join("");
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
async function loadTablePreview(schema, table) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
previewTitle.textContent = `${table}`;
|
||||
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||
previewMeta.innerHTML = "";
|
||||
previewHead.innerHTML = "";
|
||||
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
renderTablePreview(payload);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
document.getElementById("generated-at").textContent = payload.generated_at
|
||||
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||
: "갱신 시각 없음";
|
||||
renderOverview(payload.overview || {});
|
||||
renderTables(payload.tables || []);
|
||||
renderBatches(payload.import_batches || []);
|
||||
renderBinarySources(payload.binary_sources || []);
|
||||
renderNotes(payload.notes || []);
|
||||
renderGroupSummary(payload.group_summary || {});
|
||||
}
|
||||
|
||||
document.getElementById("preview-close").addEventListener("click", () => {
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||
if (event.target.id !== "preview-modal") return;
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1377
incoming-files/served/ledger/MH 통합 대시보드_260320.css
Normal file
1377
incoming-files/served/ledger/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
21
incoming-files/served/ledger/README.md
Normal file
21
incoming-files/served/ledger/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Ledger Served Assets
|
||||
|
||||
`8081` 사업관리대장 화면이 실제로 읽는 런타임 파일 모음이다.
|
||||
|
||||
source-of-truth:
|
||||
|
||||
- [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger)
|
||||
- 반영 스크립트: [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)
|
||||
|
||||
- `index.html`: `/integrations/ledger` 응답 본문
|
||||
- `frontend/apps/ledger/index.html` 템플릿에서 publish 시 생성
|
||||
- `MH 통합 대시보드_260320.css`: ledger base stylesheet
|
||||
- `ledger-override.css`: 8081 ledger 디자인/레이아웃 오버라이드
|
||||
- `ledger-override.js`: 8081 ledger 상호작용/테이블/팝업 오버라이드
|
||||
- `사업관리대장-1.xlsx`: startup 시 기본 원본 DB 동기화에 사용하는 기본 데이터 파일
|
||||
|
||||
규칙:
|
||||
|
||||
- backend는 `사업관리대장` 원본 wrapper를 더 이상 직접 읽지 않는다.
|
||||
- runtime asset 수정은 `frontend/apps/ledger` 기준으로 먼저 반영하고, 이 디렉터리로 publish 한다.
|
||||
- 원본 비교가 필요하면 `incoming-files/reference/ledger/`를 본다.
|
||||
954
incoming-files/served/ledger/index.html
Normal file
954
incoming-files/served/ledger/index.html
Normal file
@@ -0,0 +1,954 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>사업관리대장 Dashboard</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f8fafc;color:#0f172a;font-family:'Pretendard','Noto Sans KR','Malgun Gothic',sans-serif}
|
||||
.wrap{max-width:1600px;margin:0 auto;padding:20px}
|
||||
.top{display:grid;grid-template-columns:1fr minmax(260px,520px);gap:12px;align-items:end}
|
||||
.title{font-size:34px;font-weight:900;letter-spacing:-.03em;margin:0}
|
||||
.sub{font-size:12px;color:#64748b;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
|
||||
.controls{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
|
||||
.btn{border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:12px;padding:10px 14px;font-size:13px;font-weight:800;cursor:pointer}
|
||||
.search{flex:1;min-width:250px;border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;font-size:13px;font-weight:700}
|
||||
.status{margin:10px 0 14px;font-size:12px;font-weight:700;color:#64748b}
|
||||
.cards{display:grid;grid-template-columns:repeat(5,minmax(150px,1fr));gap:10px;margin-bottom:12px}
|
||||
.card{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:10px 12px}
|
||||
.card .k{font-size:11px;font-weight:800;color:#64748b}
|
||||
.card .v{font-size:19px;font-weight:900;white-space:nowrap}
|
||||
.panel{background:#fff;border:1px solid #e2e8f0;border-radius:20px;overflow:hidden}
|
||||
.table-wrap{overflow:auto}
|
||||
table{width:100%;min-width:1250px;border-collapse:collapse}
|
||||
thead th{background:#0f172a;color:#ffffffd1;font-size:11px;text-transform:uppercase;letter-spacing:.12em;padding:12px 10px;text-align:left;white-space:nowrap;vertical-align:middle}
|
||||
.th-head{position:relative;display:flex;align-items:center}
|
||||
.th-head.end{justify-content:flex-end}
|
||||
.th-trigger{display:inline-flex;align-items:center;gap:6px;border:0;background:none;padding:0;color:#ffffffd1;font:inherit;font-weight:900;letter-spacing:inherit;text-transform:inherit;cursor:pointer}
|
||||
.th-trigger:hover,.th-trigger.active,.th-trigger.open{color:#fff}
|
||||
.th-title{display:inline-block}
|
||||
.th-meta{font-size:10px;color:#93c5fd;font-weight:800;letter-spacing:0;text-transform:none}
|
||||
.th-mark{display:inline-flex;align-items:center;justify-content:center;min-width:8px;color:#60a5fa;font-size:12px;line-height:1}
|
||||
.th-caret{font-size:10px;color:#93c5fd;transition:transform .15s ease}
|
||||
.th-trigger.open .th-caret{transform:rotate(180deg)}
|
||||
.th-menu{position:absolute;top:calc(100% + 8px);left:0;display:none;min-width:180px;max-width:320px;max-height:280px;overflow:auto;padding:6px;background:#fff;border:1px solid #cbd5e1;border-radius:12px;box-shadow:0 16px 40px #0f172a26;z-index:15}
|
||||
.th-head.end .th-menu{left:auto;right:0}
|
||||
.th-menu.open{display:block}
|
||||
.th-option{display:block;width:100%;border:0;background:none;border-radius:8px;padding:9px 10px;text-align:left;font-size:12px;font-weight:700;color:#0f172a;cursor:pointer;white-space:normal;word-break:break-word}
|
||||
.th-option:hover{background:#eff6ff}
|
||||
.th-option.active{background:#dbeafe;color:#1d4ed8}
|
||||
tbody td{padding:12px;border-bottom:1px solid #f1f5f9;font-size:13px;white-space:nowrap;vertical-align:middle}
|
||||
tbody tr:hover{background:#eff6ff}
|
||||
tbody tr.settled{background:#f8fafc;color:#94a3b8}
|
||||
tbody tr.settled:hover{background:#f1f5f9}
|
||||
tbody tr.settled .name,tbody tr.settled strong{color:#64748b}
|
||||
tbody tr.settled .badge{border-color:#cbd5e1;background:#f8fafc;color:#64748b}
|
||||
.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.name{font-weight:800;max-width:460px;overflow:hidden;text-overflow:ellipsis}
|
||||
.subline{font-size:11px;color:#94a3b8;font-weight:700;margin-top:3px}
|
||||
.badge{display:inline-flex;padding:3px 9px;border-radius:999px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;font-size:11px;font-weight:900}
|
||||
.badge.ok{border-color:#bbf7d0;background:#f0fdf4;color:#047857}
|
||||
.empty{display:none;padding:32px;text-align:center;color:#94a3b8;font-weight:800}
|
||||
.hidden{display:none}
|
||||
.modal{position:fixed;inset:0;background:#020617bf;backdrop-filter:blur(4px);display:none;align-items:center;justify-content:center;padding:16px;z-index:30}
|
||||
.modal.show{display:flex}
|
||||
.modal-card{width:min(1200px,100%);max-height:90vh;overflow:auto;background:#fff;border-radius:24px;border:1px solid #e2e8f0}
|
||||
.m-top{padding:20px;border-bottom:1px solid #f1f5f9;background:#f8fafc;display:flex;justify-content:space-between;gap:10px}
|
||||
.x{width:42px;height:42px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;font-size:22px;font-weight:900;color:#64748b;cursor:pointer}
|
||||
.m-body{padding:18px;display:grid;grid-template-columns:1.5fr 1fr;gap:12px}
|
||||
.sec{border:1px solid #e2e8f0;border-radius:16px;padding:12px}
|
||||
.sec.dark{background:#0f172a;color:#fff;border-color:#0f172a}
|
||||
.grid3{display:grid;grid-template-columns:repeat(3,minmax(100px,1fr));gap:8px}
|
||||
.grid4{display:grid;grid-template-columns:repeat(4,minmax(100px,1fr));gap:8px}
|
||||
.kv{border:1px solid #e2e8f0;border-radius:12px;padding:9px}
|
||||
.kvk{font-size:10px;color:#94a3b8;font-weight:900;text-transform:uppercase}
|
||||
.kvv{font-size:13px;font-weight:800;margin-top:3px;word-break:break-word}
|
||||
.line{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px dashed #e2e8f0;font-size:13px;font-weight:700}
|
||||
.line:last-child{border-bottom:0}
|
||||
.money{font-size:28px;font-weight:900}
|
||||
.progress{height:11px;background:#94a3b833;border-radius:999px;overflow:hidden;margin-top:7px}
|
||||
.bar{height:100%;background:#3b82f6;width:0%}
|
||||
.pay-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
||||
.pay-item{border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;background:#f8fafc}
|
||||
.pay-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
|
||||
.pay-name{font-size:13px;font-weight:900;word-break:break-word}
|
||||
.pay-meta{margin-top:6px;display:grid;grid-template-columns:repeat(2,minmax(120px,1fr));gap:6px 10px;font-size:12px;color:#475569;font-weight:700}
|
||||
.pay-empty{margin-top:10px;border:1px dashed #cbd5e1;border-radius:12px;padding:12px;color:#94a3b8;font-size:12px;font-weight:800;text-align:center}
|
||||
.pay-note{margin-top:8px;border-top:1px dashed #fecaca;padding-top:8px;font-size:12px;color:#b91c1c;font-weight:800;white-space:pre-wrap}
|
||||
.metric-btn{display:inline-flex;flex-direction:column;align-items:flex-end;gap:2px;border:0;background:none;padding:0;color:inherit;font:inherit;cursor:pointer}
|
||||
.metric-btn strong{color:#0f172a;text-decoration:underline;text-decoration-color:#bfdbfe;text-underline-offset:3px}
|
||||
tbody tr.settled .metric-btn strong{color:#64748b}
|
||||
.metric-btn:hover strong{color:#1d4ed8;text-decoration-color:#1d4ed8}
|
||||
.detail-row td{padding:0;border-bottom:1px solid #e2e8f0;background:#f8fafc}
|
||||
.detail-row:hover{background:#f8fafc}
|
||||
.detail-cell{padding:0}
|
||||
.inline-panel{padding:16px 18px}
|
||||
.inline-grid{display:grid;grid-template-columns:1.35fr 1fr;gap:12px}
|
||||
.inline-stack{display:flex;flex-direction:column;gap:10px}
|
||||
.inline-card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:12px}
|
||||
.inline-hero{background:#0f172a;color:#fff;border-color:#0f172a}
|
||||
.inline-hero-note{font-size:12px;color:#94a3b8;margin-top:6px}
|
||||
.inline-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:end}
|
||||
.inline-hero-col{min-width:0}
|
||||
.inline-hero-col.right{padding-left:14px;border-left:1px solid #334155}
|
||||
.out-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
||||
.out-item{border:1px solid #e2e8f0;border-radius:12px;padding:10px 12px;background:#f8fafc}
|
||||
.out-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}
|
||||
.out-vendor{font-size:13px;font-weight:900}
|
||||
.out-name{margin-top:6px;font-size:13px;font-weight:800;word-break:break-word}
|
||||
.out-meta{margin-top:8px;display:grid;grid-template-columns:repeat(2,minmax(140px,1fr));gap:6px 10px;font-size:12px;color:#475569;font-weight:700}
|
||||
.out-payments{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding-top:8px;border-top:1px dashed #cbd5e1}
|
||||
.out-payment{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:8px}
|
||||
.out-payment-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;font-size:12px;font-weight:800}
|
||||
.out-payment-meta{margin-top:6px;display:grid;grid-template-columns:repeat(3,minmax(120px,1fr));gap:4px 8px;font-size:12px;color:#475569;font-weight:700}
|
||||
.out-note{margin-top:8px;border-top:1px dashed #fecaca;padding-top:8px;font-size:12px;color:#b91c1c;font-weight:800;white-space:pre-wrap}
|
||||
.project-head{display:grid;grid-template-columns:1.2fr .8fr;gap:12px;margin-bottom:12px}
|
||||
.project-meta-grid{display:grid;grid-template-columns:repeat(4,minmax(110px,1fr));gap:8px}
|
||||
.project-sections{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.section-card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:14px}
|
||||
.section-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:10px}
|
||||
.section-title{font-size:16px;font-weight:900}
|
||||
.section-sub{margin-top:4px;font-size:12px;color:#64748b;font-weight:800}
|
||||
.section-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:999px;padding:5px 10px;font-size:11px;font-weight:900;white-space:nowrap}
|
||||
.section-chip.out{border-color:#fecdd3;background:#fff1f2;color:#be123c}
|
||||
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px}
|
||||
.summary-card{background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:12px;min-width:0}
|
||||
.summary-label{font-size:11px;color:#64748b;font-weight:900;text-transform:uppercase}
|
||||
.summary-value{margin-top:6px;font-size:clamp(12px,0.95vw,22px);font-weight:900;line-height:1.15;white-space:nowrap;max-width:100%;letter-spacing:-.03em}
|
||||
.summary-note{margin-top:4px;font-size:12px;color:#94a3b8;font-weight:800}
|
||||
.ledger-stack{display:flex;flex-direction:column;gap:14px}
|
||||
.ledger-block{background:#fff;border:1px solid #e2e8f0;border-radius:18px;overflow:hidden}
|
||||
.ledger-block.outsource{border-color:#fecdd3;background:#fff}
|
||||
.ledger-block.collect{border-color:#c7d2fe;background:#fff}
|
||||
.ledger-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px}
|
||||
.ledger-head-left{display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.ledger-icon{width:20px;height:20px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:900;color:#fff;flex:0 0 auto}
|
||||
.ledger-block.outsource .ledger-icon{background:#f43f5e}
|
||||
.ledger-block.collect .ledger-icon{background:#6366f1}
|
||||
.ledger-name{font-size:13px;font-weight:900}
|
||||
.ledger-sub{margin-top:2px;font-size:11px;color:#64748b;font-weight:800}
|
||||
.ledger-pill{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;font-size:11px;font-weight:900;white-space:nowrap}
|
||||
.ledger-block.outsource .ledger-pill{border:1px solid #fecdd3;background:#fff1f2;color:#e11d48}
|
||||
.ledger-block.collect .ledger-pill{border:1px solid #c7d2fe;background:#eef2ff;color:#4f46e5}
|
||||
.ledger-table-wrap{padding:0 12px 12px}
|
||||
.ledger-table{width:100%;min-width:0;border-collapse:collapse}
|
||||
.ledger-table thead th{background:transparent;color:#94a3b8;font-size:11px;font-weight:900;letter-spacing:0;text-transform:none;padding:8px 10px;border-bottom:1px solid #e2e8f0}
|
||||
.ledger-table tbody td{padding:10px;border-bottom:1px solid #eef2f7;font-size:12px;color:#334155;white-space:normal;background:#fff}
|
||||
.ledger-table tbody tr:last-child td{border-bottom:0}
|
||||
.ledger-main{font-weight:800;color:#0f172a}
|
||||
.ledger-muted{display:block;margin-top:3px;font-size:11px;color:#94a3b8;font-weight:700}
|
||||
.ledger-amount{font-weight:900;text-align:right;color:#0f172a}
|
||||
.ledger-note{font-size:11px;color:#64748b;font-weight:700}
|
||||
.ledger-empty{padding:14px 12px;color:#94a3b8;font-size:12px;font-weight:800;text-align:center}
|
||||
.ledger-block.outsource .ledger-head{background:#fff1f2;border-bottom:1px solid #fecdd3}
|
||||
.ledger-block.collect .ledger-head{background:#eef2ff;border-bottom:1px solid #c7d2fe}
|
||||
.ledger-block.outsource .ledger-table thead th{background:#fff7f8}
|
||||
.ledger-block.collect .ledger-table thead th{background:#f5f7ff}
|
||||
@media(max-width:1280px){.top{grid-template-columns:1fr}.controls{justify-content:flex-start}.cards{grid-template-columns:repeat(2,minmax(140px,1fr))}.m-body{grid-template-columns:1fr}.inline-grid{grid-template-columns:1fr}.grid4{grid-template-columns:repeat(2,minmax(100px,1fr))}.inline-hero-split{grid-template-columns:1fr}.inline-hero-col.right{padding-left:0;border-left:0;border-top:1px solid #334155;padding-top:12px}.project-head{grid-template-columns:1fr}.project-meta-grid{grid-template-columns:repeat(2,minmax(110px,1fr))}.project-sections{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,minmax(120px,1fr))}.ledger-head{align-items:flex-start;flex-direction:column}.ledger-pill{align-self:flex-start}}
|
||||
</style>
|
||||
<base href="/integrations/ledger-assets/"><link rel="stylesheet" href="/integrations/ledger-assets/MH%20통합%20대시보드_260320.css"><link rel="stylesheet" href="/integrations/ledger-assets/ledger-override.css?v=20260401-03"></head>
|
||||
<body class="mh-business-theme">
|
||||
<input id="file" type="file" accept=".csv,.xlsx,.xls" class="hidden" />
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<div><div class="sub">Live Management</div><h1 class="title">사업관리대장 <span style="font-weight:300;color:#94a3b8">| Dashboard</span></h1></div>
|
||||
<div class="controls"><button id="btnUpload" class="btn" type="button">파일 업로드</button><input id="search" class="search" placeholder="전체 검색" /></div>
|
||||
</div>
|
||||
<div id="status" class="status">CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.</div>
|
||||
<div id="cards" class="cards"></div>
|
||||
<div class="panel">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="code" data-label="구분 / 코드">
|
||||
<span class="th-title">구분 / 코드</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterCodeMenu" class="th-menu" data-filter="code"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="name" data-label="사업명">
|
||||
<span class="th-title">사업명</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterNameMenu" class="th-menu" data-filter="name"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="corp" data-label="계약법인">
|
||||
<span class="th-title">계약법인</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterCorpMenu" class="th-menu" data-filter="corp"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="status" data-label="진행상태">
|
||||
<span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterStatusMenu" class="th-menu" data-filter="status"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="th-head">
|
||||
<button type="button" class="th-trigger" data-filter="outsource" data-label="외주비">
|
||||
<span class="th-title">외주비</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th class="num">
|
||||
<div class="th-head end">
|
||||
<button type="button" class="th-trigger" data-filter="amount" data-label="계약금">
|
||||
<span class="th-title">계약금</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterAmountMenu" class="th-menu" data-filter="amount"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th class="num">
|
||||
<div class="th-head end">
|
||||
<button type="button" class="th-trigger" data-filter="collected" data-label="수금액">
|
||||
<span class="th-title">수금액</span><span class="th-meta">(VAT 별도)</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div>
|
||||
</div>
|
||||
</th>
|
||||
<th class="num">
|
||||
<div class="th-head end">
|
||||
<button type="button" class="th-trigger" data-filter="rate" data-label="수금률">
|
||||
<span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span>
|
||||
</button>
|
||||
<div id="filterRateMenu" class="th-menu" data-filter="rate"></div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty" class="empty">표시할 데이터가 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collectModal" class="modal">
|
||||
<div class="modal-card">
|
||||
<div class="m-top"><div><div id="mCat" class="badge">미분류</div><div id="mTitle" style="font-size:28px;font-weight:900;margin-top:6px"></div><div id="mSub" style="font-size:13px;color:#64748b;font-weight:700;margin-top:4px"></div></div><button id="btnCollectClose" class="x" type="button">×</button></div>
|
||||
<div class="m-body">
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="sec"><div class="grid3"><div class="kv"><div class="kvk">발주처</div><div id="mClient" class="kvv"></div></div><div class="kv"><div class="kvk">발주방법</div><div id="mOrder" class="kvv"></div></div><div class="kv"><div class="kvk">분담율</div><div id="mSplit" class="kvv"></div></div></div></div>
|
||||
<div class="sec"><div class="line"><span>착수일</span><strong id="mStartDate"></strong></div><div class="line"><span>준공일</span><strong id="mEndDate"></strong></div><div class="line"><span>대금구분</span><strong id="mPayType"></strong></div><div id="mPayItems" class="pay-list"></div></div>
|
||||
<div class="sec dark"><div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-end"><div><div style="font-size:11px;color:#94a3b8;font-weight:900">총 계약 합계(VAT 포함)</div><div id="mContractTotal" class="money"></div><div id="mContractSupply" style="font-size:12px;color:#94a3b8"></div></div><div style="text-align:right"><div style="font-size:11px;color:#60a5fa;font-weight:900">수금금액</div><div id="mCollected" class="money" style="color:#60a5fa"></div><div id="mCollectDate" style="font-size:12px;color:#94a3b8"></div></div></div><div style="margin-top:10px;display:flex;justify-content:space-between"><span style="font-size:12px;color:#94a3b8;font-weight:900">수금 진행률</span><strong id="mRate" style="font-size:28px"></strong></div><div class="progress"><div id="mRateBar" class="bar"></div></div><div style="display:flex;justify-content:space-between;margin-top:7px"><span style="color:#fda4af;font-size:12px;font-weight:900">미수 금액</span><strong id="mReceivable" style="color:#fb7185"></strong></div></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="sec"><div style="font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase">계약 / 청구 담당자</div><div style="margin-top:8px"><div id="mCmName" style="font-size:20px;font-weight:900"></div><div id="mCmOrg" style="font-size:13px;color:#0f172a;font-weight:800;margin-top:4px"></div><div id="mCmPhone" style="font-size:13px;font-weight:700;margin-top:8px"></div><div id="mCmEmail" style="font-size:13px;font-weight:700;margin-top:4px"></div></div></div>
|
||||
<div class="sec"><div style="font-size:11px;color:#64748b;font-weight:900;letter-spacing:.1em;text-transform:uppercase">부서 담당자</div><div style="margin-top:8px"><div id="mDmName" style="font-size:20px;font-weight:900"></div><div id="mDmOrg" style="font-size:13px;color:#334155;font-weight:800;margin-top:4px"></div><div id="mDmPhone" style="font-size:13px;font-weight:700;margin-top:8px"></div><div id="mDmEmail" style="font-size:13px;font-weight:700;margin-top:4px"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="outsourceModal" class="modal">
|
||||
<div class="modal-card">
|
||||
<div class="m-top"><div><div class="badge">외주비 상세</div><div id="oTitle" style="font-size:28px;font-weight:900;margin-top:6px"></div><div id="oSub" style="font-size:13px;color:#64748b;font-weight:700;margin-top:4px"></div></div><button id="btnOutsourceClose" class="x" type="button">×</button></div>
|
||||
<div class="m-body">
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="sec">
|
||||
<div class="grid3">
|
||||
<div class="kv"><div class="kvk">계약법인</div><div id="oCorp" class="kvv"></div></div>
|
||||
<div class="kv"><div class="kvk">발주처</div><div id="oClient" class="kvv"></div></div>
|
||||
<div class="kv"><div class="kvk">외주처 요약</div><div id="oVendors" class="kvv"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec">
|
||||
<div class="line"><span>외주 총액</span><strong id="oTotal"></strong></div>
|
||||
<div class="line"><span>외주 건수</span><strong id="oCount"></strong></div>
|
||||
<div class="line"><span>계약기간</span><strong id="oPeriod"></strong></div>
|
||||
<div id="oItems" class="out-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="sec dark">
|
||||
<div style="font-size:11px;color:#94a3b8;font-weight:900">총 외주비(공급가액 기준)</div>
|
||||
<div id="oTotalHero" class="money"></div>
|
||||
<div id="oTotalHint" style="font-size:12px;color:#94a3b8;margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||
<script>
|
||||
const FILTER_KEYS=["code","name","corp","status","outsource","amount","collected","rate"];
|
||||
const S={all:[],rows:[],viewRows:[],file:"",filters:{},totals:null,expanded:{key:""}};
|
||||
const E={file:document.getElementById("file"),btnUpload:document.getElementById("btnUpload"),search:document.getElementById("search"),status:document.getElementById("status"),cards:document.getElementById("cards"),tbody:document.getElementById("tbody"),empty:document.getElementById("empty"),collectModal:document.getElementById("collectModal"),btnCollectClose:document.getElementById("btnCollectClose"),outsourceModal:document.getElementById("outsourceModal"),btnOutsourceClose:document.getElementById("btnOutsourceClose"),filterButtons:Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(el=>[el.dataset.filter,el])),filterMenus:Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(el=>[el.dataset.filter,el]))};
|
||||
const G=id=>document.getElementById(id);
|
||||
const esc=v=>String(v||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
const escAttr=v=>esc(v).replace(/"/g,""");
|
||||
const n=v=>String(v||"").replace(/[\s\r\n]+/g,"").toLowerCase();
|
||||
const num=v=>{v=String(v||"").trim();if(!v||v.startsWith("="))return 0;return parseFloat(v.replace(/[^0-9.\-]/g,""))||0;};
|
||||
const won=v=>Math.round(v||0).toLocaleString("ko-KR")+" 원";
|
||||
const d=v=>{v=String(v||"").trim();return !v||v==="~"?"-":v;};
|
||||
const rate=(raw,col,sales)=>{const x=parseFloat(String(raw||"").replace(/[^0-9.\-]/g,""));if(Number.isFinite(x))return Math.max(0,Math.min(100,x));return sales>0?Math.max(0,Math.min(100,col/sales*100)):0;};
|
||||
const score=t=>{t=String(t||"");let s=0,m=t.replace(/\s+/g,"");if(m.includes("사업관리대장"))s+=8;if(m.includes("총괄사업코드"))s+=8;if(m.includes("사업명(계약명)"))s+=7;s+=(t.match(/[가-힣]/g)||[]).length*0.01;s-=(t.match(/<2F>/g)||[]).length*0.5;return s;};
|
||||
const rowKey=r=>[r.code||"",r.name||"",r.corp||"",r.client||""].join("|");
|
||||
function parseCsv(txt){const out=[];let row=[],f="",q=false;for(let i=0;i<txt.length;i++){const c=txt[i];if(c==='"'){if(q&&txt[i+1]==='"'){f+='"';i++;}else q=!q;continue;}if(c===","&&!q){row.push(f);f="";continue;}if((c==="\n"||c==="\r")&&!q){if(c==="\r"&&txt[i+1]==="\n")i++;row.push(f);out.push(row);row=[];f="";continue;}f+=c;}row.push(f);out.push(row);if(out.length&&out[0].length)out[0][0]=String(out[0][0]||"").replace(/^\uFEFF/,"");return out;}
|
||||
function hs(rows){
|
||||
for(let i=0;i<rows.length;i++){
|
||||
const a=(rows[i]||[]).map(n);
|
||||
const hasName=a.some(v=>v.includes("사업명(계약명)")||v==="사업명"||v.includes("사업명"));
|
||||
const hasCode=a.some(v=>v.includes("총괄사업코드")||v.includes("사업코드"));
|
||||
const hasClient=a.some(v=>v.includes("발주처(매출처)")||v.includes("발주처"));
|
||||
if(hasName&&(hasCode||hasClient)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function ch(a,b){a=a||[];b=b||[];const m=Math.max(a.length,b.length),o=[];let carry="";for(let i=0;i<m;i++){const t=String(a[i]||"").replace(/\s+/g," ").trim(),s=String(b[i]||"").replace(/\s+/g," ").trim();if(t)carry=t;const top=t||carry;o.push(top&&s?(top+" "+s).trim():(top||s||""));}return o;}
|
||||
function hi(headers,cands){const C=(cands||[]).map(n).filter(Boolean);for(const c of C){for(let i=0;i<headers.length;i++)if(n(headers[i])===c)return i;}return -1;}
|
||||
function parseLedgerRows(R){
|
||||
if(R.length&&R[0].length)R[0][0]=String(R[0][0]||"").replace(/^\uFEFF/,"");
|
||||
const h=hs(R);if(h<0)throw new Error("헤더를 찾지 못했습니다.");
|
||||
const H=ch(R[h],R[h+1]||[]),I={cat:hi(H,["사업구분","사업 구분"]),corp:hi(H,["계약법인","계약 법인"]),code:hi(H,["총괄사업코드","총괄 사업코드","사업코드"]),name:hi(H,["사업명 (계약명)","사업명(계약명)","사업명"]),pay:hi(H,["대금구분","대금 구분"]),yn:hi(H,["계약여부"]),order:hi(H,["발주방법"]),pm:hi(H,["pm"]),status:hi(H,["진행상태"]),client:hi(H,["발주처 (매출처)","발주처(매출처)","발주처"]),split:hi(H,["분담율"]),cDate:hi(H,["계약기간 계약일","계약일","발행일"]),sDate:hi(H,["계약기간 착수일","착수일"]),eDate:hi(H,["계약기간 준공일","준공일"]),cSup:hi(H,["계약금 공급가액","매출금액 공급가액","공급가액"]),cVat:hi(H,["계약금 부가세","매출금액 부가세","부가세"]),cTot:hi(H,["계약금 합계","매출금액 합계","합계","계약금","매출금액"]),colDate:hi(H,["매출금액 수금일","수금일"]),sSup:hi(H,["매출금액 공급가액","공급가액"]),sVat:hi(H,["매출금액 부가세","부가세"]),sTot:hi(H,["매출금액 합계","합계","매출금액"]),col:hi(H,["매출금액 수금금액","수금금액","수금액"]),recv:hi(H,["매출금액 미수금액","미수금액"]),r:hi(H,["매출금액 수금율","수금율"]),note:hi(H,["비고"]),cmCo:hi(H,["계약/청구담당자 회사"]),cmNm:hi(H,["계약/청구담당자 이름"]),cmDp:hi(H,["계약/청구담당자 부서"]),cmPh:hi(H,["계약/청구담당자 연락처"]),cmEm:hi(H,["계약/청구담당자 이메일"]),dmCo:hi(H,["부서담당자 회사"]),dmNm:hi(H,["부서담당자 이름"]),dmDp:hi(H,["부서담당자 부서"]),dmPh:hi(H,["부서담당자 연락처"]),dmEm:hi(H,["부서담당자 이메일"])};
|
||||
const out=[];for(const row of R.slice(h+2)){const x={cat:I.cat>=0?String(row[I.cat]||"").trim():"",corp:I.corp>=0?String(row[I.corp]||"").trim():"",code:I.code>=0?String(row[I.code]||"").trim():"",name:I.name>=0?String(row[I.name]||"").trim():"",pay:I.pay>=0?String(row[I.pay]||"").trim():"",yn:I.yn>=0?String(row[I.yn]||"").trim():"",order:I.order>=0?String(row[I.order]||"").trim():"",pm:I.pm>=0?String(row[I.pm]||"").trim():"",status:I.status>=0?String(row[I.status]||"").trim():"",client:I.client>=0?String(row[I.client]||"").trim():"",split:I.split>=0?String(row[I.split]||"").trim():"",cDate:I.cDate>=0?String(row[I.cDate]||"").trim():"",sDate:I.sDate>=0?String(row[I.sDate]||"").trim():"",eDate:I.eDate>=0?String(row[I.eDate]||"").trim():"",cSup:I.cSup>=0?num(row[I.cSup]):0,cVat:I.cVat>=0?num(row[I.cVat]):0,cTot:I.cTot>=0?num(row[I.cTot]):0,colDate:I.colDate>=0?String(row[I.colDate]||"").trim():"",sSup:I.sSup>=0?num(row[I.sSup]):0,sVat:I.sVat>=0?num(row[I.sVat]):0,sTot:I.sTot>=0?num(row[I.sTot]):0,col:I.col>=0?num(row[I.col]):0,recv:I.recv>=0?num(row[I.recv]):0,rateRaw:I.r>=0?String(row[I.r]||"").trim():"",note:I.note>=0?String(row[I.note]||"").trim():"",cmCo:I.cmCo>=0?String(row[I.cmCo]||"").trim():"",cmNm:I.cmNm>=0?String(row[I.cmNm]||"").trim():"",cmDp:I.cmDp>=0?String(row[I.cmDp]||"").trim():"",cmPh:I.cmPh>=0?String(row[I.cmPh]||"").trim():"",cmEm:I.cmEm>=0?String(row[I.cmEm]||"").trim():"",dmCo:I.dmCo>=0?String(row[I.dmCo]||"").trim():"",dmNm:I.dmNm>=0?String(row[I.dmNm]||"").trim():"",dmDp:I.dmDp>=0?String(row[I.dmDp]||"").trim():"",dmPh:I.dmPh>=0?String(row[I.dmPh]||"").trim():"",dmEm:I.dmEm>=0?String(row[I.dmEm]||"").trim():""};
|
||||
if(!x.name&&!x.code)continue;if(!x.code&&!x.corp&&!x.client&&!x.pm)continue;if(!x.cTot)x.cTot=x.cSup+x.cVat;if(!x.sTot)x.sTot=x.sSup+x.sVat;if(!x.recv)x.recv=Math.max(0,x.sTot-x.col);x.rate=rate(x.rateRaw,x.col,x.sTot);out.push(x);}
|
||||
return out;
|
||||
}
|
||||
const hk=v=>String(v||"").normalize("NFKC").toLowerCase().replace(/[^0-9a-z가-힣]+/g,"");
|
||||
function findHeaderIndex(headers,cands){
|
||||
const normalized=(headers||[]).map(hk);
|
||||
const candidates=(cands||[]).map(hk).filter(Boolean);
|
||||
for(const c of candidates){
|
||||
for(let i=0;i<normalized.length;i++){
|
||||
if(!normalized[i]) continue;
|
||||
if(normalized[i]===c||normalized[i].includes(c)||c.includes(normalized[i])) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function textAt(row,idx){return idx>=0?String(row[idx]??"").replace(/\u00a0/g," ").replace(/\s+/g," ").trim():"";}
|
||||
function moneyAt(row,idx){return idx>=0?num(row[idx]):0;}
|
||||
function lastText(values){for(let i=values.length-1;i>=0;i--){const v=d(values[i]);if(v!=="-")return v;}return "-";}
|
||||
function paymentSummary(payments){
|
||||
const labels=[...new Set((payments||[]).map(p=>String(p.pay||"").trim()).filter(Boolean))];
|
||||
if(!labels.length) return "-";
|
||||
if(labels.length<=2) return labels.join(", ");
|
||||
return `${labels.slice(0,2).join(", ")} 외 ${labels.length-2}건`;
|
||||
}
|
||||
function paymentRecord(x,fallbackPay){
|
||||
const supply=x.sSup||0,vat=x.sVat||0,total=x.sTot||supply+vat,collected=x.col||0;
|
||||
return {pay:String(x.pay||x.name||fallbackPay||"미입력").trim(),status:x.status||"",issueDate:x.issueDate||x.cDate||"",collectDate:x.colDate||"",supply,vat,total,collected,receivable:x.recv||Math.max(0,total-collected),rate:rate(x.rateRaw,collected,total),note:String(x.note||"").trim()};
|
||||
}
|
||||
function finalizeProject(project){
|
||||
const payments=(project.payments||[]).filter(p=>p.pay||p.issueDate||p.collectDate||p.total||p.collected||p.receivable);
|
||||
if(!payments.length&&(project.issueDate||project.colDate||project.sSup||project.sVat||project.sTot||project.col||project.recv)) payments.push(paymentRecord(project,project.pay||"일괄"));
|
||||
project.payments=payments;
|
||||
project.pay=paymentSummary(payments);
|
||||
project.periodText=(d(project.sDate)==="-"&&d(project.eDate)==="-")?"-":`${d(project.sDate)} ~ ${d(project.eDate)}`;
|
||||
project.issueDateSummary=lastText(payments.map(p=>p.issueDate));
|
||||
project.collectDateSummary=lastText(payments.map(p=>p.collectDate));
|
||||
return project;
|
||||
}
|
||||
function normalizeProjectKey(v){return hk(v);}
|
||||
function normalizeProjectBase(v){
|
||||
return hk(String(v||"").replace(/\([^)]*\)/g," ").replace(/\[[^\]]*\]/g," "));
|
||||
}
|
||||
function summarizeOutsourceVendors(vendors){
|
||||
const list=(vendors||[]).filter(Boolean);
|
||||
if(!list.length) return "";
|
||||
if(list.length<=2) return list.join(", ");
|
||||
return `${list.slice(0,2).join(", ")} \uC678 ${list.length-2}\uACF3`;
|
||||
}
|
||||
function calcVatExcluded(total){return total>0?Math.round(total/1.1):0;}
|
||||
function outsourceTotalLabel(item){
|
||||
const ex=Math.round(item&&item.contractEx||0);
|
||||
const total=Math.round(item&&item.contractIn||0);
|
||||
if(ex>0) return won(ex);
|
||||
if(total>0) return won(calcVatExcluded(total));
|
||||
return "-";
|
||||
}
|
||||
function cleanVendorName(value,sheetName){
|
||||
const raw=String(value||sheetName||"").trim();
|
||||
return raw.replace(/^\(\uC8FC\)\s*/,"").replace(/^\uC8FC\uC2DD\uD68C\uC0AC\s*/,"").replace(/^\uC678\uC8FC/,"").trim()||String(sheetName||"\uC678\uC8FC").replace(/^\uC678\uC8FC/,"").trim()||"\uC678\uC8FC";
|
||||
}
|
||||
function getOutsourceLayout(rows){
|
||||
const header=rows[3]||[];
|
||||
const hasVatContract=String(header[9]??"").includes("VAT\uD3EC\uD568");
|
||||
if(hasVatContract){
|
||||
return {hasVatContract:true,contractEx:8,contractIn:9,invoiceDate:10,paymentDate:11,paymentAmount:12,remainingAmount:13,progress:14,label:15,note:16};
|
||||
}
|
||||
return {hasVatContract:false,contractEx:8,contractIn:-1,invoiceDate:9,paymentDate:10,paymentAmount:11,remainingAmount:12,progress:13,label:-1,note:14};
|
||||
}
|
||||
function shouldStopOutsourceRows(row){
|
||||
const first=String(row[0]??"").trim();
|
||||
const project=String(row[2]??"").trim();
|
||||
const detail=String(row[3]??"").trim();
|
||||
const joined=[row[0],row[2],row[3],row[13],row[14],row[15],row[16]].map(v=>String(v??"").trim()).join(" ");
|
||||
return first==="\uB0A0\uC9DC"||first.startsWith("*\uC790\uB8CC\uCD9C\uCC98")||project==="\uC801\uC694"||detail==="\uC801\uC694"||project.includes("\uC790\uB8CC\uCD9C\uCC98")||joined.includes("\uC6D0\uACC4\uC57D\uAE08")||joined.includes("\uC218\uAE08/\uC9C0\uAE09\uCC98");
|
||||
}
|
||||
function getOutsourceEntry(map,key,name){
|
||||
const current=map.get(key);
|
||||
if(current) return current;
|
||||
const next={name,key,baseKey:normalizeProjectBase(name),vendors:new Set(),items:[],contract:0,contractIn:0,paid:0,paidIn:0,remaining:0,remainingIn:0};
|
||||
map.set(key,next);
|
||||
return next;
|
||||
}
|
||||
function createOutsourceItem(entry,vendor,projectName,detail,row,layout){
|
||||
const contractEx=num(row[layout.contractEx]);
|
||||
const contractIn=layout.contractIn>=0?num(row[layout.contractIn]):0;
|
||||
const next={
|
||||
vendor,
|
||||
projectName,
|
||||
detail:String(detail||"-").trim()||"-",
|
||||
contractDate:String(row[4]??"").trim(),
|
||||
startDate:String(row[5]??"").trim(),
|
||||
endDate:String(row[7]??"").trim(),
|
||||
contractEx,
|
||||
contractIn,
|
||||
invoiceDate:String(row[layout.invoiceDate]??"").trim(),
|
||||
progress:String(row[layout.progress]??"").trim(),
|
||||
note:"",
|
||||
payments:[]
|
||||
};
|
||||
entry.items.push(next);
|
||||
return next;
|
||||
}
|
||||
function buildOutsourcePayment(item,row,layout){
|
||||
const invoiceDate=String(row[layout.invoiceDate]??"").trim();
|
||||
const paymentDate=String(row[layout.paymentDate]??"").trim();
|
||||
const paymentCell=String(row[layout.paymentAmount]??"").trim();
|
||||
const remainingCell=String(row[layout.remainingAmount]??"").trim();
|
||||
const paymentRaw=num(row[layout.paymentAmount]);
|
||||
const remainingRaw=num(row[layout.remainingAmount]);
|
||||
const label=layout.label>=0?String(row[layout.label]??"").trim():"";
|
||||
const note=layout.note>=0?String(row[layout.note]??"").trim():String(row[14]??"").trim();
|
||||
if(!(invoiceDate||paymentDate||paymentRaw||remainingRaw||label||note)) return null;
|
||||
if(note&&!label&&!paymentDate&&!paymentRaw&&!remainingRaw&&!invoiceDate){
|
||||
item.note=note;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
note,
|
||||
invoiceDate,
|
||||
paymentDate,
|
||||
paymentKnown:paymentCell!=="",
|
||||
remainingKnown:remainingCell!=="",
|
||||
paymentEx:paymentRaw?(layout.hasVatContract?calcVatExcluded(paymentRaw):paymentRaw):0,
|
||||
paymentIn:layout.hasVatContract?paymentRaw:0,
|
||||
remainingEx:remainingRaw?(layout.hasVatContract?calcVatExcluded(remainingRaw):remainingRaw):0,
|
||||
remainingIn:layout.hasVatContract?remainingRaw:0
|
||||
};
|
||||
}
|
||||
function finalizeOutsourceItem(item){
|
||||
const payments=Array.isArray(item.payments)?item.payments.filter(Boolean):[];
|
||||
const paidEx=Math.round(payments.reduce((sum,p)=>sum+(p.paymentEx||0),0));
|
||||
const paidIn=Math.round(payments.reduce((sum,p)=>sum+(p.paymentIn||0),0));
|
||||
let remainingEx=0;
|
||||
let remainingIn=0;
|
||||
for(let i=payments.length-1;i>=0;i--){
|
||||
const payment=payments[i];
|
||||
if(payment.remainingKnown){
|
||||
remainingEx=Math.round(payment.remainingEx||0);
|
||||
remainingIn=Math.round(payment.remainingIn||0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!remainingEx&&item.contractEx>0) remainingEx=Math.max(0,Math.round(item.contractEx-paidEx));
|
||||
if(!remainingIn&&item.contractIn>0) remainingIn=Math.max(0,Math.round(item.contractIn-paidIn));
|
||||
return {...item,payments,paidEx,paidIn,remainingEx,remainingIn};
|
||||
}
|
||||
function parseOutsourceRows(rows,sheetName,map){
|
||||
if(!rows||rows.length<6) return;
|
||||
const vendor=cleanVendorName((rows[1]||[])[0],sheetName);
|
||||
const layout=getOutsourceLayout(rows);
|
||||
let currentKey="",currentName="",currentItem=null;
|
||||
for(const row of rows.slice(5)){
|
||||
if(shouldStopOutsourceRows(row)) break;
|
||||
const projectName=String(row[2]??"").trim();
|
||||
const projectKey=normalizeProjectKey(projectName);
|
||||
const detail=String(row[3]??"").trim();
|
||||
const validProject=projectKey&&projectKey!=="ref";
|
||||
if(validProject){
|
||||
currentKey=projectKey;
|
||||
currentName=projectName;
|
||||
const entry=getOutsourceEntry(map,currentKey,currentName);
|
||||
entry.vendors.add(vendor);
|
||||
currentItem=createOutsourceItem(entry,vendor,currentName,detail,row,layout);
|
||||
const firstPayment=buildOutsourcePayment(currentItem,row,layout);
|
||||
if(firstPayment) currentItem.payments.push(firstPayment);
|
||||
continue;
|
||||
}
|
||||
if(!currentKey) continue;
|
||||
const entry=getOutsourceEntry(map,currentKey,currentName);
|
||||
entry.vendors.add(vendor);
|
||||
const contractEx=num(row[layout.contractEx]);
|
||||
const contractIn=layout.contractIn>=0?num(row[layout.contractIn]):0;
|
||||
const hasFinancialRow=!!(contractEx||contractIn||num(row[layout.paymentAmount])||num(row[layout.remainingAmount]));
|
||||
const hasMetaRow=!!(String(row[layout.invoiceDate]??"").trim()||String(row[layout.paymentDate]??"").trim()||String(row[layout.progress]??"").trim()||detail);
|
||||
if(detail&&hasMetaRow){
|
||||
currentItem=createOutsourceItem(entry,vendor,currentName,detail,row,layout);
|
||||
const payment=buildOutsourcePayment(currentItem,row,layout);
|
||||
if(payment) currentItem.payments.push(payment);
|
||||
continue;
|
||||
}
|
||||
if(!currentItem){
|
||||
if(!(hasFinancialRow||hasMetaRow)) continue;
|
||||
currentItem=createOutsourceItem(entry,vendor,currentName,detail||"\uC678\uC8FC \uACC4\uC57D",row,layout);
|
||||
}else{
|
||||
if(contractEx>0) currentItem.contractEx+=contractEx;
|
||||
if(contractIn>0) currentItem.contractIn+=contractIn;
|
||||
if(!currentItem.progress) currentItem.progress=String(row[layout.progress]??"").trim();
|
||||
}
|
||||
const payment=buildOutsourcePayment(currentItem,row,layout);
|
||||
if(payment) currentItem.payments.push(payment);
|
||||
}
|
||||
}
|
||||
function parseOutsourceSheets(workbook){
|
||||
const map=new Map();
|
||||
const names=(workbook&&workbook.SheetNames)||[];
|
||||
for(const sheetName of names){
|
||||
if(!String(sheetName||"").startsWith("\uC678\uC8FC")) continue;
|
||||
const sheet=workbook.Sheets[sheetName];
|
||||
if(!sheet) continue;
|
||||
const rows=XLSX.utils.sheet_to_json(sheet,{header:1,raw:false,defval:""});
|
||||
parseOutsourceRows(rows,sheetName,map);
|
||||
}
|
||||
for(const entry of map.values()){
|
||||
entry.items=entry.items.map(finalizeOutsourceItem).filter(item=>item.contractEx||item.contractIn||item.paidEx||item.paidIn||item.remainingEx||item.remainingIn||item.detail||item.payments.length);
|
||||
entry.contract=Math.round(entry.items.reduce((sum,item)=>sum+(item.contractEx||0),0));
|
||||
entry.contractIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.contractIn||0),0));
|
||||
entry.paid=Math.round(entry.items.reduce((sum,item)=>sum+(item.paidEx||0),0));
|
||||
entry.paidIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.paidIn||0),0));
|
||||
entry.remaining=Math.round(entry.items.reduce((sum,item)=>sum+(item.remainingEx||0),0));
|
||||
entry.remainingIn=Math.round(entry.items.reduce((sum,item)=>sum+(item.remainingIn||0),0));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
function resolveOutsourceEntry(record,outsourceMap){
|
||||
const fullKey=normalizeProjectKey(record.name||"");
|
||||
const baseKey=normalizeProjectBase(record.name||"");
|
||||
if(fullKey&&outsourceMap.has(fullKey)) return outsourceMap.get(fullKey);
|
||||
if(baseKey&&outsourceMap.has(baseKey)) return outsourceMap.get(baseKey);
|
||||
let best=null,bestScore=0;
|
||||
for(const entry of outsourceMap.values()){
|
||||
const entryFull=String(entry&&entry.key||"");
|
||||
const entryBase=String(entry&&entry.baseKey||normalizeProjectBase(entry&&entry.name||""));
|
||||
for(const candidate of [entryFull,entryBase]){
|
||||
if(!candidate) continue;
|
||||
const matched=(fullKey&&fullKey.includes(candidate))||(candidate&&fullKey&&candidate.includes(fullKey))||(baseKey&&baseKey.includes(candidate))||(candidate&&baseKey&&candidate.includes(baseKey));
|
||||
if(matched&&candidate.length>bestScore){
|
||||
best=entry;
|
||||
bestScore=candidate.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function attachOutsourceCosts(records,outsourceMap){
|
||||
return (records||[]).map(record=>{
|
||||
const entry=resolveOutsourceEntry(record,outsourceMap);
|
||||
const outsourceCost=entry?Math.round(entry.contract||0):0;
|
||||
const outsourcePaid=entry?Math.round(entry.paid||0):0;
|
||||
const outsourceRemaining=entry?Math.round(entry.remaining||0):0;
|
||||
const outsourceCostIn=entry?Math.round(entry.contractIn||0):0;
|
||||
const outsourcePaidIn=entry?Math.round(entry.paidIn||0):0;
|
||||
const outsourceRemainingIn=entry?Math.round(entry.remainingIn||0):0;
|
||||
const outsourceVendors=entry?Array.from(entry.vendors):[];
|
||||
const outsourceItems=entry&&Array.isArray(entry.items)?entry.items.slice():[];
|
||||
return {
|
||||
...record,
|
||||
outsourceCost,
|
||||
outsourcePaid,
|
||||
outsourceRemaining,
|
||||
outsourceCostIn,
|
||||
outsourcePaidIn,
|
||||
outsourceRemainingIn,
|
||||
outsourceVendors,
|
||||
outsourceVendorText:summarizeOutsourceVendors(outsourceVendors),
|
||||
outsourceItems
|
||||
};
|
||||
});
|
||||
}
|
||||
function parseLedgerRecords(R){
|
||||
if(R.length&&R[0].length)R[0][0]=String(R[0][0]||"").replace(/^\uFEFF/,"");
|
||||
const h=hs(R);if(h<0)throw new Error("헤더를 찾지 못했습니다.");
|
||||
ch(R[h],R[h+1]||[]);
|
||||
const I={cat:1,corp:4,code:5,name:6,pay:7,yn:8,order:9,pm:10,status:11,client:12,split:13,cDate:14,sDate:15,eDate:17,cSup:18,cVat:19,cTot:20,issueDate:21,colDate:22,sSup:23,sVat:24,sTot:25,col:26,recv:27,r:28,note:29,cmCo:30,cmNm:31,cmDp:32,cmPh:33,cmEm:34,dmCo:35,dmNm:36,dmDp:37,dmPh:38,dmEm:39};
|
||||
const out=[];let current=null;
|
||||
for(const row of R.slice(h+2)){
|
||||
const x={
|
||||
cat:textAt(row,I.cat),corp:textAt(row,I.corp),code:textAt(row,I.code),name:textAt(row,I.name),pay:textAt(row,I.pay),
|
||||
yn:textAt(row,I.yn),order:textAt(row,I.order),pm:textAt(row,I.pm),status:textAt(row,I.status),client:textAt(row,I.client),
|
||||
split:textAt(row,I.split),cDate:textAt(row,I.cDate),sDate:textAt(row,I.sDate),eDate:textAt(row,I.eDate),
|
||||
cSup:moneyAt(row,I.cSup),cVat:moneyAt(row,I.cVat),cTot:moneyAt(row,I.cTot),issueDate:textAt(row,I.issueDate),colDate:textAt(row,I.colDate),
|
||||
sSup:moneyAt(row,I.sSup),sVat:moneyAt(row,I.sVat),sTot:moneyAt(row,I.sTot),col:moneyAt(row,I.col),recv:moneyAt(row,I.recv),rateRaw:textAt(row,I.r),
|
||||
note:textAt(row,I.note),cmCo:textAt(row,I.cmCo),cmNm:textAt(row,I.cmNm),cmDp:textAt(row,I.cmDp),cmPh:textAt(row,I.cmPh),cmEm:textAt(row,I.cmEm),
|
||||
dmCo:textAt(row,I.dmCo),dmNm:textAt(row,I.dmNm),dmDp:textAt(row,I.dmDp),dmPh:textAt(row,I.dmPh),dmEm:textAt(row,I.dmEm)
|
||||
};
|
||||
if(!x.cTot) x.cTot=x.cSup+x.cVat;
|
||||
if(!x.sTot) x.sTot=x.sSup+x.sVat;
|
||||
if(!x.recv) x.recv=Math.max(0,x.sTot-x.col);
|
||||
x.rate=rate(x.rateRaw,x.col,x.sTot);
|
||||
const isProject=!!(x.code||(x.name&&(x.cat||x.corp||x.client||x.yn||x.order||x.pm)));
|
||||
const isPayment=!isProject&&!!(x.pay||x.name||x.issueDate||x.colDate||x.sSup||x.sVat||x.sTot||x.col||x.recv);
|
||||
if(isProject){
|
||||
if(!x.name&&!x.code) continue;
|
||||
if(current) out.push(finalizeProject(current));
|
||||
current={...x,payments:[]};
|
||||
continue;
|
||||
}
|
||||
if(isPayment&¤t) current.payments.push(paymentRecord(x,x.pay));
|
||||
}
|
||||
if(current) out.push(finalizeProject(current));
|
||||
return out;
|
||||
}
|
||||
function extractLedgerTotals(rows){
|
||||
const indexes={contract:20,collected:26,receivable:27,rate:28};
|
||||
let summaryRow=null;
|
||||
for(let i=(rows||[]).length-1;i>=0;i--){
|
||||
const row=rows[i]||[];
|
||||
const hasSummaryLabel=row.some(cell=>String(cell??"").replace(/\s+/g,"").includes("합계"));
|
||||
if(hasSummaryLabel){summaryRow=row;break;}
|
||||
}
|
||||
if(!summaryRow) return null;
|
||||
const contract=num(summaryRow[indexes.contract]);
|
||||
const collected=num(summaryRow[indexes.collected]);
|
||||
const receivable=num(summaryRow[indexes.receivable]);
|
||||
const rateRaw=String(summaryRow[indexes.rate]??"").trim();
|
||||
if(!(contract||collected||receivable||rateRaw)) return null;
|
||||
const totalBase=collected+receivable;
|
||||
return {contract,collected,receivable,rate:rate(rateRaw,collected,totalBase)};
|
||||
}
|
||||
function parseLedger(txt){
|
||||
const rows=parseCsv(txt);
|
||||
return {records:parseLedgerRecords(rows),totals:extractLedgerTotals(rows)};
|
||||
}
|
||||
function parseLedgerExcel(buf){
|
||||
if(typeof XLSX==="undefined")throw new Error("XLSX 라이브러리를 불러오지 못했습니다.");
|
||||
const wb=XLSX.read(buf,{type:"array",cellDates:false});
|
||||
const outsourceMap=parseOutsourceSheets(wb);
|
||||
const names=wb.SheetNames||[];
|
||||
const preferredNames=names.filter(name=>String(name||"").includes("공유사업관리대장"));
|
||||
const candidateNames=preferredNames.length?preferredNames:[...names];
|
||||
let bestRecords=null;
|
||||
let bestSheet="";
|
||||
let bestScore=-1;
|
||||
let bestTotals=null;
|
||||
for(const name of candidateNames){
|
||||
try{
|
||||
const sheet=wb.Sheets[name];
|
||||
const rows=XLSX.utils.sheet_to_json(sheet,{header:1,raw:false,defval:""});
|
||||
const normalized=(rows||[]).map(r=>Array.isArray(r)?r.map(v=>String(v??"")):[]);
|
||||
const records=attachOutsourceCosts(parseLedgerRecords(normalized),outsourceMap);
|
||||
if(!records.length) continue;
|
||||
const totals=extractLedgerTotals(normalized);
|
||||
const bonus=String(name||"").includes("공유사업관리대장")?1000000:/사업관리대장/i.test(String(name||""))?10000:0;
|
||||
const score=records.length+bonus;
|
||||
if(score>bestScore){
|
||||
bestScore=score;
|
||||
bestRecords=records;
|
||||
bestSheet=name;
|
||||
bestTotals=totals;
|
||||
}
|
||||
}catch(_){
|
||||
// try next sheet
|
||||
}
|
||||
}
|
||||
if(!bestRecords) throw new Error("엑셀에서 사업관리대장 헤더를 찾지 못했습니다.");
|
||||
return { records: bestRecords, sheetName: bestSheet, totals: bestTotals };
|
||||
}
|
||||
function decode(buf){const u=new TextDecoder("utf-8").decode(buf);let e="";try{e=new TextDecoder("euc-kr").decode(buf);}catch(_){e=u;}return score(e)>score(u)?e:u;}
|
||||
function sumRows(rows){return rows.reduce((a,r)=>(a.c+=r.cTot||0,a.s+=r.sTot||0,a.col+=r.col||0,a.recv+=r.recv||0,a),{c:0,s:0,col:0,recv:0});}
|
||||
function isSettledRow(r){
|
||||
const noSales=(r.sTot||0)<=0&&(r.col||0)<=0&&(r.recv||0)<=0;
|
||||
const statusDone=String(r.status||"").includes("완료");
|
||||
const coopDone=String(r.yn||"").includes("업무협조")&&statusDone&&noSales;
|
||||
return coopDone||(statusDone&&Math.round(r.recv||0)<=0&&(r.rate||0)>=100);
|
||||
}
|
||||
function hasActiveDashboardFilters(){
|
||||
return !!String(E.search.value||"").trim()||FILTER_KEYS.some(key=>!!S.filters[key]);
|
||||
}
|
||||
function codeFilterLabel(r){return r.cat||"-";}
|
||||
function periodFilterLabel(r){return `${d(r.sDate)} ~ ${d(r.eDate)}`;}
|
||||
function outsourceFilterLabel(r){return r.outsourceCost?won(r.outsourceCost):"-";}
|
||||
function amountFilterLabel(r){return won(r.cSup);}
|
||||
function collectedFilterLabel(r){return won(r.col);}
|
||||
function rateFilterLabel(r){return r.rate.toFixed(2)+"%";}
|
||||
function uniqueFilterValues(rows,mapFn){
|
||||
const seen=new Set(),out=[];
|
||||
for(const row of rows){
|
||||
const value=String(mapFn(row)||"").trim();
|
||||
if(!value||seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function filterDefinitions(){
|
||||
return [
|
||||
{key:"code",map:codeFilterLabel},
|
||||
{key:"name",map:r=>r.name||"-"},
|
||||
{key:"corp",map:r=>r.corp||"-"},
|
||||
{key:"status",map:r=>r.status||"-"},
|
||||
{key:"outsource",map:outsourceFilterLabel},
|
||||
{key:"amount",map:amountFilterLabel},
|
||||
{key:"collected",map:collectedFilterLabel},
|
||||
{key:"rate",map:rateFilterLabel}
|
||||
];
|
||||
}
|
||||
function closeFilterMenus(){
|
||||
Object.values(E.filterMenus).forEach(menu=>menu.classList.remove("open"));
|
||||
Object.values(E.filterButtons).forEach(btn=>btn.classList.remove("open"));
|
||||
}
|
||||
function updateFilterButtons(){
|
||||
FILTER_KEYS.forEach(key=>{
|
||||
const btn=E.filterButtons[key];
|
||||
if(!btn) return;
|
||||
const active=!!S.filters[key];
|
||||
btn.classList.toggle("active",active);
|
||||
btn.title=active?`${btn.dataset.label}: ${S.filters[key]}`:btn.dataset.label||"";
|
||||
const mark=btn.querySelector(".th-mark");
|
||||
if(mark) mark.textContent=active?"•":"";
|
||||
});
|
||||
}
|
||||
function renderFilterMenu(key,values){
|
||||
const menu=E.filterMenus[key];
|
||||
if(!menu) return;
|
||||
const current=String(S.filters[key]||"");
|
||||
menu.innerHTML=`<button type="button" class="th-option${!current?" active":""}" data-filter-value="">전체</button>`+values.map(v=>`<button type="button" class="th-option${current===v?" active":""}" data-filter-value="${escAttr(v)}">${esc(v)}</button>`).join("");
|
||||
}
|
||||
function syncColumnFilters(rows){
|
||||
filterDefinitions().forEach(def=>{
|
||||
const values=uniqueFilterValues(rows,def.map);
|
||||
if(S.filters[def.key]&&!values.includes(S.filters[def.key])) delete S.filters[def.key];
|
||||
renderFilterMenu(def.key,values);
|
||||
});
|
||||
updateFilterButtons();
|
||||
}
|
||||
function toggleFilterMenu(key){
|
||||
const menu=E.filterMenus[key],btn=E.filterButtons[key];
|
||||
if(!menu||!btn) return;
|
||||
const willOpen=!menu.classList.contains("open");
|
||||
closeFilterMenus();
|
||||
if(willOpen){
|
||||
menu.classList.add("open");
|
||||
btn.classList.add("open");
|
||||
}
|
||||
}
|
||||
function setFilterValue(key,value){
|
||||
if(value) S.filters[key]=value;
|
||||
else delete S.filters[key];
|
||||
syncColumnFilters(S.all);
|
||||
closeFilterMenus();
|
||||
filter();
|
||||
}
|
||||
function matchesColumnFilters(r){
|
||||
if(S.filters.code&&codeFilterLabel(r)!==S.filters.code) return false;
|
||||
if(S.filters.name&&(r.name||"-")!==S.filters.name) return false;
|
||||
if(S.filters.corp&&(r.corp||"-")!==S.filters.corp) return false;
|
||||
if(S.filters.status&&(r.status||"-")!==S.filters.status) return false;
|
||||
if(S.filters.outsource&&outsourceFilterLabel(r)!==S.filters.outsource) return false;
|
||||
if(S.filters.amount&&amountFilterLabel(r)!==S.filters.amount) return false;
|
||||
if(S.filters.collected&&collectedFilterLabel(r)!==S.filters.collected) return false;
|
||||
if(S.filters.rate&&rateFilterLabel(r)!==S.filters.rate) return false;
|
||||
return true;
|
||||
}
|
||||
function setText(id,v){const el=G(id);if(el)el.textContent=v||"-";}
|
||||
function renderPaymentsHtml(payments){
|
||||
if(!payments||!payments.length) return '<div class="pay-empty">대금 차수 정보가 없습니다.</div>';
|
||||
return payments.map(p=>`<div class="pay-item"><div class="pay-head"><div class="pay-name">${esc(p.pay||"미입력")}</div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap">${esc(p.status||"-")}</div></div><div class="pay-meta"><span>발행일 ${esc(d(p.issueDate))}</span><span>수금일 ${esc(d(p.collectDate))}</span><span>공급가액 ${esc(won(p.supply))}</span><span>수금금액 ${esc(won(p.collected))}</span></div>${p.note?`<div class="pay-note">비고: ${esc(p.note)}</div>`:""}</div>`).join("");
|
||||
}
|
||||
function renderOutsourcePayments(payments){
|
||||
const list=(payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn));
|
||||
if(!list.length) return "";
|
||||
return `<div class="out-payments">${list.map((payment,index)=>`<div class="out-payment"><div class="out-payment-head"><span>${esc(payment.label||`\uC9C0\uAE09 ${index+1}`)}</span><span>${esc(payment.paymentDate?d(payment.paymentDate):"-")}</span></div><div class="out-payment-meta"><span>\uACC4\uC0B0\uC11C\uC77C\uC790 ${esc(payment.invoiceDate?d(payment.invoiceDate):"-")}</span><span>\uC9C0\uAE09\uAE08\uC561 ${esc(payment.paymentEx?won(payment.paymentEx):"-")}</span><span>\uC794\uC5EC\uAE08\uC561 ${esc(payment.remainingEx||payment.remainingEx===0?won(payment.remainingEx):"-")}</span></div>${payment.note?`<div class="out-note">\uBE44\uACE0: ${esc(payment.note)}</div>`:""}</div>`).join("")}</div>`;
|
||||
}
|
||||
function countOutsourceStages(r){
|
||||
return (r.outsourceItems||[]).reduce((sum,item)=>{
|
||||
const stages=(item.payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn));
|
||||
return sum+(stages.length||1);
|
||||
},0);
|
||||
}
|
||||
function summarizeOutsourceCounts(r){
|
||||
const vendors=(r.outsourceVendors||[]).length;
|
||||
const contracts=(r.outsourceItems||[]).length;
|
||||
const stages=countOutsourceStages(r);
|
||||
const parts=[];
|
||||
if(vendors) parts.push(`외주처 ${vendors.toLocaleString("ko-KR")}곳`);
|
||||
if(contracts) parts.push(`계약 ${contracts.toLocaleString("ko-KR")}건`);
|
||||
if(stages) parts.push(`지급단계 ${stages.toLocaleString("ko-KR")}건`);
|
||||
return parts.join(" · ")||"외주 내역 없음";
|
||||
}
|
||||
function renderOutsourceHtml(items){
|
||||
if(!items||!items.length) return '<div class="pay-empty">외주 상세 정보가 없습니다.</div>';
|
||||
return items.map(item=>{
|
||||
const stageCount=(item.payments||[]).filter(payment=>payment&&(payment.label||payment.note||payment.invoiceDate||payment.paymentDate||payment.paymentEx||payment.remainingEx||payment.paymentIn||payment.remainingIn)).length;
|
||||
const stageText=stageCount?`지급단계 ${stageCount.toLocaleString("ko-KR")}건`:"지급내역 없음";
|
||||
const periodText=(d(item.startDate)==="-"&&d(item.endDate)==="-")?"-":`${d(item.startDate)} ~ ${d(item.endDate)}`;
|
||||
return `<div class="out-item"><div class="out-head"><div><div class="out-vendor">${esc(item.vendor||"외주")}</div><div class="out-name">${esc(item.detail||"-")}</div></div><div style="font-size:11px;color:#64748b;font-weight:800;white-space:nowrap">${esc(item.progress||stageText)}</div></div><div class="out-meta"><span>계약기간 ${esc(periodText)}</span><span>계약금액 ${esc(item.contractEx?won(item.contractEx):"-")}</span><span>지급금액 ${esc(item.paidEx||item.paidEx===0?won(item.paidEx):"-")}</span><span>잔여금액 ${esc(item.remainingEx||item.remainingEx===0?won(item.remainingEx):"-")}</span><span>계산서일자 ${esc(item.invoiceDate?d(item.invoiceDate):"-")}</span><span>${esc(stageText)}</span></div>${item.note?`<div class="out-note">비고: ${esc(item.note)}</div>`:""}${renderOutsourcePayments(item.payments||[])}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
function renderContactCompact(label,name,company,dept,phone,email){
|
||||
return `<div class="summary-card"><div class="summary-label">${esc(label)}</div><div style="margin-top:6px;font-size:16px;font-weight:900">${esc(name||"-")}</div><div class="summary-note">${esc([company||"-",dept||"-"].join(" · "))}</div><div class="summary-note">${esc(`전화 ${phone||"-"} / 메일 ${email||"-"}`)}</div></div>`;
|
||||
}
|
||||
function renderOutsourceBoard(r){
|
||||
const items=r.outsourceItems||[];
|
||||
if(!items.length){
|
||||
return `<div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">등록된 외주 데이터 없음</div></div></div><div class="ledger-pill">총 계약 0원</div></div><div class="ledger-empty">외주 상세 정보가 없습니다.</div></div>`;
|
||||
}
|
||||
return `<div class="ledger-block outsource"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">O</div><div><div class="ledger-name">외주 계약 / 지급 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 계약 ${esc(r.outsourceCost?won(r.outsourceCost):"-")}</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>외주처 / 계약명</th><th>계약기간</th><th style="text-align:right">계약금액</th><th style="text-align:right">지급금액</th><th style="text-align:right">잔여금액</th><th>진행현황</th><th>비고</th></tr></thead><tbody>${items.map(item=>{const periodText=(d(item.startDate)==="-"&&d(item.endDate)==="-")?"-":`${d(item.startDate)} ~ ${d(item.endDate)}`;const noteLines=(item.payments||[]).map(payment=>{const label=String(payment.label||"").trim();const note=String(payment.note||"").trim();if(!label&&!note) return "";if(label&¬e) return `${label}: ${note}`;return label||note;}).filter(Boolean);if(item.note) noteLines.unshift(item.note);return `<tr><td><span class="ledger-main">${esc(item.vendor||"외주")}</span><span class="ledger-muted">${esc(item.detail||"-")}</span></td><td><span class="ledger-main">${esc(periodText)}</span></td><td class="ledger-amount">${esc(item.contractEx?won(item.contractEx):"-")}</td><td class="ledger-amount">${esc(item.paidEx||item.paidEx===0?won(item.paidEx):"-")}</td><td class="ledger-amount">${esc(item.remainingEx||item.remainingEx===0?won(item.remainingEx):"-")}</td><td><span class="ledger-note">${esc(item.progress||"-")}</span></td><td><span class="ledger-note">${esc(noteLines.join(" / ")||"-")}</span></td></tr>`;}).join("")}</tbody></table></div></div>`;
|
||||
}
|
||||
function renderCollectionBoard(r){
|
||||
const payments=r.payments&&r.payments.length?r.payments:[{pay:r.pay||"-",issueDate:r.issueDate||"",collectDate:r.collectDateSummary||r.colDate||"",supply:r.sSup||0,collected:r.col||0,receivable:r.recv||Math.max(0,(r.sTot||0)-(r.col||0)),rate:r.rate||0,note:r.note||"",status:r.status||"-"}];
|
||||
return `<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">VAT 별도</div></div></div><div class="ledger-pill">총 수금 ${esc(won(r.col))}</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>발행 / 수금일</th><th>구분</th><th style="text-align:right">공급가액</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th style="text-align:right">수금율</th><th>비고</th></tr></thead><tbody>${payments.map(payment=>{const dateParts=[payment.issueDate?`발행 ${d(payment.issueDate)}`:"",payment.collectDate?`수금 ${d(payment.collectDate)}`:""].filter(Boolean);const noteParts=[];if(payment.status) noteParts.push(payment.status);if(payment.note) noteParts.push(payment.note);return `<tr><td><span class="ledger-main">${esc(dateParts[0]||"-")}</span><span class="ledger-muted">${esc(dateParts[1]||"수금일 없음")}</span></td><td><span class="ledger-main">${esc(payment.pay||"미입력")}</span></td><td class="ledger-amount">${esc(won(payment.supply||0))}</td><td class="ledger-amount">${esc(won(payment.collected||0))}</td><td class="ledger-amount">${esc(won(payment.receivable||0))}</td><td class="ledger-amount">${esc(((payment.rate||0).toFixed?payment.rate.toFixed(2):Number(payment.rate||0).toFixed(2))+"%")}</td><td><span class="ledger-note">${esc(noteParts.join(" / ")||"-")}</span></td></tr>`;}).join("")}</tbody></table></div></div>`;
|
||||
}
|
||||
function renderProjectInline(r){
|
||||
const payments=r.payments||[];
|
||||
const latestCollect=d(r.collectDateSummary||r.colDate);
|
||||
const collectCountText=payments.length?`차수 ${payments.length.toLocaleString("ko-KR")}건`:"수금 내역 없음";
|
||||
const outsourceCountText=summarizeOutsourceCounts(r);
|
||||
const hasOutsource=(r.outsourceItems||[]).length>0||(r.outsourceCost||0)>0||(r.outsourcePaid||0)>0||(r.outsourceRemaining||0)>0;
|
||||
const summaryCards=[
|
||||
`<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">${esc(won(r.cSup))}</div><div class="summary-note">VAT 별도</div></div>`,
|
||||
`<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">${esc(won(r.col))}</div><div class="summary-note">${esc(latestCollect==="-"?"수금일 없음":`최종 수금일 ${latestCollect}`)}</div></div>`,
|
||||
`<div class="summary-card"><div class="summary-label">수금율</div><div class="summary-value">${esc(r.rate.toFixed(2)+"%")}</div><div class="summary-note">${esc(collectCountText)}</div></div>`
|
||||
].filter(Boolean).join("");
|
||||
const bottomNotes=[
|
||||
`<div class="summary-note">미수금액 ${esc(won(r.recv))}</div>`
|
||||
].join("");
|
||||
const boards=[
|
||||
hasOutsource?renderOutsourceBoard(r):"",
|
||||
renderCollectionBoard(r)
|
||||
].filter(Boolean).join("");
|
||||
return `<div class="inline-panel"><div class="project-head"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">${esc(r.corp||"-")}</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">${esc(r.client||"-")}</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">${esc(r.order||"-")}</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">${esc(r.pm||"-")}</div></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px">${renderContactCompact("계약 / 청구 담당자",r.cmNm,r.cmCo,r.cmDp,r.cmPh,r.cmEm)}${renderContactCompact("부서 담당자",r.dmNm,r.dmCo,r.dmDp,r.dmPh,r.dmEm)}</div></div><div class="inline-card"><div class="summary-grid">${summaryCards}</div><div style="margin-top:10px" class="progress"><div class="bar" style="width:${Math.max(0,Math.min(100,r.rate||0))}%"></div></div><div style="display:flex;justify-content:space-between;gap:10px;margin-top:10px">${bottomNotes}</div></div></div><div class="ledger-stack">${boards}</div></div>`;
|
||||
}
|
||||
function closeAllModals(){
|
||||
E.collectModal.classList.remove("show");
|
||||
E.outsourceModal.classList.remove("show");
|
||||
}
|
||||
function toggleInlineDetail(r){
|
||||
const key=rowKey(r);
|
||||
S.expanded.key=S.expanded.key===key?"":key;
|
||||
render();
|
||||
}
|
||||
function openCollectionModal(r){
|
||||
setText("mCat",r.cat||"미분류");G("mCat").classList.toggle("ok",(r.status||"").includes("완료"));setText("mTitle",r.name||"-");setText("mSub","Project Code: "+(r.code||"-")+" · 계약법인: "+(r.corp||"-"));
|
||||
setText("mClient",r.client||"-");setText("mOrder",r.order||"-");setText("mSplit",r.split||"-");setText("mStartDate",d(r.sDate));setText("mEndDate",d(r.eDate));setText("mPayType",r.pay||"-");G("mPayItems").innerHTML=renderPaymentsHtml(r.payments||[]);
|
||||
setText("mContractTotal",won(r.cTot));setText("mContractSupply","공급가액: "+won(r.cSup));setText("mCollected",won(r.col));setText("mCollectDate",(r.payments&&r.payments.length>1?"최근 수금일: ":"수금일: ")+d(r.collectDateSummary||r.colDate));setText("mRate",r.rate.toFixed(2)+"%");setText("mReceivable",won(r.recv));G("mRateBar").style.width=Math.max(0,Math.min(100,r.rate||0))+"%";
|
||||
setText("mCmName",r.cmNm||"-");setText("mCmOrg",(r.cmCo||"-")+" · "+(r.cmDp||"-"));setText("mCmPhone","전화: "+(r.cmPh||"-"));setText("mCmEmail","메일: "+(r.cmEm||"-"));
|
||||
setText("mDmName",r.dmNm||"-");setText("mDmOrg",(r.dmCo||"-")+" · "+(r.dmDp||"-"));setText("mDmPhone","전화: "+(r.dmPh||"-"));setText("mDmEmail","메일: "+(r.dmEm||"-"));
|
||||
closeAllModals();
|
||||
E.collectModal.classList.add("show");
|
||||
}
|
||||
function openOutsourceModal(r){
|
||||
setText("oTitle",r.name||"-");
|
||||
setText("oSub","Project Code: "+(r.code||"-")+" · PM: "+(r.pm||"-"));
|
||||
setText("oCorp",r.corp||"-");
|
||||
setText("oClient",r.client||"-");
|
||||
setText("oVendors",r.outsourceVendorText||"-");
|
||||
setText("oTotal",r.outsourceCost?won(r.outsourceCost):"-");
|
||||
setText("oCount",(r.outsourceItems||[]).length?`${(r.outsourceItems||[]).length.toLocaleString("ko-KR")}건`:"0건");
|
||||
setText("oPeriod",r.periodText||"-");
|
||||
setText("oTotalHero",r.outsourceCost?won(r.outsourceCost):"-");
|
||||
setText("oTotalHint",(r.outsourceItems||[]).length?"시트별 외주 상세 내역 합산":"외주 상세 정보가 없습니다.");
|
||||
G("oItems").innerHTML=renderOutsourceHtml(r.outsourceItems||[]);
|
||||
closeAllModals();
|
||||
E.outsourceModal.classList.add("show");
|
||||
}
|
||||
function outsourceSummaryText(r){
|
||||
const contracts=(r.outsourceItems||[]).length;
|
||||
const stages=countOutsourceStages(r);
|
||||
const parts=[];
|
||||
if(contracts) parts.push(`계약 ${contracts.toLocaleString("ko-KR")}건`);
|
||||
if(stages) parts.push(`지급단계 ${stages.toLocaleString("ko-KR")}건`);
|
||||
if(parts.length) return parts.join(" · ");
|
||||
return "-";
|
||||
}
|
||||
function render(){
|
||||
const rows=S.rows,t=sumRows(rows),viewRows=rows.slice().sort((a,b)=>{const as=isSettledRow(a),bs=isSettledRow(b);if(as!==bs)return as?1:-1;return (b.recv||0)-(a.recv||0);});
|
||||
const useSheetTotals=!!(S.totals&&!hasActiveDashboardFilters());
|
||||
const totalContract=useSheetTotals?S.totals.contract:t.c;
|
||||
const totalCollected=useSheetTotals?S.totals.collected:t.col;
|
||||
const totalReceivable=useSheetTotals?S.totals.receivable:t.recv;
|
||||
const totalRate=useSheetTotals?S.totals.rate:rate("",totalCollected,totalCollected+totalReceivable);
|
||||
S.viewRows=viewRows;
|
||||
E.cards.innerHTML=[["총 프로젝트수",rows.length.toLocaleString("ko-KR")+" 건"],["총 계약금",won(totalContract)],["총 수금금액",won(totalCollected)],["총 미수금액",won(totalReceivable)],["총 수금율",totalRate.toFixed(2)+"%"]].map(c=>`<div class="card"><div class="k">${esc(c[0])}</div><div class="v">${esc(c[1])}</div></div>`).join("");
|
||||
E.tbody.innerHTML=viewRows.map((r,i)=>{
|
||||
const key=rowKey(r);
|
||||
const detailOpen=S.expanded.key===key;
|
||||
const detailHtml=detailOpen?renderProjectInline(r):"";
|
||||
return `<tr data-i="${i}" class="${isSettledRow(r)?"settled":""}"><td><div class="badge">${esc(r.cat||"-")}</div><div class="subline">ID: ${esc(r.code||"-")}</div></td><td><div class="name">${esc(r.name||"-")}</div><div class="subline">${esc(r.periodText||"-")}</div></td><td><div>${esc(r.corp||"-")}</div></td><td><div class="badge ${(r.status||"").includes("완료")?"ok":""}">${esc(r.status||"-")}</div><div class="subline">${esc(r.yn||"-")}</div></td><td class="num"><strong>${esc(r.outsourceCost?won(r.outsourceCost):"-")}</strong></td><td class="num"><strong>${esc(won(r.cSup))}</strong></td><td class="num"><strong>${esc(won(r.col))}</strong></td><td class="num"><strong style="color:${isSettledRow(r)?"#94a3b8":"#2563eb"}">${esc(r.rate.toFixed(2)+"%")}</strong></td></tr>${detailHtml?`<tr class="detail-row"><td class="detail-cell" colspan="8">${detailHtml}</td></tr>`:""}`;
|
||||
}).join("");
|
||||
E.empty.style.display=rows.length?"none":"block";
|
||||
const settledCount=S.all.filter(isSettledRow).length;
|
||||
E.status.textContent=S.all.length?`로드 완료: ${S.all.length.toLocaleString("ko-KR")}건${S.file?` · 파일: ${S.file}`:""}${settledCount?` · 완납 ${settledCount.toLocaleString("ko-KR")}건 하단 정렬`:""}`:"CSV/XLSX 파일을 업로드하면 데이터가 표시됩니다.";
|
||||
}
|
||||
function filter(){const q=String(E.search.value||"").trim().toLowerCase();const searched=!q?S.all.slice():S.all.filter(r=>[r.code,r.name,r.client,r.pm,r.status,r.cat,r.corp,r.pay,(r.payments||[]).map(p=>p.pay).join(" "),r.periodText,r.outsourceVendorText,(r.outsourceItems||[]).map(item=>[item.vendor,item.detail,item.progress,item.note,(item.payments||[]).map(payment=>[payment.label,payment.note,payment.invoiceDate,payment.paymentDate].join(" ")).join(" ")].join(" ")).join(" "),outsourceFilterLabel(r),amountFilterLabel(r),collectedFilterLabel(r)].join(" ").toLowerCase().includes(q));S.rows=searched.filter(matchesColumnFilters);render();}
|
||||
function applyParsedLedgerResult(fileName,parsed,sheetName){
|
||||
S.all=parsed.records;
|
||||
S.totals=parsed.totals||null;
|
||||
S.file=(fileName||"")+(sheetName?` [${sheetName}]`:"");
|
||||
syncColumnFilters(S.all);
|
||||
filter();
|
||||
}
|
||||
async function loadLedgerFile(buffer,fileName){
|
||||
const isExcel=/\.(xlsx|xls)$/i.test(String(fileName||""));
|
||||
if(isExcel){
|
||||
const parsed=parseLedgerExcel(buffer);
|
||||
applyParsedLedgerResult(fileName,parsed,parsed.sheetName||"");
|
||||
return;
|
||||
}
|
||||
const parsed=parseLedger(decode(buffer));
|
||||
applyParsedLedgerResult(fileName,parsed,"");
|
||||
}
|
||||
E.btnUpload.addEventListener("click",()=>E.file.click());
|
||||
E.file.addEventListener("change",async e=>{
|
||||
const f=e.target.files&&e.target.files[0];
|
||||
try{
|
||||
if(f){
|
||||
const buf=await f.arrayBuffer();
|
||||
await loadLedgerFile(buf,f.name||"");
|
||||
}
|
||||
}catch(err){
|
||||
S.all=[];S.rows=[];S.totals=null;syncColumnFilters([]);closeAllModals();render();E.status.textContent="업로드 실패: "+(err&&err.message?err.message:String(err));
|
||||
}
|
||||
e.target.value="";
|
||||
});
|
||||
E.search.addEventListener("input",filter);
|
||||
Object.values(E.filterButtons).forEach(btn=>btn.addEventListener("click",e=>{e.stopPropagation();toggleFilterMenu(btn.dataset.filter);}));
|
||||
Object.values(E.filterMenus).forEach(menu=>menu.addEventListener("click",e=>{
|
||||
e.stopPropagation();
|
||||
const option=e.target&&e.target.closest?e.target.closest("button[data-filter-value]"):null;
|
||||
if(!option) return;
|
||||
setFilterValue(menu.dataset.filter,option.getAttribute("data-filter-value")||"");
|
||||
}));
|
||||
E.tbody.addEventListener("click",e=>{
|
||||
const rowEl=e.target&&e.target.closest?e.target.closest("tr[data-i]"):null;
|
||||
if(!rowEl) return;
|
||||
const r=S.viewRows[parseInt(rowEl.getAttribute("data-i"),10)];
|
||||
if(!r) return;
|
||||
toggleInlineDetail(r);
|
||||
});
|
||||
E.btnCollectClose.addEventListener("click",closeAllModals);
|
||||
E.btnOutsourceClose.addEventListener("click",closeAllModals);
|
||||
E.collectModal.addEventListener("click",e=>{if(e.target===E.collectModal)closeAllModals();});
|
||||
E.outsourceModal.addEventListener("click",e=>{if(e.target===E.outsourceModal)closeAllModals();});
|
||||
document.addEventListener("click",e=>{if(!(e.target&&e.target.closest&&e.target.closest(".th-head")))closeFilterMenus();});
|
||||
document.addEventListener("keydown",e=>{if(e.key==="Escape"){closeFilterMenus();closeAllModals();}});
|
||||
window.addEventListener("message",async e=>{
|
||||
const data=e.data||{};
|
||||
if(data.source==="total-control"&&data.type==="embedded-host") E.btnUpload.style.display="none";
|
||||
if(data.source!=="total-upload"||data.type!=="business") return;
|
||||
try{
|
||||
const buffer=data.buffer instanceof ArrayBuffer?data.buffer:(data.buffer&&data.buffer.buffer instanceof ArrayBuffer?data.buffer.buffer:null);
|
||||
if(!buffer) throw new Error("업로드 데이터가 비어 있습니다.");
|
||||
await loadLedgerFile(buffer,data.fileName||"사업관리대장.xlsx");
|
||||
}catch(err){
|
||||
S.all=[];S.rows=[];S.totals=null;syncColumnFilters([]);closeAllModals();render();E.status.textContent="업로드 실패: "+(err&&err.message?err.message:String(err));
|
||||
}
|
||||
});
|
||||
syncColumnFilters([]);
|
||||
render();
|
||||
</script>
|
||||
<script src="/integrations/ledger-assets/ledger-override.js?v=20260401-03"></script></body>
|
||||
</html>
|
||||
328
incoming-files/served/ledger/ledger-override.css
Normal file
328
incoming-files/served/ledger/ledger-override.css
Normal file
@@ -0,0 +1,328 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body.mh-business-theme {
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(214, 138, 58, 0.16), transparent 24%),
|
||||
radial-gradient(circle at top right, rgba(47, 153, 115, 0.10), transparent 20%),
|
||||
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
|
||||
}
|
||||
|
||||
body.mh-business-theme .wrap {
|
||||
width: min(100%, 2000px);
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 18px 26px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.mh-business-theme .top,
|
||||
body.mh-business-theme .status {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .business-shell {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 2px;
|
||||
padding: 18px;
|
||||
border-radius: 32px;
|
||||
background:
|
||||
radial-gradient(circle at 16% 14%, rgba(255,255,255,0.05), transparent 18%),
|
||||
radial-gradient(circle at 88% 8%, rgba(255,255,255,0.04), transparent 16%),
|
||||
linear-gradient(145deg, #0b352b 0%, #174e41 52%, #245f50 100%);
|
||||
box-shadow: 0 26px 54px rgba(15, 58, 47, 0.16);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 10px 0 2px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-search {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: min(360px, 100%);
|
||||
flex: 1 1 320px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-search .search {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(255,255,255,0.10);
|
||||
color: #f4efe6;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-search .search::placeholder {
|
||||
color: rgba(244, 239, 230, 0.74);
|
||||
}
|
||||
|
||||
body.mh-business-theme #btnUpload {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-year-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 60px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #f4efe6;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-year-chip.active {
|
||||
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
|
||||
color: #0a2a22;
|
||||
border-color: rgba(242, 196, 132, 0.58);
|
||||
box-shadow: 0 12px 28px rgba(10, 42, 34, 0.18);
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 98px;
|
||||
padding: 18px 22px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.07) 100%);
|
||||
color: #f4efe6;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 16px 30px rgba(7, 28, 22, 0.14);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip.active {
|
||||
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%);
|
||||
color: #0a2a22;
|
||||
border-color: rgba(242, 196, 132, 0.58);
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip .label {
|
||||
color: rgba(244, 239, 230, 0.78);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip.active .label {
|
||||
color: rgba(10, 42, 34, 0.78);
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip .count {
|
||||
color: #fff7e6;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip.active .count {
|
||||
color: #b86b1f;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip .meta {
|
||||
color: #f2c484;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body.mh-business-theme .summary-filter-chip.active .meta {
|
||||
color: #7c5a20;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card {
|
||||
grid-column: span 2;
|
||||
min-height: 110px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(217, 197, 168, 0.55);
|
||||
background: linear-gradient(180deg, rgba(255,250,243,0.96) 0%, rgba(248,242,232,0.96) 100%);
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
|
||||
}
|
||||
|
||||
body.mh-business-theme .card.management {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card .k {
|
||||
color: #5b6d63;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card .v {
|
||||
margin-top: 8px;
|
||||
color: #17392f;
|
||||
font-size: 30px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card .n {
|
||||
margin-top: 8px;
|
||||
color: #7b6953;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body.mh-business-theme .panel {
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(217, 197, 168, 0.55);
|
||||
box-shadow: 0 18px 32px rgba(15, 58, 47, 0.08);
|
||||
}
|
||||
|
||||
body.mh-business-theme .table-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 28px;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
body.mh-business-theme .table-vat-note {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.mh-business-theme table {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
table-layout: fixed;
|
||||
background: rgba(255, 250, 243, 0.96);
|
||||
}
|
||||
|
||||
body.mh-business-theme thead th {
|
||||
background: #0f352b;
|
||||
color: #fff5e6;
|
||||
border-right: 1px solid rgba(242, 196, 132, 0.2);
|
||||
}
|
||||
|
||||
body.mh-business-theme tbody td {
|
||||
background: rgba(255, 250, 243, 0.96);
|
||||
}
|
||||
|
||||
body.mh-business-theme .group-row td {
|
||||
padding: 12px 14px 10px;
|
||||
background: linear-gradient(180deg, rgba(255, 248, 238, 0.98) 0%, rgba(242, 222, 192, 0.78) 100%);
|
||||
border-top: 1px solid rgba(214, 138, 58, 0.26);
|
||||
border-bottom: 1px solid rgba(217, 197, 168, 0.54);
|
||||
}
|
||||
|
||||
body.mh-business-theme .group-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 250, 243, 0.98);
|
||||
border: 1px solid rgba(214, 138, 58, 0.3);
|
||||
color: #17392f;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 8px 18px rgba(15, 58, 47, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.mh-business-theme .group-chip .group-toggle {
|
||||
margin-left: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(242, 196, 132, 0.18);
|
||||
color: #b66e22;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
body.mh-business-theme .project-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #17392f;
|
||||
font: inherit;
|
||||
font-weight: 900;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.mh-business-theme .project-link:hover {
|
||||
color: #0f6a55;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
body.mh-business-theme .cards-toolbar-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card {
|
||||
grid-column: span 4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
body.mh-business-theme .wrap {
|
||||
padding: 12px 12px 20px;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
body.mh-business-theme .card {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
body.mh-business-theme .cards-toolbar-search {
|
||||
margin-left: 0;
|
||||
max-width: none;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
498
incoming-files/served/ledger/ledger-override.js
Normal file
498
incoming-files/served/ledger/ledger-override.js
Normal file
@@ -0,0 +1,498 @@
|
||||
(function () {
|
||||
window.__mhLedgerEnhancementLoaded = false;
|
||||
if (typeof S === "undefined" || typeof E === "undefined" || typeof render !== "function") return;
|
||||
window.__mhLedgerEnhancementLoaded = true;
|
||||
if (!S.dashboard) S.dashboard = { year: "", section: "active" };
|
||||
if (!S.collapsedGroups) S.collapsedGroups = {};
|
||||
|
||||
function bgToday() {
|
||||
var now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
function bgParseDate(value) {
|
||||
var text = String(value || "").trim();
|
||||
if (!text) return null;
|
||||
var match = text.match(/(20\d{2})\D?(\d{1,2})\D?(\d{1,2})/);
|
||||
if (match) {
|
||||
var parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
var fallback = new Date(text);
|
||||
if (isNaN(fallback.getTime())) return null;
|
||||
return new Date(fallback.getFullYear(), fallback.getMonth(), fallback.getDate());
|
||||
}
|
||||
|
||||
function bgYearFromText(value) {
|
||||
var match = String(value || "").trim().match(/(20\d{2})/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function bgStartYear(row) {
|
||||
return bgYearFromText(row && row.sDate);
|
||||
}
|
||||
|
||||
function bgEndYear(row) {
|
||||
return bgYearFromText(row && row.eDate);
|
||||
}
|
||||
|
||||
function bgDisplayYear(row) {
|
||||
var start = bgStartYear(row);
|
||||
if (start) return start;
|
||||
var contractMatch = String((row && row.cDate) || "").trim().match(/(20\d{2})/);
|
||||
if (contractMatch) return contractMatch[1];
|
||||
var nameMatch = String((row && row.name) || "").trim().match(/^(20\d{2})/);
|
||||
if (nameMatch) return nameMatch[1];
|
||||
return bgEndYear(row) || "미지정";
|
||||
}
|
||||
|
||||
function bgCompletionYear(row) {
|
||||
return bgEndYear(row) || bgDisplayYear(row);
|
||||
}
|
||||
|
||||
function bgDateOrYearStart(row) {
|
||||
var yearText = bgDisplayYear(row);
|
||||
return bgParseDate(row && row.sDate) || bgParseDate(row && row.cDate) || (/^20\d{2}$/.test(yearText) ? new Date(Number(yearText), 0, 1) : null);
|
||||
}
|
||||
|
||||
function bgDateOrYearEnd(row) {
|
||||
var completionYear = bgCompletionYear(row);
|
||||
return bgParseDate(row && row.eDate) || (/^20\d{2}$/.test(completionYear) ? new Date(Number(completionYear), 11, 31) : null);
|
||||
}
|
||||
|
||||
function bgYearCutoff(year) {
|
||||
var targetYear = Number(year || 0);
|
||||
if (!targetYear) return null;
|
||||
var today = bgToday();
|
||||
if (targetYear < today.getFullYear()) return new Date(targetYear, 11, 31);
|
||||
if (targetYear === today.getFullYear()) return today;
|
||||
return null;
|
||||
}
|
||||
|
||||
function bgYearStartDate(year) {
|
||||
var targetYear = Number(year || 0);
|
||||
return targetYear ? new Date(targetYear, 0, 1) : null;
|
||||
}
|
||||
|
||||
function bgActiveInYear(row, year) {
|
||||
var cutoff = bgYearCutoff(year);
|
||||
var yearStart = bgYearStartDate(year);
|
||||
var startDate = bgDateOrYearStart(row);
|
||||
var endDate = bgDateOrYearEnd(row);
|
||||
if (!(cutoff && yearStart && startDate)) return false;
|
||||
if (startDate > cutoff) return false;
|
||||
if (endDate && endDate < yearStart) return false;
|
||||
return !(endDate && endDate <= cutoff);
|
||||
}
|
||||
|
||||
function bgStartedInYear(row, year) {
|
||||
var cutoff = bgYearCutoff(year);
|
||||
var startDate = bgDateOrYearStart(row);
|
||||
if (!(cutoff && startDate)) return false;
|
||||
return startDate.getFullYear() === Number(year || 0) && startDate <= cutoff;
|
||||
}
|
||||
|
||||
function bgCompletedInYear(row, year) {
|
||||
var cutoff = bgYearCutoff(year);
|
||||
var endDate = bgDateOrYearEnd(row);
|
||||
if (!(cutoff && endDate)) return false;
|
||||
return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
|
||||
}
|
||||
|
||||
function bgYearRange(row) {
|
||||
var years = [];
|
||||
var startYear = Number(bgDisplayYear(row) || 0);
|
||||
var endYear = Number(bgCompletionYear(row) || 0);
|
||||
if (startYear && endYear && endYear >= startYear) {
|
||||
for (var year = startYear; year <= endYear; year += 1) years.push(String(year));
|
||||
} else if (startYear) {
|
||||
years.push(String(startYear));
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
function bgYears(rows) {
|
||||
var currentYear = new Date().getFullYear();
|
||||
var years = Array.from(new Set((Array.isArray(rows) ? rows : []).flatMap(bgYearRange).filter(function (year) {
|
||||
return /^20\d{2}$/.test(year);
|
||||
}))).sort(function (a, b) {
|
||||
return Number(b) - Number(a);
|
||||
});
|
||||
years = years.filter(function (year) {
|
||||
var numericYear = Number(year);
|
||||
return numericYear >= 2018 && numericYear <= currentYear;
|
||||
});
|
||||
return years.length ? years : [String(currentYear)];
|
||||
}
|
||||
|
||||
function bgEnsureYear(rows) {
|
||||
var years = bgYears(rows);
|
||||
if (!years.includes(S.dashboard.year)) S.dashboard.year = years[0];
|
||||
return years;
|
||||
}
|
||||
|
||||
function bgTotals(targetRows) {
|
||||
return (Array.isArray(targetRows) ? targetRows : []).reduce(function (acc, row) {
|
||||
acc.c += Number((row && row.cSup) || 0);
|
||||
acc.col += Number((row && row.col) || 0);
|
||||
acc.recv += Number((row && row.recv) || 0);
|
||||
return acc;
|
||||
}, { c: 0, col: 0, recv: 0 });
|
||||
}
|
||||
|
||||
function isSupportServiceRow(row) {
|
||||
var category = String((row && row.cat) || "").trim();
|
||||
return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
|
||||
}
|
||||
|
||||
function isBaronProjectRow(row) {
|
||||
var category = String((row && row.cat) || "").trim();
|
||||
if (category.indexOf("바론") < 0) return false;
|
||||
if (isSupportServiceRow(row)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function bgSummarize(rows, selectedYear) {
|
||||
var items = Array.isArray(rows) ? rows : [];
|
||||
var targetYear = selectedYear || bgEnsureYear(items)[0];
|
||||
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
|
||||
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
|
||||
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
|
||||
var managementRows = newProjectRows.filter(isSupportServiceRow);
|
||||
return {
|
||||
targetYear: targetYear,
|
||||
activeRows: activeRows,
|
||||
newProjectRows: newProjectRows,
|
||||
completedRows: completedRows,
|
||||
managementRows: managementRows,
|
||||
managementTotals: bgTotals(managementRows)
|
||||
};
|
||||
}
|
||||
|
||||
function bgMatches(row) {
|
||||
var section = S.dashboard.section || "active";
|
||||
var selectedYear = S.dashboard.year || bgEnsureYear(S.all)[0];
|
||||
if (section === "new") return bgStartedInYear(row, selectedYear);
|
||||
if (section === "completed") return bgCompletedInYear(row, selectedYear);
|
||||
return bgActiveInYear(row, selectedYear);
|
||||
}
|
||||
|
||||
function normalizeStatusLabel(status) {
|
||||
var value = String(status || "").trim();
|
||||
if (!value) return "-";
|
||||
if (value.indexOf("진행") >= 0) return "과업 진행중";
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatSplitPercent(split) {
|
||||
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
|
||||
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
|
||||
return "분담율 " + numeric.toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function projectYear(row) {
|
||||
var start = String((row && row.sDate) || "").trim();
|
||||
var startMatch = start.match(/(20\d{2})/);
|
||||
if (startMatch) return startMatch[1];
|
||||
var name = String((row && row.name) || "").trim();
|
||||
var nameMatch = name.match(/^(20\d{2})/);
|
||||
if (nameMatch) return nameMatch[1];
|
||||
var end = String((row && row.eDate) || "").trim();
|
||||
var endMatch = end.match(/(20\d{2})/);
|
||||
if (endMatch) return endMatch[1];
|
||||
return "미지정";
|
||||
}
|
||||
|
||||
function groupSortRank(row) {
|
||||
var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
|
||||
var startYear = Number(projectYear(row) || 0);
|
||||
if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
|
||||
if (!startYear) return 9998;
|
||||
return startYear;
|
||||
}
|
||||
|
||||
function tableGroupLabel(row) {
|
||||
var startYear = projectYear(row);
|
||||
if (/^20\d{2}$/.test(startYear)) return startYear + "년";
|
||||
return "미지정";
|
||||
}
|
||||
|
||||
function renderLedgerTable() {
|
||||
var table = document.querySelector(".panel table");
|
||||
if (!table || !E.tbody) return;
|
||||
var thead = table.querySelector("thead");
|
||||
if (thead) {
|
||||
thead.innerHTML = '<tr>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="cat" data-label="구분"><span class="th-title">구분</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCatMenu" class="th-menu" data-filter="cat"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="code" data-label="사업코드"><span class="th-title">사업코드</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCodeMenu" class="th-menu" data-filter="code"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="name" data-label="사업명(계약명)"><span class="th-title">사업명(계약명)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterNameMenu" class="th-menu" data-filter="name"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="client" data-label="발주처(계약처)"><span class="th-title">발주처(계약처)</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterClientMenu" class="th-menu" data-filter="client"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="order" data-label="발주방법"><span class="th-title">발주방법</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOrderMenu" class="th-menu" data-filter="order"></div></div></th>'
|
||||
+ '<th><div class="th-head"><button type="button" class="th-trigger" data-filter="status" data-label="진행상태"><span class="th-title">진행상태</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterStatusMenu" class="th-menu" data-filter="status"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="amount" data-label="계약금"><span class="th-title">계약금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterAmountMenu" class="th-menu" data-filter="amount"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="outsource" data-label="외주비"><span class="th-title">외주비</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterOutsourceMenu" class="th-menu" data-filter="outsource"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="receivable" data-label="미수금"><span class="th-title">미수금</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterReceivableMenu" class="th-menu" data-filter="receivable"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="collected" data-label="수금액"><span class="th-title">수금액</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterCollectedMenu" class="th-menu" data-filter="collected"></div></div></th>'
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
|
||||
+ "</tr>";
|
||||
}
|
||||
var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
|
||||
var ar = groupSortRank(a);
|
||||
var br = groupSortRank(b);
|
||||
if (ar !== br) return ar - br;
|
||||
return Number(b.recv || 0) - Number(a.recv || 0);
|
||||
});
|
||||
S.viewRows = rows;
|
||||
var lastGroupLabel = "";
|
||||
E.tbody.innerHTML = rows.map(function (r) {
|
||||
var groupLabel = tableGroupLabel(r);
|
||||
var isCollapsed = !!S.collapsedGroups[groupLabel];
|
||||
var groupRow = "";
|
||||
if (groupLabel !== lastGroupLabel) {
|
||||
groupRow = '<tr class="group-row"><td colspan="11"><button type="button" class="group-chip" data-group-label="' + escAttr(groupLabel) + '"><span>' + esc(groupLabel) + '</span><span class="group-toggle" aria-hidden="true">' + (isCollapsed ? "+" : "-") + "</span></button></td></tr>";
|
||||
lastGroupLabel = groupLabel;
|
||||
}
|
||||
if (isCollapsed) return groupRow;
|
||||
return groupRow + '<tr class="' + (isSettledRow(r) ? 'settled' : '') + '">'
|
||||
+ '<td><div class="badge ' + esc(String(r.cat || "").indexOf("바론") >= 0 ? 'badge-baron' : 'badge-family') + '">' + esc(r.cat || "-") + '</div></td>'
|
||||
+ '<td><div class="subline" style="margin-top:0;font-size:12px;color:#66756d">' + esc(r.code || "-") + '</div></td>'
|
||||
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
|
||||
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
|
||||
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
|
||||
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.col || 0)) + '</strong></td>'
|
||||
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
||||
+ '</tr>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderCollectionBoard(r) {
|
||||
var payments = Array.isArray(r.payments) && r.payments.length ? r.payments : [{
|
||||
pay: r.pay || "-",
|
||||
issueDate: r.issueDate || "",
|
||||
collectDate: r.collectDateSummary || r.colDate || "",
|
||||
collected: r.col || 0,
|
||||
receivable: r.recv || Math.max(0, Number(r.sTot || 0) - Number(r.col || 0)),
|
||||
note: r.note || "",
|
||||
status: r.status || ""
|
||||
}];
|
||||
return '<div class="ledger-block collect"><div class="ledger-head"><div class="ledger-head-left"><div class="ledger-icon">C</div><div><div class="ledger-name">수금 및 기성 현황</div><div class="ledger-sub">기성 차수별 세금계산서 발행 및 수금 내역</div></div></div><div class="ledger-pill">총 수금 ' + esc(won(r.col || 0)) + '</div></div><div class="ledger-table-wrap"><table class="ledger-table"><thead><tr><th>기성 차수</th><th>세금계산서 발행일</th><th>수금일</th><th style="text-align:right">수금금액</th><th style="text-align:right">미수금액</th><th>비고</th></tr></thead><tbody>'
|
||||
+ payments.map(function (payment, index) {
|
||||
var noteParts = [];
|
||||
if (payment.status) noteParts.push(payment.status);
|
||||
if (payment.note) noteParts.push(payment.note);
|
||||
return '<tr><td><span class="ledger-main">' + esc((index + 1) + "차") + '</span><span class="ledger-muted">' + esc(payment.pay || "-") + '</span></td><td><span class="ledger-main">' + esc(payment.issueDate ? d(payment.issueDate) : "-") + '</span></td><td><span class="ledger-main">' + esc(payment.collectDate ? d(payment.collectDate) : "-") + '</span></td><td class="ledger-amount">' + esc(won(payment.collected || 0)) + '</td><td class="ledger-amount" style="color:#a94832">' + esc(won(payment.receivable || 0)) + '</td><td><span class="ledger-note">' + esc(noteParts.join(" / ") || "-") + '</span></td></tr>';
|
||||
}).join("")
|
||||
+ "</tbody></table></div></div>";
|
||||
}
|
||||
|
||||
function renderContactCard(label, name, company, department, phone, email) {
|
||||
var hasValue = [name, company, department, phone, email].some(function (value) {
|
||||
return String(value || "").trim() !== "";
|
||||
});
|
||||
if (!hasValue) {
|
||||
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="summary-note">등록된 담당자 정보가 없습니다.</div></div>';
|
||||
}
|
||||
return '<div class="inline-card"><div class="kvk">' + esc(label) + '</div><div class="project-meta-grid">'
|
||||
+ '<div class="kv"><div class="kvk">이름</div><div class="kvv">' + esc(name || "-") + '</div></div>'
|
||||
+ '<div class="kv"><div class="kvk">소속</div><div class="kvv">' + esc(company || "-") + '</div><div class="summary-note">' + esc(department || "-") + '</div></div>'
|
||||
+ '<div class="kv"><div class="kvk">연락처</div><div class="kvv">' + esc(phone || "-") + '</div></div>'
|
||||
+ '<div class="kv"><div class="kvk">이메일</div><div class="kvv">' + esc(email || "-") + '</div></div>'
|
||||
+ "</div></div>";
|
||||
}
|
||||
|
||||
function renderProjectInline(r) {
|
||||
var payments = Array.isArray(r.payments) ? r.payments : [];
|
||||
var latestCollect = d(r.collectDateSummary || r.colDate);
|
||||
var hasOutsource = (Array.isArray(r.outsourceItems) && r.outsourceItems.length > 0) || Number(r.outsourceCost || 0) > 0 || Number(r.outsourcePaid || 0) > 0 || Number(r.outsourceRemaining || 0) > 0;
|
||||
var clientDisplay = typeof normalizeClientDisplay === "function" ? normalizeClientDisplay(r.client) : (String(r.client || "").trim() || "-");
|
||||
var splitDisplay = typeof formatSplitDisplay === "function" ? formatSplitDisplay(r.split) : formatSplitPercent(r.split).replace("분담율 ", "");
|
||||
var summaryCards = [
|
||||
'<div class="summary-card"><div class="summary-label">계약금</div><div class="summary-value">' + esc(won(r.cSup || 0)) + '</div><div class="summary-note"></div></div>',
|
||||
'<div class="summary-card"><div class="summary-label">수금액</div><div class="summary-value">' + esc(won(r.col || 0)) + '</div><div class="summary-note">' + esc(latestCollect === "-" ? "수금일 없음" : "최종 수금일 " + latestCollect) + '</div></div>',
|
||||
'<div class="summary-card"><div class="summary-label">수금률</div><div class="summary-value">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</div><div class="summary-note">' + esc(payments.length ? "기성 " + payments.length + "차까지 반영" : "차수 정보 없음") + '</div></div>',
|
||||
'<div class="summary-card receivable"><div class="summary-label">미수금액</div><div class="summary-value">' + esc(won(r.recv || 0)) + '</div><div class="summary-note">잔여 수금 필요 금액</div></div>'
|
||||
].join("");
|
||||
var boards = [
|
||||
hasOutsource && typeof renderOutsourceBoard === "function" ? renderOutsourceBoard(r) : "",
|
||||
renderCollectionBoard(r)
|
||||
].filter(Boolean).join("");
|
||||
return '<div class="inline-panel"><div class="project-head project-head-grid"><div class="project-head-main"><div class="inline-card"><div class="project-meta-grid"><div class="kv"><div class="kvk">계약법인</div><div class="kvv">' + esc(r.corp || "-") + '</div></div><div class="kv"><div class="kvk">발주처</div><div class="kvv">' + esc(clientDisplay) + '</div><div class="summary-note">' + esc(splitDisplay ? "분담율 " + splitDisplay : "분담율 -") + '</div></div><div class="kv"><div class="kvk">발주방법</div><div class="kvv">' + esc(r.order || "-") + '</div></div><div class="kv"><div class="kvk">PM</div><div class="kvv">' + esc(r.pm || "-") + '</div></div></div></div><div class="inline-card"><div class="summary-grid">' + summaryCards + '</div><div class="project-progress progress"><div class="bar" style="width:' + esc(String(Math.max(0, Math.min(100, Number(r.rate || 0))))) + '%"></div></div></div></div><div class="project-contact-stack">' + renderContactCard("계약 / 청구 담당자", r.cmNm, r.cmCo, r.cmDp, r.cmPh, r.cmEm) + renderContactCard("부서 담당자", r.dmNm, r.dmCo, r.dmDp, r.dmPh, r.dmEm) + '</div></div><div class="ledger-stack">' + boards + '</div></div>';
|
||||
}
|
||||
|
||||
function openProjectWindow(r) {
|
||||
var popupKey = typeof rowKey === "function"
|
||||
? rowKey(r).replace(/[^0-9a-zA-Z]/g, "_")
|
||||
: String((r.code || "project") + "_" + (r.name || "")).replace(/[^0-9a-zA-Z_]/g, "_");
|
||||
var popup = window.open("", "business_project_" + popupKey, "width=1600,height=980,resizable=yes,scrollbars=yes");
|
||||
if (!popup) return;
|
||||
var styleText = Array.from(document.querySelectorAll("style")).map(function (el) {
|
||||
return el.textContent || "";
|
||||
}).join("\n");
|
||||
var detailHtml = renderProjectInline(r);
|
||||
var pageHtml = '<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>'
|
||||
+ esc(r.name || "사업 상세")
|
||||
+ '</title><link rel="stylesheet" href="/design-tokens.css?v=20260401-01"><link rel="stylesheet" href="/design-patterns.css?v=20260401-01"><style>' + styleText
|
||||
+ 'body{margin:0;background:#f1eadf;color:#10251d;font-family:"Pretendard","Noto Sans KR","Malgun Gothic",sans-serif;}'
|
||||
+ '.popup-wrap{max-width:1680px;margin:0 auto;padding:20px;}'
|
||||
+ '@media (max-width: 1180px){.project-head-grid{grid-template-columns:1fr;}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.project-meta-grid{grid-template-columns:1fr;}}'
|
||||
+ '@media (max-width: 760px){.popup-wrap{padding:14px;}.summary-grid{grid-template-columns:1fr;}.ledger-head{flex-direction:column;align-items:flex-start;}.ledger-pill{white-space:normal;}.ledger-table-wrap{padding:0 10px 12px;overflow-x:auto;}}'
|
||||
+ '</style></head><body><div class="popup-wrap"><div class="popup-head"><div class="popup-title">' + esc(r.name || "-") + '</div><div class="popup-sub">사업코드 ' + esc(r.code || "-") + ' · 계약법인 ' + esc(r.corp || "-") + '</div></div>' + detailHtml + "</div></body></html>";
|
||||
popup.document.open();
|
||||
popup.document.write(pageHtml);
|
||||
popup.document.close();
|
||||
popup.focus();
|
||||
}
|
||||
|
||||
async function tryLoadDbDefaultBusinessLedger() {
|
||||
if (window.__mhBusinessDefaultLoaded) return;
|
||||
window.__mhBusinessDefaultLoaded = true;
|
||||
try {
|
||||
var response = await fetch("/api/integration/business-ledger-default");
|
||||
if (!response.ok) throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
|
||||
var fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
|
||||
var buffer = await response.arrayBuffer();
|
||||
if (!buffer || !buffer.byteLength) throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
|
||||
await loadLedgerFile(buffer, fileName);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDashboardChrome() {
|
||||
if (!E.cards) return;
|
||||
document.body.setAttribute("data-mh-ledger-enhanced", "true");
|
||||
var wrap = document.querySelector(".wrap");
|
||||
var panel = document.querySelector(".panel");
|
||||
if (wrap && panel) {
|
||||
var shell = wrap.querySelector(".business-shell");
|
||||
if (!shell) {
|
||||
shell = document.createElement("div");
|
||||
shell.className = "business-shell";
|
||||
wrap.insertBefore(shell, E.cards);
|
||||
}
|
||||
if (E.cards.parentNode !== shell) shell.appendChild(E.cards);
|
||||
if (panel.parentNode !== shell) shell.appendChild(panel);
|
||||
}
|
||||
var years = bgEnsureYear(S.all);
|
||||
var summary = bgSummarize(S.all, S.dashboard.year);
|
||||
var rows = Array.isArray(S.rows) ? S.rows : [];
|
||||
var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
|
||||
var totals = bgTotals(visibleBaronProjectRows);
|
||||
var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
|
||||
var toolbarHtml = '<div class="cards-toolbar">'
|
||||
+ '<div class="cards-toolbar-row">'
|
||||
+ years.map(function (year) {
|
||||
return '<button type="button" class="summary-year-chip ' + (S.dashboard.year === year ? "active" : "") + '" data-dashboard-year="' + escAttr(year) + '">' + esc(year) + "</button>";
|
||||
}).join("")
|
||||
+ '<div class="cards-toolbar-search"></div>'
|
||||
+ "</div>"
|
||||
+ '<div class="cards-toolbar-metrics">'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
|
||||
+ "</div></div>";
|
||||
var cards = [
|
||||
{ label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
|
||||
{ label: "계약금", value: won(totals.c), note: "" },
|
||||
{ label: "수금액", value: won(totals.col), note: "" },
|
||||
{ label: "미수금", value: won(totals.recv), note: "" },
|
||||
{ label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
|
||||
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
|
||||
];
|
||||
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
|
||||
return '<div class="card ' + esc(card.className || "") + '"><div class="k">' + esc(card.label) + '</div><div class="v">' + esc(card.value) + '</div><div class="n">' + esc(card.note || "") + "</div></div>";
|
||||
}).join("");
|
||||
var searchWrap = E.cards.querySelector(".cards-toolbar-search");
|
||||
if (searchWrap && E.search) {
|
||||
searchWrap.appendChild(E.search);
|
||||
E.search.placeholder = "전체 검색";
|
||||
}
|
||||
}
|
||||
|
||||
var originalRender = render;
|
||||
render = function () {
|
||||
originalRender();
|
||||
applyDashboardChrome();
|
||||
renderLedgerTable();
|
||||
};
|
||||
|
||||
filter = function () {
|
||||
bgEnsureYear(S.all);
|
||||
var q = String(E.search.value || "").trim().toLowerCase();
|
||||
var searched = !q ? S.all.slice() : S.all.filter(function (r) {
|
||||
return [r.code, r.name, r.client, r.pm, r.status, r.cat, r.corp, r.pay, (r.payments || []).map(function (p) { return p.pay; }).join(" "), r.periodText].join(" ").toLowerCase().includes(q);
|
||||
});
|
||||
S.rows = searched.filter(function (r) {
|
||||
return bgMatches(r) && matchesColumnFilters(r);
|
||||
});
|
||||
render();
|
||||
};
|
||||
|
||||
if (E.cards && !E.cards.dataset.dashboardBound) {
|
||||
E.cards.dataset.dashboardBound = "true";
|
||||
E.cards.addEventListener("click", function (event) {
|
||||
var yearButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-year]") : null;
|
||||
if (yearButton) {
|
||||
S.dashboard.year = yearButton.getAttribute("data-dashboard-year") || S.dashboard.year;
|
||||
filter();
|
||||
return;
|
||||
}
|
||||
var sectionButton = event.target && event.target.closest ? event.target.closest("[data-dashboard-section]") : null;
|
||||
if (sectionButton) {
|
||||
S.dashboard.section = sectionButton.getAttribute("data-dashboard-section") || "active";
|
||||
filter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (E.tbody && !E.tbody.dataset.projectBound) {
|
||||
E.tbody.dataset.projectBound = "true";
|
||||
E.tbody.addEventListener("click", function (event) {
|
||||
var groupButton = event.target && event.target.closest ? event.target.closest("[data-group-label]") : null;
|
||||
if (groupButton) {
|
||||
var label = groupButton.getAttribute("data-group-label") || "";
|
||||
if (label) {
|
||||
S.collapsedGroups[label] = !S.collapsedGroups[label];
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var trigger = event.target && event.target.closest ? event.target.closest(".project-link") : null;
|
||||
if (!trigger) return;
|
||||
var key = trigger.getAttribute("data-project-key") || "";
|
||||
var rows = Array.isArray(S.viewRows) ? S.viewRows : [];
|
||||
var row = rows.find(function (item) {
|
||||
return (String(item.code || "") + "|" + String(item.name || "")) === key;
|
||||
});
|
||||
if (row) openProjectWindow(row);
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
try {
|
||||
filter();
|
||||
if (typeof loadLedgerFile === "function") {
|
||||
tryLoadDbDefaultBusinessLedger();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
var data = event.data || {};
|
||||
if (data.source !== "total-upload" || data.type !== "business") return;
|
||||
setTimeout(function () {
|
||||
try {
|
||||
applyDashboardChrome();
|
||||
renderLedgerTable();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
})();
|
||||
BIN
incoming-files/served/ledger/사업관리대장-1.xlsx
Normal file
BIN
incoming-files/served/ledger/사업관리대장-1.xlsx
Normal file
Binary file not shown.
@@ -32,6 +32,13 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -44,7 +44,7 @@ copy_optional_path "incoming-files/1.png"
|
||||
copy_optional_path "incoming-files/260320.html"
|
||||
copy_optional_path "incoming-files/sample style.css"
|
||||
copy_optional_path "incoming-files/seat/center_chair_people_map(2).html"
|
||||
copy_optional_path "incoming-files/사업관리대장"
|
||||
copy_optional_path "incoming-files/reference/ledger"
|
||||
|
||||
echo "[6/6] Dev worktree ready"
|
||||
echo "Path: ${DEV_DIR}"
|
||||
|
||||
14
scripts/publish_db_status_app.sh
Executable file
14
scripts/publish_db_status_app.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
APP_SRC_DIR="${REPO_ROOT}/frontend/apps/db-status"
|
||||
SERVED_DIR="${REPO_ROOT}/incoming-files/served/db-status"
|
||||
FRONTEND_PUBLIC_DIR="${REPO_ROOT}/frontend/public"
|
||||
|
||||
mkdir -p "${SERVED_DIR}"
|
||||
cp "${APP_SRC_DIR}/index.html" "${SERVED_DIR}/index.html"
|
||||
cp "${APP_SRC_DIR}/index.html" "${FRONTEND_PUBLIC_DIR}/db-status.html"
|
||||
|
||||
echo "Published db-status app to ${SERVED_DIR} and ${FRONTEND_PUBLIC_DIR}/db-status.html"
|
||||
22
scripts/publish_ledger_app.sh
Executable file
22
scripts/publish_ledger_app.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_DIR="${ROOT_DIR}/frontend/apps/ledger"
|
||||
TARGET_DIR="${ROOT_DIR}/incoming-files/served/ledger"
|
||||
LEDGER_ASSET_VERSION="${LEDGER_ASSET_VERSION:-20260401-03}"
|
||||
|
||||
mkdir -p "${TARGET_DIR}"
|
||||
|
||||
cp "${APP_DIR}/assets/MH 통합 대시보드_260320.css" "${TARGET_DIR}/MH 통합 대시보드_260320.css"
|
||||
cp "${APP_DIR}/assets/ledger-override.css" "${TARGET_DIR}/ledger-override.css"
|
||||
cp "${APP_DIR}/assets/ledger-override.js" "${TARGET_DIR}/ledger-override.js"
|
||||
|
||||
HEAD_ASSETS='<base href="/integrations/ledger-assets/"><link rel="stylesheet" href="/integrations/ledger-assets/MH%20통합%20대시보드_260320.css"><link rel="stylesheet" href="/integrations/ledger-assets/ledger-override.css?v='"${LEDGER_ASSET_VERSION}"'">'
|
||||
BODY_SCRIPTS='<script src="/integrations/ledger-assets/ledger-override.js?v='"${LEDGER_ASSET_VERSION}"'"></script>'
|
||||
|
||||
perl -0pe 's|__LEDGER_HEAD_ASSETS__|'"${HEAD_ASSETS}"'|g; s|__LEDGER_BODY_SCRIPTS__|'"${BODY_SCRIPTS}"'|g' \
|
||||
"${APP_DIR}/index.html" > "${TARGET_DIR}/index.html"
|
||||
|
||||
echo "Published ledger app source to ${TARGET_DIR}"
|
||||
13
scripts/publish_payment_app.sh
Executable file
13
scripts/publish_payment_app.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_DIR="${ROOT_DIR}/frontend/apps/payment"
|
||||
TARGET_FILE="${ROOT_DIR}/incoming-files/served/payment.html"
|
||||
COMPARE_FILE="${ROOT_DIR}/incoming-files/payment.html"
|
||||
|
||||
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
|
||||
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
|
||||
|
||||
echo "Published payment app source to ${TARGET_FILE}"
|
||||
13
scripts/publish_team_app.sh
Executable file
13
scripts/publish_team_app.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_DIR="${ROOT_DIR}/frontend/apps/team"
|
||||
TARGET_FILE="${ROOT_DIR}/incoming-files/served/mh.html"
|
||||
COMPARE_FILE="${ROOT_DIR}/incoming-files/mh.html"
|
||||
|
||||
cp "${APP_DIR}/index.html" "${TARGET_FILE}"
|
||||
cp "${APP_DIR}/index.html" "${COMPARE_FILE}"
|
||||
|
||||
echo "Published team app source to ${TARGET_FILE}"
|
||||
Reference in New Issue
Block a user