Compare commits
2 Commits
1e82572e15
...
total
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ff4f01ac | ||
|
|
d0e055973e |
@@ -21,7 +21,7 @@ import ezdxf
|
|||||||
from ezdxf import recover
|
from ezdxf import recover
|
||||||
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -42,6 +42,11 @@ app.add_middleware(
|
|||||||
|
|
||||||
LEGACY_STATIC_DIR = LEGACY_DIR / "static"
|
LEGACY_STATIC_DIR = LEGACY_DIR / "static"
|
||||||
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
|
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
|
||||||
|
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
|
||||||
|
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
|
||||||
|
BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장"
|
||||||
|
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_SOURCE_KEY = "technical-development-center"
|
||||||
FIXED_OFFICE_CONFIGS = {
|
FIXED_OFFICE_CONFIGS = {
|
||||||
"technical-development-center": {
|
"technical-development-center": {
|
||||||
@@ -61,6 +66,7 @@ FIXED_OFFICE_CONFIGS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
_fixed_office_cache: dict[str, dict[str, object]] = {}
|
_fixed_office_cache: dict[str, dict[str, object]] = {}
|
||||||
|
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
|
||||||
AUTH_DEFAULT_PASSWORD = "1111"
|
AUTH_DEFAULT_PASSWORD = "1111"
|
||||||
AUTH_PASSWORD_ITERATIONS = 390000
|
AUTH_PASSWORD_ITERATIONS = 390000
|
||||||
AUTH_SESSION_HOURS = 12
|
AUTH_SESSION_HOURS = 12
|
||||||
@@ -82,6 +88,66 @@ MH_HEADER_ORDER = [
|
|||||||
"사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)"
|
"사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def sync_default_business_ledger_source(cur) -> None:
|
||||||
|
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",
|
||||||
|
BUSINESS_DASHBOARD_DIR / "사업관리 대장.xlsx",
|
||||||
|
]
|
||||||
|
source_path = next((candidate for candidate in candidates if candidate.exists()), None)
|
||||||
|
if source_path is None:
|
||||||
|
return
|
||||||
|
content = source_path.read_bytes()
|
||||||
|
content_sha256 = hashlib.sha256(content).hexdigest()
|
||||||
|
meta_json = {
|
||||||
|
"byte_size": len(content),
|
||||||
|
"source_path": str(source_path),
|
||||||
|
"synced_from": "startup",
|
||||||
|
}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO integration_binary_sources (
|
||||||
|
source_key, source_name, filename, mime_type, content, content_sha256, meta_json, imported_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
|
||||||
|
ON CONFLICT (source_key) DO UPDATE
|
||||||
|
SET source_name = EXCLUDED.source_name,
|
||||||
|
filename = EXCLUDED.filename,
|
||||||
|
mime_type = EXCLUDED.mime_type,
|
||||||
|
content = EXCLUDED.content,
|
||||||
|
content_sha256 = EXCLUDED.content_sha256,
|
||||||
|
meta_json = EXCLUDED.meta_json,
|
||||||
|
imported_at = NOW()
|
||||||
|
WHERE integration_binary_sources.content_sha256 IS DISTINCT FROM EXCLUDED.content_sha256
|
||||||
|
OR integration_binary_sources.filename IS DISTINCT FROM EXCLUDED.filename
|
||||||
|
OR integration_binary_sources.mime_type IS DISTINCT FROM EXCLUDED.mime_type
|
||||||
|
OR integration_binary_sources.meta_json IS DISTINCT FROM EXCLUDED.meta_json
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,
|
||||||
|
"사업관리대장 기본 원본",
|
||||||
|
source_path.name,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
content,
|
||||||
|
content_sha256,
|
||||||
|
json.dumps(meta_json, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app.mount(
|
||||||
|
"/integrations/ledger-assets",
|
||||||
|
StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False),
|
||||||
|
name="integration-ledger-assets",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MemberPayload(BaseModel):
|
class MemberPayload(BaseModel):
|
||||||
id: int | None = None
|
id: int | None = None
|
||||||
@@ -3910,6 +3976,7 @@ def startup() -> None:
|
|||||||
init_db()
|
init_db()
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
sync_default_business_ledger_source(cur)
|
||||||
sync_auth_users_from_members(cur)
|
sync_auth_users_from_members(cur)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -3939,6 +4006,37 @@ def health() -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/integration/business-ledger-default")
|
||||||
|
def integration_business_ledger_default() -> Response:
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT filename, mime_type, content
|
||||||
|
FROM integration_binary_sources
|
||||||
|
WHERE source_key = %s
|
||||||
|
ORDER BY imported_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(BUSINESS_LEDGER_DEFAULT_SOURCE_KEY,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Business ledger default source not found.")
|
||||||
|
filename = str(row["filename"] or "사업관리대장-1.xlsx")
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": 'inline; filename="business-ledger-default.xlsx"',
|
||||||
|
"X-Source-Filename": "business-ledger-default.xlsx",
|
||||||
|
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
content=bytes(row["content"]),
|
||||||
|
media_type=str(row["mime_type"] or "application/octet-stream"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
def auth_login(
|
def auth_login(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -4500,15 +4598,30 @@ def legacy_organization_backup() -> FileResponse:
|
|||||||
|
|
||||||
@app.get("/integrations/payment")
|
@app.get("/integrations/payment")
|
||||||
def integration_payment() -> FileResponse:
|
def integration_payment() -> FileResponse:
|
||||||
target = INCOMING_FILES_DIR / "payment.html"
|
# 8081 phase-1 cleanup: integration HTML is served only from incoming-files/served.
|
||||||
|
target = INCOMING_SERVED_DIR / "payment.html"
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise HTTPException(status_code=404, detail="Payment integration file not found.")
|
raise HTTPException(status_code=404, detail="Payment integration file not found.")
|
||||||
return FileResponse(target)
|
return FileResponse(target)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/integrations/ledger")
|
||||||
|
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.")
|
||||||
|
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")
|
@app.get("/integrations/mh")
|
||||||
def integration_mh() -> FileResponse:
|
def integration_mh() -> FileResponse:
|
||||||
target = INCOMING_FILES_DIR / "mh.html"
|
# Keep the served path explicit so comparison/reference copies are never picked up by accident.
|
||||||
|
target = INCOMING_SERVED_DIR / "mh.html"
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise HTTPException(status_code=404, detail="MH integration file not found.")
|
raise HTTPException(status_code=404, detail="MH integration file not found.")
|
||||||
return FileResponse(target)
|
return FileResponse(target)
|
||||||
|
|||||||
@@ -228,10 +228,354 @@
|
|||||||
|
|
||||||
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
|
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
|
||||||
|
|
||||||
|
## 12. 2026-04-01 구조 안정화, DB 가시화, 자리배치도 정리
|
||||||
|
|
||||||
|
### 왜 이 작업을 했는가
|
||||||
|
|
||||||
|
이번 작업의 목적은 새 기능을 더 붙이기 전에, 지금까지 쌓인 구조를 먼저 안정적으로 정리하는 것이었다.
|
||||||
|
겉으로 보기에는 화면이 어느 정도 동작하고 있었지만, 실제 내부는 다음과 같은 위험이 있었다.
|
||||||
|
|
||||||
|
- 화면마다 구현 방식이 달라서 어디를 수정해야 하는지 바로 알기 어려움
|
||||||
|
- 원본 참고 파일과 실제 서비스 파일이 섞여 있어, 작업할수록 다시 꼬일 가능성이 큼
|
||||||
|
- DB는 이미 중요한 역할을 하고 있었지만, 비개발자 입장에서는 "정말 저장이 되고 있는가", "무엇이 들어 있는가"를 직접 확인하기 어려움
|
||||||
|
- 구조를 건드릴 때 사업관리대장처럼 예상하지 못한 회귀가 생길 수 있었음
|
||||||
|
|
||||||
|
즉, 이번 작업은 "새 기능 추가"보다 "앞으로 기능을 안전하게 추가할 수 있는 바닥공사"에 가까웠다.
|
||||||
|
|
||||||
|
### 무엇을 바꿨는가
|
||||||
|
|
||||||
|
이번에는 크게 다섯 가지 축으로 정리했다.
|
||||||
|
|
||||||
|
1. 디자인과 화면 구조 기준 정리
|
||||||
|
2. 실제 서비스 코드와 참고 원본 파일 분리
|
||||||
|
3. 백엔드 라우트 구조 분리
|
||||||
|
4. DB 상태를 눈으로 볼 수 있는 운영 화면 추가
|
||||||
|
5. 자리배치도 실사용성 개선과 회귀 방지 장치 추가
|
||||||
|
|
||||||
|
이 작업은 단순 정리처럼 보일 수 있지만, 실제로는 "어디가 진짜 기준인지"를 다시 세우는 과정이었다.
|
||||||
|
|
||||||
|
추가로, 사용자가 실제로 가장 자주 보는 상단 탭 경험도 함께 다시 손봤다.
|
||||||
|
이번에 정리한 상단 주요 화면은 다음과 같다.
|
||||||
|
|
||||||
|
- 사업관리대장
|
||||||
|
- 프로젝트별 분석
|
||||||
|
- 팀/개인별 분석
|
||||||
|
- 조직현황
|
||||||
|
|
||||||
|
이 네 화면은 이전까지는 각각 따로 발전해 온 흔적이 강했다.
|
||||||
|
즉, 같은 시스템 안에 있지만 화면마다 표정이 달랐고, 어떤 화면은 오래된 파란 톤이 남아 있었고, 어떤 화면은 새 스타일이 일부만 적용되어 있었다.
|
||||||
|
이번에는 이 네 화면을 "각자 따로 만들어진 페이지"가 아니라 "하나의 대시보드 안에 있는 연결된 기능"처럼 보이도록 맞추는 작업도 함께 진행했다.
|
||||||
|
|
||||||
|
### 리팩토링을 왜 했는가
|
||||||
|
|
||||||
|
기존에는 하나의 파일이나 하나의 화면이 너무 많은 역할을 동시에 맡고 있었다.
|
||||||
|
예를 들어 백엔드 메인 파일은 인증, 멤버, 통합 데이터, 정적 파일 서빙, 자리배치도까지 한곳에 몰려 있었고, 프런트도 화면에 따라 원본 파일을 직접 쓰는 곳과 override를 덧씌우는 곳이 섞여 있었다.
|
||||||
|
|
||||||
|
이 구조는 처음엔 빠르게 화면을 올리는 데 도움이 되지만, 일정 시점이 지나면 문제가 생긴다.
|
||||||
|
|
||||||
|
- 작은 수정이 예상치 못한 다른 화면에 영향을 줄 수 있음
|
||||||
|
- 회귀 원인을 찾는 데 시간이 오래 걸림
|
||||||
|
- 새 작업자가 들어오면 전체 구조를 이해하기 어려움
|
||||||
|
- 특정 파일이 "원본인지", "실행본인지", "참고용 복사본인지" 헷갈리게 됨
|
||||||
|
|
||||||
|
그래서 이번에는 "기능을 더 붙이기 전에 구조를 분리하는 것"을 우선했다.
|
||||||
|
|
||||||
|
### 리팩토링을 어떻게 진행했는가
|
||||||
|
|
||||||
|
#### 1. 실제 서비스 코드와 참고 원본을 분리
|
||||||
|
|
||||||
|
사업관리대장, 프로젝트별 분석, 팀/개인별 분석은 처음엔 원본 파일, 참고 파일, 실제 서비스 파일이 섞여 있는 상태였다.
|
||||||
|
이 상태에서는 수정할 때마다 "지금 내가 만지는 파일이 실제 서비스에 반영되는 파일이 맞는가"를 계속 확인해야 했다.
|
||||||
|
|
||||||
|
그래서 다음 기준으로 재정리했다.
|
||||||
|
|
||||||
|
- `reference`: 비교와 복구를 위한 참고 원본
|
||||||
|
- `served`: 실제 서비스가 읽는 런타임 파일
|
||||||
|
- `frontend/apps/*`: 앞으로 수정해야 하는 앱 소스
|
||||||
|
|
||||||
|
특히 `ledger`, `payment`, `team` 화면은 모두 `app source -> publish -> served` 구조로 다시 맞췄다.
|
||||||
|
이 의미는 다음과 같다.
|
||||||
|
|
||||||
|
- 작업자는 원본 참고 파일을 직접 수정하지 않는다
|
||||||
|
- 앱 소스에서 수정한다
|
||||||
|
- publish 스크립트로 실제 서비스 파일을 만든다
|
||||||
|
- 백엔드는 이 실제 서비스 파일만 서빙한다
|
||||||
|
|
||||||
|
이렇게 하면 나중에 유지보수할 때 "수정 원본"과 "실행 결과물"이 명확히 나뉜다.
|
||||||
|
|
||||||
|
#### 2. 디자인 기준을 공통 SSOT로 승격
|
||||||
|
|
||||||
|
이전에는 각 화면에 과거 파란 톤, 임시 색상, override 스타일이 섞여 있었다.
|
||||||
|
그래서 어떤 화면은 새 디자인 규칙을 따르는데, 어떤 화면은 예전 색이 다시 튀어나오는 문제가 반복됐다.
|
||||||
|
|
||||||
|
이번에는 이를 막기 위해 다음 기준을 승격했다.
|
||||||
|
|
||||||
|
- `design-tokens.css`
|
||||||
|
- `design-patterns.css`
|
||||||
|
- `DESIGN_SSOT.md`
|
||||||
|
|
||||||
|
즉, 앞으로 디자인 수정은 "이 화면만 예쁘게"가 아니라 "공통 디자인 규칙 안에서 일관되게" 하는 방향으로 정리했다.
|
||||||
|
비개발자 관점에서는 "화면마다 조금씩 다른 앱"처럼 보이던 것을, "하나의 시스템처럼 보이게" 만드는 작업이었다고 볼 수 있다.
|
||||||
|
|
||||||
|
이 과정에서 실제로 한 작업은 다음과 같다.
|
||||||
|
|
||||||
|
- 사업관리대장, 프로젝트별 분석, 팀/개인별 분석, 조직현황의 메인 폭을 같은 기준으로 맞춤
|
||||||
|
- 공통 카드, 버튼, KPI, 표, 팝업의 색과 대비를 비슷한 문법으로 정리
|
||||||
|
- 과거 파란 계열이 다시 드러나는 부분을 찾아 공통 토큰 기준으로 재정리
|
||||||
|
- 각 화면에서 "지금 당장 보기 좋게" 끝내지 않고, 앞으로도 같은 규칙을 따라갈 수 있도록 공통 패턴으로 승격
|
||||||
|
|
||||||
|
특히 프로젝트별 분석과 팀/개인별 분석은 원래 화면 내부에 이전 스타일 흔적이 많이 남아 있었는데, 이번에는 이 부분을 단순 덮어쓰기보다 "기준 디자인을 바라보게 만드는 방향"으로 손봤다.
|
||||||
|
|
||||||
|
#### 2-1. 왜 네 개 탭을 먼저 다시 맞췄는가
|
||||||
|
|
||||||
|
이번 세션에서는 단순 리팩토링만 한 것이 아니다.
|
||||||
|
사용자가 실제로 매일 보는 네 개 주요 탭의 경험을 먼저 안정화하는 것이 중요했다.
|
||||||
|
|
||||||
|
그 이유는 다음과 같다.
|
||||||
|
|
||||||
|
- 화면마다 스타일이 다르면 사용자는 기능이 다른 것보다 "시스템이 불안정하다"는 인상을 먼저 받음
|
||||||
|
- 새 기능을 추가할 때마다 이전 스타일이 다시 나타나면, 작업 결과가 누적되지 않고 계속 되돌아감
|
||||||
|
- 세미나나 설명 자리에서도 "정리되고 있다"는 느낌을 전달하려면, 먼저 눈에 보이는 화면이 하나의 제품처럼 보여야 함
|
||||||
|
|
||||||
|
그래서 이번에는 단순히 코드 구조를 정리하는 것과 함께, 네 개 탭의 인상과 문법을 맞추는 작업도 같이 진행했다.
|
||||||
|
|
||||||
|
#### 2-2. 사업관리대장은 어디까지 손봤는가
|
||||||
|
|
||||||
|
사업관리대장은 이번 세션에서 가장 많은 변화가 있었던 화면 중 하나다.
|
||||||
|
|
||||||
|
- 상단 탭에서 직접 열리도록 연결
|
||||||
|
- 기본 로우데이터 엑셀과 연동
|
||||||
|
- 원본 화면 구조를 참고해 연도 버튼, KPI, 본문 표, 상세 팝업까지 단계적으로 복원
|
||||||
|
- 클릭 시 프로젝트 상세 정보를 열 수 있게 연결
|
||||||
|
- 메인 화면과 상세 팝업 디자인을 현재 디자인 큐에 맞게 정리
|
||||||
|
|
||||||
|
다만 중요한 점은, 이번에 맞춘 것은 "보이는 구조와 기본 기능"까지라는 것이다.
|
||||||
|
세부 숫자와 집계 기준, 어떤 값이 어떻게 계산되는지는 원본 작성자 기준 확인이 필요해 후속으로 남겨 두었다.
|
||||||
|
|
||||||
|
즉, 이번에는 사업관리대장을 "쓸 수 있는 상태"까지 올렸고, 다음 단계에서 "정확한 상태"로 맞출 준비를 끝낸 것이다.
|
||||||
|
|
||||||
|
#### 2-3. 프로젝트별 분석과 팀/개인별 분석은 무엇이 바뀌었는가
|
||||||
|
|
||||||
|
두 화면은 모두 이미 기능은 있었지만, 디자인과 유지보수 구조가 흔들리는 상태였다.
|
||||||
|
|
||||||
|
이번에 바뀐 점은 다음과 같다.
|
||||||
|
|
||||||
|
- 프로젝트별 분석
|
||||||
|
- 메인 표, KPI, 필터, 패널, 상세 강조 색을 공통 디자인 기준으로 재정리
|
||||||
|
- 실제 서비스 파일과 수정 원본의 기준을 명확히 분리
|
||||||
|
- 팀/개인별 분석
|
||||||
|
- 배경, 카드, 보조 정보, 캘린더 note, 상태 표현 등을 공통 디자인 기준으로 재정리
|
||||||
|
- 과거 스타일 흔적을 줄이고, 앞으로도 같은 방식으로 고칠 수 있는 구조로 이동
|
||||||
|
|
||||||
|
즉, 두 화면 모두 "이번 한 번 예쁘게 고친" 것이 아니라 "앞으로도 같은 기준으로 유지될 수 있게" 손봤다는 점이 중요하다.
|
||||||
|
|
||||||
|
#### 2-4. 조직현황은 무엇이 바뀌었는가
|
||||||
|
|
||||||
|
조직현황은 기존에도 중요한 화면이었지만, 스타일과 인터랙션이 다소 오래된 느낌으로 남아 있었다.
|
||||||
|
|
||||||
|
이번에는 다음을 정리했다.
|
||||||
|
|
||||||
|
- 상세 프로필, 수정 모달, 버튼, 카드, 탭, 통계 영역의 색과 대비 조정
|
||||||
|
- 관리자 모드 버튼, 추가 버튼, 상세 정보 패널의 톤 정리
|
||||||
|
- 자리배치도와 연결되는 미리보기 카드, 조직 구조 표현 가독성 개선
|
||||||
|
|
||||||
|
즉, 조직현황은 단순 디자인 수정이 아니라 "관리자가 실제로 쓰는 화면"으로서 읽기 편하게 정리하는 방향으로 손봤다.
|
||||||
|
|
||||||
|
#### 3. 백엔드 메인 파일의 역할 분리
|
||||||
|
|
||||||
|
백엔드도 한 파일에 너무 많은 기능이 몰려 있었다.
|
||||||
|
그래서 메인 파일에서 기능별 라우트를 분리했다.
|
||||||
|
|
||||||
|
이번에 분리한 범위는 다음과 같다.
|
||||||
|
|
||||||
|
- 시스템/서빙 라우트
|
||||||
|
- 인증 라우트
|
||||||
|
- 멤버/히스토리 라우트
|
||||||
|
- 통합 데이터 라우트
|
||||||
|
- 자리배치도/업로드 라우트
|
||||||
|
|
||||||
|
이 작업을 통해 얻은 가장 큰 장점은 "문제가 났을 때 어디를 봐야 하는지가 빨라졌다"는 점이다.
|
||||||
|
예전에는 메인 파일을 전체 검색해야 했다면, الآن은 인증 문제면 인증 파일을, 자리배치도 문제면 자리배치도 라우트 파일을 먼저 보면 된다.
|
||||||
|
|
||||||
|
### DB 작업을 왜 했는가
|
||||||
|
|
||||||
|
이번 세션에서 DB 작업을 한 이유는 "DB가 이상해서"가 아니라, "DB가 이미 중요한 역할을 하고 있는데 너무 안 보였다"는 점 때문이다.
|
||||||
|
|
||||||
|
실제로는 이미 많은 데이터가 DB에 저장되고 있었다.
|
||||||
|
|
||||||
|
- 구성원 정보
|
||||||
|
- 자리배치도 정보
|
||||||
|
- 통합 원본 적재 정보
|
||||||
|
- 인증 정보
|
||||||
|
- 이력 관련 테이블
|
||||||
|
|
||||||
|
하지만 비개발자 입장에서는 이것이 잘 보이지 않았다.
|
||||||
|
즉, "DB가 있다"고만 듣고 실제로 어떤 테이블이 있고 무슨 역할인지 보지 못하면, 운영 기준을 잡기 어렵다.
|
||||||
|
|
||||||
|
그래서 이번에는 DB를 "보이지 않는 저장소"에서 "운영자가 확인할 수 있는 대상"으로 바꾸는 작업을 했다.
|
||||||
|
|
||||||
|
### DB 작업을 어떻게 했는가
|
||||||
|
|
||||||
|
#### 1. DB 상태 탭 추가
|
||||||
|
|
||||||
|
허브 안에 `DB 상태` 탭을 만들었다.
|
||||||
|
이 화면에서는 다음을 확인할 수 있다.
|
||||||
|
|
||||||
|
- 전체 테이블 수
|
||||||
|
- 등록 인원/재직 인원
|
||||||
|
- 자리배치도 도면 현황
|
||||||
|
- 핵심 운영 테이블과 전체 테이블 목록
|
||||||
|
- 테이블별 간단 설명
|
||||||
|
- 테이블 클릭 시 컬럼과 샘플 데이터 미리보기
|
||||||
|
- CSV 다운로드
|
||||||
|
|
||||||
|
즉, 이제는 SQL을 직접 몰라도 "어떤 데이터가 어디에 저장되는지"를 눈으로 볼 수 있다.
|
||||||
|
|
||||||
|
#### 2. 테이블 역할 분류
|
||||||
|
|
||||||
|
전체 테이블을 그냥 나열만 하면 오히려 더 복잡해 보이기 때문에, 역할별로 다시 분류했다.
|
||||||
|
|
||||||
|
- 유지
|
||||||
|
- 주의
|
||||||
|
- 원본/추적
|
||||||
|
- 정리 후보
|
||||||
|
|
||||||
|
이 분류를 통해 "지금 DB가 너무 큰가?"라는 질문에 대해, 단순 개수 대신 역할 기준으로 판단할 수 있게 만들었다.
|
||||||
|
|
||||||
|
#### 3. 불필요한 테이블과 과거 실험 흔적 정리
|
||||||
|
|
||||||
|
이번에 실제로 확인해보니, 현재 코드에서 쓰지 않는 테이블이 하나 있었고, 과거 DXF 시도본도 많이 쌓여 있었다.
|
||||||
|
|
||||||
|
그래서 다음 정리를 진행했다.
|
||||||
|
|
||||||
|
- 미사용 테이블 `entity_change_events` 삭제
|
||||||
|
- 과거 DXF 시도본 정리
|
||||||
|
- 최신 DXF 1개와 실제 운영용 고정 도면 3개만 유지
|
||||||
|
|
||||||
|
이 작업은 "DB를 줄였다"기보다 "운영에 필요한 것과 과거 흔적을 분리했다"는 의미에 가깝다.
|
||||||
|
|
||||||
|
#### 4. 8080과 8081의 역할도 다시 정리
|
||||||
|
|
||||||
|
이번 세션에서는 개발용 `8081`에서 검증된 코드 중, 안정적으로 승격 가능한 부분만 `8080` 기준 코드로 올리는 작업도 진행했다.
|
||||||
|
|
||||||
|
여기서 중요한 원칙은 "통째로 덮어쓰기"가 아니라 "검증된 것만 선별 승격"이었다.
|
||||||
|
|
||||||
|
즉 다음 원칙을 지켰다.
|
||||||
|
|
||||||
|
- `8081`은 계속 작업과 검증을 위한 공간으로 유지
|
||||||
|
- `8080`은 공개 기준으로 유지
|
||||||
|
- 디자인 SSOT, 앱 소스 구조, 런타임 서빙 구조처럼 안정성이 확인된 부분만 `total`로 승격
|
||||||
|
- DB 자체는 함부로 합치지 않고, 코드와 구조만 먼저 정리
|
||||||
|
|
||||||
|
이렇게 해야 운영 기준을 흔들지 않으면서도, 개선된 구조를 실제 기준 코드에 반영할 수 있다.
|
||||||
|
|
||||||
|
### 무엇이 개선되었는가
|
||||||
|
|
||||||
|
이번 작업으로 개선된 점은 매우 명확하다.
|
||||||
|
|
||||||
|
#### 1. 유지보수 포인트가 분명해졌다
|
||||||
|
|
||||||
|
예전에는 같은 기능을 수정해도 어디를 건드려야 하는지 여러 파일을 동시에 의심해야 했다.
|
||||||
|
지금은 앱 소스, 서비스 파일, 참고 원본의 역할이 나뉘어서 수정 위치가 명확해졌다.
|
||||||
|
|
||||||
|
#### 2. 화면 회귀를 더 빨리 잡을 수 있게 됐다
|
||||||
|
|
||||||
|
사업관리대장 데이터가 한 번 끊겼을 때 원인은 DB 문제가 아니라, 한글 파일명을 응답 헤더에 그대로 넣으면서 생긴 인코딩 오류였다.
|
||||||
|
이런 문제는 구조가 정리돼 있지 않으면 찾는 데 오래 걸린다.
|
||||||
|
|
||||||
|
이번에는 원인을 빠르게 좁혀서 복구했고, 같은 문제가 다시 생기지 않도록 `8081` smoke check 스크립트도 추가했다.
|
||||||
|
즉, 이제는 구조를 바꾼 뒤 바로 핵심 화면과 API를 빠르게 점검할 수 있다.
|
||||||
|
|
||||||
|
#### 3. DB를 설명 가능한 상태로 만들었다
|
||||||
|
|
||||||
|
이전에는 "DB가 있다"는 사실만 있었고, 실제로 어떤 상태인지 보기 어려웠다.
|
||||||
|
이제는 운영자가 DB 상태를 화면으로 확인하고, 테이블을 눌러 실제 샘플 데이터를 볼 수 있다.
|
||||||
|
세미나나 내부 설명 자리에서도 훨씬 설명하기 쉬운 상태가 됐다.
|
||||||
|
|
||||||
|
#### 4. 자리배치도 기능이 실사용 방향으로 조금 더 진전됐다
|
||||||
|
|
||||||
|
자리배치도에서는 다음이 개선됐다.
|
||||||
|
|
||||||
|
- 클릭한 인원의 상위 조직 트리 표시
|
||||||
|
- 검색 카드 동작 정리
|
||||||
|
- 인원 카드 정보 구조 정리
|
||||||
|
- 비관리자 모드 재렌더 안정화
|
||||||
|
- 미배치/배치 상태 시각화 기준 정리 준비
|
||||||
|
- 팀 구역 오버레이 기능 시도와 요구사항 정리
|
||||||
|
|
||||||
|
즉, 단순히 "보이는 화면"이 아니라, 실제 조직과 사람을 읽기 쉬운 화면으로 한 걸음 더 나아갔다.
|
||||||
|
|
||||||
|
#### 5. 회귀 방지 체계를 붙였다
|
||||||
|
|
||||||
|
이번 세션에서 중요한 개선 중 하나는 "문제가 생긴 뒤 찾는 방식"에서 "문제가 생겼는지 바로 확인하는 방식"으로 한 걸음 이동한 점이다.
|
||||||
|
|
||||||
|
이를 위해 `8081` smoke check 스크립트를 추가했다.
|
||||||
|
이 스크립트는 다음을 한 번에 점검한다.
|
||||||
|
|
||||||
|
- 서버 health
|
||||||
|
- DB 상태 화면
|
||||||
|
- 사업관리대장 기본 원본 API
|
||||||
|
- 프로젝트별 분석
|
||||||
|
- 팀/개인별 분석
|
||||||
|
- 사업관리대장
|
||||||
|
- 조직현황 연결
|
||||||
|
|
||||||
|
즉, 구조를 고친 뒤 "겉으로는 멀쩡해 보이는데 실제로는 한 기능이 깨져 있는 상태"를 빨리 잡을 수 있게 된 것이다.
|
||||||
|
|
||||||
|
### 오늘 확인된 문제와 한계
|
||||||
|
|
||||||
|
이번 작업이 모든 것을 끝낸 것은 아니다.
|
||||||
|
오히려 구조를 정리하면서, 앞으로 무엇을 더 손봐야 하는지도 더 분명해졌다.
|
||||||
|
|
||||||
|
#### 1. 사업관리대장 세부 데이터 정합성은 아직 보류
|
||||||
|
|
||||||
|
사업관리대장은 디자인과 기본 기능 연결은 올라왔지만, 세부 수치와 표출 규칙은 원본 작성자와 기준을 맞춰야 한다.
|
||||||
|
즉, "대충 맞아 보이는 수준"이 아니라 "원본 의도와 동일한 수준"으로 맞추려면 담당자 확인이 필요하다.
|
||||||
|
|
||||||
|
#### 2. 자리배치도 `#7`은 아직 재작업 필요
|
||||||
|
|
||||||
|
팀 구역 오버레이 기능은 의도 자체는 맞게 해석했고 데이터도 들어가지만, 화면에서 반짝 나타났다가 사라지는 문제가 남아 있다.
|
||||||
|
즉, 기능 방향은 맞지만 렌더링 타이밍이나 레이어 처리에서 다시 손봐야 한다.
|
||||||
|
|
||||||
|
#### 3. 조직현황은 아직 앱 구조로 완전히 승격되지 않음
|
||||||
|
|
||||||
|
`ledger`, `payment`, `team`은 앱 소스 구조로 정리했지만, 조직현황은 아직 레거시 구조를 유지하고 있다.
|
||||||
|
장기적으로는 이것도 같은 기준으로 승격하는 것이 맞다.
|
||||||
|
|
||||||
|
### 앞으로 남은 목표
|
||||||
|
|
||||||
|
이번 작업 이후의 목표는 다음과 같다.
|
||||||
|
|
||||||
|
#### 1. 사업관리대장 기준 정렬 후 정합성 보정
|
||||||
|
|
||||||
|
원본 작성자와 함께 세부 데이터 표출 규칙, KPI 집계 방식, 상세 팝업 기준을 확인한 뒤 정확도를 맞춘다.
|
||||||
|
|
||||||
|
#### 2. 자리배치도 `#7`, `#8` 완성
|
||||||
|
|
||||||
|
- 팀 구역 오버레이를 안정적으로 보이게 수정
|
||||||
|
- 배치/미배치 시각 규칙 정리
|
||||||
|
- 검색과 클릭 시 정보 노출 방식 마무리
|
||||||
|
|
||||||
|
#### 3. 백엔드 정리 후속
|
||||||
|
|
||||||
|
라우트 분리는 많이 진행됐지만, 장기적으로는 도메인 로직까지 더 분리해서 유지보수성을 높일 필요가 있다.
|
||||||
|
|
||||||
|
#### 4. DB 운영 문서와 상태 화면 고도화
|
||||||
|
|
||||||
|
지금은 DB를 "볼 수 있게 만든" 단계다.
|
||||||
|
앞으로는 화면별 데이터 흐름, 적재 이력, 원본 로우데이터 확인 기능까지 더 강화하면 운영 설명력이 더 올라간다.
|
||||||
|
|
||||||
|
#### 5. 네 개 주요 탭의 공통 문법을 계속 지켜야 한다
|
||||||
|
|
||||||
|
이번에 디자인과 구조를 다시 맞췄다고 해서 끝난 것은 아니다.
|
||||||
|
앞으로 새 기능을 넣을 때도 각 화면이 제각각 다른 방식으로 다시 흩어지지 않게 유지해야 한다.
|
||||||
|
|
||||||
|
즉, 이번 작업의 진짜 성과는 "한 번 예쁘게 고쳤다"가 아니라 "앞으로도 같은 방식으로 고칠 수 있는 기준을 세웠다"는 데 있다.
|
||||||
|
|
||||||
## Next Focus
|
## Next Focus
|
||||||
|
|
||||||
- `#2` 영속성 운영 검증과 문서 기준 정리
|
- 사업관리대장 원본 담당자와 세부 데이터 규칙 정렬
|
||||||
|
- 자리배치도 `#7`, `#8` 재작업 및 마무리
|
||||||
- 권한 제어와 mock login 정리
|
- 권한 제어와 mock login 정리
|
||||||
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
|
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
|
||||||
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
|
- 조직현황의 장기적 앱 구조 승격 검토
|
||||||
- 프로젝트별 분석의 남은 소수점/분류 오차 정리
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compos
|
|||||||
- 로컬 전용 디자인 참고 자산 복사
|
- 로컬 전용 디자인 참고 자산 복사
|
||||||
- `incoming-files/sample style.css`
|
- `incoming-files/sample style.css`
|
||||||
- `incoming-files/260320.html`
|
- `incoming-files/260320.html`
|
||||||
- `incoming-files/사업관리대장/`
|
- `incoming-files/reference/ledger/`
|
||||||
- `incoming-files/1.png`
|
- `incoming-files/1.png`
|
||||||
- `incoming-files/seat/center_chair_people_map(2).html`
|
- `incoming-files/seat/center_chair_people_map(2).html`
|
||||||
|
|
||||||
|
|||||||
@@ -2,193 +2,152 @@
|
|||||||
|
|
||||||
## Current Base
|
## Current Base
|
||||||
|
|
||||||
- branch: `total`
|
- `8080` 공개 기준 브랜치: `total`
|
||||||
- latest checked commit: `24852d4`
|
- `8081` 작업 기준 브랜치: `work-8081`
|
||||||
- main history doc: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEVELOPMENT_HISTORY.md)
|
- `8080` 공개 기준 커밋: `637b390`
|
||||||
- work rulebook: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md)
|
- `8081` worktree 경로: `/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081`
|
||||||
- execution flow: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_EXECUTION_FLOW.md)
|
- `8081` 실제 서빙 책임 맵: [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
|
||||||
- dev/prod protocol: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/docs/DEV_PROD_DB_PROTOCOL.md)
|
- 메인 히스토리: [DEVELOPMENT_HISTORY.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEVELOPMENT_HISTORY.md)
|
||||||
- regression checklist: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/docs/REGRESSION_CHECKLIST.md)
|
- 작업 룰북: [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md)
|
||||||
- today prep note: [TODAY_WORK_PREP_2026-03-30.md](/home/hyunho/projects/mh-dashboard-organization/docs/TODAY_WORK_PREP_2026-03-30.md)
|
- 실행 플로우: [WORK_EXECUTION_FLOW.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_EXECUTION_FLOW.md)
|
||||||
|
- dev/prod DB 프로토콜: [DEV_PROD_DB_PROTOCOL.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/DEV_PROD_DB_PROTOCOL.md)
|
||||||
|
- 회귀 체크리스트: [REGRESSION_CHECKLIST.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/REGRESSION_CHECKLIST.md)
|
||||||
|
|
||||||
## Mandatory Start Rule
|
## Mandatory Start Rule
|
||||||
|
|
||||||
매일 아침 또는 그날의 첫 작업을 시작할 때는 코드를 수정하기 전에 반드시 아래 순서를 먼저 수행해야 한다.
|
당일 첫 작업 전에는 아래 순서를 먼저 확인한다.
|
||||||
|
|
||||||
1. Gitea 브랜치 상태 확인
|
1. 브랜치 기준 확인
|
||||||
2. 열린 이슈 확인
|
2. 열린 이슈 확인
|
||||||
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/docs/WORK_RULEBOOK.md) 확인
|
3. [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md) 확인
|
||||||
4. 이 문서 확인
|
4. 이 문서 확인
|
||||||
5. 현재 워크트리의 미푸시 커밋, 변경 파일, 미추적 파일 확인
|
5. `git status`, 변경 파일, 미추적 파일 확인
|
||||||
|
|
||||||
주의:
|
주의:
|
||||||
|
|
||||||
- 위 절차를 확인하기 전에는 새 코드 작성이나 기존 코드 수정부터 시작하지 않는다.
|
- `8080` 기준 코드는 직접 수정하지 않는다.
|
||||||
- 커밋과 푸시는 자동으로 하지 않고, 사용자 지시가 있을 때만 수행한다.
|
- 새 작업은 항상 `.dev-worktree-8081`에서 진행한다.
|
||||||
|
- 커밋과 푸시는 사용자 지시가 있을 때만 수행한다.
|
||||||
|
|
||||||
## What Was Finished
|
## Confirmed Runtime Rule
|
||||||
|
|
||||||
### Dashboard Integration
|
- `8080`은 루트 workspace의 `total` 기준으로 유지한다.
|
||||||
|
- `8081`은 `.dev-worktree-8081` + `work-8081` 기준으로만 수정한다.
|
||||||
|
- `main`, `hyunho`는 보류 브랜치이며 현재 작업에 사용하지 않는다.
|
||||||
|
- `8081` 변경을 `8080`에 올릴 때는 reviewed file diff 기준으로만 반영한다.
|
||||||
|
- `8081` DB는 운영 정본이 아니라 `8080` 기준 검증용 복제본처럼 다룬다.
|
||||||
|
|
||||||
- `조직 현황`, `프로젝트별 분석`, `팀/개인별 분석`, `자리배치도`를 하나의 허브에 통합
|
## What Was Stabilized
|
||||||
- `payment.html`, `mh.html`을 현재 프로젝트에 편입
|
|
||||||
- 공통 헤더, 탭, 로그인 정보, 공통 기간 제어 구성
|
|
||||||
|
|
||||||
### Integrated DB
|
### Branch / Worktree Safety
|
||||||
|
|
||||||
- `organization.xlsx`, `MH.xlsx`, `payment.csv`, `ptj.csv` 기반 통합 DB 구성
|
- 기존 `8081` 작업본은 [`.dev-worktree-8081-backup-2026-04-01`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081-backup-2026-04-01)로 보존
|
||||||
- raw/staging/standard 성격의 구조를 PostgreSQL에 반영
|
- 현재 [`.dev-worktree-8081`](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081)는 `work-8081` 기준으로 재생성
|
||||||
- `members`, `seat_maps`, `seat_slots`, `seat_positions`
|
- `8080` 루트 workspace는 그대로 두고 분리 운영
|
||||||
- `integration_raw_*`, `integration_work_logs`, `integration_work_log_segments`, `integration_vouchers`
|
|
||||||
- 프로젝트 카테고리 매핑 반영
|
|
||||||
|
|
||||||
### Team / Member Analysis
|
### 8081 Design / Serving Baseline
|
||||||
|
|
||||||
- `omh.html` 원본 기준으로 계산식/카테고리/디자인 복원
|
- 디자인 SSOT 토큰:
|
||||||
- DB raw MH 데이터를 원본 입력 구조처럼 다시 공급하는 방식으로 정리
|
- [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
|
||||||
|
- 디자인 SSOT 패턴:
|
||||||
|
- [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)
|
||||||
|
- 로그인 기본 스타일은 [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`를 참조
|
||||||
|
- 프로젝트별 분석 수정 원본은 [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)로 한다.
|
||||||
|
- 사업관리대장 실제 서비스 코드는 [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) 기준으로 본다.
|
||||||
|
|
||||||
### Project Analysis
|
디자인 수정 우선순위:
|
||||||
|
|
||||||
- `opayment.html` 원본 기준으로 화면 복원
|
1. [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
|
||||||
- `payment.csv` 분류 우선, `ptj.csv` fallback 적용
|
2. [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
|
||||||
- 연장근무는 `연장근무 시간(가공)` 기준으로 반영
|
3. 화면별 실제 서빙 파일
|
||||||
|
|
||||||
### Organization / Seat Map
|
|
||||||
|
|
||||||
- 조직도 상세 프로필에 `재석위치` preview 연결
|
|
||||||
- 관리자/비관리자 자리배치도 화면 분리
|
|
||||||
- 저장 후 조직도와 비관리자 열람에 반영되도록 seat save 흐름 정리
|
|
||||||
- seat persistence bug 수정
|
|
||||||
- 원인: `seat_positions_map_cell_idx`가 slot 기반 도면에도 적용됨
|
|
||||||
- 조치: `seat_slot_id IS NULL`인 grid map에만 적용되도록 수정
|
|
||||||
|
|
||||||
### Member Data Governance
|
|
||||||
|
|
||||||
- 이름 alias, 퇴사 제외, 조직 override를 DB 테이블 기반으로 전환
|
|
||||||
- 사용 테이블:
|
|
||||||
- `member_aliases`
|
|
||||||
- `member_retirements`
|
|
||||||
- `member_overrides`
|
|
||||||
|
|
||||||
### Auth Baseline
|
|
||||||
|
|
||||||
- 실제 로그인 API 연결 완료
|
|
||||||
- 프런트 로그인 화면이 `/api/auth/login` 사용
|
|
||||||
- 세션/로그아웃/세션 조회 API 구성 완료
|
|
||||||
- 사용 테이블:
|
|
||||||
- `auth.users`
|
|
||||||
- `auth.sessions`
|
|
||||||
- `auth.login_audit_logs`
|
|
||||||
- 현재 남은 범위:
|
|
||||||
- mock login 정리
|
|
||||||
- 역할별 권한 체크 적용
|
|
||||||
- 쓰기 API 보호 범위 정리
|
|
||||||
|
|
||||||
### External Access
|
|
||||||
|
|
||||||
- WSL 내부 8080 리슨 확인
|
|
||||||
- 현재 다른 PC에서 접속 확인
|
|
||||||
- 현재 기준 주소:
|
|
||||||
- `http://172.16.40.144:8080`
|
|
||||||
|
|
||||||
## Important Runtime Notes
|
|
||||||
|
|
||||||
### Dev / Prod Protocol
|
|
||||||
|
|
||||||
- 코드 선행은 `8081`, 공개 반영은 `8080`
|
|
||||||
- 데이터 정본은 `8080` DB
|
|
||||||
- `8081` DB는 독립 정본이 아니라 `8080` 기준 복제본처럼 관리해야 함
|
|
||||||
- `8081` 코드는 `.dev-worktree-8081` 기준으로 유지
|
|
||||||
- 조직도, 멤버, 자리배치 검증 전에는 `DEV_PROD_DB_PROTOCOL.md`를 먼저 확인
|
|
||||||
- 기능 수정 후 완료 판단은 `REGRESSION_CHECKLIST.md`를 기준으로 해야 함
|
|
||||||
- 빠른 재시작은 `./scripts/start_local_dashboards.sh`
|
|
||||||
|
|
||||||
### Seat Map Save
|
|
||||||
|
|
||||||
- 저장이 안 되면 먼저 backend 로그에서 `PUT /api/seat-maps/{id}/layout` 상태코드 확인
|
|
||||||
- 과거 핵심 장애는 DB 인덱스 충돌이었다
|
|
||||||
- 현재 저장 구조는:
|
|
||||||
- `seat_positions`
|
|
||||||
- `members.seat_label`
|
|
||||||
둘 다 같이 갱신
|
|
||||||
|
|
||||||
### External Access
|
|
||||||
|
|
||||||
- Windows LAN IP가 바뀌면 접속 주소가 바뀔 수 있음
|
|
||||||
- WSL IP가 바뀌면 `portproxy connectaddress`를 다시 맞춰야 함
|
|
||||||
- 다음 확인 명령:
|
|
||||||
- Windows: `ipconfig`
|
|
||||||
- WSL: `hostname -I`
|
|
||||||
- Windows: `netsh interface portproxy show all`
|
|
||||||
|
|
||||||
## Open Issues
|
|
||||||
|
|
||||||
- `#2` 백엔드 영속 저장 구조 운영 마무리
|
|
||||||
- `#3` 사무실 좌석 배치도 조회 및 관리자 편집 기능 고도화
|
|
||||||
- `#5` 실제 인증 체계 전환
|
|
||||||
- `#7` 자리배치도 팀별 색상 오버레이 표시
|
|
||||||
- `#8` 자리배치도 좌석 클릭 시 개인 상위 조직 트리 표시
|
|
||||||
- `#9` 조직도·자리배치도 변경 이력 버전 누적 저장
|
|
||||||
|
|
||||||
현재 해석:
|
|
||||||
- `#6`은 코드 기준 사실상 완료 상태이며 Gitea 정리 대상
|
|
||||||
- `#5`는 "로그인 구현"보다 "권한 제어 마무리"가 핵심
|
|
||||||
- `#2`의 기존 "스냅샷 검증" 범위는 현재 코드와 불일치하므로 범위 재정의 필요
|
|
||||||
|
|
||||||
## Unfinished Ideas Discussed Today
|
|
||||||
|
|
||||||
### Seat Map UX
|
|
||||||
|
|
||||||
- 자리배치도 내 인원 등록 시 팀별 색상 표시
|
|
||||||
- 좌석 클릭 시 본인까지의 상위 조직 트리 표시
|
|
||||||
- 나머지 사무실 2개 도면 추가
|
|
||||||
- `한맥빌딩 7층`
|
|
||||||
- `한맥빌딩 6층`
|
|
||||||
- 비관리자 열람 화면 품질 추가 점검
|
|
||||||
|
|
||||||
### History / Versioning
|
|
||||||
|
|
||||||
- 조직도와 자리배치도 수정 이력을 버전 누적형으로 저장
|
|
||||||
- 원본 DB와 별도의 history/version 구조 설계
|
|
||||||
- `valid_from`, `valid_to` 기반 시점 조회(as-of date) 구조 적용
|
|
||||||
- 날짜 또는 revision label 기준으로 버전 묶음 관리
|
|
||||||
- 상세 설계 문서:
|
|
||||||
- [HISTORY_ASOF_DB_PLAN.md](/home/hyunho/projects/mh-dashboard-organization/docs/HISTORY_ASOF_DB_PLAN.md)
|
|
||||||
|
|
||||||
주의:
|
주의:
|
||||||
- 현재 코드에는 조직도/자리배치도 버전 이력 기능이 아직 없음
|
|
||||||
- 월간 스냅샷 방향은 범위에서 제외
|
|
||||||
|
|
||||||
### Project Analysis Accuracy
|
- `incoming-files/sample style.css`는 참고 기준이지만 직접 런타임 수정 파일이 아니다.
|
||||||
|
- `incoming-files` 원본/reference 파일을 먼저 고치지 않는다.
|
||||||
|
- 새 디자인 수정은 먼저 토큰/패턴 파일에서 해결 가능한지 확인한 뒤, 불가피할 때만 화면별 파일에 내린다.
|
||||||
|
|
||||||
- 총합은 거의 맞았지만 일부 프로젝트 단위 소수점/분류 오차는 추가 정밀 보정 필요
|
### 1차 구조 정리 진행분
|
||||||
- `opayment` 기준으로 특정 프로젝트 차이를 계속 줄여야 함
|
|
||||||
|
|
||||||
### Auth / Permission
|
- 이슈 기준:
|
||||||
|
- `#14` 전체 구조 정리 umbrella
|
||||||
|
- `#18` 1차: 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
|
||||||
|
- `#19` 2차: 백엔드 라우터/서빙 책임 분리
|
||||||
|
- `#20` 3차: worktree/스크립트/문서 정리
|
||||||
|
- 책임 맵 문서 추가:
|
||||||
|
- [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md)
|
||||||
|
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일을 분리:
|
||||||
|
- [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html)
|
||||||
|
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
|
||||||
|
- 기존 [incoming-files/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/payment.html), [incoming-files/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/mh.html)은 비교/복구용 복사본으로 당분간 유지
|
||||||
|
- backend 서빙 경로는 [backend/app/main.py](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/backend/app/main.py)에서 `incoming-files/served/*`를 보도록 정리 시작
|
||||||
|
|
||||||
- mock login을 개발용 fallback 수준으로 제한하거나 제거
|
## Current Actual Serving Map
|
||||||
- 역할별 접근 제어 정리
|
|
||||||
- 조직도/자리배치도/분석 화면 권한 경계 재정리
|
- `/`:
|
||||||
|
- [frontend/public/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/index.html)
|
||||||
|
- `/styles.css`:
|
||||||
|
- [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css)
|
||||||
|
- `/styles-8081-design.css`:
|
||||||
|
- [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)
|
||||||
|
- `/legacy/organization`:
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
## Cross Checks Last Confirmed
|
||||||
|
|
||||||
|
- `8080`: `curl http://localhost:8080/api/health` 정상
|
||||||
|
- `8081` dev 컨테이너: proxy/backend/frontend/db `healthy`
|
||||||
|
- `8081` backend 내부 확인:
|
||||||
|
- `/api/health` 200
|
||||||
|
- `/legacy/organization` 200
|
||||||
|
- `/integrations/payment` 200
|
||||||
|
- `/integrations/ledger` 200
|
||||||
|
- `/integrations/mh` 200
|
||||||
|
- `incoming-files/served` 내 실제 서빙 파일 존재 확인
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- Codex 터미널 세션에서는 `curl http://localhost:8081`가 간헐적으로 실패할 수 있다.
|
||||||
|
- 이 경우 브라우저 확인 또는 컨테이너 내부 라우트 확인을 기준으로 판단한다.
|
||||||
|
|
||||||
|
## Open Issues Relevant Now
|
||||||
|
|
||||||
|
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
|
||||||
|
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
|
||||||
|
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
|
||||||
|
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
|
||||||
|
- `#19` 8081 백엔드 라우터/서빙 책임 분리
|
||||||
|
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
|
||||||
|
- `#21` reference 의존 제거 및 8081 실제 서비스 코드 독립화
|
||||||
|
|
||||||
## Recommended Next Work Order
|
## Recommended Next Work Order
|
||||||
|
|
||||||
1. `#2` 범위를 현재 코드 기준으로 재정의하고 영속성 운영 검증 완료
|
1. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
|
||||||
2. `#5`에서 권한 체크, mock login 정리, 쓰기 API 보호 적용
|
2. 사업관리대장 세부 데이터 정합성 보정
|
||||||
3. `8081` DB를 `8080` 정본 기준으로 동기화하는 반복 가능한 절차 마련
|
3. 그 다음 화면별 앱 구조 승격 검토
|
||||||
4. `#9`를 as-of date 기반 history 구조로 설계 후 `members`, `seat_positions` 부터 이력화
|
4. 필요 시 `#19`, `#20` 잔여 정리 항목 재평가
|
||||||
5. 그 다음 `#8`, 나머지 도면 추가, `#7`, 프로젝트 분석 오차 보정 순으로 진행
|
|
||||||
|
|
||||||
## Quick Resume Prompt
|
## Quick Resume Prompt
|
||||||
|
|
||||||
다음 세션 시작 시 아래 기준으로 이어가면 된다.
|
다음 세션 시작 시 아래 기준으로 이어가면 된다.
|
||||||
|
|
||||||
- 브랜치 `total`에서 시작
|
- `8080` 기준은 `total`
|
||||||
- 최근 커밋 `1d15cf9` 확인
|
- `8081` 작업은 `work-8081` + `.dev-worktree-8081`
|
||||||
- `docs/DEVELOPMENT_HISTORY.md`
|
- 먼저 [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) 확인
|
||||||
- `docs/NEXT_SESSION_CHECKPOINT.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) 먼저 확인
|
||||||
- `docs/DEV_PROD_DB_PROTOCOL.md`
|
- 현재 구조 독립화 기준 이슈는 `#21`
|
||||||
- `docs/REGRESSION_CHECKLIST.md`
|
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`를 먼저 확인
|
||||||
- `docs/HISTORY_ASOF_DB_PLAN.md`
|
|
||||||
- Gitea 이슈 `#2`, `#5`, `#9`
|
|
||||||
|
|
||||||
그리고 먼저 현재 외부 접속, 자리배치 저장, 실제 로그인 동작을 확인한 뒤 다음 기능 개발로 넘어간다.
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
- 기능 검증 기준: `8081`
|
- 기능 검증 기준: `8081`
|
||||||
- 사업관리대장 디자인 기준: `MH 통합 대시보드_260320.html`
|
- 사업관리대장 디자인 기준: `MH 통합 대시보드_260320.html`
|
||||||
- 허브 공통 시각 언어 기준: `sample style.css`
|
- 허브 공통 시각 언어 기준: `sample style.css`
|
||||||
|
- 런타임 디자인 토큰 기준: `frontend/public/design-tokens.css`
|
||||||
|
- 런타임 디자인 패턴 기준: `frontend/public/design-patterns.css`
|
||||||
- 현재 작업 지시 기준: 연결된 Gitea 이슈
|
- 현재 작업 지시 기준: 연결된 Gitea 이슈
|
||||||
|
|
||||||
작업 시작 전에 먼저 정해야 하는 질문:
|
작업 시작 전에 먼저 정해야 하는 질문:
|
||||||
@@ -51,6 +53,14 @@
|
|||||||
|
|
||||||
이걸 모르고 코드를 건드리면 높은 확률로 엉뚱한 파일을 수정하게 된다.
|
이걸 모르고 코드를 건드리면 높은 확률로 엉뚱한 파일을 수정하게 된다.
|
||||||
|
|
||||||
|
디자인 작업 추가 규칙:
|
||||||
|
|
||||||
|
- 디자인 수정은 항상 `design-tokens.css`와 `design-patterns.css`를 먼저 확인한다.
|
||||||
|
- 색/패널/버튼/테이블/팝업이 공통 규칙으로 해결 가능한지 먼저 본다.
|
||||||
|
- 해결 가능하면 화면별 파일을 고치지 않고 토큰/패턴 파일에서 수정한다.
|
||||||
|
- 화면별 실제 서빙 파일은 마지막 단계에서만 조정한다.
|
||||||
|
- 원본/reference 파일은 비교용이지 직접 수정 우선 대상이 아니다.
|
||||||
|
|
||||||
## 2. 이슈 생성 또는 연결
|
## 2. 이슈 생성 또는 연결
|
||||||
|
|
||||||
작업은 이슈 없이 하지 않는다.
|
작업은 이슈 없이 하지 않는다.
|
||||||
|
|||||||
@@ -218,6 +218,26 @@ mock, fallback, hotfix, 임시 우회 로직은 허용할 수 있다.
|
|||||||
|
|
||||||
## Rule 13. 8081 Must Start From The Isolated Worktree
|
## Rule 13. 8081 Must Start From The Isolated Worktree
|
||||||
|
|
||||||
|
`8081` 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
|
||||||
|
|
||||||
|
세부 규칙:
|
||||||
|
|
||||||
|
- 디자인 작업도 예외가 아니다.
|
||||||
|
- 허브/조직현황/프로젝트별 분석/사업관리대장 수정 전에 현재 실제 서빙 파일과 SSOT 파일을 먼저 확인한다.
|
||||||
|
|
||||||
|
디자인 작업 강제 우선순위:
|
||||||
|
|
||||||
|
1. `frontend/public/design-tokens.css`
|
||||||
|
2. `frontend/public/design-patterns.css`
|
||||||
|
3. `docs/architecture/DESIGN_SSOT.md`
|
||||||
|
4. 그 다음 화면별 실제 서빙 파일
|
||||||
|
|
||||||
|
금지:
|
||||||
|
|
||||||
|
- reference/original 파일을 먼저 수정하기
|
||||||
|
- 예전 파란톤/indigo/slate 계열을 새 기본값으로 다시 넣기
|
||||||
|
- 토큰/패턴으로 해결 가능한 문제를 화면별 임시 하드코딩으로 처리하기
|
||||||
|
|
||||||
`8081` 작업용은 포트만 다른 복제 서버가 아니라, 코드 소스까지 분리된 전용 worktree여야 한다.
|
`8081` 작업용은 포트만 다른 복제 서버가 아니라, 코드 소스까지 분리된 전용 worktree여야 한다.
|
||||||
|
|
||||||
세부 규칙:
|
세부 규칙:
|
||||||
|
|||||||
112
docs/architecture/8081_SERVING_MAP.md
Normal file
112
docs/architecture/8081_SERVING_MAP.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 8081 Serving Map
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
이 문서는 `8081` 작업용에서 어떤 URL이 어떤 파일을 실제로 읽는지 고정하기 위한 책임 맵이다.
|
||||||
|
이번 1차 정리의 목표는 기능 변경이 아니라 `실제 서빙 파일`, `공통 기본 스타일`, `8081 전용 오버라이드`, `참고 원본 자산`의 경계를 분명히 하는 것이다.
|
||||||
|
|
||||||
|
## Runtime Entry Points
|
||||||
|
|
||||||
|
- 허브 엔트리: `/`
|
||||||
|
- 파일: `frontend/public/index.html`
|
||||||
|
- 허브 공통 스크립트:
|
||||||
|
- 파일: `frontend/public/app.js`
|
||||||
|
- 허브 공통 기본 스타일:
|
||||||
|
- 파일: `frontend/public/styles.css`
|
||||||
|
- 허브 8081 전용 디자인 오버라이드:
|
||||||
|
- 파일: `frontend/public/styles-8081-design.css`
|
||||||
|
|
||||||
|
## Login Rules
|
||||||
|
|
||||||
|
- 로그인 화면 기본 구조와 스타일은 `8080` 공통 기준을 따른다.
|
||||||
|
- 로그인 기본 스타일은 `frontend/public/styles.css`에서만 정의한다.
|
||||||
|
- `frontend/public/styles-8081-design.css`에는 로그인 관련 셀렉터를 넣지 않는다.
|
||||||
|
|
||||||
|
## Legacy Organization
|
||||||
|
|
||||||
|
- URL: `/legacy/organization`
|
||||||
|
- HTML 파일:
|
||||||
|
- `DashBoard-organization.html`
|
||||||
|
- 정적 자산:
|
||||||
|
- `legacy/static/common.css`
|
||||||
|
- `legacy/static/organization.css`
|
||||||
|
- `legacy/static/organization.js`
|
||||||
|
|
||||||
|
## Integration Screens
|
||||||
|
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
정리 원칙:
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
- 허브 화면 구성:
|
||||||
|
- `frontend/public/index.html`
|
||||||
|
- `frontend/public/app.js`
|
||||||
|
- `frontend/public/styles.css`
|
||||||
|
- `frontend/public/styles-8081-design.css`
|
||||||
|
- API / viewer:
|
||||||
|
- `backend/app/main.py`
|
||||||
|
- `backend/app/db.py`
|
||||||
|
- `backend/app/center_chair_viewer_template.html`
|
||||||
|
|
||||||
|
## Incoming Files Classification
|
||||||
|
|
||||||
|
### Served
|
||||||
|
|
||||||
|
- 실제 URL에서 직접 읽는 파일
|
||||||
|
- 예:
|
||||||
|
- `served/payment.html`
|
||||||
|
- `served/mh.html`
|
||||||
|
|
||||||
|
### Reference
|
||||||
|
|
||||||
|
- 원본 HTML/CSS/XLSX/CSV
|
||||||
|
- 복구 비교용 자산
|
||||||
|
- 직접 서빙하지 않는 참고 파일
|
||||||
|
- 필요 시 다음 차수에서 `reference/` 하위로 단계적 재배치한다.
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- `260320.html`
|
||||||
|
- `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
|
||||||
|
|
||||||
|
- DB 스키마 의미 변경
|
||||||
|
- 계산식 변경
|
||||||
|
- 권한 로직 변경
|
||||||
|
- 신규 기능 추가
|
||||||
|
- backend 라우터 대분해
|
||||||
|
|
||||||
|
## Phase 1 Success Criteria
|
||||||
|
|
||||||
|
- 수정 대상 파일을 화면별로 즉시 찾을 수 있다.
|
||||||
|
- 로그인은 `styles.css`만 본다.
|
||||||
|
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
||||||
|
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
||||||
|
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||||
129
docs/architecture/DESIGN_SSOT.md
Normal file
129
docs/architecture/DESIGN_SSOT.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Design SSOT
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
- Primary visual source: [incoming-files/sample style.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/sample%20style.css)
|
||||||
|
- Runtime token file: [design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css)
|
||||||
|
- Runtime pattern file: [design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
|
||||||
|
|
||||||
|
`sample style.css` defines the intended MH visual language. `design-tokens.css` is the token-level SSOT, and `design-patterns.css` is the component-level SSOT that packages those tokens into reusable runtime patterns.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- New UI must use `design-tokens.css` variables first.
|
||||||
|
- New UI must use `design-patterns.css` patterns before adding page-local variants.
|
||||||
|
- Direct hex values are exceptions, not defaults.
|
||||||
|
- Page files may define layout and composition, but color, panel, border, radius, and shadow values must come from tokens.
|
||||||
|
- Shared aliases in `legacy/static/common.css` and `frontend/public/styles.css` exist only to bridge older code to the SSOT.
|
||||||
|
- Reference files under `incoming-files/*` are not visual authority. Runtime visuals must follow `design-tokens.css` and `design-patterns.css`.
|
||||||
|
|
||||||
|
## Fixed vs Flexible
|
||||||
|
|
||||||
|
SSOT is not a pixel-locked screenshot spec. It is a design rule system with two layers.
|
||||||
|
|
||||||
|
### Fixed rules
|
||||||
|
|
||||||
|
These should be treated as stable defaults across screens.
|
||||||
|
|
||||||
|
- Brand color family and accent family
|
||||||
|
- Surface, border, text, and shadow tokens
|
||||||
|
- Radius scale
|
||||||
|
- Button, tab, input, panel, and card visual language
|
||||||
|
- Typography tone and hierarchy
|
||||||
|
- Background atmosphere and overall contrast direction
|
||||||
|
|
||||||
|
### Flexible rules
|
||||||
|
|
||||||
|
These must be interpreted per screen based on content density and interaction needs.
|
||||||
|
|
||||||
|
- KPI card width and number of columns
|
||||||
|
- Sidebar/content split ratios
|
||||||
|
- Table column widths
|
||||||
|
- Search/filter placement
|
||||||
|
- Card stacking and wrap behavior
|
||||||
|
- Desktop/mobile breakpoint behavior
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
- Wrong SSOT: `KPI width is 100px`
|
||||||
|
- Correct SSOT: `KPI cards use the shared panel, radius, spacing, and text hierarchy tokens, and their width adapts to content without collapsing readability`
|
||||||
|
|
||||||
|
## When SSOT does not define a component
|
||||||
|
|
||||||
|
If a screen needs a pattern that SSOT does not explicitly define yet, do not fall back to arbitrary legacy styling.
|
||||||
|
|
||||||
|
Use this order:
|
||||||
|
|
||||||
|
1. Reuse existing tokens and the nearest shared pattern
|
||||||
|
2. Design the missing component in the same visual grammar
|
||||||
|
3. If the pattern is likely to repeat, document and promote it into SSOT
|
||||||
|
|
||||||
|
This applies to examples such as:
|
||||||
|
|
||||||
|
- A table pattern that does not exist in the current SSOT
|
||||||
|
- A KPI strip that needs a different density than the sample
|
||||||
|
- A new modal layout for a data-heavy screen
|
||||||
|
|
||||||
|
## Candidate and deprecated styles
|
||||||
|
|
||||||
|
Not every style already visible in the product is automatically part of SSOT.
|
||||||
|
|
||||||
|
- `SSOT`
|
||||||
|
- Approved and repeatable patterns
|
||||||
|
- Token-backed visual rules
|
||||||
|
- `candidate`
|
||||||
|
- Screen-local styles that look usable but do not yet have a documented basis
|
||||||
|
- Can be promoted later if they prove reusable
|
||||||
|
- `deprecated`
|
||||||
|
- Old blue/slate/indigo defaults
|
||||||
|
- Temporary hardcoded fixes
|
||||||
|
- Styles that conflict with the sample-based MH visual language
|
||||||
|
|
||||||
|
When a screen has a design with no clear basis, classify it as `candidate` first. Promote it only after it has been checked for reuse and consistency.
|
||||||
|
|
||||||
|
## Token groups
|
||||||
|
|
||||||
|
- Surface: `--ds-bg`, `--ds-panel`, `--ds-panel-soft`, `--ds-panel-strong`
|
||||||
|
- Text: `--ds-ink`, `--ds-text-soft`, `--ds-text-muted`
|
||||||
|
- Brand: `--ds-brand`, `--ds-brand-deep`, `--ds-brand-soft`, `--ds-accent`, `--ds-accent-soft`, `--ds-mint`
|
||||||
|
- Borders and shadows: `--ds-line`, `--ds-line-soft`, `--ds-shadow-*`
|
||||||
|
- Layout primitives: `--ds-radius-*`, `--ds-space-*`, `--ds-page-max-width`
|
||||||
|
|
||||||
|
## Promoted runtime patterns
|
||||||
|
|
||||||
|
These are now the official reusable patterns for current screens.
|
||||||
|
|
||||||
|
- Panels and heads: `.ds-panel`, `.ds-panel-head`
|
||||||
|
- KPI cards: `.ds-kpi-card`, `.ds-kpi-people`, `.ds-kpi-inverse`
|
||||||
|
- Filter surfaces and toggles: `.ds-filter-surface`, `.ds-filter-toggle`, `.ds-reset-button`
|
||||||
|
- Tables: `.ds-table-head`, `.ds-table-head-row`, `.ds-table-row`, `.ds-axis-cell`, `.ds-axis-cell-idle`, `.ds-axis-cell-active`
|
||||||
|
- Value emphasis: `.ds-project-cell`, `.ds-income`, `.ds-expense`, `.ds-subhead`, `.ds-empty`, `.ds-strong`, `.ds-muted`
|
||||||
|
- Breakdown/detail UI: `.ds-progress-track*`, `.ds-mode-chip`, `.ds-name-chip`, `.ds-mini-table-*`, `.ds-group-title`
|
||||||
|
- Position chips: `.ds-position-*` via `position-*` compatibility classes
|
||||||
|
- Business ledger popup/detail blocks: `.popup-*`, `.inline-card`, `.project-head-*`, `.summary-*`, `.ledger-*`, `.badge`, `.project-link`
|
||||||
|
- Organization modal forms/buttons: `.member-form-*`, `.modal-btn*`
|
||||||
|
- Seatmap action visibility: `.seatmap-actions .ghost-button`
|
||||||
|
|
||||||
|
These patterns may still have compatibility selectors for existing screen classes, but they should now be treated as the official design layer.
|
||||||
|
|
||||||
|
## Migration order
|
||||||
|
|
||||||
|
1. Token file and common aliases
|
||||||
|
2. Hub shell and shared controls
|
||||||
|
3. Team/Personal analysis and Organization
|
||||||
|
4. Project analysis
|
||||||
|
5. Business ledger detail cleanup
|
||||||
|
|
||||||
|
## Implementation guidance
|
||||||
|
|
||||||
|
- Prefer tokenized ranges over hardcoded single values when layout depends on data volume
|
||||||
|
- Prefer `design-patterns.css` component rules over one-off inline colors
|
||||||
|
- If a new pattern is introduced during implementation, update this document once the pattern is stable
|
||||||
|
- If a screen needs an exception, keep the exception local and explain why it cannot follow the shared pattern
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Adding new `#4f46e5`, `#4338ca`, `bg-slate-*`, `text-indigo-*` style defaults
|
||||||
|
- Reintroducing separate page-level color systems
|
||||||
|
- Hardcoding “quick fix” brand colors in JS templates when a token/class can carry the same intent
|
||||||
|
- Letting reference/original files override runtime pattern files
|
||||||
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` 이후 `사업관리대장`을 화면별 앱 구조로 승격하기 위한 첫 단계다.
|
||||||
|
- 아직 프레임워크 앱은 아니고, 독립 관리되는 정식 화면 소스 디렉터리다.
|
||||||
328
frontend/apps/ledger/assets/ledger-override.css
Normal file
328
frontend/apps/ledger/assets/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
frontend/apps/ledger/assets/ledger-override.js
Normal file
498
frontend/apps/ledger/assets/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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
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
@@ -15,6 +15,8 @@ const organizationHistoryControls = document.getElementById("organization-histor
|
|||||||
const organizationMonthSelect = document.getElementById("organization-month-select");
|
const organizationMonthSelect = document.getElementById("organization-month-select");
|
||||||
const organizationCompareBtn = document.getElementById("organization-compare-btn");
|
const organizationCompareBtn = document.getElementById("organization-compare-btn");
|
||||||
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
|
const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
|
||||||
|
const ledgerFrame = document.getElementById("ledger-frame");
|
||||||
|
const ledgerStage = document.getElementById("ledger-stage");
|
||||||
const organizationFrame = document.getElementById("organization-frame");
|
const organizationFrame = document.getElementById("organization-frame");
|
||||||
const organizationStage = document.getElementById("organization-stage");
|
const organizationStage = document.getElementById("organization-stage");
|
||||||
const projectFrame = document.getElementById("project-frame");
|
const projectFrame = document.getElementById("project-frame");
|
||||||
@@ -151,7 +153,7 @@ const seatMapState = {
|
|||||||
forceReadOnly: false,
|
forceReadOnly: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentView = "project";
|
let currentView = "ledger";
|
||||||
const globalDateState = {
|
const globalDateState = {
|
||||||
loaded: false,
|
loaded: false,
|
||||||
startDate: "",
|
startDate: "",
|
||||||
@@ -364,6 +366,10 @@ function buildSeatMapAsOfQuery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notifyEmbeddedTabActivated() {
|
function notifyEmbeddedTabActivated() {
|
||||||
|
if (currentView === "ledger" && ledgerFrame?.contentWindow) {
|
||||||
|
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "embedded-host" }, window.location.origin);
|
||||||
|
ledgerFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "business" }, window.location.origin);
|
||||||
|
}
|
||||||
if (currentView === "project" && projectFrame?.contentWindow) {
|
if (currentView === "project" && projectFrame?.contentWindow) {
|
||||||
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
|
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
|
||||||
}
|
}
|
||||||
@@ -372,6 +378,49 @@ function notifyEmbeddedTabActivated() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ledgerDefaultSourcePromise = null;
|
||||||
|
|
||||||
|
async function fetchDefaultLedgerSource() {
|
||||||
|
if (!ledgerDefaultSourcePromise) {
|
||||||
|
ledgerDefaultSourcePromise = fetch("/api/integration/business-ledger-default")
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("기본 사업관리대장 원본을 불러오지 못했습니다.");
|
||||||
|
}
|
||||||
|
const fileName = response.headers.get("x-source-filename") || "사업관리대장-1.xlsx";
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (!buffer || !buffer.byteLength) {
|
||||||
|
throw new Error("기본 사업관리대장 원본 데이터가 비어 있습니다.");
|
||||||
|
}
|
||||||
|
return { fileName, buffer };
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
ledgerDefaultSourcePromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ledgerDefaultSourcePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushDefaultLedgerSourceToFrame(force = false) {
|
||||||
|
if (!ledgerFrame?.contentWindow) return;
|
||||||
|
if (ledgerFrame.dataset.defaultLedgerLoaded === "true" && !force) return;
|
||||||
|
try {
|
||||||
|
const { fileName, buffer } = await fetchDefaultLedgerSource();
|
||||||
|
ledgerFrame.contentWindow.postMessage(
|
||||||
|
{ source: "total-control", type: "embedded-host" },
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
ledgerFrame.contentWindow.postMessage(
|
||||||
|
{ source: "total-upload", type: "business", fileName, buffer },
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
ledgerFrame.dataset.defaultLedgerLoaded = "true";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("사업관리대장 기본 원본 전달에 실패했습니다.", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureGlobalDateRangeLoaded() {
|
async function ensureGlobalDateRangeLoaded() {
|
||||||
if (globalDateState.loaded) return;
|
if (globalDateState.loaded) return;
|
||||||
try {
|
try {
|
||||||
@@ -1571,10 +1620,15 @@ function setActiveView(view) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isOrganization = currentView === "organization";
|
const isOrganization = currentView === "organization";
|
||||||
|
const isLedger = currentView === "ledger";
|
||||||
const isProject = currentView === "project";
|
const isProject = currentView === "project";
|
||||||
const isTeam = currentView === "team";
|
const isTeam = currentView === "team";
|
||||||
const isSeatMapAdmin = currentView === "seatmap-admin";
|
const isSeatMapAdmin = currentView === "seatmap-admin";
|
||||||
const isSeatMapReadonly = currentView === "seatmap-readonly";
|
const isSeatMapReadonly = currentView === "seatmap-readonly";
|
||||||
|
if (ledgerStage) {
|
||||||
|
ledgerStage.hidden = !isLedger;
|
||||||
|
ledgerStage.style.display = isLedger ? "flex" : "none";
|
||||||
|
}
|
||||||
if (organizationStage) {
|
if (organizationStage) {
|
||||||
organizationStage.hidden = !isOrganization;
|
organizationStage.hidden = !isOrganization;
|
||||||
organizationStage.style.display = isOrganization ? "flex" : "none";
|
organizationStage.style.display = isOrganization ? "flex" : "none";
|
||||||
@@ -1596,11 +1650,15 @@ function setActiveView(view) {
|
|||||||
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
|
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
|
||||||
}
|
}
|
||||||
if (emptyStage) {
|
if (emptyStage) {
|
||||||
const showEmpty = !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
|
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
|
||||||
emptyStage.hidden = !showEmpty;
|
emptyStage.hidden = !showEmpty;
|
||||||
emptyStage.style.display = showEmpty ? "flex" : "none";
|
emptyStage.style.display = showEmpty ? "flex" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLedger && previousView !== "ledger" && ledgerFrame) {
|
||||||
|
const frameSrc = ledgerFrame.dataset.src || ledgerFrame.src;
|
||||||
|
ledgerFrame.src = resolveAppUrl(frameSrc);
|
||||||
|
}
|
||||||
if (isOrganization && previousView !== "organization" && organizationFrame) {
|
if (isOrganization && previousView !== "organization" && organizationFrame) {
|
||||||
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
|
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
|
||||||
organizationFrame.src = resolveAppUrl(frameSrc);
|
organizationFrame.src = resolveAppUrl(frameSrc);
|
||||||
@@ -1671,7 +1729,7 @@ if (loginForm) {
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
setSession(payload);
|
setSession(payload);
|
||||||
setActiveView("project");
|
setActiveView("ledger");
|
||||||
loginForm.reset();
|
loginForm.reset();
|
||||||
loginMessage.textContent = "";
|
loginMessage.textContent = "";
|
||||||
renderAuth();
|
renderAuth();
|
||||||
@@ -1728,6 +1786,13 @@ organizationFrame?.addEventListener("load", () => {
|
|||||||
postOrganizationHistoryState();
|
postOrganizationHistoryState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ledgerFrame?.addEventListener("load", () => {
|
||||||
|
if (currentView === "ledger") {
|
||||||
|
notifyEmbeddedTabActivated();
|
||||||
|
}
|
||||||
|
void pushDefaultLedgerSourceToFrame(true);
|
||||||
|
});
|
||||||
|
|
||||||
projectFrame?.addEventListener("load", () => {
|
projectFrame?.addEventListener("load", () => {
|
||||||
postGlobalDateRangeToFrame(projectFrame);
|
postGlobalDateRangeToFrame(projectFrame);
|
||||||
if (currentView === "project") {
|
if (currentView === "project") {
|
||||||
|
|||||||
730
frontend/public/design-patterns.css
Normal file
730
frontend/public/design-patterns.css
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
@import url("/design-tokens.css?v=20260401-01");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ds-hero-text: #f7f0e4;
|
||||||
|
--ds-hero-border: rgba(242, 196, 132, 0.22);
|
||||||
|
--ds-hero-surface: rgba(255, 255, 255, 0.08);
|
||||||
|
--ds-hero-surface-strong: rgba(255, 255, 255, 0.1);
|
||||||
|
--ds-hero-text-muted: rgba(255, 244, 230, 0.72);
|
||||||
|
--ds-hero-text-soft: rgba(255, 244, 230, 0.56);
|
||||||
|
--ds-hero-line: rgba(242, 196, 132, 0.18);
|
||||||
|
--ds-danger-soft: rgba(169, 72, 50, 0.1);
|
||||||
|
--ds-danger-line: rgba(169, 72, 50, 0.22);
|
||||||
|
--ds-success-soft: rgba(47, 153, 115, 0.14);
|
||||||
|
--ds-success-line: rgba(47, 153, 115, 0.24);
|
||||||
|
--ds-brand-soft-surface: rgba(15, 58, 47, 0.1);
|
||||||
|
--ds-brand-soft-line: rgba(15, 58, 47, 0.18);
|
||||||
|
--ds-accent-soft-surface: rgba(242, 196, 132, 0.18);
|
||||||
|
--ds-accent-soft-line: rgba(214, 138, 58, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-panel,
|
||||||
|
.payment-panel {
|
||||||
|
background: rgba(255, 250, 243, 0.96);
|
||||||
|
border: 1px solid var(--ds-line-soft);
|
||||||
|
box-shadow: var(--ds-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-panel-head,
|
||||||
|
.payment-panel-head {
|
||||||
|
background: rgba(255, 250, 243, 0.92);
|
||||||
|
border-bottom: 1px solid var(--ds-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-kpi-card,
|
||||||
|
.payment-kpi-card {
|
||||||
|
border: 1px solid var(--ds-line-soft);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 250, 243, 0.96) 0%, rgba(248, 242, 232, 0.96) 100%);
|
||||||
|
box-shadow: var(--ds-shadow-soft);
|
||||||
|
color: var(--ds-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-kpi-inverse,
|
||||||
|
.payment-kpi-inverse {
|
||||||
|
color: #fffaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-kpi-people,
|
||||||
|
.payment-kpi-people {
|
||||||
|
background: linear-gradient(135deg, var(--ds-brand) 0%, var(--ds-brand-soft) 100%);
|
||||||
|
border-color: rgba(15, 58, 47, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-subhead,
|
||||||
|
.payment-subhead {
|
||||||
|
color: var(--ds-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-empty,
|
||||||
|
.payment-empty {
|
||||||
|
color: #9b937f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-tooltip,
|
||||||
|
.payment-tooltip {
|
||||||
|
background: var(--ds-brand-deep);
|
||||||
|
color: #fffaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-filter-surface,
|
||||||
|
.payment-filter-bar {
|
||||||
|
background: rgba(246, 237, 221, 0.8);
|
||||||
|
border: 1px solid var(--ds-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-filter-toggle,
|
||||||
|
.payment-filter-toggle {
|
||||||
|
background: var(--ds-brand);
|
||||||
|
border-color: rgba(15, 58, 47, 0.28);
|
||||||
|
color: #fffaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reset-button,
|
||||||
|
.payment-reset-btn {
|
||||||
|
background: rgba(255, 250, 243, 0.96);
|
||||||
|
border: 1px solid var(--ds-line);
|
||||||
|
color: var(--ds-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-reset-button:hover,
|
||||||
|
.payment-reset-btn:hover {
|
||||||
|
color: var(--ds-brand-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-table-head,
|
||||||
|
.payment-table-head {
|
||||||
|
background: rgba(246, 237, 221, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-table-head-row,
|
||||||
|
.payment-table-head-row {
|
||||||
|
color: var(--ds-brand-deep);
|
||||||
|
border-bottom: 1px solid var(--ds-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-table-row,
|
||||||
|
.payment-data-row {
|
||||||
|
border-color: #f0e5d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-table-row:hover,
|
||||||
|
.payment-data-row:hover {
|
||||||
|
background: #f6eddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-axis-cell,
|
||||||
|
.payment-axis-cell {
|
||||||
|
border-right: 1px solid var(--ds-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-axis-cell-idle,
|
||||||
|
.payment-axis-cell-idle {
|
||||||
|
background: rgba(255, 250, 243, 0.96);
|
||||||
|
color: var(--ds-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-axis-cell-idle:hover,
|
||||||
|
.payment-axis-cell-idle:hover {
|
||||||
|
background: rgba(234, 220, 196, 0.52);
|
||||||
|
color: var(--ds-brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-axis-cell-active,
|
||||||
|
.payment-axis-cell-active {
|
||||||
|
background: rgba(234, 220, 196, 0.78);
|
||||||
|
color: var(--ds-brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-project-cell,
|
||||||
|
.payment-project-cell {
|
||||||
|
color: var(--ds-brand-deep);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-project-cell:hover,
|
||||||
|
.payment-project-cell:hover {
|
||||||
|
background: #efe2ca;
|
||||||
|
color: #214634;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-income,
|
||||||
|
.payment-income {
|
||||||
|
color: var(--ds-status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-expense,
|
||||||
|
.payment-expense {
|
||||||
|
color: var(--ds-status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-progress-track,
|
||||||
|
.payment-progress-track {
|
||||||
|
background: rgba(217, 197, 168, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-progress-track-grand,
|
||||||
|
.payment-progress-track-grand {
|
||||||
|
background: rgba(75, 135, 179, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-progress-track-mid,
|
||||||
|
.payment-progress-track-mid {
|
||||||
|
background: rgba(214, 138, 58, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-mode-chip,
|
||||||
|
.payment-mode-chip {
|
||||||
|
color: var(--ds-brand-soft);
|
||||||
|
background: rgba(242, 196, 132, 0.22);
|
||||||
|
border: 1px solid rgba(214, 138, 58, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-name-chip,
|
||||||
|
.payment-name-chip {
|
||||||
|
background: rgba(246, 237, 221, 0.76);
|
||||||
|
border: 1px solid var(--ds-line-soft);
|
||||||
|
color: var(--ds-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-divider-top,
|
||||||
|
.payment-divider-top {
|
||||||
|
border-top: 1px solid var(--ds-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-divider-left,
|
||||||
|
.payment-divider-left {
|
||||||
|
border-left: 1px solid var(--ds-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-divider-mark,
|
||||||
|
.payment-divider-mark {
|
||||||
|
color: rgba(183, 170, 147, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-mini-table-shell,
|
||||||
|
.payment-mini-table-shell {
|
||||||
|
border: 1px solid var(--ds-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-mini-table-head,
|
||||||
|
.payment-mini-table-head {
|
||||||
|
background: rgba(246, 237, 221, 0.68);
|
||||||
|
color: var(--ds-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-mini-table-row,
|
||||||
|
.payment-mini-table-row {
|
||||||
|
border-top: 1px solid rgba(217, 197, 168, 0.36);
|
||||||
|
color: var(--ds-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-group-title,
|
||||||
|
.payment-group-title {
|
||||||
|
background: var(--ds-brand);
|
||||||
|
color: #fffaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-strong,
|
||||||
|
.payment-strong {
|
||||||
|
color: var(--ds-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-muted,
|
||||||
|
.payment-muted {
|
||||||
|
color: var(--ds-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-accent-text,
|
||||||
|
.payment-icon-accent {
|
||||||
|
color: var(--ds-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-position-chip,
|
||||||
|
.position-chip {
|
||||||
|
background: rgba(246, 237, 221, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-position-text,
|
||||||
|
.position-text {
|
||||||
|
color: var(--ds-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-position-border,
|
||||||
|
.position-border {
|
||||||
|
border-color: rgba(217, 197, 168, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-position-dot,
|
||||||
|
.position-dot {
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 250, 243, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-executive.position-chip { background: rgba(15, 58, 47, 0.1); }
|
||||||
|
.position-executive.position-text { color: var(--ds-brand); }
|
||||||
|
.position-executive.position-border { border-color: rgba(15, 58, 47, 0.22); }
|
||||||
|
.position-executive.position-dot { background: var(--ds-brand); }
|
||||||
|
|
||||||
|
.position-principal.position-chip { background: rgba(26, 86, 69, 0.1); }
|
||||||
|
.position-principal.position-text { color: var(--ds-brand-soft); }
|
||||||
|
.position-principal.position-border { border-color: rgba(26, 86, 69, 0.22); }
|
||||||
|
.position-principal.position-dot { background: var(--ds-brand-soft); }
|
||||||
|
|
||||||
|
.position-senior.position-chip { background: rgba(47, 153, 115, 0.12); }
|
||||||
|
.position-senior.position-text { color: var(--ds-mint); }
|
||||||
|
.position-senior.position-border { border-color: rgba(47, 153, 115, 0.26); }
|
||||||
|
.position-senior.position-dot { background: var(--ds-mint); }
|
||||||
|
|
||||||
|
.position-associate.position-chip { background: rgba(75, 135, 179, 0.12); }
|
||||||
|
.position-associate.position-text { color: var(--ds-info); }
|
||||||
|
.position-associate.position-border { border-color: rgba(75, 135, 179, 0.22); }
|
||||||
|
.position-associate.position-dot { background: var(--ds-info); }
|
||||||
|
|
||||||
|
.position-staff.position-chip { background: rgba(214, 138, 58, 0.12); }
|
||||||
|
.position-staff.position-text { color: var(--ds-status-warning); }
|
||||||
|
.position-staff.position-border { border-color: rgba(214, 138, 58, 0.24); }
|
||||||
|
.position-staff.position-dot { background: var(--ds-status-warning); }
|
||||||
|
|
||||||
|
.position-member.position-chip { background: rgba(102, 117, 109, 0.12); }
|
||||||
|
.position-member.position-text { color: var(--ds-text-soft); }
|
||||||
|
.position-member.position-border { border-color: rgba(102, 117, 109, 0.24); }
|
||||||
|
.position-member.position-dot { background: var(--ds-text-soft); }
|
||||||
|
|
||||||
|
.position-unset.position-chip { background: rgba(183, 170, 147, 0.18); }
|
||||||
|
.position-unset.position-text { color: #8b7e69; }
|
||||||
|
.position-unset.position-border { border-color: rgba(183, 170, 147, 0.3); }
|
||||||
|
.position-unset.position-dot { background: #b7aa93; }
|
||||||
|
|
||||||
|
.popup-wrap {
|
||||||
|
max-width: 1680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-head {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid rgba(217, 197, 168, 0.62);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, #fff8ee 0%, #f4e9d7 100%);
|
||||||
|
box-shadow: 0 18px 36px rgba(15, 58, 47, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--ds-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ds-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-panel {
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-head-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.95fr) minmax(280px, 0.72fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-head-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-contact-stack {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card,
|
||||||
|
.ledger-block,
|
||||||
|
.popup-wrap .ledger-block.collect {
|
||||||
|
background: rgba(255, 250, 243, 0.98) !important;
|
||||||
|
border: 1px solid rgba(217, 197, 168, 0.56) !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
box-shadow: 0 16px 32px rgba(15, 58, 47, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%);
|
||||||
|
border: 1px solid rgba(217, 197, 168, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kvk,
|
||||||
|
.summary-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8a6b3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kvv {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--ds-ink);
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-note {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ds-text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%);
|
||||||
|
border: 1px solid rgba(217, 197, 168, 0.46);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.receivable {
|
||||||
|
background: linear-gradient(180deg, var(--ds-danger-soft) 0%, rgba(255, 248, 238, 0.98) 100%);
|
||||||
|
border-color: var(--ds-danger-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--ds-ink);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.receivable .summary-value {
|
||||||
|
color: var(--ds-status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-progress {
|
||||||
|
margin-top: 10px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: var(--ds-radius-pill);
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(217, 197, 168, 0.48);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(15, 58, 47, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress .bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--ds-radius-pill);
|
||||||
|
background: linear-gradient(90deg, var(--ds-brand-soft) 0%, var(--ds-mint) 100%);
|
||||||
|
box-shadow: 0 8px 18px rgba(47, 153, 115, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 18px 14px;
|
||||||
|
border-bottom: 1px solid rgba(217, 197, 168, 0.38) !important;
|
||||||
|
background: linear-gradient(180deg, #fffdf8 0%, #f4e9d7 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-head-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(180deg, #fff8ee 0%, #f2dec0 100%) !important;
|
||||||
|
color: var(--ds-accent-strong) !important;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--ds-ink) !important;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ds-text-muted) !important;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--ds-radius-pill);
|
||||||
|
background: var(--ds-brand-soft-surface) !important;
|
||||||
|
border: 1px solid var(--ds-brand-soft-line) !important;
|
||||||
|
color: var(--ds-brand-soft) !important;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-table-wrap {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-table thead th {
|
||||||
|
padding: 12px 10px;
|
||||||
|
background: var(--ds-brand) !important;
|
||||||
|
color: #fff5e6 !important;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: left;
|
||||||
|
border-right: 1px solid rgba(242, 196, 132, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-table thead th:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-table tbody td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-bottom: 1px solid rgba(217, 197, 168, 0.34) !important;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ds-ink) !important;
|
||||||
|
background: rgba(255, 250, 243, 0.72) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-main {
|
||||||
|
display: block;
|
||||||
|
color: var(--ds-ink) !important;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-muted,
|
||||||
|
.ledger-note {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ds-text-muted) !important;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-amount {
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--ds-radius-pill);
|
||||||
|
border: 1px solid rgba(217, 197, 168, 0.5);
|
||||||
|
background: rgba(255, 250, 243, 0.96);
|
||||||
|
color: #17392f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.badge-baron {
|
||||||
|
background: var(--ds-brand-soft-surface);
|
||||||
|
border-color: var(--ds-brand-soft-line);
|
||||||
|
color: var(--ds-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.badge-family {
|
||||||
|
background: var(--ds-accent-soft-surface);
|
||||||
|
border-color: var(--ds-accent-soft-line);
|
||||||
|
color: var(--ds-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok {
|
||||||
|
background: var(--ds-success-soft);
|
||||||
|
border-color: var(--ds-success-line);
|
||||||
|
color: var(--ds-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link:hover {
|
||||||
|
color: #0f6a55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-form-label {
|
||||||
|
color: var(--ds-text-soft);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-form-input,
|
||||||
|
.member-form-select,
|
||||||
|
.member-form-time {
|
||||||
|
border: 1px solid var(--ds-line-soft);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 250, 243, 0.92);
|
||||||
|
color: var(--ds-ink);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-form-input:focus,
|
||||||
|
.member-form-select:focus,
|
||||||
|
.member-form-time:focus {
|
||||||
|
border-color: rgba(47, 153, 115, 0.45);
|
||||||
|
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--ds-radius-pill);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-cancel {
|
||||||
|
background: rgba(255, 250, 243, 0.96);
|
||||||
|
border-color: var(--ds-line);
|
||||||
|
color: var(--ds-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-save {
|
||||||
|
background: var(--ds-brand-soft);
|
||||||
|
border-color: rgba(15, 58, 47, 0.22);
|
||||||
|
color: #fffaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-delete {
|
||||||
|
background: rgba(169, 72, 50, 0.12);
|
||||||
|
border-color: rgba(169, 72, 50, 0.24);
|
||||||
|
color: var(--ds-status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-close {
|
||||||
|
background: rgba(242, 196, 132, 0.18);
|
||||||
|
border-color: rgba(214, 138, 58, 0.24);
|
||||||
|
color: var(--ds-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-actions .ghost-button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
border-radius: var(--ds-radius-pill);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
box-shadow: var(--ds-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
frontend/public/design-tokens.css
Normal file
60
frontend/public/design-tokens.css
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
:root {
|
||||||
|
--ds-font-sans: "Pretendard", "Malgun Gothic", sans-serif;
|
||||||
|
|
||||||
|
--ds-bg: #f1eadf;
|
||||||
|
--ds-bg-soft: #f4e9d7;
|
||||||
|
--ds-bg-gradient:
|
||||||
|
radial-gradient(circle at top left, rgba(214, 138, 58, 0.18), transparent 24%),
|
||||||
|
radial-gradient(circle at top right, rgba(47, 153, 115, 0.12), transparent 22%),
|
||||||
|
linear-gradient(180deg, #f6efe6 0%, #f1eadf 100%);
|
||||||
|
|
||||||
|
--ds-panel: #fffaf3;
|
||||||
|
--ds-panel-soft: rgba(255, 250, 243, 0.9);
|
||||||
|
--ds-panel-strong: #eadcc4;
|
||||||
|
|
||||||
|
--ds-ink: #10251d;
|
||||||
|
--ds-text-soft: #425148;
|
||||||
|
--ds-text-muted: #66756d;
|
||||||
|
|
||||||
|
--ds-line: #d9c5a8;
|
||||||
|
--ds-line-soft: rgba(217, 197, 168, 0.45);
|
||||||
|
|
||||||
|
--ds-brand: #0f3a2f;
|
||||||
|
--ds-brand-deep: #0a2a22;
|
||||||
|
--ds-brand-soft: #1a5645;
|
||||||
|
--ds-accent: #d68a3a;
|
||||||
|
--ds-accent-soft: #f2c484;
|
||||||
|
--ds-accent-strong: #b66e22;
|
||||||
|
--ds-mint: #2f9973;
|
||||||
|
--ds-info: #4b87b3;
|
||||||
|
|
||||||
|
--ds-status-success: #2f6b52;
|
||||||
|
--ds-status-warning: #9a6422;
|
||||||
|
--ds-status-danger: #a94832;
|
||||||
|
|
||||||
|
--ds-surface-tint: rgba(255, 255, 255, 0.72);
|
||||||
|
--ds-surface-tint-strong: rgba(255, 255, 255, 0.88);
|
||||||
|
--ds-glass-dark: rgba(20, 45, 37, 0.34);
|
||||||
|
--ds-glass-dark-soft: rgba(16, 37, 29, 0.16);
|
||||||
|
--ds-glass-line: rgba(255, 255, 255, 0.14);
|
||||||
|
|
||||||
|
--ds-shadow-soft: 0 10px 24px rgba(15, 58, 47, 0.08);
|
||||||
|
--ds-shadow-card: 0 22px 54px rgba(15, 58, 47, 0.12);
|
||||||
|
--ds-shadow-float: 0 18px 36px rgba(15, 58, 47, 0.16);
|
||||||
|
--ds-shadow-hero: 0 28px 70px rgba(15, 58, 47, 0.22);
|
||||||
|
|
||||||
|
--ds-radius-sm: 8px;
|
||||||
|
--ds-radius-md: 12px;
|
||||||
|
--ds-radius-lg: 18px;
|
||||||
|
--ds-radius-xl: 24px;
|
||||||
|
--ds-radius-pill: 999px;
|
||||||
|
|
||||||
|
--ds-space-1: 4px;
|
||||||
|
--ds-space-2: 8px;
|
||||||
|
--ds-space-3: 12px;
|
||||||
|
--ds-space-4: 16px;
|
||||||
|
--ds-space-5: 20px;
|
||||||
|
--ds-space-6: 24px;
|
||||||
|
|
||||||
|
--ds-page-max-width: 2000px;
|
||||||
|
}
|
||||||
@@ -12,8 +12,13 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||||
|
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||||
<link rel="stylesheet" href="/legacy/static/common.css">
|
<link rel="stylesheet" href="/legacy/static/common.css">
|
||||||
|
<!-- Keep login and common hub defaults aligned with 8080. -->
|
||||||
<link rel="stylesheet" href="/styles.css?v=20260330-01">
|
<link rel="stylesheet" href="/styles.css?v=20260330-01">
|
||||||
|
<!-- 8081-only hub overrides must not restyle the login screen. -->
|
||||||
|
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section id="login-panel" class="login-screen">
|
<section id="login-panel" class="login-screen">
|
||||||
@@ -91,18 +96,26 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="dashboard-main">
|
<main class="dashboard-main">
|
||||||
|
<section id="ledger-stage" class="main-stage" hidden>
|
||||||
|
<div class="stage-frame">
|
||||||
|
<iframe id="ledger-frame" src="/integrations/ledger?v=20260401-02" data-src="/integrations/ledger?v=20260401-02" title="사업관리대장 화면"></iframe>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section id="organization-stage" class="main-stage">
|
<section id="organization-stage" class="main-stage">
|
||||||
<div class="stage-frame">
|
<div class="stage-frame">
|
||||||
<iframe id="organization-frame" src="/legacy/organization?v=20260331-01" data-src="/legacy/organization?v=20260331-01" title="조직도 메인 화면"></iframe>
|
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
|
||||||
|
<iframe id="organization-frame" src="/legacy/organization?v=20260330-02" data-src="/legacy/organization?v=20260330-02" title="조직도 메인 화면"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="project-stage" class="main-stage" hidden>
|
<section id="project-stage" class="main-stage" hidden>
|
||||||
<div class="stage-frame">
|
<div class="stage-frame">
|
||||||
|
<!-- Integration HTML is served from incoming-files/served/payment.html. -->
|
||||||
<iframe id="project-frame" src="/integrations/payment" data-src="/integrations/payment" title="프로젝트별 분석 화면"></iframe>
|
<iframe id="project-frame" src="/integrations/payment" data-src="/integrations/payment" title="프로젝트별 분석 화면"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="team-stage" class="main-stage" hidden>
|
<section id="team-stage" class="main-stage" hidden>
|
||||||
<div class="stage-frame">
|
<div class="stage-frame">
|
||||||
|
<!-- Integration HTML is served from incoming-files/served/mh.html. -->
|
||||||
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
|
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -213,6 +226,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script src="/app.js?v=20260330-01"></script>
|
<script src="/app.js?v=20260401-02"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
100
frontend/public/styles-8081-design.css
Normal file
100
frontend/public/styles-8081-design.css
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
.dashboard-header {
|
||||||
|
min-height: 68px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 18%, rgba(242, 196, 132, 0.16), transparent 24%),
|
||||||
|
linear-gradient(145deg, rgba(10, 42, 34, 0.96) 0%, rgba(15, 58, 47, 0.96) 52%, rgba(26, 86, 69, 0.96) 100%);
|
||||||
|
color: #f7f0e4;
|
||||||
|
border-bottom: 1px solid rgba(242, 196, 132, 0.22);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: var(--ds-shadow-float);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header .eyebrow {
|
||||||
|
color: rgba(242, 196, 132, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header h2 {
|
||||||
|
color: #fff7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(242, 196, 132, 0.28);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 244, 230, 0.78);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill.active {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 253, 248, 0.98), rgba(245, 235, 221, 0.94));
|
||||||
|
border-color: rgba(242, 196, 132, 0.34);
|
||||||
|
color: var(--ds-ink);
|
||||||
|
box-shadow: var(--ds-shadow-float);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill.muted {
|
||||||
|
color: rgba(255, 244, 230, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-pill:hover {
|
||||||
|
color: #fff7ea;
|
||||||
|
border-color: rgba(242, 196, 132, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
border-left: 1px solid rgba(242, 196, 132, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-date-label {
|
||||||
|
color: rgba(255, 244, 230, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-date-field {
|
||||||
|
border: 1px solid rgba(242, 196, 132, 0.22);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-date-field input,
|
||||||
|
.header-date-field select {
|
||||||
|
color: #fff7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-date-sep {
|
||||||
|
color: rgba(255, 244, 230, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
border: 1px solid rgba(242, 196, 132, 0.22);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background: rgba(242, 196, 132, 0.14);
|
||||||
|
border-color: rgba(242, 196, 132, 0.32);
|
||||||
|
color: #fff7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button-soft {
|
||||||
|
background: rgba(239, 228, 208, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-status[data-tone="error"] {
|
||||||
|
color: var(--ds-status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-status[data-tone="success"] {
|
||||||
|
color: var(--ds-status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatmap-board-wrap,
|
||||||
|
.seatmap-dxf-canvas {
|
||||||
|
background: var(--ds-panel);
|
||||||
|
}
|
||||||
@@ -1,3 +1,30 @@
|
|||||||
|
:root {
|
||||||
|
--color-bg: var(--ds-bg);
|
||||||
|
--color-surface: var(--ds-panel);
|
||||||
|
--color-surface-soft: var(--ds-panel-soft);
|
||||||
|
--color-surface-strong: var(--ds-panel-strong);
|
||||||
|
--color-text: var(--ds-ink);
|
||||||
|
--color-text-soft: var(--ds-text-soft);
|
||||||
|
--color-text-muted: var(--ds-text-muted);
|
||||||
|
--color-border: var(--ds-line);
|
||||||
|
--color-border-soft: var(--ds-line-soft);
|
||||||
|
--color-brand: var(--ds-brand);
|
||||||
|
--color-brand-deep: var(--ds-brand-deep);
|
||||||
|
--color-brand-soft: var(--ds-brand-soft);
|
||||||
|
--color-accent: var(--ds-accent);
|
||||||
|
--color-accent-soft: var(--ds-accent-soft);
|
||||||
|
--color-success: var(--ds-status-success);
|
||||||
|
--color-danger: var(--ds-status-danger);
|
||||||
|
--radius-sm: var(--ds-radius-sm);
|
||||||
|
--radius-md: var(--ds-radius-md);
|
||||||
|
--radius-lg: var(--ds-radius-lg);
|
||||||
|
--radius-xl: var(--ds-radius-xl);
|
||||||
|
--radius-pill: var(--ds-radius-pill);
|
||||||
|
--shadow-soft: var(--ds-shadow-soft);
|
||||||
|
--shadow-card: var(--ds-shadow-card);
|
||||||
|
--shadow-float: var(--ds-shadow-float);
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-shell,
|
.dashboard-shell,
|
||||||
.dashboard-main,
|
.dashboard-main,
|
||||||
.main-stage,
|
.main-stage,
|
||||||
@@ -31,7 +58,7 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(15, 23, 42, 0.42), rgba(30, 41, 59, 0.18)),
|
linear-gradient(135deg, rgba(10, 42, 34, 0.42), rgba(26, 86, 69, 0.18)),
|
||||||
url("https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=1800&q=80")
|
url("https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=1800&q=80")
|
||||||
center center / cover no-repeat;
|
center center / cover no-repeat;
|
||||||
}
|
}
|
||||||
@@ -54,10 +81,10 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.3fr 0.7fr;
|
grid-template-columns: 1.3fr 0.7fr;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid var(--ds-glass-line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: rgba(71, 85, 105, 0.34);
|
background: var(--ds-glass-dark);
|
||||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24);
|
box-shadow: var(--ds-shadow-hero);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +95,8 @@ body {
|
|||||||
padding: 30px 30px;
|
padding: 30px 30px;
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(15, 23, 42, 0.08), rgba(255, 255, 255, 0.02)),
|
linear-gradient(90deg, rgba(10, 42, 34, 0.08), rgba(255, 255, 255, 0.02)),
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(15, 23, 42, 0.08));
|
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(10, 42, 34, 0.08));
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand .eyebrow {
|
.login-brand .eyebrow {
|
||||||
@@ -83,7 +110,7 @@ body {
|
|||||||
font-size: clamp(1.7rem, 3.2vw, 2.5rem);
|
font-size: clamp(1.7rem, 3.2vw, 2.5rem);
|
||||||
line-height: 0.96;
|
line-height: 0.96;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
color: #f8fafc;
|
color: #f7f0e4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form-wrap {
|
.login-form-wrap {
|
||||||
@@ -91,7 +118,7 @@ body {
|
|||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background: rgba(15, 23, 42, 0.12);
|
background: var(--ds-glass-dark-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card label {
|
.login-card label {
|
||||||
@@ -140,8 +167,8 @@ body {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: rgba(31, 41, 55, 0.82);
|
background: rgba(10, 42, 34, 0.82);
|
||||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.22);
|
box-shadow: var(--shadow-float);
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -167,9 +194,9 @@ body {
|
|||||||
|
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
min-height: 68px;
|
min-height: 68px;
|
||||||
background: rgba(255, 255, 255, 0.94);
|
background: rgba(255, 250, 243, 0.94);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border-bottom: 1px solid #d7dee8;
|
border-bottom: 1px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -241,7 +268,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
border-bottom: 3px solid transparent;
|
border-bottom: 3px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #64748b;
|
color: var(--color-text-muted);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -255,7 +282,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-pill.muted {
|
.nav-pill.muted {
|
||||||
color: #94a3b8;
|
color: rgba(102, 117, 109, 0.64);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-pill:hover {
|
.nav-pill:hover {
|
||||||
@@ -269,7 +296,7 @@ body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
border-left: 1px solid #dbe2ea;
|
border-left: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-date-controls {
|
.header-date-controls {
|
||||||
@@ -284,7 +311,7 @@ body {
|
|||||||
.header-date-label {
|
.header-date-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #64748b;
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-date-field {
|
.header-date-field {
|
||||||
@@ -292,9 +319,9 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-date-field input {
|
.header-date-field input {
|
||||||
@@ -318,15 +345,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-date-sep {
|
.header-date-sep {
|
||||||
color: #94a3b8;
|
color: var(--color-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-button {
|
.ghost-button {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid var(--color-border);
|
||||||
background: #fff;
|
background: var(--color-surface);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -342,12 +369,12 @@ body {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #f8fafc;
|
background: var(--color-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button:hover {
|
.icon-button:hover {
|
||||||
background: #f1f5f9;
|
background: var(--ds-bg-soft);
|
||||||
border-color: #cbd5e1;
|
border-color: var(--color-border);
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
@@ -363,7 +390,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ghost-button-soft {
|
.ghost-button-soft {
|
||||||
background: #f8fafc;
|
background: var(--color-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-chip {
|
.user-chip {
|
||||||
@@ -381,8 +408,8 @@ body {
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #e2e8f0;
|
background: var(--color-surface-strong);
|
||||||
color: #475569;
|
color: var(--color-text-soft);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -421,10 +448,10 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid #dbe2ea;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(255, 255, 255, 0.96);
|
background: rgba(255, 250, 243, 0.96);
|
||||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14);
|
box-shadow: var(--shadow-float);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
}
|
}
|
||||||
@@ -440,7 +467,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-popover-row + .user-popover-row {
|
.user-popover-row + .user-popover-row {
|
||||||
border-top: 1px solid #eef2f7;
|
border-top: 1px solid rgba(217, 197, 168, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-popover-label {
|
.user-popover-label {
|
||||||
@@ -454,7 +481,7 @@ body {
|
|||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #0f172a;
|
background: var(--color-brand);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -485,7 +512,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: #fff;
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-empty {
|
.stage-empty {
|
||||||
@@ -502,9 +529,7 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background: var(--ds-bg-gradient);
|
||||||
linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(241, 245, 249, 0.92)),
|
|
||||||
radial-gradient(circle at top left, rgba(14, 165, 233, 0.1), transparent 32%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatmap-topbar {
|
.seatmap-topbar {
|
||||||
@@ -561,6 +586,54 @@ body {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatmap-actions .ghost-button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#seatmap-admin-save-btn {
|
||||||
|
border-color: var(--color-brand-soft);
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: #fffaf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#seatmap-admin-save-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-brand);
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-float);
|
||||||
|
}
|
||||||
|
|
||||||
|
#seatmap-admin-save-btn:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: rgba(26, 86, 69, 0.24);
|
||||||
|
background: rgba(26, 86, 69, 0.18);
|
||||||
|
color: rgba(16, 37, 29, 0.72);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#seatmap-admin-exit-btn,
|
||||||
|
#seatmap-readonly-exit-btn {
|
||||||
|
border-color: rgba(214, 138, 58, 0.48);
|
||||||
|
background: rgba(242, 196, 132, 0.22);
|
||||||
|
color: var(--color-brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
#seatmap-admin-exit-btn:hover,
|
||||||
|
#seatmap-readonly-exit-btn:hover {
|
||||||
|
background: rgba(242, 196, 132, 0.34);
|
||||||
|
border-color: rgba(182, 110, 34, 0.56);
|
||||||
|
color: var(--color-brand);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.seatmap-status {
|
.seatmap-status {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
38
incoming-files/README.md
Normal file
38
incoming-files/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# incoming-files Layout
|
||||||
|
|
||||||
|
`8081` 1차 구조 정리 기준으로 `incoming-files`는 아래처럼 해석한다.
|
||||||
|
|
||||||
|
## Served
|
||||||
|
|
||||||
|
- 실제 URL에서 직접 서빙되는 HTML
|
||||||
|
- 현재 사용 파일:
|
||||||
|
- `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
|
||||||
|
|
||||||
|
- 원본 참고 자산
|
||||||
|
- 복구 비교용 자산
|
||||||
|
- 직접 서빙하지 않는 파일
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- 원본 `xlsx`, `csv`
|
||||||
|
- 샘플 스타일 파일
|
||||||
|
- 원본/백업 HTML
|
||||||
|
- 디자인 비교용 파일
|
||||||
|
- `reference/ledger/MH 통합 대시보드_260320.html`
|
||||||
|
- `reference/ledger/MH 통합 대시보드_260320.css`
|
||||||
|
|
||||||
|
## Temporary Comparison Copies
|
||||||
|
|
||||||
|
- 현재 루트의 `payment.html`, `mh.html`은 당장 삭제하지 않는다.
|
||||||
|
- 이 두 파일은 기존 recovery 작업본과 현재 `served/*`를 비교하거나 되돌릴 때만 본다.
|
||||||
|
- 다음 차수에서 안전성이 확보되면 `reference/` 하위로 재배치 여부를 검토한다.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -110,35 +110,35 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const costCategories = [
|
const costCategories = [
|
||||||
{ name: '인건비', color: '#6366f1' },
|
{ name: '인건비', color: '#0f3a2f' },
|
||||||
{ name: '출장비', color: '#f43f5e' },
|
{ name: '출장비', color: '#a94832' },
|
||||||
{ name: '복리후생비', color: '#fbbf24' },
|
{ name: '복리후생비', color: '#d68a3a' },
|
||||||
{ name: '구매비', color: '#0ea5e9' },
|
{ name: '구매비', color: '#4b87b3' },
|
||||||
{ name: '외주비', color: '#94a3b8' }
|
{ name: '외주비', color: '#66756d' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const positionStyles = {
|
const positionStyles = {
|
||||||
'수석연구원': { bg: 'bg-purple-50', text: 'text-purple-600', border: 'border-purple-100', icon: 'bg-purple-600' },
|
'수석연구원': { bg: 'position-chip position-executive', text: 'position-text position-executive', border: 'position-border position-executive', icon: 'position-dot position-executive' },
|
||||||
'책임연구원': { bg: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-100', icon: 'bg-blue-600' },
|
'책임연구원': { bg: 'position-chip position-principal', text: 'position-text position-principal', border: 'position-border position-principal', icon: 'position-dot position-principal' },
|
||||||
'선임연구원': { bg: 'bg-indigo-50', text: 'text-indigo-600', border: 'border-indigo-100', icon: 'bg-indigo-600' },
|
'선임연구원': { bg: 'position-chip position-senior', text: 'position-text position-senior', border: 'position-border position-senior', icon: 'position-dot position-senior' },
|
||||||
'전임연구원': { bg: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-100', icon: 'bg-emerald-600' },
|
'전임연구원': { bg: 'position-chip position-associate', text: 'position-text position-associate', border: 'position-border position-associate', icon: 'position-dot position-associate' },
|
||||||
'주임연구원': { bg: 'bg-slate-50', text: 'text-slate-600', border: 'border-slate-100', icon: 'bg-slate-600' },
|
'주임연구원': { bg: 'position-chip position-staff', text: 'position-text position-staff', border: 'position-border position-staff', icon: 'position-dot position-staff' },
|
||||||
'연구원': { bg: 'bg-slate-50', text: 'text-slate-500', border: 'border-slate-100', icon: 'bg-slate-400' },
|
'연구원': { bg: 'position-chip position-member', text: 'position-text position-member', border: 'position-border position-member', icon: 'position-dot position-member' },
|
||||||
'미지정': { bg: 'bg-gray-50', text: 'text-gray-400', border: 'border-gray-100', icon: 'bg-gray-300' }
|
'미지정': { bg: 'position-chip position-unset', text: 'position-text position-unset', border: 'position-border position-unset', icon: 'position-dot position-unset' }
|
||||||
};
|
};
|
||||||
const positionOrder = { '수석연구원': 1, '책임연구원': 2, '선임연구원': 3, '연구원': 4 };
|
const positionOrder = { '수석연구원': 1, '책임연구원': 2, '선임연구원': 3, '연구원': 4 };
|
||||||
const positionColorMap = {
|
const positionColorMap = {
|
||||||
'수석연구원': '#7c3aed',
|
'수석연구원': '#0f3a2f',
|
||||||
'책임연구원': '#2563eb',
|
'책임연구원': '#1a5645',
|
||||||
'선임연구원': '#4f46e5',
|
'선임연구원': '#2f9973',
|
||||||
'전임연구원': '#059669',
|
'전임연구원': '#4b87b3',
|
||||||
'주임연구원': '#475569',
|
'주임연구원': '#9a6422',
|
||||||
'연구원': '#64748b',
|
'연구원': '#66756d',
|
||||||
'미지정': '#9ca3af'
|
'미지정': '#b7aa93'
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPositionStyle = (pos) => positionStyles[pos] || positionStyles['미지정'];
|
const getPositionStyle = (pos) => positionStyles[pos] || positionStyles['미지정'];
|
||||||
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#94a3b8';
|
const getCostColor = (name) => costCategories.find(c => c.name === name)?.color || '#66756d';
|
||||||
const getPositionColor = (name) => positionColorMap[name] || positionColorMap['미지정'];
|
const getPositionColor = (name) => positionColorMap[name] || positionColorMap['미지정'];
|
||||||
const twoLineClampStyle = {
|
const twoLineClampStyle = {
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
@@ -164,7 +164,7 @@ const App = () => {
|
|||||||
|
|
||||||
const buildDonutGradient = (items) => {
|
const buildDonutGradient = (items) => {
|
||||||
const total = items.reduce((sum, item) => sum + (item.value || 0), 0);
|
const total = items.reduce((sum, item) => sum + (item.value || 0), 0);
|
||||||
if (total <= 0) return 'conic-gradient(#e2e8f0 0deg 360deg)';
|
if (total <= 0) return 'conic-gradient(#eadcc4 0deg 360deg)';
|
||||||
let start = 0;
|
let start = 0;
|
||||||
const slices = items.map((item) => {
|
const slices = items.map((item) => {
|
||||||
const deg = ((item.value || 0) / total) * 360;
|
const deg = ((item.value || 0) / total) * 360;
|
||||||
@@ -177,7 +177,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderBreakdownTooltip = (breakdown, total) => (
|
const renderBreakdownTooltip = (breakdown, total) => (
|
||||||
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] font-bold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
||||||
{costCategories.map((cat) => {
|
{costCategories.map((cat) => {
|
||||||
const val = breakdown?.[cat.name] || 0;
|
const val = breakdown?.[cat.name] || 0;
|
||||||
const ratio = total > 0 ? ((val / total) * 100).toFixed(1) : '0.0';
|
const ratio = total > 0 ? ((val / total) * 100).toFixed(1) : '0.0';
|
||||||
@@ -195,7 +195,7 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderPositionBreakdownTooltip = (breakdown, totalHrs) => (
|
const renderPositionBreakdownTooltip = (breakdown, totalHrs) => (
|
||||||
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] font-bold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
<div className="pointer-events-none absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-[110%] whitespace-nowrap rounded-lg payment-tooltip px-3 py-2 text-[12px] font-bold opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
||||||
{Object.entries(breakdown || {})
|
{Object.entries(breakdown || {})
|
||||||
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
|
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
|
||||||
.map(([pos, val]) => {
|
.map(([pos, val]) => {
|
||||||
@@ -226,9 +226,9 @@ const App = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-2 grid grid-cols-[72px_1fr] items-center gap-2">
|
<div className="mt-2 grid grid-cols-[72px_1fr] items-center gap-2">
|
||||||
<div className="self-center text-center">
|
<div className="self-center text-center">
|
||||||
<div className="text-[16px] leading-none font-black text-slate-800">{Number(totalWorkers || 0)}명</div>
|
<div className="text-[16px] leading-none font-black payment-strong">{Number(totalWorkers || 0)}명</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black text-slate-600 leading-tight">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black payment-muted leading-tight">
|
||||||
{entries.map(([pos, val]) => {
|
{entries.map(([pos, val]) => {
|
||||||
const count = details?.[pos]?.names?.size || 0;
|
const count = details?.[pos]?.names?.size || 0;
|
||||||
const hrsText = Number(val || 0).toFixed(1).replace(/\.0$/, '');
|
const hrsText = Number(val || 0).toFixed(1).replace(/\.0$/, '');
|
||||||
@@ -258,7 +258,7 @@ const App = () => {
|
|||||||
{cells.map((cell) => {
|
{cells.map((cell) => {
|
||||||
const amount = Math.round(breakdown?.[cell.key] || 0);
|
const amount = Math.round(breakdown?.[cell.key] || 0);
|
||||||
return (
|
return (
|
||||||
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black text-slate-700 whitespace-nowrap">
|
<div key={`${cell.key}-v`} className="px-2 py-1.5 text-right text-[11px] font-black payment-muted whitespace-nowrap">
|
||||||
{amount === 0 ? '-' : `${amount.toLocaleString()}원`}
|
{amount === 0 ? '-' : `${amount.toLocaleString()}원`}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1134,23 +1134,23 @@ const App = () => {
|
|||||||
const isAllFiltersApplied = selectedRev !== '전체' && selectedD1 !== '전체' && selectedD2 !== '전체' && selectedProject !== '전체';
|
const isAllFiltersApplied = selectedRev !== '전체' && selectedD1 !== '전체' && selectedD2 !== '전체' && selectedProject !== '전체';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f8fafc] p-6 font-sans text-slate-900">
|
<div className="payment-theme min-h-screen p-6 font-sans">
|
||||||
<div className="w-full mx-auto space-y-6">
|
<div className="w-full mx-auto space-y-6" style={{ maxWidth: '2000px' }}>
|
||||||
|
|
||||||
{!isAllFiltersApplied && (
|
{!isAllFiltersApplied && (
|
||||||
<>
|
<>
|
||||||
{/* KPIs */}
|
{/* KPIs */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 bg-[#f8fafc] pb-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 sticky top-0 z-30 payment-kpi-grid pb-3">
|
||||||
{[
|
{[
|
||||||
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'text-indigo-600' },
|
{ label: '총 수입(매출)', value: formatWonRounded(viewData.kpis.income), totalValue: formatWonRounded(viewData.kpisAll.income), icon: Wallet, color: 'payment-kpi-income' },
|
||||||
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'text-slate-600' },
|
{ label: '인건비 합계', value: formatWonRounded(viewData.kpis.labor), totalValue: formatWonRounded(viewData.kpisAll.labor), icon: Briefcase, color: 'payment-kpi-labor' },
|
||||||
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'text-rose-600' },
|
{ label: '출장비', value: formatWonRounded(viewData.kpis.travel), totalValue: formatWonRounded(viewData.kpisAll.travel), icon: MapPin, color: 'payment-kpi-travel' },
|
||||||
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'text-amber-600' },
|
{ label: '복리후생비', value: formatWonRounded(viewData.kpis.welfare), totalValue: formatWonRounded(viewData.kpisAll.welfare), icon: Coffee, color: 'payment-kpi-welfare' },
|
||||||
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'text-slate-500' },
|
{ label: '구매/외주비', value: formatWonRounded(viewData.kpis.others), totalValue: formatWonRounded(viewData.kpisAll.others), icon: Package, color: 'payment-kpi-others' },
|
||||||
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'text-indigo-600' },
|
{ label: '투입시간', value: `${viewData.kpis.hours.toLocaleString()}h`, totalValue: `${viewData.kpisAll.hours.toLocaleString()}h`, icon: Clock, color: 'payment-kpi-hours' },
|
||||||
{ label: '참여인원', value: `${viewData.kpis.workers}명`, totalValue: `${viewData.kpisAll.workers}명`, icon: Users, color: 'text-white', bg: 'bg-slate-900' },
|
{ label: '참여인원', value: `${viewData.kpis.workers}명`, totalValue: `${viewData.kpisAll.workers}명`, icon: Users, color: 'payment-kpi-inverse', bg: 'payment-kpi-people' },
|
||||||
].map((kpi, i) => (
|
].map((kpi, i) => (
|
||||||
<div key={i} className={`${kpi.bg || 'bg-white'} ${kpi.color} p-4 rounded-[22px] border border-slate-100 shadow-sm flex flex-col h-24`}>
|
<div key={i} className={`payment-kpi-card ${kpi.bg || ''} ${kpi.color} p-4 rounded-[22px] flex flex-col h-24`}>
|
||||||
<span className="text-[11px] font-black uppercase opacity-60 flex justify-between">{kpi.label} <kpi.icon size={10}/></span>
|
<span className="text-[11px] font-black uppercase opacity-60 flex justify-between">{kpi.label} <kpi.icon size={10}/></span>
|
||||||
<div className="flex flex-col leading-tight mt-1 gap-1">
|
<div className="flex flex-col leading-tight mt-1 gap-1">
|
||||||
<span className="text-lg font-black truncate">{kpi.value}</span>
|
<span className="text-lg font-black truncate">{kpi.value}</span>
|
||||||
@@ -1163,16 +1163,16 @@ const App = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 상세 분석 테이블 */}
|
{/* 상세 분석 테이블 */}
|
||||||
<section className="bg-white rounded-[35px] shadow-sm border border-slate-100 overflow-visible">
|
<section className="payment-panel payment-table-panel rounded-[35px] overflow-visible">
|
||||||
<div className={`px-6 py-4 border-b border-slate-50 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 bg-white/95 backdrop-blur-sm`}>
|
<div className={`payment-panel-head px-6 py-4 flex items-center justify-between gap-4 sticky ${!isAllFiltersApplied ? 'top-[108px]' : 'top-0'} z-40 backdrop-blur-sm`}>
|
||||||
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="text-indigo-600" /> 분야별 프로젝트 상세 분석</h2>
|
<h2 className="text-lg font-black flex items-center gap-3"><List size={20} className="payment-icon-accent" /> 분야별 프로젝트 상세 분석</h2>
|
||||||
<div className="group relative shrink-0">
|
<div className="group relative shrink-0">
|
||||||
<button type="button" className="px-3 py-2 bg-slate-900 text-white rounded-xl text-[12px] font-black tracking-wide shadow-sm border border-slate-800">
|
<button type="button" className="payment-filter-toggle px-3 py-2 rounded-xl text-[12px] font-black tracking-wide shadow-sm border">
|
||||||
카테고리 필터
|
카테고리 필터
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
|
<div className="payment-filter-pop absolute right-0 top-full mt-2 z-30 w-[min(980px,92vw)] rounded-2xl p-3 shadow-2xl opacity-0 pointer-events-none translate-y-1 transition-all duration-200 group-hover:opacity-100 group-hover:pointer-events-auto group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-y-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex gap-2 bg-slate-50/80 p-1.5 rounded-2xl border border-slate-100 flex-1 min-w-[420px]">
|
<div className="payment-filter-bar flex gap-2 p-1.5 rounded-2xl flex-1 min-w-[420px]">
|
||||||
<select value={selectedRev} onChange={e => {setSelectedRev(e.target.value); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체');}} className="filter-select flex-1">
|
<select value={selectedRev} onChange={e => {setSelectedRev(e.target.value); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체');}} className="filter-select flex-1">
|
||||||
<option value="전체">대분류 전체</option>
|
<option value="전체">대분류 전체</option>
|
||||||
{Object.keys(viewData.hierarchy)
|
{Object.keys(viewData.hierarchy)
|
||||||
@@ -1209,7 +1209,7 @@ const App = () => {
|
|||||||
className="filter-select flex-[1.1]"
|
className="filter-select flex-[1.1]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="p-1.5 bg-white rounded-xl border border-slate-200 text-slate-400 hover:text-indigo-600 transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
|
<button onClick={() => {setSelectedRev('전체'); setSelectedD1('전체'); setSelectedD2('전체'); setSelectedProject('전체'); setProjectSearch('');}} className="payment-reset-btn p-1.5 rounded-xl transition-all shadow-sm shrink-0"><RefreshCw size={14}/></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1226,17 +1226,17 @@ const App = () => {
|
|||||||
<col style={{ width: '23%' }} />
|
<col style={{ width: '23%' }} />
|
||||||
<col style={{ width: '26%' }} />
|
<col style={{ width: '26%' }} />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="bg-slate-50/80">
|
<thead className="payment-table-head">
|
||||||
<tr className="text-[11px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
|
<tr className="text-[12px] font-extrabold uppercase tracking-widest payment-table-head-row">
|
||||||
<th className="px-4 py-3 whitespace-nowrap">대분류</th>
|
<th className="px-4 py-3 whitespace-nowrap">대분류</th>
|
||||||
<th className="px-4 py-3 whitespace-nowrap">중분류</th>
|
<th className="px-4 py-3 whitespace-nowrap">중분류</th>
|
||||||
<th className="px-4 py-3 whitespace-nowrap">소분류</th>
|
<th className="px-4 py-3 whitespace-nowrap">소분류</th>
|
||||||
<th className="px-4 py-3 whitespace-nowrap">{viewData.isAllFiltersOff ? '' : '프로젝트명'}</th>
|
<th className="px-4 py-3 whitespace-nowrap">프로젝트명</th>
|
||||||
<th className="px-4 py-3 text-right whitespace-nowrap">수입(매출)</th>
|
<th className="px-4 py-3 text-right whitespace-nowrap">수입(매출)</th>
|
||||||
<th className="px-4 py-3 text-right whitespace-nowrap">지출 합계</th>
|
<th className="px-4 py-3 text-right whitespace-nowrap">지출 합계</th>
|
||||||
<th className="px-4 py-3 whitespace-nowrap text-center">
|
<th className="px-4 py-3 whitespace-nowrap text-center">
|
||||||
<div className="text-[11px] font-black text-slate-500 mb-1 text-center">지출 구성비</div>
|
<div className="text-[11px] font-black payment-subhead mb-1 text-center">지출 구성비</div>
|
||||||
<div className="grid grid-cols-5 text-[10px] font-black text-slate-600 normal-case tracking-normal">
|
<div className="grid grid-cols-5 text-[10px] font-black payment-subhead normal-case tracking-normal">
|
||||||
<span className="py-1 text-center">인건비</span>
|
<span className="py-1 text-center">인건비</span>
|
||||||
<span className="py-1 text-center">출장비</span>
|
<span className="py-1 text-center">출장비</span>
|
||||||
<span className="py-1 text-center">복리후생비</span>
|
<span className="py-1 text-center">복리후생비</span>
|
||||||
@@ -1250,7 +1250,7 @@ const App = () => {
|
|||||||
<tbody className="text-[13px] font-bold">
|
<tbody className="text-[13px] font-bold">
|
||||||
{viewData.finalDisplayList.length === 0 && (
|
{viewData.finalDisplayList.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-12 text-center text-slate-400 font-bold">표시할 데이터가 없습니다.</td>
|
<td colSpan={8} className="px-4 py-12 text-center payment-empty font-bold">표시할 데이터가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{viewData.finalDisplayList.map((item, idx) => {
|
{viewData.finalDisplayList.map((item, idx) => {
|
||||||
@@ -1259,16 +1259,16 @@ const App = () => {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`subtotal-${idx}`}
|
key={`subtotal-${idx}`}
|
||||||
className={`h-12 border-y ${isGrandTotal ? 'bg-indigo-100 border-indigo-300 shadow-[inset_0_1px_0_rgba(99,102,241,0.35)]' : 'bg-amber-50 border-amber-200'}`}
|
className={`h-12 border-y ${isGrandTotal ? 'payment-subtotal payment-subtotal-grand shadow-[inset_0_1px_0_rgba(33,70,52,0.18)]' : 'payment-subtotal payment-subtotal-mid'}`}
|
||||||
>
|
>
|
||||||
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'text-indigo-900 text-[14px] font-extrabold' : 'text-amber-900 font-black'}`}>
|
<td colSpan={item.labelColSpan || 4} className={`px-4 py-3 whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-label-grand text-[14px] font-extrabold' : 'payment-subtotal-label-mid font-black'}`}>
|
||||||
{item.subtotalLabel}
|
{item.subtotalLabel}
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'text-indigo-800 text-[14px]' : 'text-amber-800'}`}>{formatWonDash(item.income)}</td>
|
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-income-grand text-[14px]' : 'payment-subtotal-income-mid'}`}>{formatWonDash(item.income)}</td>
|
||||||
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'text-indigo-900 text-[14px]' : 'text-amber-900'}`}>{formatWonRoundedDash(item.total)}</td>
|
<td className={`px-4 py-3 text-right font-black whitespace-nowrap ${isGrandTotal ? 'payment-subtotal-total-grand text-[14px]' : 'payment-subtotal-total-mid'}`}>{formatWonRoundedDash(item.total)}</td>
|
||||||
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
|
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'bg-indigo-200/80' : 'bg-amber-100'}`}>
|
<div className={`h-2.5 rounded-full overflow-hidden flex shadow-inner ${isGrandTotal ? 'payment-progress-track-grand' : 'payment-progress-track-mid'}`}>
|
||||||
{Object.entries(item.positionBreakdown || {})
|
{Object.entries(item.positionBreakdown || {})
|
||||||
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
|
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
|
||||||
.map(([pos, val]) => {
|
.map(([pos, val]) => {
|
||||||
@@ -1284,12 +1284,12 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={`row-${idx}`} className="h-12 hover:bg-indigo-50/30 transition-all border-b border-slate-50 group">
|
<tr key={`row-${idx}`} className="payment-data-row h-12 transition-all border-b group">
|
||||||
{item.d1Span > 0 && (
|
{item.d1Span > 0 && (
|
||||||
<td
|
<td
|
||||||
rowSpan={item.d1Span}
|
rowSpan={item.d1Span}
|
||||||
onClick={() => handleD1Click(item.d1)}
|
onClick={() => handleD1Click(item.d1)}
|
||||||
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
|
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
|
||||||
>
|
>
|
||||||
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d1}</span>
|
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d1}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -1298,7 +1298,7 @@ const App = () => {
|
|||||||
<td
|
<td
|
||||||
rowSpan={item.d2Span}
|
rowSpan={item.d2Span}
|
||||||
onClick={() => handleD2Click(item.d1, item.d2)}
|
onClick={() => handleD2Click(item.d1, item.d2)}
|
||||||
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
|
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
|
||||||
>
|
>
|
||||||
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d2}</span>
|
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d2}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -1307,22 +1307,22 @@ const App = () => {
|
|||||||
<td
|
<td
|
||||||
rowSpan={item.d3Span}
|
rowSpan={item.d3Span}
|
||||||
onClick={() => handleD3Click(item.d1, item.d2, item.d3)}
|
onClick={() => handleD3Click(item.d1, item.d2, item.d3)}
|
||||||
className={`px-3 py-3 border-r border-slate-100 align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'bg-slate-200 text-slate-900' : 'bg-white text-slate-900 hover:bg-slate-50 hover:text-slate-900'}`}
|
className={`px-3 py-3 payment-axis-cell align-middle font-black cursor-pointer transition-colors whitespace-normal ${selectedRev === item.d1 && selectedD1 === item.d2 && selectedD2 === item.d3 ? 'payment-axis-cell-active' : 'payment-axis-cell-idle'}`}
|
||||||
>
|
>
|
||||||
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d3}</span>
|
<span className="block whitespace-normal break-words leading-tight" style={twoLineClampStyle}>{item.d3}</span>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td
|
<td
|
||||||
onClick={() => { if (!viewData.isAllFiltersOff) handleD4Click(item.d1, item.d2, item.d3, item.name); }}
|
onClick={() => { handleD4Click(item.d1, item.d2, item.d3, item.name); }}
|
||||||
className={`px-4 py-3 text-slate-700 transition-colors ${viewData.isAllFiltersOff ? '' : 'truncate cursor-pointer hover:bg-indigo-50 hover:text-indigo-800'}`}
|
className="px-4 py-3 payment-project-cell font-extrabold truncate cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
{viewData.isAllFiltersOff ? '\u00A0' : item.name}
|
{item.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-emerald-700 font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
|
<td className="px-4 py-3 text-right payment-income font-extrabold whitespace-nowrap">{formatWonDash(item.income)}</td>
|
||||||
<td className="px-4 py-3 text-right text-rose-700 font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
|
<td className="px-4 py-3 text-right payment-expense font-extrabold whitespace-nowrap">{formatWonRoundedDash(item.total)}</td>
|
||||||
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
|
<td className="px-4 py-3">{renderCostBreakdownTable(item.costBreakdown)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="h-2.5 bg-slate-100 rounded-full overflow-hidden flex shadow-inner">
|
<div className="h-2.5 payment-progress-track rounded-full overflow-hidden flex shadow-inner">
|
||||||
{Object.entries(item.positionBreakdown || {})
|
{Object.entries(item.positionBreakdown || {})
|
||||||
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
|
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
|
||||||
.map(([pos, val]) => {
|
.map(([pos, val]) => {
|
||||||
@@ -1343,8 +1343,8 @@ const App = () => {
|
|||||||
|
|
||||||
{/* 하단 상세 차트 */}
|
{/* 하단 상세 차트 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pb-12">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pb-12">
|
||||||
<div className="lg:col-span-5 bg-white p-8 rounded-[40px] shadow-sm border border-slate-100 min-h-[480px] flex flex-col">
|
<div className="lg:col-span-5 payment-panel p-8 rounded-[40px] min-h-[480px] flex flex-col">
|
||||||
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="text-indigo-600"/> 지출 구성 상세</h3>
|
<h3 className="text-lg font-black mb-4 flex items-center gap-3"><Target className="payment-icon-accent"/> 지출 구성 상세</h3>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{viewData.categoryData.length > 0 ? (
|
{viewData.categoryData.length > 0 ? (
|
||||||
<div className="h-full flex flex-col gap-5">
|
<div className="h-full flex flex-col gap-5">
|
||||||
@@ -1354,9 +1354,9 @@ const App = () => {
|
|||||||
className="relative h-56 w-56 rounded-full"
|
className="relative h-56 w-56 rounded-full"
|
||||||
style={{ background: buildDonutGradient(viewData.categoryData) }}
|
style={{ background: buildDonutGradient(viewData.categoryData) }}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-11 rounded-full bg-white border border-slate-100 flex flex-col items-center justify-center">
|
<div className="absolute inset-11 payment-donut-center rounded-full flex flex-col items-center justify-center">
|
||||||
<span className="text-[12px] font-black text-slate-500">총 지출</span>
|
<span className="text-[12px] font-black payment-subhead">총 지출</span>
|
||||||
<span className="text-[15px] font-black text-slate-900">
|
<span className="text-[15px] font-black payment-strong">
|
||||||
{formatWon(viewData.categoryData.reduce((sum, item) => sum + (item.value || 0), 0))}
|
{formatWon(viewData.categoryData.reduce((sum, item) => sum + (item.value || 0), 0))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1374,13 +1374,13 @@ const App = () => {
|
|||||||
if (!isSelectable) return;
|
if (!isSelectable) return;
|
||||||
setSelectedExpenseDetailCategory((prev) => (prev === item.name ? '' : item.name));
|
setSelectedExpenseDetailCategory((prev) => (prev === item.name ? '' : item.name));
|
||||||
}}
|
}}
|
||||||
className={`flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'hover:bg-slate-50 cursor-pointer' : 'cursor-default'} ${isSelected ? 'bg-indigo-50' : ''}`}
|
className={`payment-cost-row flex items-center justify-between gap-2 text-[13px] font-bold text-left rounded-lg px-2 py-1.5 transition-colors ${isSelectable ? 'cursor-pointer' : 'cursor-default'} ${isSelected ? 'payment-cost-row-active' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 text-slate-600 truncate">
|
<span className="flex items-center gap-2 payment-muted truncate">
|
||||||
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getCostColor(item.name) }}></span>
|
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getCostColor(item.name) }}></span>
|
||||||
{item.name} ({item.ratio}%)
|
{item.name} ({item.ratio}%)
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-900">{formatWon(item.value)}</span>
|
<span className="payment-strong">{formatWon(item.value)}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1388,20 +1388,20 @@ const App = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewData.isAllFiltersOff && (
|
{viewData.isAllFiltersOff && (
|
||||||
<div className="w-full mt-4 text-[12px] text-slate-400 font-bold text-center">
|
<div className="w-full mt-4 text-[12px] payment-empty font-bold text-center">
|
||||||
상세 내역은 필터 적용 시 표시됩니다.
|
상세 내역은 필터 적용 시 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!viewData.isAllFiltersOff && selectedExpenseDetailCategory && selectedExpenseDetailCategory !== '인건비' && (
|
{!viewData.isAllFiltersOff && selectedExpenseDetailCategory && selectedExpenseDetailCategory !== '인건비' && (
|
||||||
<div className="w-full mt-5 pt-4 border-t border-slate-100">
|
<div className="w-full mt-5 pt-4 payment-divider-top">
|
||||||
<div className="text-[12px] font-black text-slate-600 mb-2">
|
<div className="text-[12px] font-black payment-subhead mb-2">
|
||||||
{selectedExpenseDetailCategory} 지출 구성 상세 내역
|
{selectedExpenseDetailCategory} 지출 구성 상세 내역
|
||||||
</div>
|
</div>
|
||||||
{(viewData.expenseDetailByCategory?.[selectedExpenseDetailCategory] || []).length > 0 ? (
|
{(viewData.expenseDetailByCategory?.[selectedExpenseDetailCategory] || []).length > 0 ? (
|
||||||
<div className="max-h-56 overflow-y-auto rounded-lg border border-slate-100 custom-scrollbar">
|
<div className="max-h-56 overflow-y-auto rounded-lg payment-mini-table-shell custom-scrollbar">
|
||||||
<table className="w-full text-[12px] table-fixed border-collapse">
|
<table className="w-full text-[12px] table-fixed border-collapse">
|
||||||
<thead className="bg-slate-50 text-slate-500 font-black">
|
<thead className="payment-mini-table-head font-black">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-2 py-2 text-left w-[74px]">발행월</th>
|
<th className="px-2 py-2 text-left w-[74px]">발행월</th>
|
||||||
<th className="px-2 py-2 text-left w-[88px]">발행일</th>
|
<th className="px-2 py-2 text-left w-[88px]">발행일</th>
|
||||||
@@ -1412,7 +1412,7 @@ const App = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(viewData.expenseDetailByCategory[selectedExpenseDetailCategory] || []).map((row, idx) => (
|
{(viewData.expenseDetailByCategory[selectedExpenseDetailCategory] || []).map((row, idx) => (
|
||||||
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="border-t border-slate-50 text-slate-700">
|
<tr key={`${selectedExpenseDetailCategory}-${idx}`} className="payment-mini-table-row">
|
||||||
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueMonth || '-'}</td>
|
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueMonth || '-'}</td>
|
||||||
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueDate || '-'}</td>
|
<td className="px-2 py-1.5 whitespace-nowrap">{row.issueDate || '-'}</td>
|
||||||
<td className="px-2 py-1.5 truncate">{row.summary || '-'}</td>
|
<td className="px-2 py-1.5 truncate">{row.summary || '-'}</td>
|
||||||
@@ -1424,21 +1424,21 @@ const App = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[12px] text-slate-400 font-bold">표시할 전표 데이터가 없습니다.</div>
|
<div className="text-[12px] payment-empty font-bold">표시할 전표 데이터가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-slate-300 text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
|
<div className="h-full flex items-center justify-center payment-empty text-sm font-bold">표시할 지출 데이터가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-7 bg-white p-8 rounded-[40px] shadow-sm border border-slate-100 flex flex-col h-[560px] overflow-hidden">
|
<div className="lg:col-span-7 payment-panel p-8 rounded-[40px] flex flex-col h-[560px] overflow-hidden">
|
||||||
<h3 className="text-lg font-black mb-4 flex items-center gap-3 shrink-0">
|
<h3 className="text-lg font-black mb-4 flex items-center gap-3 shrink-0">
|
||||||
<UserCheck className="text-indigo-600"/> 직급별 인원 투입 상세
|
<UserCheck className="payment-icon-accent"/> 직급별 인원 투입 상세
|
||||||
<span className="ml-1 text-[11px] font-black text-indigo-600 bg-indigo-50 border border-indigo-100 px-2 py-1 rounded-lg">
|
<span className="payment-mode-chip ml-1 text-[11px] font-black px-2 py-1 rounded-lg">
|
||||||
기준: {viewData.positionGroupMode}
|
기준: {viewData.positionGroupMode}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -1453,33 +1453,33 @@ const App = () => {
|
|||||||
})
|
})
|
||||||
.map(([pName, positions]) => (
|
.map(([pName, positions]) => (
|
||||||
<div key={pName} className="mb-8 last:mb-0">
|
<div key={pName} className="mb-8 last:mb-0">
|
||||||
<div className="bg-slate-900 px-4 py-1.5 rounded-xl text-[12px] font-black text-white mb-4 sticky top-0 z-10">{pName}</div>
|
<div className="payment-group-title px-4 py-1.5 rounded-xl text-[12px] font-black mb-4 sticky top-0 z-10">{pName}</div>
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
{Object.entries(positions)
|
{Object.entries(positions)
|
||||||
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
|
.sort(([a], [b]) => (positionOrder[a] || 99) - (positionOrder[b] || 99) || a.localeCompare(b))
|
||||||
.map(([pos, data]) => {
|
.map(([pos, data]) => {
|
||||||
const style = getPositionStyle(pos);
|
const style = getPositionStyle(pos);
|
||||||
return (
|
return (
|
||||||
<div key={pos} className={`bg-white border ${style.border} rounded-[28px] p-5 flex items-center gap-6 hover:shadow-md transition-all`}>
|
<div key={pos} className={`payment-position-card border ${style.border} rounded-[28px] p-5 flex items-center gap-6 transition-all`}>
|
||||||
<div className={`flex items-center gap-3 w-1/4 shrink-0 px-4 py-2 rounded-2xl ${style.bg} border ${style.border}`}>
|
<div className={`flex items-center gap-3 w-1/4 shrink-0 px-4 py-2 rounded-2xl ${style.bg} border ${style.border}`}>
|
||||||
<div className={`w-3 h-3 rounded-full ${style.icon} shadow-sm`}></div>
|
<div className={`w-3 h-3 rounded-full ${style.icon} shadow-sm`}></div>
|
||||||
<div className={`text-[14px] font-black ${style.text}`}>{pos}</div>
|
<div className={`text-[14px] font-black ${style.text}`}>{pos}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 grid grid-cols-2 gap-8 border-l border-slate-100 pl-8">
|
<div className="flex-1 grid grid-cols-2 gap-8 payment-divider-left pl-8">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-slate-400 font-black uppercase mb-1">Estimated Cost</div>
|
<div className="text-[11px] payment-empty font-black uppercase mb-1">Estimated Cost</div>
|
||||||
<div className="text-[16px] font-black text-indigo-600 font-mono">₩{Math.round(data.labor).toLocaleString()}</div>
|
<div className="text-[16px] font-black payment-icon-accent font-mono">₩{Math.round(data.labor).toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-slate-400 font-black uppercase mb-1">Hours & Count</div>
|
<div className="text-[11px] payment-empty font-black uppercase mb-1">Hours & Count</div>
|
||||||
<div className="text-[16px] font-black text-slate-900">{data.hrs.toFixed(2)}h <span className="text-slate-300 mx-1">|</span> {data.names.size}명</div>
|
<div className="text-[16px] font-black payment-strong">{data.hrs.toFixed(2)}h <span className="payment-divider-mark mx-1">|</span> {data.names.size}명</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/3 min-w-[260px] border-l border-slate-100 pl-4">
|
<div className="w-1/3 min-w-[260px] payment-divider-left pl-4">
|
||||||
<div className="overflow-x-auto overflow-y-hidden custom-scrollbar">
|
<div className="overflow-x-auto overflow-y-hidden custom-scrollbar">
|
||||||
<div className="grid grid-rows-2 grid-flow-col auto-cols-max gap-x-1.5 gap-y-1.5 min-w-max pb-1">
|
<div className="grid grid-rows-2 grid-flow-col auto-cols-max gap-x-1.5 gap-y-1.5 min-w-max pb-1">
|
||||||
{Array.from(data.names).map(name => (
|
{Array.from(data.names).map(name => (
|
||||||
<span key={name} className="px-2 py-0.5 bg-slate-50 text-slate-500 rounded-lg text-[11px] font-bold border border-slate-100 whitespace-nowrap">{name}</span>
|
<span key={name} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">{name}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1491,7 +1491,7 @@ const App = () => {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-slate-300 gap-3">
|
<div className="h-full flex flex-col items-center justify-center payment-empty gap-3">
|
||||||
<Info size={40} />
|
<Info size={40} />
|
||||||
<span className="text-sm font-bold">표시할 데이터가 없습니다.</span>
|
<span className="text-sm font-bold">표시할 데이터가 없습니다.</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1500,18 +1500,18 @@ const App = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="bg-white rounded-[35px] shadow-sm border border-slate-100 overflow-hidden">
|
<section className="payment-panel rounded-[35px] overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-slate-50 flex items-center justify-between gap-4">
|
<div className="payment-panel-head px-6 py-4 flex items-center justify-between gap-4">
|
||||||
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="text-indigo-600" /> 프로젝트별 Activity 분석</h3>
|
<h3 className="text-lg font-black flex items-center gap-3"><List size={18} className="payment-icon-accent" /> 프로젝트별 Activity 분석</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{viewData.projectActivityList.length > 0 ? (
|
{viewData.projectActivityList.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{viewData.projectActivityList.map((project) => (
|
{viewData.projectActivityList.map((project) => (
|
||||||
<div key={`activity-${project.projectName}`} className="border border-slate-200 rounded-2xl overflow-hidden">
|
<div key={`activity-${project.projectName}`} className="payment-activity-card border rounded-2xl overflow-hidden">
|
||||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between gap-3">
|
<div className="payment-activity-card-head px-4 py-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-[14px] font-black text-slate-900 truncate">{project.projectName}</div>
|
<div className="text-[14px] font-black payment-strong truncate">{project.projectName}</div>
|
||||||
<div className="text-[12px] font-black text-indigo-700 whitespace-nowrap">총 {formatHours(project.totalHours)}h · {project.workerCount}명</div>
|
<div className="text-[12px] font-black payment-icon-accent whitespace-nowrap">총 {formatHours(project.totalHours)}h · {project.workerCount}명</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse table-fixed">
|
<table className="w-full text-left border-collapse table-fixed">
|
||||||
@@ -1521,8 +1521,8 @@ const App = () => {
|
|||||||
<col style={{ width: '90px' }} />
|
<col style={{ width: '90px' }} />
|
||||||
<col style={{ width: 'auto' }} />
|
<col style={{ width: 'auto' }} />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="bg-slate-50/70 border-b border-slate-100">
|
<thead className="payment-mini-table-head border-b">
|
||||||
<tr className="text-[11px] font-black text-slate-500 uppercase tracking-wide">
|
<tr className="text-[11px] font-black payment-subhead uppercase tracking-wide">
|
||||||
<th className="px-3 py-2 whitespace-nowrap">Activity</th>
|
<th className="px-3 py-2 whitespace-nowrap">Activity</th>
|
||||||
<th className="px-3 py-2 text-right whitespace-nowrap">투입시간</th>
|
<th className="px-3 py-2 text-right whitespace-nowrap">투입시간</th>
|
||||||
<th className="px-3 py-2 text-right whitespace-nowrap">투입인원</th>
|
<th className="px-3 py-2 text-right whitespace-nowrap">투입인원</th>
|
||||||
@@ -1531,14 +1531,14 @@ const App = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{project.activities.map((activity) => (
|
{project.activities.map((activity) => (
|
||||||
<tr key={`${project.projectName}-${activity.activityName}`} className="border-b border-slate-50 last:border-b-0">
|
<tr key={`${project.projectName}-${activity.activityName}`} className="payment-mini-table-row last:border-b-0">
|
||||||
<td className="px-3 py-2 text-[12px] font-black text-slate-800 whitespace-nowrap truncate">{activity.activityName}</td>
|
<td className="px-3 py-2 text-[12px] font-black payment-strong whitespace-nowrap truncate">{activity.activityName}</td>
|
||||||
<td className="px-3 py-2 text-[12px] font-black text-right text-indigo-700 whitespace-nowrap">{formatHours(activity.hours)}h</td>
|
<td className="px-3 py-2 text-[12px] font-black text-right payment-icon-accent whitespace-nowrap">{formatHours(activity.hours)}h</td>
|
||||||
<td className="px-3 py-2 text-[12px] font-black text-right text-slate-700 whitespace-nowrap">{activity.workerCount}명</td>
|
<td className="px-3 py-2 text-[12px] font-black text-right payment-muted whitespace-nowrap">{activity.workerCount}명</td>
|
||||||
<td className="px-3 py-2 text-[12px] text-slate-600">
|
<td className="px-3 py-2 text-[12px] payment-muted">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{activity.members.map((m) => (
|
{activity.members.map((m) => (
|
||||||
<span key={`${activity.activityName}-${m.name}`} className="px-2 py-0.5 rounded-lg bg-slate-50 border border-slate-100 text-[11px] font-bold text-slate-600 whitespace-nowrap">
|
<span key={`${activity.activityName}-${m.name}`} className="payment-name-chip px-2 py-0.5 rounded-lg text-[11px] font-bold whitespace-nowrap">
|
||||||
{m.name} ({formatHours(m.hours)}h)
|
{m.name} ({formatHours(m.hours)}h)
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -1553,24 +1553,46 @@ const App = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-10 text-center text-slate-300 text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
|
<div className="py-10 text-center payment-empty text-sm font-bold">표시할 Activity 데이터가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
|
@import url('/design-tokens.css');
|
||||||
|
@import url('/design-patterns.css');
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap');
|
||||||
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: #f8fafc; }
|
body { font-family: 'Pretendard', sans-serif; letter-spacing: -0.025em; -webkit-font-smoothing: antialiased; background-color: var(--ds-bg); color: var(--ds-ink); }
|
||||||
|
.payment-theme { color: var(--ds-ink); }
|
||||||
|
.payment-kpi-income, .payment-kpi-hours { color: var(--ds-brand-soft); }
|
||||||
|
.payment-kpi-labor, .payment-kpi-others { color: var(--ds-text-soft); }
|
||||||
|
.payment-kpi-travel { color: var(--ds-status-danger); }
|
||||||
|
.payment-kpi-welfare { color: var(--ds-status-warning); }
|
||||||
|
.payment-filter-pop { border: 1px solid var(--ds-line); background: rgba(255,250,243,0.98); }
|
||||||
|
.payment-subtotal { border-color: var(--ds-line); }
|
||||||
|
.payment-subtotal-grand { background: #efe2ca; }
|
||||||
|
.payment-subtotal-mid { background: #f6e6c9; }
|
||||||
|
.payment-subtotal-label-grand, .payment-subtotal-total-grand { color: var(--ds-brand-deep); }
|
||||||
|
.payment-subtotal-income-grand { color: var(--ds-brand-soft); }
|
||||||
|
.payment-subtotal-label-mid, .payment-subtotal-total-mid { color: #9a6422; }
|
||||||
|
.payment-subtotal-income-mid { color: #7b5a20; }
|
||||||
|
.payment-donut-center { background: rgba(255,250,243,0.98); border: 1px solid var(--ds-line-soft); }
|
||||||
|
.payment-cost-row:hover { background: rgba(234,220,196,0.34); }
|
||||||
|
.payment-cost-row-active { background: rgba(242,196,132,0.18); }
|
||||||
|
.payment-position-card { background: rgba(255,250,243,0.96); box-shadow: var(--ds-shadow-soft); }
|
||||||
|
.payment-activity-card { border-color: var(--ds-line-soft); }
|
||||||
|
.payment-activity-card-head { background: rgba(246,237,221,0.68); border-bottom: 1px solid var(--ds-line-soft); }
|
||||||
.filter-select {
|
.filter-select {
|
||||||
background-color: transparent; border: none; padding: 0.35rem 1.6rem 0.35rem 0.5rem; font-size: 10px; font-weight: 800;
|
background-color: transparent; border: none; padding: 0.35rem 1.6rem 0.35rem 0.5rem; font-size: 10px; font-weight: 800;
|
||||||
outline: none; appearance: none; cursor: pointer; transition: all 0.2s;
|
outline: none; appearance: none; cursor: pointer; transition: all 0.2s;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
color: var(--ds-ink);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2366756d'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat; background-position: right 0.4rem center; background-size: 0.6rem;
|
background-repeat: no-repeat; background-position: right 0.4rem center; background-size: 0.6rem;
|
||||||
}
|
}
|
||||||
.filter-select:hover { color: #6366f1; background-color: white; border-radius: 8px; }
|
.filter-select:hover { color: var(--ds-brand-soft); background-color: rgba(255,255,255,0.98); border-radius: 8px; }
|
||||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
|
.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: var(--ds-line); border-radius: 10px; }
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
25
incoming-files/reference/README.md
Normal file
25
incoming-files/reference/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Reference Assets
|
||||||
|
|
||||||
|
이 디렉터리는 `8081`에서 직접 서빙하지 않는 참고 원본/복구 비교 자산을 모으는 공간이다.
|
||||||
|
|
||||||
|
`#21` 2차부터 실제 reference 재배치를 시작했다.
|
||||||
|
|
||||||
|
현재 포함:
|
||||||
|
|
||||||
|
- `ledger/`
|
||||||
|
- 사업관리대장 원본 wrapper/html/css/xlsx
|
||||||
|
- 이전 override 복사본
|
||||||
|
- 중첩 백업 디렉터리
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- runtime은 이 디렉터리를 직접 서빙하지 않는다.
|
||||||
|
- 실제 서비스 수정은 `incoming-files/served/` 기준으로 먼저 반영한다.
|
||||||
|
- reference는 비교, 복구, 출처 확인이 필요할 때만 본다.
|
||||||
|
|
||||||
|
예상 대상:
|
||||||
|
|
||||||
|
- 원본 HTML/CSS 참고본
|
||||||
|
- 원본 xlsx/csv
|
||||||
|
- 복구 비교용 자산
|
||||||
|
- 디자인 레퍼런스 파일
|
||||||
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
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
2598
incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.html
Normal file
2598
incoming-files/reference/ledger/사업관리대장/MH 통합 대시보드_260320.html
Normal file
File diff suppressed because one or more lines are too long
BIN
incoming-files/reference/ledger/사업관리대장/사업관리대장-1.xlsx
Normal file
BIN
incoming-files/reference/ledger/사업관리대장/사업관리대장-1.xlsx
Normal file
Binary file not shown.
23
incoming-files/served/README.md
Normal file
23
incoming-files/served/README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Served Assets
|
||||||
|
|
||||||
|
이 디렉터리는 `8081`에서 실제 URL 응답으로 직접 서빙되는 integration HTML 파일만 둔다.
|
||||||
|
|
||||||
|
현재 사용 중:
|
||||||
|
|
||||||
|
- `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`로 반영한다.
|
||||||
|
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.
|
||||||
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.
3472
incoming-files/served/mh.html
Normal file
3472
incoming-files/served/mh.html
Normal file
File diff suppressed because it is too large
Load Diff
1622
incoming-files/served/payment.html
Normal file
1622
incoming-files/served/payment.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,41 @@
|
|||||||
|
@import url("/design-tokens.css?v=20260401-01");
|
||||||
|
@import url("/design-patterns.css?v=20260401-01");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: "Pretendard", sans-serif;
|
--font-sans: var(--ds-font-sans);
|
||||||
|
|
||||||
--color-bg: #f1f5f9;
|
--color-bg: var(--ds-bg);
|
||||||
--color-bg-soft: #eef2ff;
|
--color-bg-soft: var(--ds-bg-soft);
|
||||||
--color-surface: #ffffff;
|
--color-surface: var(--ds-panel);
|
||||||
--color-surface-soft: rgba(255, 255, 255, 0.88);
|
--color-surface-soft: var(--ds-panel-soft);
|
||||||
--color-surface-strong: #e2e8f0;
|
--color-surface-strong: var(--ds-panel-strong);
|
||||||
--color-text: #1e293b;
|
--color-text: var(--ds-ink);
|
||||||
--color-text-soft: #475569;
|
--color-text-soft: var(--ds-text-soft);
|
||||||
--color-text-muted: #64748b;
|
--color-text-muted: var(--ds-text-muted);
|
||||||
--color-border: #cbd5e1;
|
--color-border: var(--ds-line);
|
||||||
--color-border-soft: rgba(148, 163, 184, 0.3);
|
--color-border-soft: var(--ds-line-soft);
|
||||||
--color-header: #1e293b;
|
--color-header: var(--ds-brand);
|
||||||
--color-header-soft: #334155;
|
--color-header-soft: var(--ds-brand-soft);
|
||||||
--color-accent: #4f46e5;
|
--color-accent: var(--ds-accent);
|
||||||
--color-accent-soft: #e0e7ff;
|
--color-accent-soft: var(--ds-accent-soft);
|
||||||
--color-accent-strong: #4338ca;
|
--color-accent-strong: var(--ds-accent-strong);
|
||||||
|
|
||||||
--radius-sm: 8px;
|
--radius-sm: var(--ds-radius-sm);
|
||||||
--radius-md: 12px;
|
--radius-md: var(--ds-radius-md);
|
||||||
--radius-lg: 18px;
|
--radius-lg: var(--ds-radius-lg);
|
||||||
--radius-xl: 24px;
|
--radius-xl: var(--ds-radius-xl);
|
||||||
--radius-pill: 999px;
|
--radius-pill: var(--ds-radius-pill);
|
||||||
|
|
||||||
--shadow-soft: 0 4px 14px rgba(15, 23, 42, 0.08);
|
--shadow-soft: var(--ds-shadow-soft);
|
||||||
--shadow-card: 0 18px 44px rgba(15, 23, 42, 0.12);
|
--shadow-card: var(--ds-shadow-card);
|
||||||
--shadow-float: 0 18px 36px rgba(79, 70, 229, 0.16);
|
--shadow-float: var(--ds-shadow-float);
|
||||||
|
|
||||||
--space-1: 4px;
|
--space-1: var(--ds-space-1);
|
||||||
--space-2: 8px;
|
--space-2: var(--ds-space-2);
|
||||||
--space-3: 12px;
|
--space-3: var(--ds-space-3);
|
||||||
--space-4: 16px;
|
--space-4: var(--ds-space-4);
|
||||||
--space-5: 20px;
|
--space-5: var(--ds-space-5);
|
||||||
--space-6: 24px;
|
--space-6: var(--ds-space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -46,15 +49,13 @@ body {
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background:
|
background: var(--ds-bg-gradient);
|
||||||
radial-gradient(circle at top left, rgba(79, 70, 229, 0.12), transparent 22%),
|
|
||||||
radial-gradient(circle at bottom right, rgba(148, 163, 184, 0.18), transparent 28%),
|
|
||||||
var(--color-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--ds-bg-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -92,18 +93,18 @@ a {
|
|||||||
.ui-button-secondary {
|
.ui-button-secondary {
|
||||||
border: 1px solid var(--color-border-soft);
|
border: 1px solid var(--color-border-soft);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: var(--ds-surface-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-input {
|
.ui-input {
|
||||||
border: 1px solid var(--color-border-soft);
|
border: 1px solid var(--color-border-soft);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: rgba(255, 255, 255, 0.88);
|
background: var(--ds-surface-tint-strong);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-input:focus {
|
.ui-input:focus {
|
||||||
border-color: rgba(79, 70, 229, 0.45);
|
border-color: rgba(47, 153, 115, 0.45);
|
||||||
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.08);
|
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -860,18 +860,20 @@ function openUnitAddModal(event) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">상위 위치 선택</label>
|
<label class="member-form-label block">상위 위치 선택</label>
|
||||||
<select id="new-unit-parent" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"></select>
|
<select id="new-unit-parent" class="member-form-select"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">신규 명칭 입력</label>
|
<label class="member-form-label block">신규 명칭 입력</label>
|
||||||
<input id="new-unit-name" placeholder="예: 신규개발팀" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
|
<input id="new-unit-name" placeholder="예: 신규개발팀" class="member-form-input">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
updateParentList();
|
updateParentList();
|
||||||
document.getElementById('modal-footer-area').innerHTML = `
|
document.getElementById('modal-footer-area').innerHTML = `
|
||||||
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
<div class="modal-footer-actions">
|
||||||
<button onclick="saveNewUnit()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
|
||||||
|
<button onclick="saveNewUnit()" class="modal-btn modal-btn-save">저장</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
@@ -934,14 +936,16 @@ function openOrgEditModal(level, oldName) {
|
|||||||
fieldsArea.style.maxHeight = 'none';
|
fieldsArea.style.maxHeight = 'none';
|
||||||
fieldsArea.innerHTML = `
|
fieldsArea.innerHTML = `
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="text-[11px] font-black text-slate-400 block">새로운 ${level} 명칭</label>
|
<label class="member-form-label block">새로운 ${level} 명칭</label>
|
||||||
<input id="new-org-name" value="${oldName}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
|
<input id="new-org-name" value="${oldName}" class="member-form-input">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('modal-footer-area').innerHTML = `
|
document.getElementById('modal-footer-area').innerHTML = `
|
||||||
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>
|
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-delete">삭제</button>
|
||||||
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
<div class="modal-footer-actions">
|
||||||
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
|
||||||
|
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-save">저장</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
@@ -1102,12 +1106,8 @@ function switchModalTab(tab) {
|
|||||||
const isBasic = tab === 'basic';
|
const isBasic = tab === 'basic';
|
||||||
document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic);
|
document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic);
|
||||||
document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic);
|
document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic);
|
||||||
document.getElementById('modal-tab-basic').className = isBasic
|
document.getElementById('modal-tab-basic').className = isBasic ? 'member-modal-tab is-active' : 'member-modal-tab';
|
||||||
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
|
document.getElementById('modal-tab-org').className = !isBasic ? 'member-modal-tab is-active' : 'member-modal-tab';
|
||||||
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
|
|
||||||
document.getElementById('modal-tab-org').className = !isBasic
|
|
||||||
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
|
|
||||||
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal(id) {
|
function openModal(id) {
|
||||||
@@ -1124,14 +1124,14 @@ function openModal(id) {
|
|||||||
fieldsArea.style.maxHeight = 'none';
|
fieldsArea.style.maxHeight = 'none';
|
||||||
fieldsArea.innerHTML = `
|
fieldsArea.innerHTML = `
|
||||||
<div class="member-detail-top-row">
|
<div class="member-detail-top-row">
|
||||||
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg">
|
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 shadow-lg" style="border-color: var(--color-surface-strong);">
|
||||||
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
|
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
|
||||||
</div>
|
</div>
|
||||||
<div class="member-detail-summary">
|
<div class="member-detail-summary">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
|
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
|
||||||
<p class="text-indigo-600 font-bold">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
|
<p class="font-bold" style="color: var(--color-header);">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
|
||||||
<p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p>
|
<p class="text-xs mt-1 font-medium" style="color: var(--color-text-muted);">${(member._path || []).map((path) => path.name).join(' > ')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-inline-info-grid">
|
<div class="member-inline-info-grid">
|
||||||
<div class="member-inline-info-card">
|
<div class="member-inline-info-card">
|
||||||
@@ -1149,7 +1149,7 @@ function openModal(id) {
|
|||||||
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
footer.innerHTML = '<button onclick="closeModal()" class="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</button>';
|
footer.innerHTML = '<button onclick="closeModal()" class="modal-btn modal-btn-close">닫기</button>';
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
hydrateMemberSeatPreview(member);
|
hydrateMemberSeatPreview(member);
|
||||||
return;
|
return;
|
||||||
@@ -1168,14 +1168,14 @@ function openModal(id) {
|
|||||||
const currentValue = member[field] || '';
|
const currentValue = member[field] || '';
|
||||||
orgFields += `
|
orgFields += `
|
||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">${field}</label>
|
<label class="member-form-label block">${field}</label>
|
||||||
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="w-full bg-white p-3 rounded-xl border text-sm font-bold text-slate-700 outline-none">
|
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="member-form-select">
|
||||||
<option value="__NEW__" class="text-indigo-600 font-bold">+ 직접/신규 입력</option>
|
<option value="__NEW__" class="member-form-new-option">+ 직접/신규 입력</option>
|
||||||
<option value="__NONE__" ${currentValue === '' ? 'selected' : ''}>-- 선택 안 함 --</option>
|
<option value="__NONE__" ${currentValue === '' ? 'selected' : ''}>-- 선택 안 함 --</option>
|
||||||
${uniqueValues.map((value) => `<option value="${value}" ${value === currentValue ? 'selected' : ''}>${value}</option>`).join('')}
|
${uniqueValues.map((value) => `<option value="${value}" ${value === currentValue ? 'selected' : ''}>${value}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<div id="manual-${field}" class="hidden mt-2">
|
<div id="manual-${field}" class="hidden member-form-manual">
|
||||||
<input id="input-${field}" placeholder="직접 입력" class="w-full bg-indigo-50 p-3 rounded-xl border-indigo-200 border text-sm font-bold">
|
<input id="input-${field}" placeholder="직접 입력" class="member-form-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1184,30 +1184,30 @@ function openModal(id) {
|
|||||||
const isFlexible = member['근무시간'] === '유연근무제';
|
const isFlexible = member['근무시간'] === '유연근무제';
|
||||||
orgFields += `
|
orgFields += `
|
||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label>
|
<label class="member-form-label block">근무 상태</label>
|
||||||
<select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
|
<select id="m-status" class="member-form-select">
|
||||||
<option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option>
|
<option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option>
|
||||||
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
|
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
|
||||||
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
|
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">근무 시간</label>
|
<label class="member-form-label block">근무 시간</label>
|
||||||
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
|
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="member-form-select">
|
||||||
<option value="09~18" ${!isFlexible ? 'selected' : ''}>09~18</option>
|
<option value="09~18" ${!isFlexible ? 'selected' : ''}>09~18</option>
|
||||||
<option value="유연근무제" ${isFlexible ? 'selected' : ''}>유연근무제</option>
|
<option value="유연근무제" ${isFlexible ? 'selected' : ''}>유연근무제</option>
|
||||||
</select>
|
</select>
|
||||||
<div id="flexible-time-area" class="${isFlexible ? '' : 'hidden'} mt-2 flex items-center gap-2">
|
<div id="flexible-time-area" class="${isFlexible ? '' : 'hidden'} mt-2 flex items-center gap-2">
|
||||||
<input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
|
<input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="member-form-time">
|
||||||
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
|
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="member-form-time">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
fieldsArea.innerHTML = `
|
fieldsArea.innerHTML = `
|
||||||
<div class="flex border-b mb-6 sticky top-0 bg-white z-10">
|
<div class="member-modal-tabs">
|
||||||
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button>
|
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="member-modal-tab is-active">기본 정보</button>
|
||||||
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button>
|
<button id="modal-tab-org" onclick="switchModalTab('org')" class="member-modal-tab">조직 및 근무</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="modal-sec-basic" class="modal-form-grid member-basic-editor">
|
<div id="modal-sec-basic" class="modal-form-grid member-basic-editor">
|
||||||
<input type="hidden" id="m-id" value="${id || ''}">
|
<input type="hidden" id="m-id" value="${id || ''}">
|
||||||
@@ -1216,6 +1216,7 @@ function openModal(id) {
|
|||||||
<div class="member-basic-split">
|
<div class="member-basic-split">
|
||||||
<div class="member-basic-left">
|
<div class="member-basic-left">
|
||||||
<div class="member-photo-panel">
|
<div class="member-photo-panel">
|
||||||
|
<p class="member-modal-panel-title">기본 정보</p>
|
||||||
<div class="member-photo-upload-card member-photo-upload-card-inline">
|
<div class="member-photo-upload-card member-photo-upload-card-inline">
|
||||||
<div class="member-photo-card-title">프로필 사진</div>
|
<div class="member-photo-card-title">프로필 사진</div>
|
||||||
<div class="member-photo-preview-wrap">
|
<div class="member-photo-preview-wrap">
|
||||||
@@ -1230,42 +1231,46 @@ function openModal(id) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-basic-fields">
|
<div class="member-basic-fields member-modal-panel">
|
||||||
|
<p class="member-modal-panel-title">기본 정보</p>
|
||||||
<div class="member-basic-field">
|
<div class="member-basic-field">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
|
<label class="member-form-label block">이름 (필수)</label>
|
||||||
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
|
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="member-form-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="member-basic-field">
|
<div class="member-basic-field">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">사번</label>
|
<label class="member-form-label block">사번</label>
|
||||||
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
|
<input id="m-employee-id" value="${member['사번'] || ''}" class="member-form-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="member-basic-field">
|
<div class="member-basic-field">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">전화번호</label>
|
<label class="member-form-label block">전화번호</label>
|
||||||
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
|
<input id="m-phone" value="${member['전화번호'] || ''}" class="member-form-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="member-basic-field">
|
<div class="member-basic-field">
|
||||||
<label class="text-[11px] font-black text-slate-600 block">이메일</label>
|
<label class="member-form-label block">이메일</label>
|
||||||
<input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
|
<input id="m-email" value="${member['이메일'] || ''}" class="member-form-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-basic-right">
|
<div class="member-basic-right">
|
||||||
|
<p class="member-modal-panel-title" style="padding:16px 16px 0;">조직 및 근무</p>
|
||||||
<div class="member-seat-field member-seat-field-compact">
|
<div class="member-seat-field member-seat-field-compact">
|
||||||
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${orgFields}
|
<div class="member-modal-panel">${orgFields}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
resetPhotoPreviewObjectUrl();
|
resetPhotoPreviewObjectUrl();
|
||||||
|
|
||||||
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>` : '';
|
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="modal-btn modal-btn-delete">삭제</button>` : '';
|
||||||
footer.innerHTML = `
|
footer.innerHTML = `
|
||||||
${deleteBtn}
|
${deleteBtn}
|
||||||
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
<div class="modal-footer-actions">
|
||||||
<button onclick="saveMember()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
|
||||||
|
<button onclick="saveMember()" class="modal-btn modal-btn-save">저장</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -1420,8 +1425,8 @@ function openListViewModal(event) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-toolbar-row">
|
<div class="list-toolbar-row">
|
||||||
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
|
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
|
||||||
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button>
|
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="list-view-status" class="list-view-status"></div>
|
<div id="list-view-status" class="list-view-status"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1453,19 +1458,19 @@ function renderListViewFooter() {
|
|||||||
footer.innerHTML = `
|
footer.innerHTML = `
|
||||||
<div class="flex gap-2 w-full justify-between items-center">
|
<div class="flex gap-2 w-full justify-between items-center">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick="openAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 구성원 추가</button>
|
<button onclick="openAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 구성원 추가</button>
|
||||||
<button onclick="openUnitAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 조직 추가</button>
|
<button onclick="openUnitAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 조직 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<p class="text-[10px] text-slate-400 font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
|
<p class="text-[10px] text-[#8b8a77] font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
|
||||||
<button onclick="closeModal()" class="bg-slate-100 text-slate-600 px-6 py-2 rounded-lg text-xs font-bold">취소</button>
|
<button onclick="closeModal()" class="bg-[#efe4d0] text-[#5b665a] px-6 py-2 rounded-lg text-xs font-bold">취소</button>
|
||||||
<button onclick="applyListViewChanges()" class="bg-indigo-600 text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">반영하기</button>
|
<button onclick="applyListViewChanges()" class="bg-[#214634] text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">반영하기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button></div>';
|
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-[#214634] text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">닫기</button></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRenderableListMembers() {
|
function getRenderableListMembers() {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ copy_optional_path "incoming-files/1.png"
|
|||||||
copy_optional_path "incoming-files/260320.html"
|
copy_optional_path "incoming-files/260320.html"
|
||||||
copy_optional_path "incoming-files/sample style.css"
|
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/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 "[6/6] Dev worktree ready"
|
||||||
echo "Path: ${DEV_DIR}"
|
echo "Path: ${DEV_DIR}"
|
||||||
|
|||||||
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