Compare commits
44 Commits
c8e0a2bd7f
...
work-8081
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c52fa53e8 | ||
|
|
f5c68ada80 | ||
|
|
c0564ee326 | ||
|
|
f8ea345882 | ||
|
|
8125193378 | ||
|
|
a4480c3435 | ||
|
|
19c8c6ade1 | ||
|
|
c3afc0c772 | ||
|
|
03e90d18a3 | ||
|
|
57d9f630bc | ||
|
|
1e82572e15 | ||
|
|
e58e584a15 | ||
|
|
fb5b0f00c2 | ||
|
|
637b390024 | ||
|
|
4b4ffafbd2 | ||
|
|
1cd0f21a36 | ||
|
|
f77be3f482 | ||
|
|
2e8c79bb43 | ||
|
|
8121c9cf41 | ||
|
|
e67fd41cbf | ||
|
|
c9a93ea936 | ||
|
|
8d0cc78abc | ||
|
|
bbebe24763 | ||
|
|
2053791589 | ||
|
|
fc23156b2c | ||
|
|
33f157cb08 | ||
|
|
b735a4cdd1 | ||
|
|
6e55b99e9a | ||
|
|
cbae8769bf | ||
|
|
bc60f932c3 | ||
|
|
ca57a4a1e4 | ||
|
|
e50b24c25b | ||
|
|
24852d4401 | ||
|
|
d66614123e | ||
|
|
1d15cf9b9b | ||
|
|
61b5638cb1 | ||
|
|
baf6019c1c | ||
|
|
69a14fab51 | ||
|
|
8efb5da65f | ||
|
|
6f5e61ca1a | ||
|
|
e62a6a5458 | ||
|
|
8f073e1458 | ||
|
|
8ac6aa6b72 | ||
|
|
485a581089 |
@@ -3,6 +3,4 @@ POSTGRES_USER=orgapp
|
||||
POSTGRES_PASSWORD=change-me
|
||||
DATABASE_URL=postgresql://orgapp:change-me@db:5432/orgdb
|
||||
UPLOAD_DIR=/data/uploads
|
||||
SNAPSHOT_DIR=/data/snapshots
|
||||
MOCK_LOGIN_ENABLED=true
|
||||
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -8,4 +8,12 @@ backend/data/
|
||||
uploads/
|
||||
snapshots/
|
||||
node_modules/
|
||||
incoming-files/*.Zone.Identifier
|
||||
*:Zone.Identifier
|
||||
incoming-files/~$*
|
||||
|
||||
# Local-only inspection / conversion artifacts
|
||||
incoming-files/6f.html
|
||||
incoming-files/7f.html
|
||||
incoming-files/center.html
|
||||
.dev-worktree-8081/
|
||||
|
||||
59
CONTRIBUTING.md
Normal file
59
CONTRIBUTING.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Contributing
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
- `main`은 팀 기준 브랜치로 사용합니다.
|
||||
- `dev`는 팀 개발 통합 브랜치로 사용합니다.
|
||||
- `main`, `dev`는 브랜치이고 `8080`, `8081`은 실행 환경입니다.
|
||||
- 권장 운영은 `main -> 8080`, `dev 또는 작업 브랜치 -> 8081`입니다.
|
||||
- 기능 개발과 버그 수정은 각자 작업 브랜치에서 진행합니다.
|
||||
- 직접 `8080` 기준 파일을 수정하지 않습니다.
|
||||
- 검증은 먼저 `8081` 개발 환경에서 수행합니다.
|
||||
- 커밋은 한 기능 또는 한 버그 단위로 작게 나눕니다.
|
||||
- 작업 시작 전 [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)를 먼저 읽습니다.
|
||||
|
||||
## 권장 브랜치 이름
|
||||
|
||||
- `feature/<name>-<topic>`
|
||||
- `fix/<name>-<topic>`
|
||||
- `chore/<name>-<topic>`
|
||||
|
||||
예:
|
||||
|
||||
- `fix/alex-organization-save`
|
||||
- `feature/minsu-ledger-filter`
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. `main` 최신 상태를 받습니다.
|
||||
2. 작업 브랜치를 만듭니다.
|
||||
3. `.env.example`을 `.env`로 복사합니다.
|
||||
4. 필요한 경우 `./scripts/prepare_dev_worktree.sh`로 격리된 개발 워크스페이스를 준비합니다.
|
||||
5. `8081`에서 수정과 검증을 진행합니다.
|
||||
6. 관련 publish 스크립트가 있는 화면은 publish 후 실제 런타임 파일까지 확인합니다.
|
||||
7. `docs/REGRESSION_CHECKLIST.md` 기준으로 필요한 시나리오를 점검합니다.
|
||||
8. 커밋 후 PR을 생성합니다.
|
||||
|
||||
## PR 규칙
|
||||
|
||||
- PR 하나에는 한 주제만 담습니다.
|
||||
- PR 본문에 아래 내용을 포함합니다.
|
||||
- 작업 목적
|
||||
- 변경 범위
|
||||
- 검증 방법
|
||||
- DB 영향 여부
|
||||
- 공용 구조 파일 수정 시 영향 화면을 명시합니다.
|
||||
|
||||
## 파일 수정 기준
|
||||
|
||||
- 탭 화면 수정은 먼저 `frontend/apps/*`를 봅니다.
|
||||
- 조직현황은 `frontend/apps/organization`와 `legacy/static/*` 구조를 함께 확인합니다.
|
||||
- integration 화면 런타임은 `incoming-files/served/*`지만, 직접 수정 원본은 `frontend/apps/*`입니다.
|
||||
- 원본 참고 파일은 `incoming-files/reference/*`에만 둡니다.
|
||||
|
||||
## 문서 기준
|
||||
|
||||
- 저장소 진입: [README.md](README.md)
|
||||
- 작업 시작 기준: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
|
||||
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
|
||||
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)
|
||||
@@ -8,8 +8,8 @@
|
||||
<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" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/legacy/static/common.css" />
|
||||
<link rel="stylesheet" href="/legacy/static/organization.css" />
|
||||
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
|
||||
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
|
||||
</head>
|
||||
<body>
|
||||
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
|
||||
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
|
||||
<h2 class="text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
|
||||
<h2 class="stats-title text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
|
||||
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);">▼</span>
|
||||
</div>
|
||||
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
|
||||
@@ -60,6 +60,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/legacy/static/organization.js"></script>
|
||||
</body>
|
||||
<script src="/legacy/static/organization.js?v=20260402-02"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# MH Dashboard Organization
|
||||
|
||||
조직현황, 자리배치도, 프로젝트별 분석, 팀/개인별 분석을 하나의 대시보드로 제공하는 사내 웹 애플리케이션입니다.
|
||||
|
||||
## 구성
|
||||
|
||||
- `frontend/`
|
||||
- 허브 화면과 공통 스타일
|
||||
- `frontend/apps/`
|
||||
- 화면별 source-of-truth 앱 소스
|
||||
- `legacy/static/`
|
||||
- 조직현황 레거시 런타임 자산
|
||||
- `incoming-files/served/`
|
||||
- integration 화면의 실제 런타임 서빙 자산
|
||||
- `incoming-files/reference/`
|
||||
- 원본 참고 자산
|
||||
- `backend/app/`
|
||||
- FastAPI 백엔드
|
||||
- `scripts/`
|
||||
- 실행, publish, 검증, 동기화 스크립트
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
- `frontend/apps/*`가 탭별 수정 원본입니다.
|
||||
- `incoming-files/served/*`와 `legacy/static/*`는 런타임 자산입니다.
|
||||
- 조직현황/멤버/자리배치 관련 검증은 `8081` 개발 환경에서 먼저 수행합니다.
|
||||
- `8080`은 기준 데이터와 공개 환경, `8081`은 검증 환경으로 다룹니다.
|
||||
- `main`, `dev`는 Git 브랜치이고 `8080`, `8081`은 실행 환경입니다.
|
||||
- 권장 운영은 `main -> 8080`, `dev 또는 작업 브랜치 -> 8081`입니다.
|
||||
|
||||
## 시작 문서
|
||||
|
||||
- 첫 문서: [docs/TEAM_GUIDE.md](docs/TEAM_GUIDE.md)
|
||||
- 협업 규칙: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- 개발/운영 DB 원칙: [docs/DEV_PROD_DB_PROTOCOL.md](docs/DEV_PROD_DB_PROTOCOL.md)
|
||||
- 실제 서빙 책임 맵: [docs/architecture/8081_SERVING_MAP.md](docs/architecture/8081_SERVING_MAP.md)
|
||||
- 디자인 기준: [docs/architecture/DESIGN_SSOT.md](docs/architecture/DESIGN_SSOT.md)
|
||||
|
||||
## 빠른 실행
|
||||
|
||||
기본 공개 환경:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
격리된 `8081` 개발 환경:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
./scripts/prepare_dev_worktree.sh
|
||||
cd .dev-worktree-8081
|
||||
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
|
||||
```
|
||||
|
||||
## publish 스크립트
|
||||
|
||||
- 조직현황: `./scripts/publish_organization_app.sh`
|
||||
- 프로젝트별 분석: `./scripts/publish_payment_app.sh`
|
||||
- 팀/개인별 분석: `./scripts/publish_team_app.sh`
|
||||
- 사업관리대장: `./scripts/publish_ledger_app.sh`
|
||||
- DB 상태: `./scripts/publish_db_status_app.sh`
|
||||
@@ -11,7 +11,6 @@ RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
COPY backend/app /app/backend/app
|
||||
COPY DashBoard-organization.html /app/legacy/DashBoard-organization.html
|
||||
COPY DashBoard-organization-backup.html /app/legacy/DashBoard-organization-backup.html
|
||||
COPY organization.xlsx /app/legacy/organization.xlsx
|
||||
COPY legacy/static /app/legacy/static
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
497
backend/app/admin_db_status.py
Normal file
497
backend/app/admin_db_status.py
Normal file
@@ -0,0 +1,497 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .db import get_conn
|
||||
|
||||
|
||||
DB_STATUS_TABLES = [
|
||||
{
|
||||
"table_ref": "public.members",
|
||||
"label": "구성원 마스터",
|
||||
"domain": "organization",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["조직 현황", "자리배치도"],
|
||||
"description": "조직/구성원 화면의 기준이 되는 현재 인원 마스터",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.member_versions",
|
||||
"label": "구성원 이력",
|
||||
"domain": "history",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["조직 현황", "이력 비교"],
|
||||
"description": "as-of 조회와 변경 이력을 위한 시점 버전",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.seat_maps",
|
||||
"label": "자리배치도 도면",
|
||||
"domain": "seatmap",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["자리배치도"],
|
||||
"description": "오피스별 도면 메타데이터와 활성 상태",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.seat_positions",
|
||||
"label": "현재 좌석 배치",
|
||||
"domain": "seatmap",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["자리배치도"],
|
||||
"description": "현재 인원의 실제 배치 좌표/슬롯 연결",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.seat_assignment_versions",
|
||||
"label": "좌석 배치 이력",
|
||||
"domain": "history",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["자리배치도", "이력 비교"],
|
||||
"description": "자리 이동 이력과 시점 조회용 배치 버전",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_import_batches",
|
||||
"label": "원본 업로드 배치",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "imported_at",
|
||||
"related_views": ["프로젝트별 분석", "팀/개인별 분석", "조직 현황"],
|
||||
"description": "원본 파일 적재 단위와 최근 import 기록",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_projects",
|
||||
"label": "통합 프로젝트 표준화",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["프로젝트별 분석", "팀/개인별 분석"],
|
||||
"description": "프로젝트 코드/이름/카테고리 정규화 결과",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_work_logs",
|
||||
"label": "근무 로그 표준화",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["팀/개인별 분석"],
|
||||
"description": "MH workbook 기준 일자별 근무 로그 본체",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_work_log_segments",
|
||||
"label": "근무 로그 세그먼트",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["팀/개인별 분석"],
|
||||
"description": "근무 로그를 프로젝트/활동 기준으로 분해한 상세 세그먼트",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_vouchers",
|
||||
"label": "전표 표준화",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["프로젝트별 분석"],
|
||||
"description": "payment CSV 기준 프로젝트별 수입/지출 전표",
|
||||
},
|
||||
{
|
||||
"table_ref": "public.integration_binary_sources",
|
||||
"label": "바이너리 원본 보관",
|
||||
"domain": "integration",
|
||||
"timestamp_column": "imported_at",
|
||||
"related_views": ["사업관리대장"],
|
||||
"description": "엑셀/바이너리 원본을 DB에 보관하는 저장소",
|
||||
},
|
||||
{
|
||||
"table_ref": "auth.users",
|
||||
"label": "인증 사용자",
|
||||
"domain": "auth",
|
||||
"timestamp_column": "updated_at",
|
||||
"related_views": ["로그인", "권한"],
|
||||
"description": "로그인 계정, role, 활성 상태",
|
||||
},
|
||||
{
|
||||
"table_ref": "auth.sessions",
|
||||
"label": "인증 세션",
|
||||
"domain": "auth",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["로그인", "권한"],
|
||||
"description": "현재/과거 로그인 세션과 만료 상태",
|
||||
},
|
||||
{
|
||||
"table_ref": "auth.login_audit_logs",
|
||||
"label": "로그인 감사 로그",
|
||||
"domain": "auth",
|
||||
"timestamp_column": "created_at",
|
||||
"related_views": ["로그인", "권한"],
|
||||
"description": "로그인 성공/실패 기록",
|
||||
},
|
||||
]
|
||||
|
||||
DB_STATUS_TABLE_META = {str(item["table_ref"]): item for item in DB_STATUS_TABLES}
|
||||
DB_STATUS_TABLE_GROUPS = {
|
||||
"public.members": "유지",
|
||||
"public.member_versions": "유지",
|
||||
"public.seat_maps": "유지",
|
||||
"public.seat_positions": "유지",
|
||||
"public.seat_slots": "유지",
|
||||
"public.seat_assignment_versions": "유지",
|
||||
"public.history_revisions": "유지",
|
||||
"public.integration_import_batches": "유지",
|
||||
"public.integration_projects": "유지",
|
||||
"public.integration_work_logs": "유지",
|
||||
"public.integration_work_log_segments": "유지",
|
||||
"public.integration_vouchers": "유지",
|
||||
"public.integration_binary_sources": "유지",
|
||||
"auth.users": "유지",
|
||||
"auth.sessions": "유지",
|
||||
"auth.login_audit_logs": "유지",
|
||||
"public.member_overrides": "주의",
|
||||
"public.member_retirements": "주의",
|
||||
"public.member_aliases": "주의",
|
||||
"public.integration_project_aliases": "주의",
|
||||
"public.integration_project_category_mappings": "주의",
|
||||
"public.integration_project_pm_assignments": "주의",
|
||||
"public.integration_raw_organization_rows": "원본·추적",
|
||||
"public.integration_raw_mh_rows": "원본·추적",
|
||||
"public.integration_raw_mh_pm_rows": "원본·추적",
|
||||
"public.integration_raw_payment_rows": "원본·추적",
|
||||
}
|
||||
|
||||
DB_STATUS_PRODUCT_GROUPS = {
|
||||
"탭 데이터": [
|
||||
"public.members",
|
||||
"public.seat_maps",
|
||||
"public.seat_slots",
|
||||
"public.seat_positions",
|
||||
"public.integration_projects",
|
||||
"public.integration_work_logs",
|
||||
"public.integration_work_log_segments",
|
||||
"public.integration_vouchers",
|
||||
"public.integration_binary_sources",
|
||||
],
|
||||
"로그인·권한": [
|
||||
"auth.users",
|
||||
"auth.sessions",
|
||||
"auth.login_audit_logs",
|
||||
],
|
||||
"히스토리": [
|
||||
"public.history_revisions",
|
||||
"public.member_versions",
|
||||
"public.seat_assignment_versions",
|
||||
],
|
||||
"로우데이터·적재": [
|
||||
"public.integration_import_batches",
|
||||
"public.integration_raw_organization_rows",
|
||||
"public.integration_raw_mh_rows",
|
||||
"public.integration_raw_mh_pm_rows",
|
||||
"public.integration_raw_payment_rows",
|
||||
],
|
||||
"보정·보조": [
|
||||
"public.member_overrides",
|
||||
"public.member_retirements",
|
||||
"public.member_aliases",
|
||||
"public.integration_project_aliases",
|
||||
"public.integration_project_category_mappings",
|
||||
"public.integration_project_pm_assignments",
|
||||
],
|
||||
}
|
||||
|
||||
DB_STATUS_SCREEN_MAP = [
|
||||
{
|
||||
"screen": "조직 현황",
|
||||
"tables": [
|
||||
"public.members",
|
||||
"public.member_overrides",
|
||||
"public.member_retirements",
|
||||
"public.member_aliases",
|
||||
"public.member_versions",
|
||||
"public.history_revisions",
|
||||
],
|
||||
"write_flow": "원본 조직 데이터 import 후 members 계열을 갱신하고, 수정/이력 기능은 revision 기반으로 누적합니다.",
|
||||
},
|
||||
{
|
||||
"screen": "자리배치도",
|
||||
"tables": [
|
||||
"public.seat_maps",
|
||||
"public.seat_slots",
|
||||
"public.seat_positions",
|
||||
"public.seat_assignment_versions",
|
||||
"public.history_revisions",
|
||||
"public.members",
|
||||
],
|
||||
"write_flow": "고정 오피스 도면과 현재 좌석 배치를 읽고, 저장 시 현재 배치와 배치 이력을 함께 기록합니다.",
|
||||
},
|
||||
{
|
||||
"screen": "프로젝트별 분석",
|
||||
"tables": [
|
||||
"public.integration_import_batches",
|
||||
"public.integration_projects",
|
||||
"public.integration_vouchers",
|
||||
"public.integration_project_aliases",
|
||||
"public.integration_project_category_mappings",
|
||||
"public.integration_project_pm_assignments",
|
||||
],
|
||||
"write_flow": "payment 원본 import 결과와 프로젝트 보정 테이블을 조합해 프로젝트 집계를 만듭니다.",
|
||||
},
|
||||
{
|
||||
"screen": "팀/개인별 분석",
|
||||
"tables": [
|
||||
"public.integration_import_batches",
|
||||
"public.integration_projects",
|
||||
"public.integration_work_logs",
|
||||
"public.integration_work_log_segments",
|
||||
"public.integration_raw_mh_rows",
|
||||
"public.integration_raw_mh_pm_rows",
|
||||
],
|
||||
"write_flow": "MH 원본 row를 적재한 뒤 표준화 로그와 세그먼트로 분해해 화면 집계에 사용합니다.",
|
||||
},
|
||||
{
|
||||
"screen": "사업관리대장",
|
||||
"tables": [
|
||||
"public.integration_binary_sources",
|
||||
],
|
||||
"write_flow": "현재는 기본 바이너리 원본 보관 상태만 DB에 유지하며, 상세 계산 규칙은 별도 기준 정렬이 필요합니다.",
|
||||
},
|
||||
{
|
||||
"screen": "로그인 / 권한",
|
||||
"tables": [
|
||||
"auth.users",
|
||||
"auth.sessions",
|
||||
"auth.login_audit_logs",
|
||||
],
|
||||
"write_flow": "사용자 계정, 세션, 로그인 감사 로그를 auth 스키마에서 분리 운영합니다.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def make_json_safe(value: object) -> object:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, bytes):
|
||||
return f"<{len(value)} bytes>"
|
||||
if isinstance(value, dict):
|
||||
return {str(key): make_json_safe(val) for key, val in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [make_json_safe(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def fetch_db_status_snapshot() -> dict[str, object]:
|
||||
table_items: list[dict[str, object]] = []
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT schemaname, tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname IN ('public', 'auth')
|
||||
ORDER BY schemaname, tablename
|
||||
"""
|
||||
)
|
||||
all_tables = cur.fetchall()
|
||||
for row in all_tables:
|
||||
schema_name = str(row["schemaname"])
|
||||
table_name = str(row["tablename"])
|
||||
table_ref = f"{schema_name}.{table_name}"
|
||||
spec = DB_STATUS_TABLE_META.get(table_ref, {})
|
||||
cur.execute("SELECT to_regclass(%s) IS NOT NULL AS table_exists", (table_ref,))
|
||||
exists_row = cur.fetchone()
|
||||
exists = bool(exists_row["table_exists"]) if exists_row is not None else False
|
||||
row_count = 0
|
||||
last_event_at = None
|
||||
if exists:
|
||||
timestamp_column = str(spec.get("timestamp_column") or "")
|
||||
query = f"SELECT COUNT(*)::bigint AS row_count"
|
||||
if timestamp_column:
|
||||
query += f", MAX({timestamp_column}) AS last_event_at"
|
||||
else:
|
||||
query += ", NULL::timestamptz AS last_event_at"
|
||||
query += f" FROM {schema_name}.{table_name}"
|
||||
cur.execute(query)
|
||||
metric_row = cur.fetchone() or {}
|
||||
row_count = int(metric_row.get("row_count") or 0)
|
||||
last_event_at = metric_row.get("last_event_at")
|
||||
table_items.append(
|
||||
{
|
||||
"table_ref": table_ref,
|
||||
"schema": schema_name,
|
||||
"table_name": table_name,
|
||||
"label": str(spec.get("label") or table_name),
|
||||
"domain": str(spec.get("domain") or "other"),
|
||||
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
|
||||
"related_views": spec.get("related_views") or [],
|
||||
"group": DB_STATUS_TABLE_GROUPS.get(table_ref, "주의"),
|
||||
"exists": exists,
|
||||
"row_count": row_count,
|
||||
"last_event_at": last_event_at.isoformat() if last_event_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_key, source_name, row_count, source_path, imported_at
|
||||
FROM integration_import_batches
|
||||
ORDER BY imported_at DESC, id DESC
|
||||
"""
|
||||
)
|
||||
import_batches = [
|
||||
{
|
||||
"source_key": str(row["source_key"] or ""),
|
||||
"source_name": str(row["source_name"] or ""),
|
||||
"row_count": int(row["row_count"] or 0),
|
||||
"source_path": str(row["source_path"] or ""),
|
||||
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
|
||||
binary_sources: list[dict[str, object]] = []
|
||||
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
|
||||
binary_exists_row = cur.fetchone()
|
||||
binary_exists = bool(binary_exists_row["table_exists"]) if binary_exists_row is not None else False
|
||||
if binary_exists:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_key, source_name, filename, mime_type, OCTET_LENGTH(content) AS byte_size,
|
||||
content_sha256, imported_at
|
||||
FROM integration_binary_sources
|
||||
ORDER BY imported_at DESC, id DESC
|
||||
"""
|
||||
)
|
||||
binary_sources = [
|
||||
{
|
||||
"source_key": str(row["source_key"] or ""),
|
||||
"source_name": str(row["source_name"] or ""),
|
||||
"filename": str(row["filename"] or ""),
|
||||
"mime_type": str(row["mime_type"] or ""),
|
||||
"byte_size": int(row["byte_size"] or 0),
|
||||
"content_sha256": str(row["content_sha256"] or ""),
|
||||
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::bigint AS total_members,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(BTRIM(work_status), '') <> '퇴직'
|
||||
)::bigint AS active_members
|
||||
FROM members
|
||||
"""
|
||||
)
|
||||
member_row = cur.fetchone() or {}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::bigint AS active_seat_maps
|
||||
FROM seat_maps
|
||||
WHERE is_active = TRUE
|
||||
"""
|
||||
)
|
||||
seat_map_row = cur.fetchone() or {}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)::bigint AS fixed_office_maps
|
||||
FROM seat_maps
|
||||
WHERE source_type = 'fixed_html'
|
||||
"""
|
||||
)
|
||||
fixed_office_row = cur.fetchone() or {}
|
||||
|
||||
overview = {
|
||||
"visible_tables": len(DB_STATUS_TABLES),
|
||||
"total_tables": len(table_items),
|
||||
"existing_tables": sum(1 for item in table_items if item["exists"]),
|
||||
"registered_members": int(member_row.get("total_members") or 0),
|
||||
"active_members": int(member_row.get("active_members") or 0),
|
||||
"active_seat_maps": int(seat_map_row.get("active_seat_maps") or 0),
|
||||
"fixed_office_maps": int(fixed_office_row.get("fixed_office_maps") or 0),
|
||||
"import_batches": len(import_batches),
|
||||
"binary_sources": len(binary_sources),
|
||||
}
|
||||
group_summary = {
|
||||
"유지": [item["table_ref"] for item in table_items if item["group"] == "유지"],
|
||||
"주의": [item["table_ref"] for item in table_items if item["group"] == "주의"],
|
||||
"원본·추적": [item["table_ref"] for item in table_items if item["group"] == "원본·추적"],
|
||||
"정리 후보": [item["table_ref"] for item in table_items if item["group"] == "정리 후보"],
|
||||
}
|
||||
product_summary = {
|
||||
group_name: table_refs
|
||||
for group_name, table_refs in DB_STATUS_PRODUCT_GROUPS.items()
|
||||
}
|
||||
return {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"overview": overview,
|
||||
"tables": table_items,
|
||||
"import_batches": import_batches,
|
||||
"binary_sources": binary_sources,
|
||||
"group_summary": group_summary,
|
||||
"product_summary": product_summary,
|
||||
"screen_map": DB_STATUS_SCREEN_MAP,
|
||||
"notes": [
|
||||
"members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.",
|
||||
"member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.",
|
||||
"integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.",
|
||||
"integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.",
|
||||
"DB를 물리적으로 합치기보다, 화면/권한/이력/로우데이터 관점으로 묶어 보는 것이 현재 운영에 더 적합합니다.",
|
||||
"재직 인원은 조직현황과 동일하게 work_status 값이 '퇴직'이 아닌 구성원 기준입니다.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def fetch_db_table_preview(schema_name: str, table_name: str, limit: int = 50) -> dict[str, object]:
|
||||
if schema_name not in {"public", "auth"}:
|
||||
raise HTTPException(status_code=404, detail="Unknown schema.")
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = %s
|
||||
AND tablename = %s
|
||||
""",
|
||||
(schema_name, table_name),
|
||||
)
|
||||
exists_row = cur.fetchone()
|
||||
if exists_row is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown table.")
|
||||
|
||||
table_ref = f"{schema_name}.{table_name}"
|
||||
spec = DB_STATUS_TABLE_META.get(table_ref, {})
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = %s
|
||||
AND table_name = %s
|
||||
ORDER BY ordinal_position
|
||||
""",
|
||||
(schema_name, table_name),
|
||||
)
|
||||
columns = [{"name": str(row["column_name"]), "type": str(row["data_type"])} for row in cur.fetchall()]
|
||||
|
||||
cur.execute(f"SELECT COUNT(*)::bigint AS row_count FROM {schema_name}.{table_name}")
|
||||
row_count = int((cur.fetchone() or {}).get("row_count") or 0)
|
||||
|
||||
safe_limit = max(1, min(int(limit), 50))
|
||||
cur.execute(f"SELECT * FROM {schema_name}.{table_name} LIMIT {safe_limit}")
|
||||
rows = [make_json_safe(dict(row)) for row in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"table_ref": table_ref,
|
||||
"schema": schema_name,
|
||||
"table_name": table_name,
|
||||
"label": str(spec.get("label") or table_name),
|
||||
"domain": str(spec.get("domain") or "other"),
|
||||
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
|
||||
"related_views": spec.get("related_views") or [],
|
||||
"row_count": row_count,
|
||||
"limit": safe_limit,
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
}
|
||||
172
backend/app/auth_routes.py
Normal file
172
backend/app/auth_routes.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import FastAPI, Form, Header, HTTPException, Request
|
||||
|
||||
|
||||
def register_auth_routes(
|
||||
app: FastAPI,
|
||||
*,
|
||||
get_conn,
|
||||
verify_password: Callable[[str, str], bool],
|
||||
build_auth_session_payload: Callable[[dict[str, object], uuid.UUID, datetime], dict[str, object]],
|
||||
extract_bearer_token: Callable[[str | None], str | None],
|
||||
mock_login_enabled: bool,
|
||||
auth_session_hours: int,
|
||||
) -> None:
|
||||
@app.post("/api/auth/login")
|
||||
def auth_login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
) -> dict[str, object]:
|
||||
normalized_username = username.strip().lower()
|
||||
if not normalized_username or not password.strip():
|
||||
raise HTTPException(status_code=400, detail="사번과 비밀번호를 입력해주세요.")
|
||||
|
||||
ip_address = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.password_hash, u.display_name, u.role, u.member_id, u.is_active,
|
||||
m.rank
|
||||
FROM auth.users u
|
||||
LEFT JOIN members m ON m.id = u.member_id
|
||||
WHERE LOWER(u.username) = %s
|
||||
""",
|
||||
(normalized_username,),
|
||||
)
|
||||
user = cur.fetchone()
|
||||
|
||||
if user is None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.login_audit_logs (username, success, failure_reason, ip_address, user_agent)
|
||||
VALUES (%s, FALSE, %s, %s, %s)
|
||||
""",
|
||||
(normalized_username, "unknown_user", ip_address, user_agent),
|
||||
)
|
||||
conn.commit()
|
||||
raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.")
|
||||
|
||||
if not bool(user.get("is_active")):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
|
||||
VALUES (%s, %s, FALSE, %s, %s, %s)
|
||||
""",
|
||||
(normalized_username, int(user["id"]), "inactive_user", ip_address, user_agent),
|
||||
)
|
||||
conn.commit()
|
||||
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
||||
|
||||
if not verify_password(password, str(user.get("password_hash") or "")):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
|
||||
VALUES (%s, %s, FALSE, %s, %s, %s)
|
||||
""",
|
||||
(normalized_username, int(user["id"]), "invalid_password", ip_address, user_agent),
|
||||
)
|
||||
conn.commit()
|
||||
raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.")
|
||||
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=auth_session_hours)
|
||||
session_id = uuid.uuid4()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.sessions (id, user_id, expires_at, ip_address, user_agent)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(session_id, int(user["id"]), expires_at, ip_address, user_agent),
|
||||
)
|
||||
cur.execute(
|
||||
"UPDATE auth.users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
|
||||
(int(user["id"]),),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent)
|
||||
VALUES (%s, %s, TRUE, NULL, %s, %s)
|
||||
""",
|
||||
(normalized_username, int(user["id"]), ip_address, user_agent),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return build_auth_session_payload(user, session_id, expires_at)
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, bool]:
|
||||
token = extract_bearer_token(authorization)
|
||||
if not token:
|
||||
return {"ok": True}
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.sessions
|
||||
SET revoked_at = NOW()
|
||||
WHERE id = %s
|
||||
AND revoked_at IS NULL
|
||||
""",
|
||||
(token,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def auth_me(authorization: str | None = Header(default=None)) -> dict[str, object]:
|
||||
token = extract_bearer_token(authorization)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="인증 정보가 없습니다.")
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id AS session_id, s.expires_at, s.revoked_at,
|
||||
u.id, u.username, u.display_name, u.role, u.member_id, u.is_active,
|
||||
m.rank
|
||||
FROM auth.sessions s
|
||||
JOIN auth.users u ON u.id = s.user_id
|
||||
LEFT JOIN members m ON m.id = u.member_id
|
||||
WHERE s.id = %s
|
||||
""",
|
||||
(token,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None or row.get("revoked_at") is not None:
|
||||
raise HTTPException(status_code=401, detail="세션이 유효하지 않습니다.")
|
||||
|
||||
expires_at = row["expires_at"]
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if expires_at is None or expires_at <= now_utc:
|
||||
raise HTTPException(status_code=401, detail="세션이 만료되었습니다.")
|
||||
|
||||
if not bool(row.get("is_active")):
|
||||
raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
|
||||
|
||||
return build_auth_session_payload(row, uuid.UUID(str(row["session_id"])), expires_at)
|
||||
|
||||
@app.post("/api/mock-login")
|
||||
def mock_login(username: str = Form(...), password: str = Form(...)) -> dict[str, object]:
|
||||
if not mock_login_enabled:
|
||||
raise HTTPException(status_code=403, detail="Mock login is disabled.")
|
||||
if not username.strip() or not password.strip():
|
||||
raise HTTPException(status_code=400, detail="Username and password are required.")
|
||||
return {
|
||||
"user": {
|
||||
"username": username.strip(),
|
||||
"display_name": username.strip(),
|
||||
"role": "admin",
|
||||
},
|
||||
"session_expires_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
508
backend/app/center_chair_viewer_template.html
Normal file
508
backend/app/center_chair_viewer_template.html
Normal file
File diff suppressed because one or more lines are too long
@@ -5,10 +5,8 @@ import os
|
||||
BASE_DIR = Path("/app")
|
||||
LEGACY_DIR = BASE_DIR / "legacy"
|
||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads"))
|
||||
SNAPSHOT_DIR = Path(os.getenv("SNAPSHOT_DIR", "/data/snapshots"))
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://orgapp:change-me@db:5432/orgdb",
|
||||
)
|
||||
MOCK_LOGIN_ENABLED = os.getenv("MOCK_LOGIN_ENABLED", "true").lower() == "true"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
employee_id TEXT,
|
||||
company TEXT,
|
||||
rank TEXT,
|
||||
role TEXT,
|
||||
@@ -31,24 +32,589 @@ CREATE TABLE IF NOT EXISTS members (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seat_positions (
|
||||
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
||||
x INTEGER NOT NULL DEFAULT 0,
|
||||
y INTEGER NOT NULL DEFAULT 0,
|
||||
floor_label TEXT,
|
||||
CREATE TABLE IF NOT EXISTS member_overrides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
company TEXT,
|
||||
rank TEXT,
|
||||
role TEXT,
|
||||
department TEXT,
|
||||
grp TEXT,
|
||||
division TEXT,
|
||||
team TEXT,
|
||||
cell TEXT,
|
||||
work_status TEXT,
|
||||
work_time TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
seat_label TEXT,
|
||||
photo_url TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS member_retirements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS member_aliases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
alias_name TEXT NOT NULL UNIQUE,
|
||||
canonical_name TEXT NOT NULL,
|
||||
employee_id TEXT,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
CREATE TABLE IF NOT EXISTS seat_maps (
|
||||
id SERIAL PRIMARY KEY,
|
||||
snapshot_month TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL DEFAULT 'image',
|
||||
source_url TEXT,
|
||||
preview_svg TEXT,
|
||||
view_box_min_x DOUBLE PRECISION,
|
||||
view_box_min_y DOUBLE PRECISION,
|
||||
view_box_width DOUBLE PRECISION,
|
||||
view_box_height DOUBLE PRECISION,
|
||||
image_width INTEGER,
|
||||
image_height INTEGER,
|
||||
grid_rows INTEGER NOT NULL,
|
||||
grid_cols INTEGER NOT NULL,
|
||||
cell_gap INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seat_positions (
|
||||
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
||||
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||
seat_slot_id INTEGER,
|
||||
row_index INTEGER NOT NULL DEFAULT 0,
|
||||
col_index INTEGER NOT NULL DEFAULT 0,
|
||||
seat_label TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seat_slots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||
slot_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
layer_name TEXT NOT NULL DEFAULT 'chair',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (seat_map_id, slot_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_import_batches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_key TEXT NOT NULL UNIQUE,
|
||||
source_name TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_raw_organization_rows (
|
||||
id SERIAL PRIMARY KEY,
|
||||
batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE,
|
||||
row_index INTEGER NOT NULL,
|
||||
row_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_raw_mh_rows (
|
||||
id SERIAL PRIMARY KEY,
|
||||
batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE,
|
||||
row_index INTEGER NOT NULL,
|
||||
row_json JSONB NOT NULL,
|
||||
row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_raw_mh_pm_rows (
|
||||
id SERIAL PRIMARY KEY,
|
||||
batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE,
|
||||
row_index INTEGER NOT NULL,
|
||||
row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_raw_payment_rows (
|
||||
id SERIAL PRIMARY KEY,
|
||||
batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE,
|
||||
row_index INTEGER NOT NULL,
|
||||
row_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_code TEXT NOT NULL UNIQUE,
|
||||
project_name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
intranet_name TEXT NOT NULL DEFAULT '',
|
||||
business_area TEXT NOT NULL DEFAULT '',
|
||||
business_subarea TEXT NOT NULL DEFAULT '',
|
||||
project_nature TEXT NOT NULL DEFAULT '',
|
||||
main_category TEXT NOT NULL DEFAULT '',
|
||||
middle_category TEXT NOT NULL DEFAULT '',
|
||||
sub_category TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_project_aliases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES integration_projects(id) ON DELETE CASCADE,
|
||||
alias_name TEXT NOT NULL,
|
||||
alias_type TEXT NOT NULL DEFAULT 'name',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, alias_name, alias_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_project_category_mappings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_key TEXT NOT NULL DEFAULT 'ptj_csv',
|
||||
project_name TEXT NOT NULL,
|
||||
normalized_project_key TEXT NOT NULL,
|
||||
mapped_d1 TEXT NOT NULL DEFAULT '',
|
||||
mapped_d2 TEXT NOT NULL DEFAULT '',
|
||||
mapped_d3 TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (source_key, normalized_project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_project_pm_assignments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES integration_projects(id) ON DELETE CASCADE,
|
||||
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
pm_name TEXT NOT NULL,
|
||||
source_label TEXT NOT NULL DEFAULT 'mh_sheet2',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, source_label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_work_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
work_date DATE NOT NULL,
|
||||
employee_id TEXT NOT NULL,
|
||||
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
member_name TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
team_category TEXT NOT NULL DEFAULT '',
|
||||
team_name TEXT NOT NULL DEFAULT '',
|
||||
user_state TEXT NOT NULL DEFAULT '',
|
||||
shift_hours NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
weekend_late_flag TEXT NOT NULL DEFAULT '',
|
||||
review_status TEXT NOT NULL DEFAULT '',
|
||||
source_row_index INTEGER NOT NULL DEFAULT 0,
|
||||
raw_batch_id INTEGER REFERENCES integration_import_batches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (work_date, employee_id, source_row_index)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_work_log_segments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
work_log_id INTEGER NOT NULL REFERENCES integration_work_logs(id) ON DELETE CASCADE,
|
||||
slot_name TEXT NOT NULL,
|
||||
project_id INTEGER REFERENCES integration_projects(id) ON DELETE SET NULL,
|
||||
project_code TEXT NOT NULL DEFAULT '',
|
||||
project_name TEXT NOT NULL DEFAULT '',
|
||||
business_type TEXT NOT NULL DEFAULT '',
|
||||
activity_code TEXT NOT NULL DEFAULT '',
|
||||
hours NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
overtime_hours_raw NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
overtime_hours_adjusted NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
is_overtime BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_vouchers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
accounting_company TEXT NOT NULL DEFAULT '',
|
||||
claim_date DATE,
|
||||
issue_date DATE,
|
||||
issue_month TEXT NOT NULL DEFAULT '',
|
||||
account_code TEXT NOT NULL DEFAULT '',
|
||||
management_account_code TEXT NOT NULL DEFAULT '',
|
||||
account_name TEXT NOT NULL DEFAULT '',
|
||||
project_id INTEGER REFERENCES integration_projects(id) ON DELETE SET NULL,
|
||||
project_code TEXT NOT NULL DEFAULT '',
|
||||
project_name TEXT NOT NULL DEFAULT '',
|
||||
display_project_name TEXT NOT NULL DEFAULT '',
|
||||
intranet_project_name TEXT NOT NULL DEFAULT '',
|
||||
business_area TEXT NOT NULL DEFAULT '',
|
||||
business_subarea TEXT NOT NULL DEFAULT '',
|
||||
planning_dev_sales TEXT NOT NULL DEFAULT '',
|
||||
main_category TEXT NOT NULL DEFAULT '',
|
||||
middle_category TEXT NOT NULL DEFAULT '',
|
||||
sub_category TEXT NOT NULL DEFAULT '',
|
||||
department_name TEXT NOT NULL DEFAULT '',
|
||||
team_name TEXT NOT NULL DEFAULT '',
|
||||
customer_name TEXT NOT NULL DEFAULT '',
|
||||
summary_text TEXT NOT NULL DEFAULT '',
|
||||
debit_supply_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
||||
credit_supply_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
||||
expense_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
||||
income_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
||||
voucher_type TEXT NOT NULL DEFAULT '',
|
||||
project_nature TEXT NOT NULL DEFAULT '',
|
||||
raw_batch_id INTEGER REFERENCES integration_import_batches(id) ON DELETE SET NULL,
|
||||
source_row_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_binary_sources (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source_key TEXT NOT NULL UNIQUE,
|
||||
source_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
content BYTEA NOT NULL,
|
||||
content_sha256 TEXT NOT NULL DEFAULT '',
|
||||
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS history_revisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scope TEXT NOT NULL DEFAULT 'organization',
|
||||
revision_label TEXT NOT NULL,
|
||||
created_by_user_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS member_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
rank TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
department TEXT NOT NULL DEFAULT '',
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
division TEXT NOT NULL DEFAULT '',
|
||||
team TEXT NOT NULL DEFAULT '',
|
||||
cell TEXT NOT NULL DEFAULT '',
|
||||
work_status TEXT NOT NULL DEFAULT '',
|
||||
work_time TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
photo_url TEXT NOT NULL DEFAULT '',
|
||||
valid_from TIMESTAMPTZ NOT NULL,
|
||||
valid_to TIMESTAMPTZ,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seat_assignment_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||
seat_slot_id INTEGER REFERENCES seat_slots(id) ON DELETE CASCADE,
|
||||
seat_label TEXT NOT NULL DEFAULT '',
|
||||
valid_from TIMESTAMPTZ NOT NULL,
|
||||
valid_to TIMESTAMPTZ,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_from TEXT NOT NULL DEFAULT 'manual',
|
||||
last_login_at TIMESTAMPTZ,
|
||||
password_changed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.login_audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
failure_reason TEXT,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
|
||||
MIGRATION_SQL = """
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS employee_id TEXT;
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE TABLE IF NOT EXISTS member_overrides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
company TEXT,
|
||||
rank TEXT,
|
||||
role TEXT,
|
||||
department TEXT,
|
||||
grp TEXT,
|
||||
division TEXT,
|
||||
team TEXT,
|
||||
cell TEXT,
|
||||
work_status TEXT,
|
||||
work_time TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
seat_label TEXT,
|
||||
photo_url TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS member_retirements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS member_aliases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
alias_name TEXT NOT NULL UNIQUE,
|
||||
canonical_name TEXT NOT NULL,
|
||||
employee_id TEXT,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE;
|
||||
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_slot_id INTEGER;
|
||||
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS row_index INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS col_index INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_label TEXT;
|
||||
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'image';
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_url TEXT;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS preview_svg TEXT;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_x DOUBLE PRECISION;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_y DOUBLE PRECISION;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_width DOUBLE PRECISION;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_height DOUBLE PRECISION;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_width INTEGER;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_height INTEGER;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS cell_gap INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE seat_maps ALTER COLUMN image_url DROP NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seat_slots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||
slot_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
layer_name TEXT NOT NULL DEFAULT 'chair',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (seat_map_id, slot_key)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'seat_positions' AND column_name = 'x'
|
||||
) THEN
|
||||
EXECUTE 'UPDATE seat_positions SET row_index = COALESCE(y, row_index, 0), col_index = COALESCE(x, col_index, 0) WHERE seat_map_id IS NULL';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'seat_positions' AND column_name = 'floor_label'
|
||||
) THEN
|
||||
EXECUTE 'UPDATE seat_positions SET seat_label = COALESCE(seat_label, floor_label) WHERE seat_label IS NULL';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'integration_raw_mh_rows' AND column_name = 'row_values_json'
|
||||
) THEN
|
||||
ALTER TABLE integration_raw_mh_rows
|
||||
ADD COLUMN row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DROP INDEX IF EXISTS seat_positions_map_cell_idx;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_map_cell_idx
|
||||
ON seat_positions (seat_map_id, row_index, col_index)
|
||||
WHERE seat_map_id IS NOT NULL
|
||||
AND seat_slot_id IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS member_overrides_employee_id_idx
|
||||
ON member_overrides (employee_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS member_retirements_name_idx
|
||||
ON member_retirements (name);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS member_aliases_alias_name_idx
|
||||
ON member_aliases (alias_name);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_slot_idx
|
||||
ON seat_positions (seat_slot_id)
|
||||
WHERE seat_slot_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_organization_rows_batch_row_idx
|
||||
ON integration_raw_organization_rows (batch_id, row_index);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_mh_rows_batch_row_idx
|
||||
ON integration_raw_mh_rows (batch_id, row_index);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_mh_pm_rows_batch_row_idx
|
||||
ON integration_raw_mh_pm_rows (batch_id, row_index);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_payment_rows_batch_row_idx
|
||||
ON integration_raw_payment_rows (batch_id, row_index);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS integration_work_logs_employee_idx
|
||||
ON integration_work_logs (employee_id, work_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS integration_work_log_segments_project_idx
|
||||
ON integration_work_log_segments (project_code, project_name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS integration_vouchers_project_idx
|
||||
ON integration_vouchers (project_code, project_name);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
|
||||
ON integration_project_category_mappings (source_key, normalized_project_key);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_binary_sources_source_key_idx
|
||||
ON integration_binary_sources (source_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
|
||||
ON member_versions (member_id, valid_from, valid_to);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS seat_assignment_versions_member_time_idx
|
||||
ON seat_assignment_versions (member_id, valid_from, valid_to);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
|
||||
ON history_revisions (scope, created_at DESC);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'seat_positions_seat_slot_id_fkey'
|
||||
AND table_name = 'seat_positions'
|
||||
) THEN
|
||||
ALTER TABLE seat_positions
|
||||
ADD CONSTRAINT seat_positions_seat_slot_id_fkey
|
||||
FOREIGN KEY (seat_slot_id) REFERENCES seat_slots(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_from TEXT NOT NULL DEFAULT 'manual',
|
||||
last_login_at TIMESTAMPTZ,
|
||||
password_changed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.login_audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
failure_reason TEXT,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin';
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL;
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS created_from TEXT NOT NULL DEFAULT 'manual';
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ;
|
||||
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;
|
||||
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS ip_address INET;
|
||||
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
|
||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
|
||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
||||
|
||||
DROP INDEX IF EXISTS entity_change_events_entity_idx;
|
||||
DROP TABLE IF EXISTS entity_change_events;
|
||||
"""
|
||||
|
||||
|
||||
@@ -66,6 +632,7 @@ def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(SCHEMA_SQL)
|
||||
cur.execute(MIGRATION_SQL)
|
||||
ensure_history_backfill(cur)
|
||||
conn.commit()
|
||||
return
|
||||
except psycopg.OperationalError as exc:
|
||||
@@ -73,3 +640,89 @@ def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None:
|
||||
time.sleep(retry_delay)
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
|
||||
def ensure_history_backfill(cur) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM history_revisions
|
||||
WHERE scope = 'organization'
|
||||
AND revision_label = 'initial-backfill'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO history_revisions (scope, revision_label, note)
|
||||
VALUES ('organization', 'initial-backfill', 'Seeded from current members and seat_positions state')
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
revision_id = int(cur.fetchone()["id"])
|
||||
else:
|
||||
revision_id = int(row["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO member_versions (
|
||||
member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, photo_url,
|
||||
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||
)
|
||||
SELECT
|
||||
m.id, m.name, COALESCE(m.company, ''), COALESCE(m.rank, ''), COALESCE(m.role, ''),
|
||||
COALESCE(m.department, ''), COALESCE(m.grp, ''), COALESCE(m.division, ''), COALESCE(m.team, ''), COALESCE(m.cell, ''),
|
||||
COALESCE(m.work_status, ''), COALESCE(m.work_time, ''), COALESCE(m.phone, ''), COALESCE(m.email, ''), COALESCE(m.photo_url, ''),
|
||||
TIMESTAMPTZ '1970-01-01 00:00:00+00', NULL, %s, NULL, 'initial-backfill'
|
||||
FROM members AS m
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM member_versions mv
|
||||
WHERE mv.member_id = m.id
|
||||
)
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO seat_assignment_versions (
|
||||
member_id, seat_map_id, seat_slot_id, seat_label,
|
||||
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||
)
|
||||
SELECT
|
||||
sp.member_id, sp.seat_map_id, sp.seat_slot_id, COALESCE(sp.seat_label, ''),
|
||||
TIMESTAMPTZ '1970-01-01 00:00:00+00', NULL, %s, NULL, 'initial-backfill'
|
||||
FROM seat_positions AS sp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM seat_assignment_versions sav
|
||||
WHERE sav.member_id = sp.member_id
|
||||
)
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE member_versions
|
||||
SET valid_from = TIMESTAMPTZ '1970-01-01 00:00:00+00'
|
||||
WHERE revision_no = %s
|
||||
AND change_reason = 'initial-backfill'
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE seat_assignment_versions
|
||||
SET valid_from = TIMESTAMPTZ '1970-01-01 00:00:00+00'
|
||||
WHERE revision_no = %s
|
||||
AND change_reason = 'initial-backfill'
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
|
||||
50
backend/app/integration_routes.py
Normal file
50
backend/app/integration_routes.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
def register_integration_routes(
|
||||
app: FastAPI,
|
||||
*,
|
||||
import_integration_sources,
|
||||
fetch_integration_summary,
|
||||
fetch_project_metrics,
|
||||
fetch_member_metrics,
|
||||
fetch_team_metrics,
|
||||
fetch_project_breakdowns,
|
||||
fetch_payment_source_rows,
|
||||
fetch_mh_source_rows,
|
||||
) -> None:
|
||||
@app.post("/api/integration/import")
|
||||
def import_integration_data() -> dict[str, object]:
|
||||
return import_integration_sources()
|
||||
|
||||
@app.get("/api/integration/summary")
|
||||
def integration_summary() -> dict[str, object]:
|
||||
return fetch_integration_summary()
|
||||
|
||||
@app.get("/api/integration/projects")
|
||||
def integration_projects(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||
safe_limit = max(1, min(limit, 5000))
|
||||
return {"items": fetch_project_metrics(safe_limit, start_date=start_date, end_date=end_date)}
|
||||
|
||||
@app.get("/api/integration/members")
|
||||
def integration_members(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||
safe_limit = max(1, min(limit, 5000))
|
||||
return {"items": fetch_member_metrics(safe_limit, start_date=start_date, end_date=end_date)}
|
||||
|
||||
@app.get("/api/integration/teams")
|
||||
def integration_teams(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||
return {"items": fetch_team_metrics(start_date=start_date, end_date=end_date)}
|
||||
|
||||
@app.get("/api/integration/project-breakdowns")
|
||||
def integration_project_breakdowns(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||
return fetch_project_breakdowns(start_date=start_date, end_date=end_date)
|
||||
|
||||
@app.get("/api/integration/payment-source")
|
||||
def integration_payment_source() -> dict[str, object]:
|
||||
return fetch_payment_source_rows()
|
||||
|
||||
@app.get("/api/integration/mh-source")
|
||||
def integration_mh_source() -> dict[str, object]:
|
||||
return fetch_mh_source_rows()
|
||||
111
backend/app/ledger_runtime.py
Normal file
111
backend/app/ledger_runtime.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
|
||||
BUSINESS_LEDGER_DEFAULT_SOURCE_KEY = "business_ledger_default"
|
||||
|
||||
|
||||
def sync_default_business_ledger_source(cur, incoming_files_dir: Path, served_dir: Path) -> 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
|
||||
|
||||
business_dashboard_dir = incoming_files_dir / "사업관리대장"
|
||||
business_ledger_served_dir = served_dir / "ledger"
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_business_ledger_default_response(cur) -> Response:
|
||||
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",
|
||||
"X-Original-Filename": quote(filename),
|
||||
"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/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def build_ledger_index_response(ledger_index_path: Path) -> FileResponse:
|
||||
if not ledger_index_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Business ledger integration file not found.")
|
||||
response = FileResponse(ledger_index_path)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
3906
backend/app/main.py
3906
backend/app/main.py
File diff suppressed because it is too large
Load Diff
147
backend/app/member_routes.py
Normal file
147
backend/app/member_routes.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Body, FastAPI, File, HTTPException, UploadFile
|
||||
|
||||
|
||||
def register_member_routes(
|
||||
app: FastAPI,
|
||||
*,
|
||||
get_conn,
|
||||
member_payload_cls,
|
||||
member_bulk_payload_cls,
|
||||
parse_as_of: Callable[[str | None], datetime | None],
|
||||
fetch_members: Callable[[], list[dict[str, object]]],
|
||||
fetch_members_as_of: Callable[[object, datetime], list[dict[str, object]]],
|
||||
build_member_compare_items: Callable[[list[dict[str, object]], list[dict[str, object]]], list[dict[str, object]]],
|
||||
serialize_member_payload: Callable[[object, int], tuple[object, ...]],
|
||||
sync_auth_users_from_members: Callable[[object], None],
|
||||
create_history_revision: Callable[[object, str, str], int],
|
||||
fetch_history_revision_created_at: Callable[[object, int], datetime],
|
||||
sync_member_versions: Callable[[object, list[int], str, int], None],
|
||||
sync_seat_assignment_versions: Callable[[object, list[int], str, int], None],
|
||||
replace_members: Callable[[list[object]], list[dict[str, object]]],
|
||||
parse_import_rows: Callable[[UploadFile, bytes], list[object]],
|
||||
) -> None:
|
||||
@app.get("/api/members")
|
||||
def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]]:
|
||||
parsed_as_of = parse_as_of(as_of)
|
||||
if parsed_as_of is None:
|
||||
return {"items": fetch_members()}
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
return {"items": fetch_members_as_of(cur, parsed_as_of)}
|
||||
|
||||
@app.get("/api/history/members/compare")
|
||||
def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]:
|
||||
parsed_from = parse_as_of(from_date)
|
||||
parsed_to = parse_as_of(to_date)
|
||||
if parsed_from is None or parsed_to is None:
|
||||
raise HTTPException(status_code=400, detail="from_date and to_date are required.")
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
from_items = fetch_members_as_of(cur, parsed_from)
|
||||
to_items = fetch_members_as_of(cur, parsed_to)
|
||||
return {"items": build_member_compare_items(from_items, to_items)}
|
||||
|
||||
@app.post("/api/members")
|
||||
def create_member(payload: dict = Body(...)) -> dict[str, object]:
|
||||
payload = member_payload_cls.model_validate(payload)
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM members")
|
||||
next_order = int(cur.fetchone()["next_order"])
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO members (
|
||||
name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, seat_label, photo_url, sort_order
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, seat_label, photo_url,
|
||||
sort_order, created_at, updated_at
|
||||
""",
|
||||
serialize_member_payload(payload, payload.sort_order if payload.sort_order is not None else next_order),
|
||||
)
|
||||
member = cur.fetchone()
|
||||
sync_auth_users_from_members(cur)
|
||||
revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}")
|
||||
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||
sync_member_versions(cur, [int(member["id"])], "member-create", revision_no, revision_created_at)
|
||||
conn.commit()
|
||||
return {"item": member}
|
||||
|
||||
@app.put("/api/members/bulk-sync")
|
||||
def bulk_sync_members(payload: dict = Body(...)) -> dict[str, list[dict[str, object]]]:
|
||||
payload = member_bulk_payload_cls.model_validate(payload)
|
||||
return {"items": replace_members(payload.items)}
|
||||
|
||||
@app.put("/api/members/{member_id}")
|
||||
def update_member(member_id: int, payload: dict = Body(...)) -> dict[str, object]:
|
||||
payload = member_payload_cls.model_validate(payload)
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE members
|
||||
SET name = %s,
|
||||
employee_id = %s,
|
||||
company = %s,
|
||||
rank = %s,
|
||||
role = %s,
|
||||
department = %s,
|
||||
grp = %s,
|
||||
division = %s,
|
||||
team = %s,
|
||||
cell = %s,
|
||||
work_status = %s,
|
||||
work_time = %s,
|
||||
phone = %s,
|
||||
email = %s,
|
||||
seat_label = %s,
|
||||
photo_url = %s,
|
||||
sort_order = COALESCE(%s, sort_order),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, seat_label, photo_url,
|
||||
sort_order, created_at, updated_at
|
||||
""",
|
||||
(*serialize_member_payload(payload, payload.sort_order or 0)[:-1], payload.sort_order, member_id),
|
||||
)
|
||||
member = cur.fetchone()
|
||||
if member is None:
|
||||
raise HTTPException(status_code=404, detail="Member not found.")
|
||||
sync_auth_users_from_members(cur)
|
||||
revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}")
|
||||
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||
sync_member_versions(cur, [member_id], "member-update", revision_no, revision_created_at)
|
||||
sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no, revision_created_at)
|
||||
conn.commit()
|
||||
return {"item": member}
|
||||
|
||||
@app.delete("/api/members/{member_id}")
|
||||
def delete_member(member_id: int) -> dict[str, bool]:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM members WHERE id = %s", (member_id,))
|
||||
deleted = cur.rowcount > 0
|
||||
if deleted:
|
||||
sync_auth_users_from_members(cur)
|
||||
revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}")
|
||||
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||
sync_member_versions(cur, [member_id], "member-delete", revision_no, revision_created_at)
|
||||
sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no, revision_created_at)
|
||||
conn.commit()
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Member not found.")
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/members/import")
|
||||
async def import_members(file: UploadFile = File(...)) -> dict[str, list[dict[str, object]]]:
|
||||
content = await file.read()
|
||||
items = parse_import_rows(file, content)
|
||||
return {"items": replace_members(items)}
|
||||
19
backend/app/repositories/__init__.py
Normal file
19
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .organization import (
|
||||
fetch_current_member_state,
|
||||
fetch_current_seat_assignments,
|
||||
fetch_history_revision,
|
||||
fetch_history_revision_created_at,
|
||||
fetch_history_revisions,
|
||||
fetch_members,
|
||||
fetch_members_as_of,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"fetch_current_member_state",
|
||||
"fetch_current_seat_assignments",
|
||||
"fetch_history_revision",
|
||||
"fetch_history_revision_created_at",
|
||||
"fetch_history_revisions",
|
||||
"fetch_members",
|
||||
"fetch_members_as_of",
|
||||
]
|
||||
145
backend/app/repositories/organization.py
Normal file
145
backend/app/repositories/organization.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def fetch_members(get_conn) -> list[dict[str, object]]:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, seat_label, photo_url,
|
||||
sort_order, created_at, updated_at
|
||||
FROM members
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
"""
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_history_revision(cur, revision_id: int) -> dict[str, object] | None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, revision_label, created_at, note
|
||||
FROM history_revisions
|
||||
WHERE scope = 'organization'
|
||||
AND id = %s
|
||||
""",
|
||||
(revision_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def fetch_history_revisions(
|
||||
cur,
|
||||
*,
|
||||
app_timezone,
|
||||
day: date | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, object]]:
|
||||
safe_limit = max(1, min(int(limit), 500))
|
||||
if day is None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, revision_label, created_at, note
|
||||
FROM history_revisions
|
||||
WHERE scope = 'organization'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(safe_limit,),
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
day_start = datetime.combine(day, time.min, tzinfo=app_timezone)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, revision_label, created_at, note
|
||||
FROM history_revisions
|
||||
WHERE scope = 'organization'
|
||||
AND created_at >= %s
|
||||
AND created_at < %s
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(day_start, day_end, safe_limit),
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_history_revision_created_at(cur, revision_id: int) -> datetime:
|
||||
revision = fetch_history_revision(cur, revision_id)
|
||||
if revision is None:
|
||||
raise HTTPException(status_code=404, detail="History revision not found.")
|
||||
return revision["created_at"]
|
||||
|
||||
|
||||
def fetch_current_member_state(cur) -> dict[int, dict[str, object]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, seat_label, photo_url, sort_order,
|
||||
created_at, updated_at
|
||||
FROM members
|
||||
"""
|
||||
)
|
||||
return {int(row["id"]): row for row in cur.fetchall()}
|
||||
|
||||
|
||||
def fetch_current_seat_assignments(cur) -> dict[int, dict[str, object]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, seat_map_id, seat_slot_id, seat_label, updated_at
|
||||
FROM seat_positions
|
||||
"""
|
||||
)
|
||||
return {int(row["member_id"]): row for row in cur.fetchall()}
|
||||
|
||||
|
||||
def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT mv.member_id AS id,
|
||||
mv.name,
|
||||
COALESCE(m.employee_id, '') AS employee_id,
|
||||
mv.company,
|
||||
mv.rank,
|
||||
mv.role,
|
||||
mv.department,
|
||||
mv.grp,
|
||||
mv.division,
|
||||
mv.team,
|
||||
mv.cell,
|
||||
mv.work_status,
|
||||
mv.work_time,
|
||||
mv.phone,
|
||||
mv.email,
|
||||
COALESCE(sav.seat_label, '') AS seat_label,
|
||||
mv.photo_url,
|
||||
COALESCE(m.sort_order, 2147483647) AS sort_order,
|
||||
mv.created_at,
|
||||
mv.valid_from AS updated_at,
|
||||
mv.valid_to AS history_valid_to,
|
||||
mv.revision_no,
|
||||
hr.created_at AS revision_created_at
|
||||
FROM member_versions mv
|
||||
LEFT JOIN members m
|
||||
ON m.id = mv.member_id
|
||||
LEFT JOIN history_revisions hr
|
||||
ON hr.id = mv.revision_no
|
||||
LEFT JOIN seat_assignment_versions sav
|
||||
ON sav.member_id = mv.member_id
|
||||
AND sav.valid_from <= %s
|
||||
AND (sav.valid_to IS NULL OR sav.valid_to > %s)
|
||||
WHERE mv.valid_from <= %s
|
||||
AND (mv.valid_to IS NULL OR mv.valid_to > %s)
|
||||
ORDER BY COALESCE(m.sort_order, 2147483647) ASC, mv.member_id ASC
|
||||
""",
|
||||
(as_of, as_of, as_of, as_of),
|
||||
)
|
||||
return cur.fetchall()
|
||||
13
backend/app/routes/__init__.py
Normal file
13
backend/app/routes/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from .auth import register_auth_routes
|
||||
from .integration import register_integration_routes
|
||||
from .organization import register_member_routes
|
||||
from .seatmap import register_seatmap_routes
|
||||
from .system import register_system_routes
|
||||
|
||||
__all__ = [
|
||||
"register_auth_routes",
|
||||
"register_integration_routes",
|
||||
"register_member_routes",
|
||||
"register_seatmap_routes",
|
||||
"register_system_routes",
|
||||
]
|
||||
3
backend/app/routes/auth.py
Normal file
3
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..auth_routes import register_auth_routes
|
||||
|
||||
__all__ = ["register_auth_routes"]
|
||||
3
backend/app/routes/integration.py
Normal file
3
backend/app/routes/integration.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..integration_routes import register_integration_routes
|
||||
|
||||
__all__ = ["register_integration_routes"]
|
||||
3
backend/app/routes/organization.py
Normal file
3
backend/app/routes/organization.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..member_routes import register_member_routes
|
||||
|
||||
__all__ = ["register_member_routes"]
|
||||
3
backend/app/routes/seatmap.py
Normal file
3
backend/app/routes/seatmap.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..seatmap_routes import register_seatmap_routes
|
||||
|
||||
__all__ = ["register_seatmap_routes"]
|
||||
3
backend/app/routes/system.py
Normal file
3
backend/app/routes/system.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ..system_routes import register_system_routes
|
||||
|
||||
__all__ = ["register_system_routes"]
|
||||
15
backend/app/schemas/__init__.py
Normal file
15
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .organization import (
|
||||
MemberBulkPayload,
|
||||
MemberPayload,
|
||||
SeatLayoutPayload,
|
||||
SeatLayoutPlacementPayload,
|
||||
SeatMapPayload,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MemberBulkPayload",
|
||||
"MemberPayload",
|
||||
"SeatLayoutPayload",
|
||||
"SeatLayoutPlacementPayload",
|
||||
"SeatMapPayload",
|
||||
]
|
||||
58
backend/app/schemas/organization.py
Normal file
58
backend/app/schemas/organization.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MemberPayload(BaseModel):
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
employee_id: str = ""
|
||||
company: str = ""
|
||||
rank: str = ""
|
||||
role: str = ""
|
||||
department: str = ""
|
||||
grp: str = ""
|
||||
division: str = ""
|
||||
team: str = ""
|
||||
cell: str = ""
|
||||
work_status: str = ""
|
||||
work_time: str = ""
|
||||
phone: str = ""
|
||||
email: str = ""
|
||||
seat_label: str = ""
|
||||
photo_url: str = ""
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class MemberBulkPayload(BaseModel):
|
||||
items: list[MemberPayload]
|
||||
|
||||
|
||||
class SeatMapPayload(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
image_url: str = ""
|
||||
source_type: str = "image"
|
||||
source_url: str = ""
|
||||
preview_svg: str = ""
|
||||
view_box_min_x: float | None = None
|
||||
view_box_min_y: float | None = None
|
||||
view_box_width: float | None = None
|
||||
view_box_height: float | None = None
|
||||
image_width: int | None = None
|
||||
image_height: int | None = None
|
||||
grid_rows: int = Field(default=1, ge=1, le=200)
|
||||
grid_cols: int = Field(default=1, ge=1, le=200)
|
||||
cell_gap: int = Field(default=0, ge=0, le=24)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class SeatLayoutPlacementPayload(BaseModel):
|
||||
member_id: int
|
||||
seat_slot_id: int | None = None
|
||||
row_index: int = Field(default=0, ge=0)
|
||||
col_index: int = Field(default=0, ge=0)
|
||||
seat_label: str = ""
|
||||
|
||||
|
||||
class SeatLayoutPayload(BaseModel):
|
||||
placements: list[SeatLayoutPlacementPayload]
|
||||
224
backend/app/seatmap_routes.py
Normal file
224
backend/app/seatmap_routes.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
|
||||
def register_seatmap_routes(
|
||||
app: FastAPI,
|
||||
*,
|
||||
upload_dir: Path,
|
||||
get_conn,
|
||||
seat_map_payload_cls,
|
||||
seat_layout_payload_cls,
|
||||
fixed_office_source_key: str,
|
||||
parse_dxf_layout: Callable[[Path], tuple[dict[str, object], list[dict[str, object]]]],
|
||||
serialize_seat_map_payload: Callable[[object], tuple[object, ...]],
|
||||
fetch_seat_layout: Callable[[int, object], dict[str, object]],
|
||||
parse_as_of: Callable[[str | None], object],
|
||||
ensure_fixed_office_seat_map: Callable[[str, bool], dict[str, object] | None],
|
||||
build_center_chair_viewer_html: Callable[[dict[str, object]], str],
|
||||
save_seat_layout: Callable[[int, object], list[dict[str, object]]],
|
||||
) -> None:
|
||||
@app.post("/api/uploads/profile-photo")
|
||||
def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("")) -> dict[str, str]:
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
||||
raise HTTPException(status_code=400, detail="Only image files are allowed.")
|
||||
stem = member_name.strip().replace(" ", "-") or "member"
|
||||
filename = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
|
||||
target = upload_dir / filename
|
||||
with target.open("wb") as out_file:
|
||||
shutil.copyfileobj(file.file, out_file)
|
||||
return {"url": f"/uploads/{filename}"}
|
||||
|
||||
@app.post("/api/uploads/seat-map-image")
|
||||
def upload_seat_map_image(file: UploadFile = File(...), seat_map_name: str = Form("")) -> dict[str, str]:
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
||||
raise HTTPException(status_code=400, detail="Only image files are allowed.")
|
||||
stem = seat_map_name.strip().replace(" ", "-") or "seat-map"
|
||||
filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
|
||||
target = upload_dir / filename
|
||||
with target.open("wb") as out_file:
|
||||
shutil.copyfileobj(file.file, out_file)
|
||||
return {"url": f"/uploads/{filename}"}
|
||||
|
||||
@app.post("/api/seat-maps/dxf")
|
||||
async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...)) -> dict[str, object]:
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix != ".dxf":
|
||||
raise HTTPException(status_code=400, detail="DXF 파일만 업로드할 수 있습니다.")
|
||||
|
||||
stem = name.strip().replace(" ", "-") or "seat-map"
|
||||
filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
|
||||
target = upload_dir / filename
|
||||
content = await file.read()
|
||||
with target.open("wb") as out_file:
|
||||
out_file.write(content)
|
||||
|
||||
metadata, slots = parse_dxf_layout(target)
|
||||
|
||||
payload = seat_map_payload_cls(
|
||||
name=name.strip(),
|
||||
source_type="dxf",
|
||||
source_url=f"/uploads/{filename}",
|
||||
image_url="",
|
||||
preview_svg=metadata["preview_svg"],
|
||||
view_box_min_x=metadata["view_box_min_x"],
|
||||
view_box_min_y=metadata["view_box_min_y"],
|
||||
view_box_width=metadata["view_box_width"],
|
||||
view_box_height=metadata["view_box_height"],
|
||||
image_width=None,
|
||||
image_height=None,
|
||||
grid_rows=1,
|
||||
grid_cols=1,
|
||||
cell_gap=0,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO seat_maps (
|
||||
name, source_type, source_url, preview_svg,
|
||||
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||
image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, name, source_type, source_url, preview_svg,
|
||||
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||
image_url, image_width, image_height, grid_rows, grid_cols,
|
||||
cell_gap, is_active, created_at, updated_at
|
||||
""",
|
||||
serialize_seat_map_payload(payload),
|
||||
)
|
||||
seat_map = cur.fetchone()
|
||||
for slot in slots:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO seat_slots (seat_map_id, slot_key, label, x, y, rotation, layer_name)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
seat_map["id"],
|
||||
slot["slot_key"],
|
||||
slot["label"],
|
||||
slot["x"],
|
||||
slot["y"],
|
||||
slot["rotation"],
|
||||
slot["layer_name"],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return fetch_seat_layout(int(seat_map["id"]))
|
||||
|
||||
@app.get("/api/seat-maps/active")
|
||||
def get_active_seat_map(office_key: str | None = None) -> dict[str, dict[str, object]]:
|
||||
requested_key = (office_key or "").strip() or fixed_office_source_key
|
||||
seat_map = ensure_fixed_office_seat_map(requested_key, activate=requested_key == fixed_office_source_key)
|
||||
if seat_map is None:
|
||||
raise HTTPException(status_code=404, detail="Active seat map not found.")
|
||||
return {"item": seat_map}
|
||||
|
||||
@app.post("/api/seat-maps")
|
||||
def create_seat_map(payload: seat_map_payload_cls) -> dict[str, dict[str, object]]:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
if payload.is_active:
|
||||
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO seat_maps (
|
||||
name, source_type, source_url, preview_svg,
|
||||
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||
image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, name, source_type, source_url, preview_svg,
|
||||
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||
image_url, image_width, image_height, grid_rows, grid_cols,
|
||||
cell_gap, is_active, created_at, updated_at
|
||||
""",
|
||||
serialize_seat_map_payload(payload),
|
||||
)
|
||||
seat_map = cur.fetchone()
|
||||
conn.commit()
|
||||
return {"item": seat_map}
|
||||
|
||||
@app.put("/api/seat-maps/{seat_map_id}")
|
||||
def update_seat_map(seat_map_id: int, payload: seat_map_payload_cls) -> dict[str, dict[str, object]]:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
if payload.source_type != "dxf":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM seat_positions
|
||||
WHERE seat_map_id = %s
|
||||
AND (row_index >= %s OR col_index >= %s)
|
||||
""",
|
||||
(seat_map_id, payload.grid_rows, payload.grid_cols),
|
||||
)
|
||||
out_of_bounds_count = int(cur.fetchone()["count"])
|
||||
if out_of_bounds_count > 0:
|
||||
raise HTTPException(status_code=400, detail="현재 배치된 좌석이 새 그리드 범위를 벗어납니다. 먼저 좌석 배치를 정리하세요.")
|
||||
if payload.is_active:
|
||||
cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE AND id <> %s", (seat_map_id,))
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE seat_maps
|
||||
SET name = %s,
|
||||
source_type = %s,
|
||||
source_url = %s,
|
||||
preview_svg = %s,
|
||||
view_box_min_x = %s,
|
||||
view_box_min_y = %s,
|
||||
view_box_width = %s,
|
||||
view_box_height = %s,
|
||||
image_url = %s,
|
||||
image_width = %s,
|
||||
image_height = %s,
|
||||
grid_rows = %s,
|
||||
grid_cols = %s,
|
||||
cell_gap = %s,
|
||||
is_active = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, name, source_type, source_url, preview_svg,
|
||||
view_box_min_x, view_box_min_y, view_box_width, view_box_height,
|
||||
image_url, image_width, image_height, grid_rows, grid_cols,
|
||||
cell_gap, is_active, created_at, updated_at
|
||||
""",
|
||||
(*serialize_seat_map_payload(payload), seat_map_id),
|
||||
)
|
||||
seat_map = cur.fetchone()
|
||||
if seat_map is None:
|
||||
raise HTTPException(status_code=404, detail="Seat map not found.")
|
||||
conn.commit()
|
||||
return {"item": seat_map}
|
||||
|
||||
@app.get("/api/seat-maps/{seat_map_id}/layout")
|
||||
def get_seat_layout(seat_map_id: int, as_of: str | None = None) -> dict[str, object]:
|
||||
return fetch_seat_layout(seat_map_id, parse_as_of(as_of))
|
||||
|
||||
@app.get("/api/seat-maps/{seat_map_id}/viewer")
|
||||
def get_seat_map_viewer(seat_map_id: int, as_of: str | None = None) -> HTMLResponse:
|
||||
layout = fetch_seat_layout(seat_map_id, parse_as_of(as_of))
|
||||
seat_map = layout.get("seat_map") or {}
|
||||
if seat_map.get("source_type") not in {"dxf", "fixed_html"}:
|
||||
raise HTTPException(status_code=400, detail="Viewer is only available for supported seat maps.")
|
||||
return HTMLResponse(build_center_chair_viewer_html(layout))
|
||||
|
||||
@app.put("/api/seat-maps/{seat_map_id}/layout")
|
||||
def update_seat_layout(seat_map_id: int, payload: seat_layout_payload_cls) -> dict[str, list[dict[str, object]]]:
|
||||
return {"items": save_seat_layout(seat_map_id, payload)}
|
||||
21
backend/app/services/__init__.py
Normal file
21
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from .organization import (
|
||||
create_history_revision,
|
||||
merge_import_member,
|
||||
normalize_phone,
|
||||
pick_existing_member,
|
||||
replace_members,
|
||||
serialize_member_payload,
|
||||
sync_member_versions,
|
||||
sync_seat_assignment_versions,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_history_revision",
|
||||
"merge_import_member",
|
||||
"normalize_phone",
|
||||
"pick_existing_member",
|
||||
"replace_members",
|
||||
"serialize_member_payload",
|
||||
"sync_member_versions",
|
||||
"sync_seat_assignment_versions",
|
||||
]
|
||||
350
backend/app/services/organization.py
Normal file
350
backend/app/services/organization.py
Normal file
@@ -0,0 +1,350 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
from ..repositories import (
|
||||
fetch_current_member_state,
|
||||
fetch_current_seat_assignments,
|
||||
fetch_history_revision_created_at,
|
||||
fetch_members,
|
||||
)
|
||||
from ..schemas import MemberPayload
|
||||
|
||||
|
||||
def normalize_phone(value: object) -> str:
|
||||
raw = str(value or "").strip()
|
||||
digits = "".join(ch for ch in raw if ch.isdigit())
|
||||
if not digits:
|
||||
return ""
|
||||
if len(digits) == 10 and not digits.startswith("0"):
|
||||
digits = f"0{digits}"
|
||||
if len(digits) == 11 and digits.startswith("0"):
|
||||
return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}"
|
||||
if len(digits) == 10 and digits.startswith("0"):
|
||||
return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
|
||||
return raw
|
||||
|
||||
|
||||
def serialize_member_payload(item, sort_order: int) -> tuple[object, ...]:
|
||||
return (
|
||||
item.name.strip(),
|
||||
item.employee_id.strip(),
|
||||
item.company.strip(),
|
||||
item.rank.strip(),
|
||||
item.role.strip(),
|
||||
item.department.strip(),
|
||||
item.grp.strip(),
|
||||
item.division.strip(),
|
||||
item.team.strip(),
|
||||
item.cell.strip(),
|
||||
item.work_status.strip(),
|
||||
item.work_time.strip(),
|
||||
normalize_phone(item.phone),
|
||||
item.email.strip(),
|
||||
item.seat_label.strip(),
|
||||
item.photo_url.strip(),
|
||||
sort_order,
|
||||
)
|
||||
|
||||
|
||||
def create_history_revision(cur, label_prefix: str, note: str, *, app_timezone) -> int:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO history_revisions (scope, revision_label, note)
|
||||
VALUES ('organization', %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(f"{label_prefix}-{datetime.now(app_timezone).strftime('%Y%m%d-%H%M%S-%f')}", note),
|
||||
)
|
||||
return int(cur.fetchone()["id"])
|
||||
|
||||
|
||||
def sync_member_versions(
|
||||
cur,
|
||||
member_ids: list[int],
|
||||
change_reason: str,
|
||||
revision_no: int,
|
||||
effective_at: datetime | None = None,
|
||||
) -> None:
|
||||
if not member_ids:
|
||||
return
|
||||
event_at = effective_at or datetime.now(timezone.utc)
|
||||
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
|
||||
current_members = fetch_current_member_state(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, photo_url, valid_from, valid_to
|
||||
FROM member_versions
|
||||
WHERE member_id = ANY(%s)
|
||||
AND valid_to IS NULL
|
||||
""",
|
||||
(unique_ids,),
|
||||
)
|
||||
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
|
||||
|
||||
for member_id in unique_ids:
|
||||
current = current_members.get(member_id)
|
||||
active = active_versions.get(member_id)
|
||||
if current is None:
|
||||
if active is not None:
|
||||
cur.execute(
|
||||
"UPDATE member_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||
(event_at, int(active["id"])),
|
||||
)
|
||||
continue
|
||||
|
||||
current_tuple = (
|
||||
str(current.get("name") or ""),
|
||||
str(current.get("company") or ""),
|
||||
str(current.get("rank") or ""),
|
||||
str(current.get("role") or ""),
|
||||
str(current.get("department") or ""),
|
||||
str(current.get("grp") or ""),
|
||||
str(current.get("division") or ""),
|
||||
str(current.get("team") or ""),
|
||||
str(current.get("cell") or ""),
|
||||
str(current.get("work_status") or ""),
|
||||
str(current.get("work_time") or ""),
|
||||
str(current.get("phone") or ""),
|
||||
str(current.get("email") or ""),
|
||||
str(current.get("photo_url") or ""),
|
||||
)
|
||||
active_tuple = None
|
||||
if active is not None:
|
||||
active_tuple = (
|
||||
str(active.get("name") or ""),
|
||||
str(active.get("company") or ""),
|
||||
str(active.get("rank") or ""),
|
||||
str(active.get("role") or ""),
|
||||
str(active.get("department") or ""),
|
||||
str(active.get("grp") or ""),
|
||||
str(active.get("division") or ""),
|
||||
str(active.get("team") or ""),
|
||||
str(active.get("cell") or ""),
|
||||
str(active.get("work_status") or ""),
|
||||
str(active.get("work_time") or ""),
|
||||
str(active.get("phone") or ""),
|
||||
str(active.get("email") or ""),
|
||||
str(active.get("photo_url") or ""),
|
||||
)
|
||||
if active_tuple == current_tuple:
|
||||
continue
|
||||
if active is not None:
|
||||
cur.execute(
|
||||
"UPDATE member_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||
(event_at, int(active["id"])),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO member_versions (
|
||||
member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, photo_url,
|
||||
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, NULL, %s)
|
||||
""",
|
||||
(member_id, *current_tuple, event_at, revision_no, change_reason),
|
||||
)
|
||||
|
||||
|
||||
def sync_seat_assignment_versions(
|
||||
cur,
|
||||
member_ids: list[int],
|
||||
change_reason: str,
|
||||
revision_no: int,
|
||||
effective_at: datetime | None = None,
|
||||
) -> None:
|
||||
if not member_ids:
|
||||
return
|
||||
event_at = effective_at or datetime.now(timezone.utc)
|
||||
unique_ids = sorted(set(int(member_id) for member_id in member_ids))
|
||||
current_assignments = fetch_current_seat_assignments(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, member_id, seat_map_id, seat_slot_id, seat_label
|
||||
FROM seat_assignment_versions
|
||||
WHERE member_id = ANY(%s)
|
||||
AND valid_to IS NULL
|
||||
""",
|
||||
(unique_ids,),
|
||||
)
|
||||
active_versions = {int(row["member_id"]): row for row in cur.fetchall()}
|
||||
|
||||
for member_id in unique_ids:
|
||||
current = current_assignments.get(member_id)
|
||||
active = active_versions.get(member_id)
|
||||
current_tuple = None
|
||||
if current is not None:
|
||||
current_tuple = (
|
||||
current.get("seat_map_id"),
|
||||
current.get("seat_slot_id"),
|
||||
str(current.get("seat_label") or ""),
|
||||
)
|
||||
active_tuple = None
|
||||
if active is not None:
|
||||
active_tuple = (
|
||||
active.get("seat_map_id"),
|
||||
active.get("seat_slot_id"),
|
||||
str(active.get("seat_label") or ""),
|
||||
)
|
||||
if active_tuple == current_tuple:
|
||||
continue
|
||||
if active is not None:
|
||||
cur.execute(
|
||||
"UPDATE seat_assignment_versions SET valid_to = %s WHERE id = %s AND valid_to IS NULL",
|
||||
(event_at, int(active["id"])),
|
||||
)
|
||||
if current is None:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO seat_assignment_versions (
|
||||
member_id, seat_map_id, seat_slot_id, seat_label,
|
||||
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, NULL, %s, NULL, %s)
|
||||
""",
|
||||
(
|
||||
member_id,
|
||||
current.get("seat_map_id"),
|
||||
current.get("seat_slot_id"),
|
||||
str(current.get("seat_label") or ""),
|
||||
event_at,
|
||||
revision_no,
|
||||
change_reason,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def merge_import_member(item: MemberPayload, existing: dict[str, object] | None) -> MemberPayload:
|
||||
if existing is None:
|
||||
return item
|
||||
|
||||
payload = item.model_copy(deep=True)
|
||||
if not payload.photo_url.strip():
|
||||
payload.photo_url = str(existing.get("photo_url") or "")
|
||||
if not payload.seat_label.strip():
|
||||
payload.seat_label = str(existing.get("seat_label") or "")
|
||||
return payload
|
||||
|
||||
|
||||
def pick_existing_member(
|
||||
item: MemberPayload,
|
||||
existing_by_employee_id: dict[str, list[dict[str, object]]],
|
||||
existing_by_name: dict[str, list[dict[str, object]]],
|
||||
matched_ids: set[int],
|
||||
) -> dict[str, object] | None:
|
||||
employee_id = item.employee_id.strip()
|
||||
if employee_id:
|
||||
for candidate in existing_by_employee_id.get(employee_id, []):
|
||||
candidate_id = int(candidate["id"])
|
||||
if candidate_id not in matched_ids:
|
||||
return candidate
|
||||
|
||||
name = item.name.strip()
|
||||
if name:
|
||||
available = [
|
||||
candidate
|
||||
for candidate in existing_by_name.get(name, [])
|
||||
if int(candidate["id"]) not in matched_ids
|
||||
]
|
||||
if len(available) == 1:
|
||||
return available[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def replace_members(
|
||||
items: list[MemberPayload],
|
||||
*,
|
||||
get_conn,
|
||||
sync_auth_users_from_members: Callable[[object], None],
|
||||
app_timezone,
|
||||
) -> list[dict[str, object]]:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, seat_label, photo_url,
|
||||
sort_order, created_at, updated_at
|
||||
FROM members
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
)
|
||||
existing_members = cur.fetchall()
|
||||
|
||||
existing_by_employee_id: dict[str, list[dict[str, object]]] = {}
|
||||
existing_by_name: dict[str, list[dict[str, object]]] = {}
|
||||
for member in existing_members:
|
||||
employee_id = str(member.get("employee_id") or "").strip()
|
||||
name = str(member.get("name") or "").strip()
|
||||
if employee_id:
|
||||
existing_by_employee_id.setdefault(employee_id, []).append(member)
|
||||
if name:
|
||||
existing_by_name.setdefault(name, []).append(member)
|
||||
|
||||
matched_ids: set[int] = set()
|
||||
for index, item in enumerate(items):
|
||||
existing = pick_existing_member(item, existing_by_employee_id, existing_by_name, matched_ids)
|
||||
merged_item = merge_import_member(item, existing)
|
||||
if existing is None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO members (
|
||||
name, employee_id, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, seat_label, photo_url, sort_order
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
serialize_member_payload(merged_item, index),
|
||||
)
|
||||
continue
|
||||
|
||||
matched_ids.add(int(existing["id"]))
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE members
|
||||
SET name = %s,
|
||||
employee_id = %s,
|
||||
company = %s,
|
||||
rank = %s,
|
||||
role = %s,
|
||||
department = %s,
|
||||
grp = %s,
|
||||
division = %s,
|
||||
team = %s,
|
||||
cell = %s,
|
||||
work_status = %s,
|
||||
work_time = %s,
|
||||
phone = %s,
|
||||
email = %s,
|
||||
seat_label = %s,
|
||||
photo_url = %s,
|
||||
sort_order = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(*serialize_member_payload(merged_item, index), int(existing["id"])),
|
||||
)
|
||||
stale_ids = [int(member["id"]) for member in existing_members if int(member["id"]) not in matched_ids]
|
||||
if stale_ids:
|
||||
cur.execute("DELETE FROM members WHERE id = ANY(%s)", (stale_ids,))
|
||||
sync_auth_users_from_members(cur)
|
||||
cur.execute("SELECT id FROM members")
|
||||
current_ids = [int(row["id"]) for row in cur.fetchall()]
|
||||
affected_member_ids = sorted(set(current_ids + [int(member["id"]) for member in existing_members]))
|
||||
if affected_member_ids:
|
||||
revision_no = create_history_revision(
|
||||
cur,
|
||||
"members-bulk-sync",
|
||||
"Bulk member sync applied",
|
||||
app_timezone=app_timezone,
|
||||
)
|
||||
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||
sync_member_versions(cur, affected_member_ids, "members-bulk-sync", revision_no, revision_created_at)
|
||||
sync_seat_assignment_versions(cur, affected_member_ids, "members-bulk-sync", revision_no, revision_created_at)
|
||||
conn.commit()
|
||||
return fetch_members(get_conn)
|
||||
120
backend/app/system_routes.py
Normal file
120
backend/app/system_routes.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from .admin_db_status import fetch_db_status_snapshot, fetch_db_table_preview
|
||||
|
||||
|
||||
def register_system_routes(
|
||||
app: FastAPI,
|
||||
*,
|
||||
upload_dir: Path,
|
||||
legacy_dir: Path,
|
||||
incoming_files_dir: Path,
|
||||
incoming_served_dir: Path,
|
||||
db_status_served_dir: Path,
|
||||
business_ledger_index_path: Path,
|
||||
get_member_count: Callable[[], int],
|
||||
get_conn,
|
||||
build_business_ledger_default_response: Callable[[object], Response],
|
||||
build_ledger_index_response: Callable[[Path], FileResponse],
|
||||
) -> None:
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, object]:
|
||||
checks = {
|
||||
"upload_dir": upload_dir.exists(),
|
||||
}
|
||||
|
||||
try:
|
||||
member_count = get_member_count()
|
||||
checks["database"] = True
|
||||
except Exception:
|
||||
member_count = None
|
||||
checks["database"] = False
|
||||
|
||||
status = "ok" if all(checks.values()) else "degraded"
|
||||
return {
|
||||
"status": status,
|
||||
"checks": checks,
|
||||
"member_count": member_count,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
@app.get("/api/admin/db-status")
|
||||
def admin_db_status() -> dict[str, object]:
|
||||
return fetch_db_status_snapshot()
|
||||
|
||||
@app.get("/api/admin/db-status/table")
|
||||
def admin_db_status_table(schema: str, table: str, limit: int = 50) -> dict[str, object]:
|
||||
return fetch_db_table_preview(schema, table, limit)
|
||||
|
||||
@app.get("/admin/db-status")
|
||||
def admin_db_status_view() -> FileResponse:
|
||||
target = db_status_served_dir / "index.html"
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="DB status dashboard file not found.")
|
||||
response = FileResponse(target)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
@app.get("/api/integration/business-ledger-default")
|
||||
def integration_business_ledger_default() -> Response:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
return build_business_ledger_default_response(cur)
|
||||
|
||||
@app.get("/api/integration/mh-workbook")
|
||||
def integration_mh_workbook() -> FileResponse:
|
||||
target = incoming_files_dir / "MH.xlsx"
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="MH workbook not found.")
|
||||
return FileResponse(
|
||||
target,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
filename="MH.xlsx",
|
||||
)
|
||||
|
||||
@app.get("/legacy/organization")
|
||||
def legacy_organization() -> FileResponse:
|
||||
target = legacy_dir / "DashBoard-organization.html"
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Legacy dashboard file not found.")
|
||||
return FileResponse(target)
|
||||
|
||||
@app.get("/legacy/organization-backup")
|
||||
def legacy_organization_backup() -> FileResponse:
|
||||
target = legacy_dir / "DashBoard-organization-backup.html"
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Legacy dashboard backup not found.")
|
||||
return FileResponse(target)
|
||||
|
||||
@app.get("/integrations/payment")
|
||||
def integration_payment() -> FileResponse:
|
||||
target = incoming_served_dir / "payment.html"
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Payment integration file not found.")
|
||||
return FileResponse(target)
|
||||
|
||||
@app.get("/integrations/ledger")
|
||||
def integration_ledger() -> FileResponse:
|
||||
return build_ledger_index_response(business_ledger_index_path)
|
||||
|
||||
@app.get("/integrations/mh")
|
||||
def integration_mh() -> FileResponse:
|
||||
target = incoming_served_dir / "mh.html"
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="MH integration file not found.")
|
||||
return FileResponse(target)
|
||||
|
||||
@app.get("/uploads/{filename}")
|
||||
def get_upload(filename: str) -> FileResponse:
|
||||
target = upload_dir / filename
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Upload not found.")
|
||||
return FileResponse(target)
|
||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.35.0
|
||||
psycopg[binary]==3.2.9
|
||||
python-multipart==0.0.20
|
||||
openpyxl==3.1.5
|
||||
ezdxf==1.3.5
|
||||
|
||||
78
docker-compose.8081.yml
Normal file
78
docker-compose.8081.yml
Normal file
@@ -0,0 +1,78 @@
|
||||
services:
|
||||
proxy:
|
||||
image: nginx:1.27-alpine
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
volumes:
|
||||
- ./frontend/public:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
command: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/app:/app/backend/app:ro
|
||||
- ./DashBoard-organization.html:/app/legacy/DashBoard-organization.html:ro
|
||||
- ./DashBoard-organization-backup.html:/app/legacy/DashBoard-organization-backup.html:ro
|
||||
- ./legacy/static:/app/legacy/static:ro
|
||||
- ./incoming-files:/app/incoming-files:ro
|
||||
- uploads_data:/data/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\" || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 20s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
volumes:
|
||||
- ./frontend/public:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
|
||||
@@ -34,14 +36,19 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
command: uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/app:/app/backend/app:ro
|
||||
- ./DashBoard-organization.html:/app/legacy/DashBoard-organization.html:ro
|
||||
- ./DashBoard-organization-backup.html:/app/legacy/DashBoard-organization-backup.html:ro
|
||||
- ./legacy/static:/app/legacy/static:ro
|
||||
- ./incoming-files:/app/incoming-files:ro
|
||||
- uploads_data:/data/uploads
|
||||
- snapshots_data:/data/snapshots
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\" || exit 1"]
|
||||
@@ -69,4 +76,3 @@ services:
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
snapshots_data:
|
||||
|
||||
359
docs/AUTH_DB_DESIGN.md
Normal file
359
docs/AUTH_DB_DESIGN.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# Auth DB Design
|
||||
|
||||
## Goal
|
||||
|
||||
현재 조직도 업무 데이터와 로그인 데이터를 분리한다.
|
||||
|
||||
분리 원칙:
|
||||
- 업무 데이터는 기존 `public.members`, `seat_maps`, `seat_positions` 중심으로 유지
|
||||
- 인증/권한 데이터는 별도 `auth` 스키마로 분리
|
||||
- 로그인 사용자는 필요할 때만 `members.id` 와 연결
|
||||
|
||||
이 방식이면 비밀번호, 세션, 감사로그를 업무 데이터와 분리해서 관리할 수 있고,
|
||||
엑셀 임포트로 `members` 가 갱신돼도 인증 체계가 직접 흔들리지 않는다.
|
||||
|
||||
## Scope
|
||||
|
||||
이번 설계의 대상:
|
||||
- 사용자 계정
|
||||
- 비밀번호 해시
|
||||
- 세션
|
||||
- 역할과 권한
|
||||
- 로그인 감사로그
|
||||
- 사용자와 조직 구성원 연결
|
||||
|
||||
이번 설계에서 제외:
|
||||
- SSO 연동
|
||||
- OAuth/OpenID Connect
|
||||
- MFA
|
||||
- 비밀번호 재설정 메일 발송
|
||||
|
||||
## Recommended Structure
|
||||
|
||||
권장 구조는 "같은 PostgreSQL, 다른 스키마" 이다.
|
||||
|
||||
- 업무 스키마: `public`
|
||||
- 인증 스키마: `auth`
|
||||
|
||||
초기 운영에서는 DB 인스턴스를 분리하지 않아도 된다.
|
||||
대신 아래 원칙은 바로 적용한다.
|
||||
|
||||
- 애플리케이션 계정도 가능하면 읽기/쓰기 범위를 분리
|
||||
- 인증 관련 쿼리는 `auth.*` 만 접근
|
||||
- 업무 API 는 `public.*` 중심으로 접근
|
||||
|
||||
## Core Tables
|
||||
|
||||
### `auth.users`
|
||||
|
||||
로그인 가능한 계정의 기준 테이블.
|
||||
|
||||
주요 컬럼:
|
||||
- `id BIGSERIAL PRIMARY KEY`
|
||||
- `username TEXT NOT NULL UNIQUE`
|
||||
- `password_hash TEXT NOT NULL`
|
||||
- `display_name TEXT NOT NULL`
|
||||
- `email TEXT`
|
||||
- `status TEXT NOT NULL DEFAULT 'active'`
|
||||
- `member_id INTEGER NULL REFERENCES public.members(id) ON DELETE SET NULL`
|
||||
- `last_login_at TIMESTAMPTZ`
|
||||
- `password_changed_at TIMESTAMPTZ`
|
||||
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
|
||||
상태값 권장:
|
||||
- `active`
|
||||
- `locked`
|
||||
- `disabled`
|
||||
|
||||
원칙:
|
||||
- `username` 는 로그인 식별자
|
||||
- `member_id` 는 선택 연결
|
||||
- 구성원이 퇴사하거나 엑셀에서 빠져도 계정 자체는 바로 삭제하지 않음
|
||||
|
||||
### `auth.roles`
|
||||
|
||||
권한 묶음 정의.
|
||||
|
||||
주요 컬럼:
|
||||
- `id BIGSERIAL PRIMARY KEY`
|
||||
- `code TEXT NOT NULL UNIQUE`
|
||||
- `name TEXT NOT NULL`
|
||||
- `description TEXT`
|
||||
|
||||
초기 권장 역할:
|
||||
- `super_admin`
|
||||
- `org_admin`
|
||||
- `viewer`
|
||||
|
||||
### `auth.user_roles`
|
||||
|
||||
사용자와 역할의 다대다 연결.
|
||||
|
||||
주요 컬럼:
|
||||
- `user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE`
|
||||
- `role_id BIGINT NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE`
|
||||
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
- `PRIMARY KEY (user_id, role_id)`
|
||||
|
||||
### `auth.permissions`
|
||||
|
||||
세분화된 권한 코드 정의.
|
||||
|
||||
주요 컬럼:
|
||||
- `id BIGSERIAL PRIMARY KEY`
|
||||
- `code TEXT NOT NULL UNIQUE`
|
||||
- `name TEXT NOT NULL`
|
||||
- `description TEXT`
|
||||
|
||||
초기 권장 권한:
|
||||
- `member.read`
|
||||
- `member.write`
|
||||
- `member.import`
|
||||
- `seatmap.read`
|
||||
- `seatmap.write`
|
||||
- `photo.upload`
|
||||
- `admin.user.manage`
|
||||
|
||||
### `auth.role_permissions`
|
||||
|
||||
역할과 권한의 다대다 연결.
|
||||
|
||||
주요 컬럼:
|
||||
- `role_id BIGINT NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE`
|
||||
- `permission_id BIGINT NOT NULL REFERENCES auth.permissions(id) ON DELETE CASCADE`
|
||||
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
- `PRIMARY KEY (role_id, permission_id)`
|
||||
|
||||
### `auth.sessions`
|
||||
|
||||
서버 세션 저장 테이블.
|
||||
|
||||
주요 컬럼:
|
||||
- `id UUID PRIMARY KEY`
|
||||
- `user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE`
|
||||
- `refresh_token_hash TEXT`
|
||||
- `issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
- `expires_at TIMESTAMPTZ NOT NULL`
|
||||
- `revoked_at TIMESTAMPTZ`
|
||||
- `ip_address INET`
|
||||
- `user_agent TEXT`
|
||||
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
|
||||
원칙:
|
||||
- 브라우저 쿠키에는 세션 식별자만 저장
|
||||
- 토큰 자체를 평문으로 DB에 저장하지 않음
|
||||
- 만료와 강제 로그아웃을 DB에서 통제 가능하게 함
|
||||
|
||||
### `auth.login_audit_logs`
|
||||
|
||||
로그인 시도와 결과 기록.
|
||||
|
||||
주요 컬럼:
|
||||
- `id BIGSERIAL PRIMARY KEY`
|
||||
- `username TEXT NOT NULL`
|
||||
- `user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL`
|
||||
- `success BOOLEAN NOT NULL`
|
||||
- `failure_reason TEXT`
|
||||
- `ip_address INET`
|
||||
- `user_agent TEXT`
|
||||
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
|
||||
용도:
|
||||
- 로그인 실패 추적
|
||||
- 계정 잠금 기준 판단
|
||||
- 보안 감사 대응
|
||||
|
||||
## Relationship To Current `members`
|
||||
|
||||
핵심은 `auth.users.member_id -> public.members.id` 연결이다.
|
||||
|
||||
의미:
|
||||
- 로그인 계정과 조직도 인원을 분리한다
|
||||
- 로그인하지 않는 구성원은 `members` 에만 있어도 된다
|
||||
- 외부 관리자 계정은 `member_id` 없이 운영할 수 있다
|
||||
|
||||
권장 규칙:
|
||||
- 일반 사내 사용자는 `employee_id` 기준으로 계정-구성원 연결
|
||||
- 엑셀 동기화 시 `members.id` 유지가 중요하므로 이미 반영한 비교 기반 동기화 방식을 유지
|
||||
- `member_id` 연결이 끊긴 계정은 자동 삭제하지 말고 관리자 검토 대상으로 둔다
|
||||
|
||||
## Login Flow
|
||||
|
||||
### 1. 로그인 요청
|
||||
|
||||
입력:
|
||||
- `username`
|
||||
- `password`
|
||||
|
||||
처리:
|
||||
- `auth.users` 에서 `username` 조회
|
||||
- `status != active` 이면 거부
|
||||
- `password_hash` 검증
|
||||
- 성공 시 `auth.sessions` 생성
|
||||
- `auth.login_audit_logs` 기록
|
||||
|
||||
응답 권장:
|
||||
- 사용자 기본 정보
|
||||
- 역할 목록
|
||||
- 권한 목록
|
||||
- 세션 만료 시각
|
||||
|
||||
### 2. 인증 확인
|
||||
|
||||
각 보호 API 요청 시:
|
||||
- 세션 쿠키 또는 Bearer 토큰 확인
|
||||
- `auth.sessions` 조회
|
||||
- 만료/폐기 여부 확인
|
||||
- 사용자 상태와 역할 재검증
|
||||
|
||||
### 3. 로그아웃
|
||||
|
||||
처리:
|
||||
- 현재 세션의 `revoked_at` 업데이트
|
||||
- 클라이언트 쿠키 제거
|
||||
|
||||
## Authorization Model
|
||||
|
||||
초기에는 RBAC 기반으로 충분하다.
|
||||
|
||||
권장 역할별 범위:
|
||||
|
||||
`super_admin`
|
||||
- 사용자 관리
|
||||
- 권한 관리
|
||||
- 조직도/사진/자리배치 전체 수정
|
||||
|
||||
`org_admin`
|
||||
- 조직도 조회/수정
|
||||
- 엑셀 임포트
|
||||
- 사진 업로드
|
||||
- 자리배치 수정
|
||||
|
||||
`viewer`
|
||||
- 조직도 조회
|
||||
- 자리배치 조회
|
||||
|
||||
API 보호 예시:
|
||||
- `GET /api/members`: `member.read`
|
||||
- `POST /api/members/import`: `member.import`
|
||||
- `POST /api/uploads/profile-photo`: `photo.upload`
|
||||
- `PUT /api/seat-maps/{seat_map_id}/layout`: `seatmap.write`
|
||||
- 사용자 관리 API: `admin.user.manage`
|
||||
|
||||
## Password Policy
|
||||
|
||||
비밀번호는 평문 저장 금지.
|
||||
|
||||
권장:
|
||||
- `Argon2id` 우선
|
||||
- 대안으로 `bcrypt`
|
||||
|
||||
추가 원칙:
|
||||
- 첫 구현부터 해시 알고리즘 버전 정보 포함
|
||||
- 비밀번호 변경 시 `password_changed_at` 갱신
|
||||
- 실패 횟수 기반 잠금은 앱 로직 또는 별도 컬럼으로 확장 가능
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1
|
||||
|
||||
인증 스키마와 기본 테이블만 추가.
|
||||
|
||||
작업:
|
||||
- `CREATE SCHEMA IF NOT EXISTS auth`
|
||||
- `auth.users`
|
||||
- `auth.roles`
|
||||
- `auth.user_roles`
|
||||
- `auth.permissions`
|
||||
- `auth.role_permissions`
|
||||
- `auth.sessions`
|
||||
- `auth.login_audit_logs`
|
||||
|
||||
이 단계에서는 기존 `/api/mock-login` 유지 가능.
|
||||
|
||||
### Phase 2
|
||||
|
||||
관리자 1명 이상을 수동 생성하고 실제 로그인 API 추가.
|
||||
|
||||
권장 추가 API:
|
||||
- `POST /api/auth/login`
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/me`
|
||||
|
||||
### Phase 3
|
||||
|
||||
기존 프론트엔드의 mock 로그인 제거.
|
||||
|
||||
변경 대상:
|
||||
- [frontend/public/app.js](../frontend/public/app.js)
|
||||
- [backend/app/main.py](../backend/app/main.py)
|
||||
- [backend/app/config.py](../backend/app/config.py)
|
||||
|
||||
### Phase 4
|
||||
|
||||
권한 기반으로 API 보호 적용.
|
||||
|
||||
우선순위:
|
||||
1. 쓰기 API 보호
|
||||
2. 업로드 API 보호
|
||||
3. 읽기 API 권한 정리
|
||||
|
||||
## Recommended SQL Skeleton
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
member_id INTEGER NULL REFERENCES public.members(id) ON DELETE SET NULL,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
password_changed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.roles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.user_roles (
|
||||
user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role_id BIGINT NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
refresh_token_hash TEXT,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- 엑셀 임포트는 계속 `public.members` 기준으로 처리
|
||||
- 로그인 계정 생성은 엑셀 업로드와 분리
|
||||
- 프로필 사진은 현재처럼 파일 저장 + `members.photo_url` 참조 유지 가능
|
||||
- 감사로그와 세션은 삭제보다 보존 기간 정책으로 관리
|
||||
|
||||
## Decision
|
||||
|
||||
현재 프로젝트의 권장안은 아래 한 줄로 정리된다.
|
||||
|
||||
"로그인 DB는 `auth` 스키마로 분리하고, 업무 DB는 `public` 에 유지하며, 두 영역은 `auth.users.member_id` 로만 연결한다."
|
||||
@@ -8,13 +8,12 @@
|
||||
## 2. 이 프로젝트의 권장 구성
|
||||
- `proxy`: 사내 접속용 단일 진입점 역할을 하는 Nginx 리버스 프록시
|
||||
- `frontend`: 화면상 로그인과 허브 화면을 제공하는 정적 프론트엔드
|
||||
- `backend`: 구성원 데이터, 이미지 업로드, 스냅샷 생성을 처리하는 FastAPI 서버
|
||||
- `backend`: 구성원 데이터와 이미지 업로드를 처리하는 FastAPI 서버
|
||||
- `db`: 영구 저장을 담당하는 PostgreSQL 데이터베이스
|
||||
|
||||
## 3. 왜 이 구조가 지금 프로젝트에 맞는가
|
||||
- 기존 조직도 HTML 화면을 그대로 레거시 모듈로 유지할 수 있습니다.
|
||||
- 프로필 사진 업로드를 서버에 저장할 수 있습니다.
|
||||
- 월말 조직 데이터 스냅샷을 서버에서 생성하고 보관할 수 있습니다.
|
||||
- 요청하신 대로 로그인은 우선 화면상으로만 구현해 둘 수 있습니다.
|
||||
|
||||
## 4. Ubuntu 서버 준비
|
||||
@@ -24,19 +23,10 @@
|
||||
- 이 저장소를 서버로 복사합니다.
|
||||
- `.env.example`을 기준으로 `.env` 파일을 만들고 실제 DB 비밀번호를 넣습니다.
|
||||
|
||||
## 4-1. 현재 로컬 PC 기준 WSL 작업 표준
|
||||
- 현재 로컬 개발 서버는 `WSL2 + Ubuntu-24.04` 기준으로 구성했습니다.
|
||||
- 기본 작업 사용자는 `hyunho` 입니다.
|
||||
- 앞으로의 기준 작업 경로는 아래입니다.
|
||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
||||
- Windows 폴더는 원본 참고용으로 남아 있을 수 있지만, 실제 실행과 개발 기준은 WSL 내부 경로로 맞추는 것을 권장합니다.
|
||||
|
||||
## 4-2. VS Code는 어떤 경로를 열어야 하나
|
||||
- VS Code 좌측 아래에 `WSL: Ubuntu-24.04` 가 보이는 상태로 여는 것이 가장 안전합니다.
|
||||
- VS Code에서 `Remote-WSL: Reopen Folder in WSL` 기능으로 다시 열 수 있습니다.
|
||||
- 다시 열어야 할 권장 경로는 아래입니다.
|
||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
||||
- 이렇게 열면 Docker, Python, Linux 경로, 실행 환경이 실제 서버와 가장 비슷하게 맞춰집니다.
|
||||
## 4-1. 로컬 개발 환경 원칙
|
||||
- 로컬 개발은 Linux 계열 개발 환경을 권장합니다.
|
||||
- Docker, Python, 파일 경로가 실제 배포 환경과 최대한 비슷한 환경에서 작업하는 것이 안전합니다.
|
||||
- Windows 호스트를 사용하는 경우에도, 실제 실행 경로와 편집 경로가 어긋나지 않도록 주의합니다.
|
||||
|
||||
## 5. Docker 설치 관련 메모
|
||||
- 메신저에 공유된 명령어는 Ubuntu 서버에 Docker를 설치하기 위한 절차입니다.
|
||||
@@ -56,17 +46,16 @@
|
||||
## 7. 현재 단계의 데이터 및 백업 정책
|
||||
- 데이터베이스: PostgreSQL 볼륨 `postgres_data`
|
||||
- 업로드 파일: Docker 볼륨 `uploads_data`
|
||||
- 월말 스냅샷 파일: Docker 볼륨 `snapshots_data`
|
||||
- 백업 주기: 월말 스냅샷 생성 + DB 볼륨 백업
|
||||
- 백업 주기: DB 볼륨 백업
|
||||
- 복구 기준: 아직 정해지지 않았으므로, 우선은 수동 복구 절차를 먼저 문서화하고 이후에 기준을 구체화합니다.
|
||||
|
||||
## 8. 현재 구조의 한계
|
||||
- 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다.
|
||||
- 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동/스냅샷 흐름 검증이 추가로 필요합니다.
|
||||
- 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동 흐름 검증이 추가로 필요합니다.
|
||||
- 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다.
|
||||
|
||||
## 9. 다음 구현 권장 순서
|
||||
1. Docker Compose 기준 운영 검증과 스냅샷 검증을 완료합니다.
|
||||
1. Docker Compose 기준 운영 검증을 완료합니다.
|
||||
2. 4개 기능 통합 대시보드 프레임과 공통 헤더를 준비합니다.
|
||||
3. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다.
|
||||
4. 사무실 자리배치 좌표 저장 기능을 추가합니다.
|
||||
@@ -75,12 +64,8 @@
|
||||
## 10. 현재 로컬 테스트 접속 정보
|
||||
- 접속 주소: `http://localhost:8080`
|
||||
- 상태 확인 API: `http://localhost:8080/api/health`
|
||||
- WSL 내부 실행 경로:
|
||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
||||
|
||||
## 11. 운영 검증 체크포인트
|
||||
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
|
||||
- `docker compose restart backend proxy` 이후에도 데이터가 유지되는지 확인합니다.
|
||||
- `POST /api/snapshots/monthly` 호출 시 `YYYY-MM` 형식만 허용되는지 확인합니다.
|
||||
- 같은 월에 대해 중복 스냅샷 생성 시 409 에러가 반환되는지 확인합니다.
|
||||
- `docker compose down` 후 다시 `up -d` 했을 때 DB/업로드/스냅샷 데이터가 유지되는지 확인합니다.
|
||||
- `docker compose down` 후 다시 `up -d` 했을 때 DB/업로드 데이터가 유지되는지 확인합니다.
|
||||
|
||||
237
docs/DEVELOPMENT_HISTORY.md
Normal file
237
docs/DEVELOPMENT_HISTORY.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Development History
|
||||
|
||||
## Purpose
|
||||
|
||||
이 문서는 이 저장소에서 진행한 통합 작업을 기능 단위로 정리한 개발 히스토리다.
|
||||
목표는 다음 두 가지다.
|
||||
|
||||
- 지금까지 어떤 기능을 어떤 방식으로 붙였는지 빠르게 파악
|
||||
- 이후 유지보수나 추가 개발 시, 왜 그렇게 구현했는지 추적 가능하게 하기
|
||||
|
||||
## 1. 대시보드 허브 통합
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- `조직 현황`, `프로젝트별 분석`, `팀/개인별 분석`, `자리배치도`를 하나의 메인 허브에서 오갈 수 있도록 통합
|
||||
- 공통 헤더, 로그인 정보, 탭 전환, 공통 기간 캘린더 구성
|
||||
- `payment.html`, `mh.html`은 초기에는 iframe 연결 방식으로 편입
|
||||
|
||||
### 해결 방식
|
||||
|
||||
- 기존 화면을 새로 재개발하지 않고, 먼저 메인 허브 안에 편입
|
||||
- 이후 데이터는 공통 API/DB 기준으로 전환하는 2단계 방식 채택
|
||||
|
||||
## 2. 조직현황 고도화
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- 조직도 레거시 화면을 메인 허브에 연결
|
||||
- 관리자/비관리자 모드 정리
|
||||
- 구성원 상세 프로필 개선
|
||||
- 프로필 사진 업로드 연동
|
||||
- `재석위치` 카드 추가
|
||||
|
||||
### 해결 방식
|
||||
|
||||
- 레거시 조직도 화면은 유지하되 API를 통해 `members`를 읽는 구조로 전환
|
||||
- 상세 프로필의 `재석위치`는 문자열이 아니라 실제 자리배치도 viewer preview를 붙이는 방식으로 변경
|
||||
|
||||
## 3. 자리배치도 기능 재구성
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- `기술개발센터` 고정 도면 기준 자리배치도 viewer 구성
|
||||
- 관리자 편집 화면과 비관리자 열람 화면 분리
|
||||
- 조직도에서 `+` 버튼으로 자리배치도 진입
|
||||
- 배치된 좌석의 이름/직급 라벨 표시
|
||||
- 좌석 hover, 클릭, 자리 비우기, 미배치 목록 복귀 흐름 구현
|
||||
|
||||
### 해결 방식
|
||||
|
||||
- DXF 업로드 기반 편집을 그대로 밀기보다, 실제 업무에 맞는 고정 도면 중심 구조로 정리
|
||||
- viewer 코드는 공통으로 쓰되, 화면은
|
||||
- 관리자 편집
|
||||
- 비관리자 열람
|
||||
- 구성원 상세 preview
|
||||
로 역할 분리
|
||||
|
||||
## 4. 자리배치도 저장 문제 해결
|
||||
|
||||
### 문제
|
||||
|
||||
- 관리자 화면에서는 배치가 된 것처럼 보여도 저장 후 조직도 상세 프로필에서는 `미배치`
|
||||
- 비관리자 자리배치도에서도 배치 결과가 보이지 않음
|
||||
|
||||
### 원인
|
||||
|
||||
- `seat_positions_map_cell_idx (seat_map_id, row_index, col_index)` 유니크 인덱스가 슬롯 기반 도면에도 그대로 적용됨
|
||||
- 고정 도면 배치는 실제로 `seat_slot_id` 기준 저장인데, 모든 배치가 `(row_index=0, col_index=0)`으로 들어가 충돌
|
||||
- 그 결과 두 번째 좌석부터 `500 Internal Server Error`로 저장 실패
|
||||
|
||||
### 해결
|
||||
|
||||
- 슬롯 기반 도면에서는 `(seat_map_id, row_index, col_index)` 인덱스가 적용되지 않도록 수정
|
||||
- `seat_positions` 저장 후 `members.seat_label`도 같이 동기화
|
||||
- 조직도 iframe은 저장 완료 후 `seatmap-layout-updated` 메시지를 받아 cache를 비우고 재조회
|
||||
|
||||
### 결과
|
||||
|
||||
- 관리자 자리배치 저장이 실제 DB까지 반영됨
|
||||
- 저장된 배치는 재접속 후에도 유지
|
||||
- 조직현황 상세 프로필과 비관리자 자리배치도에서도 같은 배치를 읽을 수 있는 구조가 됨
|
||||
|
||||
## 5. 통합 DB 구축
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- `organization.xlsx`
|
||||
- `MH.xlsx`
|
||||
- `payment.csv`
|
||||
|
||||
세 원본을 하나의 PostgreSQL 기준으로 적재
|
||||
|
||||
### 핵심 테이블
|
||||
|
||||
- `members`
|
||||
- `seat_maps`
|
||||
- `seat_slots`
|
||||
- `seat_positions`
|
||||
- `integration_raw_organization_rows`
|
||||
- `integration_raw_mh_rows`
|
||||
- `integration_raw_payment_rows`
|
||||
- `integration_work_logs`
|
||||
- `integration_work_log_segments`
|
||||
- `integration_vouchers`
|
||||
- `integration_projects`
|
||||
- `integration_project_category_mappings`
|
||||
|
||||
### 해결 방식
|
||||
|
||||
- 원본 보존용 raw 테이블과 운영용 표준 테이블을 분리
|
||||
- 화면이 직접 파일을 읽지 않고, DB와 API를 통해 같은 데이터를 보도록 정리
|
||||
|
||||
## 6. 프로젝트별 분석 통합
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- `opayment.html` 원본 기준으로 화면, 카테고리, 계산식 복원
|
||||
- `payment.csv`와 `MH.xlsx`를 함께 쓰는 구조로 정리
|
||||
- `payment.csv` 분류를 1순위, `ptj.csv` 프로젝트 매핑을 2순위로 적용
|
||||
|
||||
### 해결한 문제
|
||||
|
||||
- 연장근무 시간을 `실제`가 아니라 `가공` 기준으로 써야 원본과 총합이 맞음
|
||||
- 프로젝트 분류는 `payment.csv`만으로 부족해 `ptj.csv` 보정이 필요
|
||||
|
||||
### 결과
|
||||
|
||||
- 총합과 대부분의 프로젝트 집계가 원본 `opayment` 기준에 가깝게 정렬됨
|
||||
- 남은 차이는 소수점 또는 추가 매핑 보정 수준으로 축소
|
||||
|
||||
## 7. 팀/개인별 분석 통합
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- `omh.html` 원본 기준으로 화면, 카테고리, 계산식 복원
|
||||
- 업로드형이 아니라 통합 DB raw MH 데이터를 다시 공급하는 방식으로 전환
|
||||
|
||||
### 해결 방식
|
||||
|
||||
- 원본 `MH.xlsx` 시트 구조가 깨지지 않도록 row 배열 자체를 DB에 저장
|
||||
- 팀별 진행 프로젝트 등 원본 계산식이 기대하는 입력 구조를 그대로 재현
|
||||
|
||||
## 8. 조직 데이터 운영 정리
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- 이름 alias, 퇴사 제외, 조직 override를 코드 상수에서 제거
|
||||
- DB 테이블 기반 운영으로 전환
|
||||
|
||||
### 운영 테이블
|
||||
|
||||
- `member_aliases`
|
||||
- `member_retirements`
|
||||
- `member_overrides`
|
||||
|
||||
### 결과
|
||||
|
||||
- 이름 치환, 퇴사 제외, 팀/직책 예외를 코드 수정 없이 DB에서 관리 가능
|
||||
|
||||
## 9. 외부 접속 설정
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- 개발 환경의 외부 접속 경로를 정리
|
||||
- 호스트 방화벽과 포트 포워딩 규칙으로 다른 PC 접속 가능하게 구성
|
||||
|
||||
### 유의사항
|
||||
|
||||
- 호스트 IP나 포워딩 대상 IP가 바뀌면 포트 포워딩 설정을 다시 맞춰야 한다
|
||||
- 운영 안정성을 위해 향후 자동화 스크립트화가 필요함
|
||||
|
||||
## 10. 인증 기본 구조 추가
|
||||
|
||||
### 작업 내용
|
||||
|
||||
- 프런트 로그인 화면을 실제 `/api/auth/login` API와 연결
|
||||
- 로그인 세션 확인용 `/api/auth/me` 추가
|
||||
- 로그아웃용 `/api/auth/logout` 추가
|
||||
- 로그인 감사로그와 세션 저장 테이블 추가
|
||||
|
||||
### 해결 방식
|
||||
|
||||
- 업무 데이터는 기존 `members` 중심으로 유지
|
||||
- 인증 데이터는 `auth.users`, `auth.sessions`, `auth.login_audit_logs` 로 분리
|
||||
- 구성원 import 시 사번 기준으로 계정을 동기화하고 기본 관리자 계정을 seed
|
||||
|
||||
### 현재 한계
|
||||
|
||||
- 권한 모델은 아직 `role` 단일 컬럼 수준이다
|
||||
- API별 세부 권한 검증은 아직 미완성이다
|
||||
- `/api/mock-login` 은 아직 남아 있어 운영 기준으로는 정리가 필요하다
|
||||
|
||||
## 11. 이력형 DB 전환 방향 확정
|
||||
|
||||
### 배경
|
||||
|
||||
- 월간 스냅샷 파일보다, 사용자가 원하는 날짜 기준으로 조직도와 자리배치도를 바로 조회하는 요구가 더 중요해졌다
|
||||
- 조직도 기본 정보나 자리배치 정보처럼 원래 날짜가 없는 데이터도 과거/현재 버전 차이를 추적해야 한다
|
||||
|
||||
### 결정
|
||||
|
||||
- 월간 스냅샷 기능은 범위에서 제외
|
||||
- 대신 DB 자체를 `valid_from`, `valid_to` 기반 버전 구조로 전환
|
||||
- 사용자 조회는 파일 스냅샷이 아니라 `as_of` 기준 조회 방식으로 설계
|
||||
|
||||
### 우선 적용 대상
|
||||
|
||||
- `members` -> `member_versions`
|
||||
- `seat_positions` -> `seat_assignment_versions`
|
||||
|
||||
### 기대 효과
|
||||
|
||||
- 특정 날짜의 조직 상태 재구성 가능
|
||||
- 특정 날짜의 자리배치도 재구성 가능
|
||||
- 기간 비교나 변경 추적 UI로 확장 가능
|
||||
|
||||
### 현재 반영 상태
|
||||
|
||||
- `history_revisions`
|
||||
- `member_versions`
|
||||
- `seat_assignment_versions`
|
||||
- `entity_change_events`
|
||||
|
||||
초기 단계로 테이블과 baseline backfill 경로를 먼저 추가했다.
|
||||
아직 조직도/자리배치도 쓰기 API가 매 수정마다 version row 를 append 하도록 완전히 전환된 상태는 아니다.
|
||||
|
||||
### 설계 문서
|
||||
|
||||
- [HISTORY_ASOF_DB_PLAN.md](HISTORY_ASOF_DB_PLAN.md)
|
||||
|
||||
## Next Focus
|
||||
|
||||
- `#2` 영속성 운영 검증과 문서 기준 정리
|
||||
- 권한 제어와 mock login 정리
|
||||
- `#9` as-of date 기반 history 구조 설계 및 점진적 도입
|
||||
- 자리배치도 조직 트리, 나머지 사무실 도면 등 실사용 기능 고도화
|
||||
- 프로젝트별 분석의 남은 소수점/분류 오차 정리
|
||||
216
docs/DEV_PROD_DB_PROTOCOL.md
Normal file
216
docs/DEV_PROD_DB_PROTOCOL.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Dev / Prod DB Protocol
|
||||
|
||||
## 목적
|
||||
|
||||
- `8081` 작업용은 기능 개발과 화면 검증을 먼저 수행하는 환경이다.
|
||||
- `8080` 공개용은 실제 기준 데이터와 운영 화면을 제공하는 환경이다.
|
||||
- 코드와 데이터의 기준을 분리해서 관리하되, 데이터 정본은 항상 `8080` 공개용 DB로 유지한다.
|
||||
|
||||
## 현재 구조
|
||||
|
||||
### 코드 경로
|
||||
|
||||
- 공개용 `8080`: 메인 workspace
|
||||
- 작업용 `8081`: 메인 workspace 아래의 격리 worktree
|
||||
|
||||
### 작업용 Compose 기준
|
||||
|
||||
- 공개용 `8080` stack: `docker-compose.yml`
|
||||
- 작업용 `8081` stack: `docker-compose.8081.yml`
|
||||
- 작업용 project name 기본값: `mh-dashboard-organization-dev`
|
||||
- 작업용 `8081`는 반드시 격리된 worktree에서 띄운다
|
||||
|
||||
### DB 볼륨
|
||||
|
||||
- 공개용 `8080`: `mh-dashboard-organization_postgres_data`
|
||||
- 작업용 `8081`: `mh-dashboard-organization-dev_postgres_data`
|
||||
|
||||
즉 현재는 `8080` 과 `8081` 이 코드 workspace 와 DB volume 모두 분리된 상태로 운영한다.
|
||||
|
||||
## 정본 기준
|
||||
|
||||
- 코드 선행 환경: `8081`
|
||||
- 데이터 정본: `8080`
|
||||
- 공개 반영 기준: `8081`에서 검증 완료된 코드만 `8080`에 승격
|
||||
|
||||
중요:
|
||||
- `8081` DB는 독립 정본이 아니다.
|
||||
- `8081` DB는 `8080` DB를 기준으로 맞춘 검증용 복제본이어야 한다.
|
||||
|
||||
## 왜 이 규칙이 필요한가
|
||||
|
||||
- 조직현황, 조직도, 자리배치 인원, 퇴사자 제외, 멤버 수는 코드보다 DB 영향이 크다.
|
||||
- 작업용 DB가 공개용과 달라지면 기능 검증 결과 자체가 왜곡된다.
|
||||
- 원인 분석 시 `코드 차이`와 `DB 차이`를 분리할 수 있어야 한다.
|
||||
|
||||
## 현재 확인된 차이 예시
|
||||
|
||||
2026-03-27 확인 기준:
|
||||
|
||||
- `members`
|
||||
- `8080`: `227`
|
||||
- `8081`: `236`
|
||||
- `member_retirements`
|
||||
- `8080`: `9`
|
||||
- `8081`: `0`
|
||||
- `seat_maps`
|
||||
- `8080`: `21`
|
||||
- `8081`: `3`
|
||||
- `seat_positions`
|
||||
- `8080`: `5`
|
||||
- `8081`: `0`
|
||||
- `seat_slots`
|
||||
- `8080`: `57308`
|
||||
- `8081`: `370`
|
||||
|
||||
## 기준 테이블 분류
|
||||
|
||||
### A. 공개용 정본 기준으로 항상 맞춰야 하는 테이블
|
||||
|
||||
- `members`
|
||||
- `member_aliases`
|
||||
- `member_overrides`
|
||||
- `member_retirements`
|
||||
- `seat_maps`
|
||||
- `seat_slots`
|
||||
- `seat_positions`
|
||||
|
||||
### B. 원본 재적재로 다시 만들 수 있는 통합 테이블
|
||||
|
||||
- `integration_import_batches`
|
||||
- `integration_raw_organization_rows`
|
||||
- `integration_raw_mh_rows`
|
||||
- `integration_raw_mh_pm_rows`
|
||||
- `integration_raw_payment_rows`
|
||||
- `integration_projects`
|
||||
- `integration_project_aliases`
|
||||
- `integration_project_category_mappings`
|
||||
- `integration_project_pm_assignments`
|
||||
- `integration_work_logs`
|
||||
- `integration_work_log_segments`
|
||||
- `integration_vouchers`
|
||||
|
||||
### C. 별도 정책이 필요한 영역
|
||||
|
||||
- `snapshots`
|
||||
- 인증 관련 스키마와 테이블
|
||||
|
||||
## 작업 프로토콜
|
||||
|
||||
### 1. 작업 시작 전
|
||||
|
||||
1. `8080`과 `8081` 모두 기동 상태 확인
|
||||
2. 이번 작업이 `코드 변경`인지 `데이터 변경`인지 먼저 구분
|
||||
3. 공개용 기준 데이터가 필요한 화면이면 `8081` DB를 먼저 `8080` 기준으로 맞춤
|
||||
4. 작업 전후 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||
|
||||
### 2. 기능 개발 중
|
||||
|
||||
1. 코드 수정은 먼저 `8081`에서 수행
|
||||
2. UI, 계산식, 자리배치도 동작은 `8081`에서 확인
|
||||
3. 조직도/멤버/자리배치 검증은 공개용 기준 데이터가 반영된 `8081` DB에서만 수행
|
||||
|
||||
### 3. 검증 완료 후
|
||||
|
||||
1. 코드만 `8080`으로 승격
|
||||
2. 데이터 반영이 필요한 기능은 별도 절차를 문서화한 뒤 적용
|
||||
3. 공개용 DB를 개발 실험용으로 사용하지 않음
|
||||
|
||||
## 금지 사항
|
||||
|
||||
- `8081` DB를 장기간 독립 정본처럼 취급하지 않기
|
||||
- 퇴사자, 멤버, 좌석 정보를 작업용에서 수작업으로만 유지하지 않기
|
||||
- DB 차이를 무시하고 `8081` 검증 결과가 `8080`과 같다고 가정하지 않기
|
||||
|
||||
## 권장 동기화 범위
|
||||
|
||||
### 최소 범위
|
||||
|
||||
조직도/자리배치도 검증 전 반드시 동기화:
|
||||
|
||||
1. `members`
|
||||
2. `member_aliases`
|
||||
3. `member_overrides`
|
||||
4. `member_retirements`
|
||||
5. `seat_maps`
|
||||
6. `seat_slots`
|
||||
7. `seat_positions`
|
||||
|
||||
### 전체 범위
|
||||
|
||||
분석 화면까지 공개용 기준으로 검증해야 하면 아래도 포함:
|
||||
|
||||
1. `integration_import_batches`
|
||||
2. `integration_raw_*`
|
||||
3. `integration_projects`
|
||||
4. `integration_project_*`
|
||||
5. `integration_work_logs`
|
||||
6. `integration_work_log_segments`
|
||||
7. `integration_vouchers`
|
||||
|
||||
## 세션 시작 체크
|
||||
|
||||
1. 지금 작업이 `코드 변경`인지 `데이터 변경`인지 구분
|
||||
2. 공개용 기준 데이터가 필요한지 판단
|
||||
3. 필요하면 `8081` DB를 `8080` 기준으로 먼저 동기화
|
||||
4. 그 뒤 기능 개발과 검증 수행
|
||||
5. 검증은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md) 기준으로 수행
|
||||
6. 검증 완료 후 공개용에 코드 승격
|
||||
|
||||
## 다음 액션
|
||||
|
||||
- `8081` DB를 `8080` 기준으로 맞추는 반복 가능한 동기화 절차를 만든다
|
||||
- 최소한 `A 그룹` 테이블은 수동 기억에 의존하지 않고 다시 수행 가능해야 한다
|
||||
- 이후 모든 작업은 이 문서를 기본 프로토콜로 따른다
|
||||
|
||||
## 실행 절차
|
||||
|
||||
반복 가능한 동기화 스크립트:
|
||||
|
||||
- [sync_prod_db_to_dev.sh](../scripts/sync_prod_db_to_dev.sh)
|
||||
- [docker-compose.8081.yml](../docker-compose.8081.yml)
|
||||
|
||||
사용 방법:
|
||||
|
||||
```bash
|
||||
./scripts/prepare_dev_worktree.sh
|
||||
cd <repo>/.dev-worktree-8081
|
||||
docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build
|
||||
./scripts/sync_prod_db_to_dev.sh minimal
|
||||
./scripts/sync_prod_db_to_dev.sh full
|
||||
```
|
||||
|
||||
`prepare_dev_worktree.sh`가 같이 처리하는 것:
|
||||
|
||||
- 메인 workspace를 `.dev-worktree-8081`로 복제 또는 재사용
|
||||
- `.env` 복사
|
||||
- 로컬 전용 디자인 참고 자산 복사
|
||||
- `incoming-files/sample style.css`
|
||||
- `incoming-files/260320.html`
|
||||
- `incoming-files/reference/ledger/`
|
||||
- `incoming-files/1.png`
|
||||
- `incoming-files/seat/center_chair_people_map(2).html`
|
||||
|
||||
중요:
|
||||
|
||||
- `8081`은 현재 메인 workspace를 직접 마운트하면 안 된다
|
||||
- 컨테이너가 메인 workspace를 직접 물고 있으면 분리 상태가 깨진 것이다
|
||||
- 정상 상태는 `docker inspect mh-dashboard-organization-dev-backend-1` 기준 마운트 소스가 격리 worktree 경로로 나와야 한다
|
||||
|
||||
규칙:
|
||||
|
||||
- `minimal`
|
||||
- 조직도, 멤버, 자리배치도 검증 전 사용
|
||||
- `full`
|
||||
- 분석 화면까지 공개용 기준 데이터로 맞춰야 할 때 사용
|
||||
|
||||
주의:
|
||||
|
||||
- 스크립트는 동기화 전에 `8081`의 `proxy`, `frontend`, `backend` 를 잠시 멈춘다
|
||||
- 이유는 중간 상태를 읽는 API 요청과 DB truncate/restore 가 충돌하면 deadlock 또는 부분 검증이 발생할 수 있기 때문이다
|
||||
- 스크립트는 `8080` DB 데이터를 덤프해서 `8081` DB의 대상 테이블을 비우고 다시 적재한다
|
||||
- `8081`에서만 존재하던 대상 테이블 데이터는 사라진다
|
||||
- `seat_positions` 는 portable CSV 경로로 별도 복원한다
|
||||
- 복원 후 `members.seat_label`, `auth.users`, history backfill 을 다시 맞춘다
|
||||
- 실행 후 주요 테이블 수량과 seat 정합성 수치를 출력한다
|
||||
- 따라서 실행 전 현재 작업용 DB 상태를 유지해야 하면 별도 백업 후 실행한다
|
||||
294
docs/HISTORY_ASOF_DB_PLAN.md
Normal file
294
docs/HISTORY_ASOF_DB_PLAN.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# History / As-Of DB Plan
|
||||
|
||||
## Goal
|
||||
|
||||
월간 스냅샷 파일을 따로 만드는 대신, DB 자체를 시간축이 있는 구조로 전환한다.
|
||||
목표는 다음과 같다.
|
||||
|
||||
- 조직도와 자리배치도를 수정할 때마다 과거 값이 사라지지 않게 누적 저장
|
||||
- 사용자가 특정 날짜 또는 기간을 선택하면 그 시점 기준 상태를 다시 조회
|
||||
- 날짜가 원래 없는 데이터도 `유효 시작일`과 `유효 종료일`을 부여해 과거 버전 조회 가능하게 만들기
|
||||
|
||||
핵심 원칙은 아래 한 줄이다.
|
||||
|
||||
- 최신 값을 덮어쓰지 않고, `valid_from`, `valid_to` 기반 버전 행을 누적한다
|
||||
|
||||
## Why This Instead Of Snapshots
|
||||
|
||||
- 월간 스냅샷 파일은 생성 시점만 남고 중간 변경 추적이 약하다
|
||||
- 원하는 날짜 기준으로 바로 조회하기 어렵다
|
||||
- 조직도만 따로 파일로 남으면 자리배치도, 권한, 운영 이력을 함께 맞추기 어렵다
|
||||
|
||||
따라서 이 프로젝트에는 "파일 스냅샷"보다 "시점 조회 가능한 버전 DB"가 더 맞다.
|
||||
|
||||
## Query Model
|
||||
|
||||
조회 기준은 `as_of` 또는 `date_from`, `date_to` 이다.
|
||||
|
||||
- 특정 날짜 조회:
|
||||
- `GET /api/members?as_of=2026-03-01`
|
||||
- `GET /api/seat-maps/active/layout?as_of=2026-03-01`
|
||||
- 기간 비교:
|
||||
- `GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31`
|
||||
|
||||
공통 조회 조건은 아래다.
|
||||
|
||||
```sql
|
||||
WHERE valid_from <= :as_of
|
||||
AND (valid_to IS NULL OR valid_to > :as_of)
|
||||
```
|
||||
|
||||
## Recommended Data Model
|
||||
|
||||
### 1. Stable Base Tables
|
||||
|
||||
식별자와 최소 메타만 유지하는 기준 테이블.
|
||||
|
||||
```sql
|
||||
CREATE TABLE members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE seat_assignment_targets (
|
||||
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
설명:
|
||||
- `members` 는 "사람 자체" 식별자 역할
|
||||
- 실제 이름, 조직, 직급, 연락처, 좌석 같은 표시 데이터는 버전 테이블로 이동
|
||||
|
||||
### 2. Member Version Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE member_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
rank TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT '',
|
||||
department TEXT NOT NULL DEFAULT '',
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
division TEXT NOT NULL DEFAULT '',
|
||||
team TEXT NOT NULL DEFAULT '',
|
||||
cell TEXT NOT NULL DEFAULT '',
|
||||
work_status TEXT NOT NULL DEFAULT '',
|
||||
work_time TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
photo_url TEXT NOT NULL DEFAULT '',
|
||||
valid_from TIMESTAMPTZ NOT NULL,
|
||||
valid_to TIMESTAMPTZ,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX member_versions_member_time_idx
|
||||
ON member_versions (member_id, valid_from, valid_to);
|
||||
```
|
||||
|
||||
설명:
|
||||
- 날짜가 원래 없던 조직도 데이터도 이 테이블에서 과거 버전 관리
|
||||
- 어떤 시점에 이름, 조직, 직책, 연락처가 어땠는지 재구성 가능
|
||||
|
||||
### 3. Seat Assignment Version Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE seat_assignment_versions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
||||
seat_slot_id INTEGER REFERENCES seat_slots(id) ON DELETE CASCADE,
|
||||
seat_label TEXT NOT NULL DEFAULT '',
|
||||
valid_from TIMESTAMPTZ NOT NULL,
|
||||
valid_to TIMESTAMPTZ,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX seat_assignment_versions_member_time_idx
|
||||
ON seat_assignment_versions (member_id, valid_from, valid_to);
|
||||
```
|
||||
|
||||
설명:
|
||||
- 현재 `seat_positions` 가 맡는 "최신 좌석 상태"를 버전형으로 저장
|
||||
- 특정 날짜의 자리배치도를 다시 그릴 수 있음
|
||||
|
||||
### 4. Optional Change Event Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE entity_change_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
revision_no BIGINT NOT NULL,
|
||||
changed_by_user_id BIGINT,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
change_reason TEXT NOT NULL DEFAULT '',
|
||||
patch_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
```
|
||||
|
||||
설명:
|
||||
- 버전 테이블은 "그 시점의 전체 값"
|
||||
- 이벤트 테이블은 "무엇이 바뀌었는지"
|
||||
- 초기에는 없어도 되지만, 추후 비교 UI와 감사로그에 유용
|
||||
|
||||
### 5. Revision Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE history_revisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scope TEXT NOT NULL DEFAULT 'organization',
|
||||
revision_label TEXT NOT NULL,
|
||||
created_by_user_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
```
|
||||
|
||||
설명:
|
||||
- 버전 묶음을 사람 친화적으로 관리할 때 사용
|
||||
- 예: `2026-03-27 1차 조직개편 반영`
|
||||
|
||||
## How Writes Change
|
||||
|
||||
현재 구조:
|
||||
- `UPDATE members SET ...`
|
||||
- `UPSERT seat_positions ...`
|
||||
|
||||
바꿀 구조:
|
||||
1. 현재 유효한 버전 행을 조회
|
||||
2. 값이 달라지면 기존 행의 `valid_to` 를 닫음
|
||||
3. 새 값을 가진 행을 `valid_from = now()` 로 insert
|
||||
4. 필요하면 최신 캐시 테이블도 함께 갱신
|
||||
|
||||
예시:
|
||||
|
||||
```sql
|
||||
UPDATE member_versions
|
||||
SET valid_to = NOW()
|
||||
WHERE member_id = :member_id
|
||||
AND valid_to IS NULL;
|
||||
|
||||
INSERT INTO member_versions (
|
||||
member_id, name, company, rank, role, department, grp, division, team, cell,
|
||||
work_status, work_time, phone, email, photo_url,
|
||||
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
||||
)
|
||||
VALUES (
|
||||
:member_id, :name, :company, :rank, :role, :department, :grp, :division, :team, :cell,
|
||||
:work_status, :work_time, :phone, :email, :photo_url,
|
||||
NOW(), NULL, :revision_no, :changed_by_user_id, :change_reason
|
||||
);
|
||||
```
|
||||
|
||||
## How Date-Bearing And Date-Less Data Coexist
|
||||
|
||||
### 날짜가 원래 있는 데이터
|
||||
|
||||
- `integration_work_logs.work_date`
|
||||
- `integration_vouchers.issue_date`
|
||||
|
||||
이 데이터는 원래 날짜 컬럼이 있으므로 그대로 사용하면 된다.
|
||||
|
||||
### 날짜가 원래 없는 데이터
|
||||
|
||||
- 조직도 인원 기본 정보
|
||||
- 조직 소속
|
||||
- 자리배치 상태
|
||||
- 사진 경로
|
||||
|
||||
이 데이터는 `valid_from`, `valid_to` 를 붙여 시점 조회가 가능하게 만든다.
|
||||
|
||||
즉, "날짜가 없는 데이터"가 아니라 "유효기간을 부여한 버전 데이터"로 바꾸는 것이다.
|
||||
|
||||
## API Direction
|
||||
|
||||
### Common UI Input
|
||||
|
||||
사용자가 실제 HTML에서 고르는 기준은 헤더의 날짜 제어를 공통 입력으로 쓰는 것이 맞다.
|
||||
|
||||
권장안:
|
||||
- 프로젝트/팀 분석: 기존처럼 `시작일 ~ 종료일`
|
||||
- 조직도/자리배치도: 우선 `기준일(as_of)` 1개를 사용
|
||||
- 필요하면 조직도 비교 화면에서 `비교 시작일`, `비교 종료일` 확장
|
||||
|
||||
현재 상태:
|
||||
- 헤더 날짜 제어는 `프로젝트별 분석`, `팀/개인별 분석` iframe에 이미 전달되고 있음
|
||||
- 조직도/자리배치도는 아직 헤더 날짜를 실제 조회 조건으로 사용하지 않음
|
||||
|
||||
권장 API:
|
||||
|
||||
```text
|
||||
GET /api/members?as_of=2026-03-27
|
||||
GET /api/members/{id}?as_of=2026-03-27
|
||||
GET /api/seat-maps/active/layout?as_of=2026-03-27
|
||||
GET /api/history/organization/compare?date_from=2026-03-01&date_to=2026-03-31
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1. History Tables Add
|
||||
|
||||
- `member_versions`
|
||||
- `seat_assignment_versions`
|
||||
- `history_revisions`
|
||||
- 필요 시 `entity_change_events`
|
||||
|
||||
현재 `members`, `seat_positions` 는 그대로 유지
|
||||
|
||||
### Phase 2. Backfill
|
||||
|
||||
- 현재 `members` 최신값을 `member_versions(valid_from = NOW(), valid_to = NULL)` 로 적재
|
||||
- 현재 `seat_positions` 최신값을 `seat_assignment_versions(valid_from = NOW(), valid_to = NULL)` 로 적재
|
||||
- 이 단계에서는 과거 진짜 이력은 없고 "현재 상태를 버전 구조에 싣는 것"이 목표
|
||||
|
||||
### Phase 3. Dual Write
|
||||
|
||||
- 조직도 수정 시:
|
||||
- 기존 `members` 갱신
|
||||
- 동시에 `member_versions` 에 append
|
||||
- 자리배치 저장 시:
|
||||
- 기존 `seat_positions` 갱신
|
||||
- 동시에 `seat_assignment_versions` 에 append
|
||||
|
||||
### Phase 4. As-Of Read APIs
|
||||
|
||||
- 조직도 API에 `as_of` 지원
|
||||
- 자리배치도 API에 `as_of` 지원
|
||||
- 헤더 날짜 제어와 연결
|
||||
|
||||
### Phase 5. Full History-First Read
|
||||
|
||||
- 최신 조회도 버전 테이블 기준으로 전환
|
||||
- `members`, `seat_positions` 는 캐시 또는 편의 테이블로 축소 가능
|
||||
|
||||
## Recommended First Scope
|
||||
|
||||
처음부터 모든 테이블을 이력화하지 말고 아래부터 시작하는 것이 안전하다.
|
||||
|
||||
1. `members` -> `member_versions`
|
||||
2. `seat_positions` -> `seat_assignment_versions`
|
||||
3. 조직도/자리배치도 조회 API에 `as_of`
|
||||
|
||||
이 세 가지가 되면 사용자는 원하는 날짜의 조직 상태와 좌석 상태를 볼 수 있다.
|
||||
|
||||
## Explicitly Removed From Scope
|
||||
|
||||
- 월간 스냅샷 파일 생성
|
||||
- 스냅샷 다운로드 기능
|
||||
- 조직도만 따로 파일로 내보내는 방식
|
||||
|
||||
이 프로젝트의 방향은 "파일 스냅샷"이 아니라 "시점 조회 가능한 버전 DB"다.
|
||||
@@ -1,5 +1,12 @@
|
||||
# 인프라 검증 체크리스트
|
||||
|
||||
## 현재 확인 상태
|
||||
- 2026-03-27 기준 `docker compose ps` 에서 `proxy`, `frontend`, `backend`, `db` 모두 `healthy`
|
||||
- 2026-03-27 기준 `curl http://localhost:8080/api/health` 정상
|
||||
- 2026-03-27 기준 `curl http://localhost:8080/api/members` 에서 `items` 비어 있지 않음
|
||||
- 다른 PC 접속도 현재 확인됨
|
||||
- 개발/운영 DB 분리 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md) 기준으로 관리
|
||||
|
||||
## 1. 컨테이너 기동
|
||||
- `docker compose build`
|
||||
- `docker compose up -d`
|
||||
@@ -12,7 +19,6 @@
|
||||
- `status` 가 `ok`
|
||||
- `checks.database` 가 `true`
|
||||
- `checks.upload_dir` 가 `true`
|
||||
- `checks.snapshot_dir` 가 `true`
|
||||
|
||||
## 3. 초기 데이터 업로드
|
||||
- 조직도 화면에서 `.xlsx` 또는 `.csv` 업로드
|
||||
@@ -27,24 +33,14 @@
|
||||
- 확인 기준:
|
||||
- 업로드했던 데이터가 그대로 유지됨
|
||||
|
||||
## 5. 스냅샷 검증
|
||||
- `curl -X POST -F snapshot_month=2026-03 http://localhost:8080/api/snapshots/monthly`
|
||||
- 확인 기준:
|
||||
- CSV 파일 경로가 반환됨
|
||||
- `/snapshots/...` 다운로드 가능
|
||||
|
||||
## 6. 중복/형식 오류 검증
|
||||
- 같은 월로 다시 스냅샷 생성
|
||||
- 확인 기준:
|
||||
- 409 에러 반환
|
||||
- 잘못된 형식으로 스냅샷 생성 예: `202603`
|
||||
- 확인 기준:
|
||||
- 400 에러 반환
|
||||
|
||||
## 7. 종료 후 재기동 확인
|
||||
## 5. 종료 후 재기동 확인
|
||||
- `docker compose down`
|
||||
- `docker compose up -d`
|
||||
- 확인 기준:
|
||||
- DB 데이터 유지
|
||||
- 업로드 파일 유지
|
||||
- 스냅샷 파일 유지
|
||||
|
||||
## 6. 제외 또는 후속 검증 항목
|
||||
- 월간 스냅샷 파일 유지 검증은 현재 코드 기준 미구현 항목
|
||||
- 스냅샷 기능을 다시 범위에 넣을 경우 별도 API/파일 경로/다운로드 검증 절차를 추가해야 함
|
||||
- `8081`에서 조직도, 멤버, 자리배치도 검증 전에는 `8080` 정본 DB 기준 동기화가 필요함
|
||||
|
||||
263
docs/INTEGRATION_DB_PLAN.md
Normal file
263
docs/INTEGRATION_DB_PLAN.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Integration DB Plan
|
||||
|
||||
## Goal
|
||||
|
||||
`organization.xlsx`, `MH.xlsx`, `payment.csv`를 하나의 통합 DB로 수용하고,
|
||||
`조직 현황`, `프로젝트별 분석`, `팀/개인별 분석`, `자리배치도`가 같은 기준 데이터를 바라보도록 정리한다.
|
||||
|
||||
## Source Summary
|
||||
|
||||
### 1. `organization.xlsx`
|
||||
|
||||
용도:
|
||||
- 인원 기본 정보
|
||||
- 조직 구조
|
||||
|
||||
주요 컬럼:
|
||||
- `name`
|
||||
- `tag`
|
||||
- `rank`
|
||||
- `pos`
|
||||
- `co`
|
||||
- `cell`
|
||||
- `team`
|
||||
- `div`
|
||||
- `gr`
|
||||
- `part`
|
||||
- `ph`
|
||||
- `mail`
|
||||
|
||||
해석:
|
||||
- `tag`는 사번 또는 내부 인원 식별자로 사용
|
||||
- 조직 정보는 `part > gr > div > team > cell` 구조
|
||||
|
||||
### 2. `MH.xlsx`
|
||||
|
||||
용도:
|
||||
- 일자별 인원 근무 실적
|
||||
- 프로젝트별 투입 시간
|
||||
- 연장근무 포함 세부 투입 슬롯
|
||||
|
||||
주요 컬럼:
|
||||
- `근무일자`
|
||||
- `팀 분류`
|
||||
- `팀`
|
||||
- `사원번호`
|
||||
- `이름`
|
||||
- `직책`
|
||||
- `user_state`
|
||||
- `시차시간`
|
||||
- `메인업무/추가업무1~5/연장근무`
|
||||
- `프로젝트 코드`
|
||||
- `프로젝트명`
|
||||
- `서브 코드`
|
||||
- `근무시간`
|
||||
|
||||
추가 시트:
|
||||
- `Sheet2`
|
||||
- 프로젝트 코드와 PM 이름 매핑으로 추정
|
||||
|
||||
### 3. `payment.csv`
|
||||
|
||||
용도:
|
||||
- 프로젝트별 수입/지출 전표
|
||||
|
||||
주요 컬럼:
|
||||
- `프로젝트코드`
|
||||
- `사업명`
|
||||
- `사업명(표출PJT)`
|
||||
- `사업명(인트라넷기준)`
|
||||
- `사업분야`
|
||||
- `세부분야`
|
||||
- `부서명`
|
||||
- `팀명`
|
||||
- `거래처`
|
||||
- `적요`
|
||||
- `차변공급가`
|
||||
- `대변공급가`
|
||||
- `지출`
|
||||
- `수입`
|
||||
- `구분`
|
||||
- `프로젝트성격`
|
||||
|
||||
## Recommended Model
|
||||
|
||||
### Raw Layer
|
||||
|
||||
원본을 그대로 적재하는 영역.
|
||||
|
||||
- `raw_organization_import`
|
||||
- `raw_mh_import`
|
||||
- `raw_payment_import`
|
||||
|
||||
원칙:
|
||||
- 원본 행을 최대한 손대지 않고 저장
|
||||
- 파일명, 업로드시각, 배치 ID 같이 저장
|
||||
|
||||
### Standard Layer
|
||||
|
||||
정규화된 운영 테이블.
|
||||
|
||||
#### Members
|
||||
|
||||
- `members`
|
||||
- `id`
|
||||
- `employee_id`
|
||||
- `name`
|
||||
- `company`
|
||||
- `rank`
|
||||
- `position`
|
||||
- `phone`
|
||||
- `email`
|
||||
- `active`
|
||||
|
||||
#### Organization
|
||||
|
||||
- `org_units`
|
||||
- `id`
|
||||
- `unit_type`
|
||||
- `name`
|
||||
- `parent_id`
|
||||
|
||||
- `member_org_assignments`
|
||||
- `id`
|
||||
- `member_id`
|
||||
- `part_name`
|
||||
- `group_name`
|
||||
- `division_name`
|
||||
- `team_name`
|
||||
- `cell_name`
|
||||
- `effective_from`
|
||||
- `effective_to`
|
||||
|
||||
#### Projects
|
||||
|
||||
- `projects`
|
||||
- `id`
|
||||
- `project_code`
|
||||
- `project_name`
|
||||
- `display_name`
|
||||
- `intranet_name`
|
||||
- `domain`
|
||||
- `subdomain`
|
||||
- `project_type`
|
||||
- `project_nature`
|
||||
|
||||
- `project_aliases`
|
||||
- `id`
|
||||
- `project_id`
|
||||
- `alias_type`
|
||||
- `alias_value`
|
||||
|
||||
- `project_pm_assignments`
|
||||
- `id`
|
||||
- `project_id`
|
||||
- `member_id`
|
||||
- `source`
|
||||
|
||||
#### Work Logs
|
||||
|
||||
- `work_logs`
|
||||
- `id`
|
||||
- `member_id`
|
||||
- `work_date`
|
||||
- `team_category`
|
||||
- `team_name`
|
||||
- `user_state`
|
||||
- `shift_hours`
|
||||
- `late_flag`
|
||||
|
||||
- `work_log_segments`
|
||||
- `id`
|
||||
- `work_log_id`
|
||||
- `project_id`
|
||||
- `activity_code`
|
||||
- `hours`
|
||||
- `is_overtime`
|
||||
- `slot_type`
|
||||
|
||||
#### Vouchers
|
||||
|
||||
- `vouchers`
|
||||
- `id`
|
||||
- `company_name`
|
||||
- `request_date`
|
||||
- `issue_date`
|
||||
- `issue_month`
|
||||
- `account_code`
|
||||
- `management_account_code`
|
||||
- `project_id`
|
||||
- `department_name`
|
||||
- `team_name`
|
||||
- `customer_name`
|
||||
- `summary`
|
||||
- `debit_amount`
|
||||
- `credit_amount`
|
||||
- `expense_amount`
|
||||
- `income_amount`
|
||||
- `voucher_type`
|
||||
- `project_nature`
|
||||
- `note`
|
||||
|
||||
#### Reference
|
||||
|
||||
- `member_cost_rates`
|
||||
- 직급별 표준 인건비
|
||||
|
||||
## Matching Rules
|
||||
|
||||
### Member Match
|
||||
|
||||
우선순위:
|
||||
1. `MH.xlsx.사원번호`
|
||||
2. `organization.xlsx.tag`
|
||||
3. 이름 단독 매칭은 보조 규칙으로만 사용
|
||||
|
||||
원칙:
|
||||
- `employee_id`가 있으면 그 값으로 병합
|
||||
- 이름만 같은 경우 자동 병합 금지
|
||||
|
||||
### Project Match
|
||||
|
||||
우선순위:
|
||||
1. `project_code`
|
||||
2. `사업명(인트라넷기준)`
|
||||
3. `사업명(표출PJT)`
|
||||
4. `프로젝트명`
|
||||
|
||||
원칙:
|
||||
- `project_code`를 정식 키로 사용
|
||||
- 이름 차이는 `project_aliases`로 흡수
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1
|
||||
|
||||
- `payment.html`, `mh.html`을 현재 대시보드 탭에 편입
|
||||
- 기존 HTML 기능은 유지
|
||||
- 파일은 backend route를 통해 iframe으로 연결
|
||||
|
||||
### Phase 2
|
||||
|
||||
- raw import 테이블 생성
|
||||
- 원본 3종 import 스크립트 작성
|
||||
- 파일별 업로드/재적재 배치 ID 관리
|
||||
|
||||
### Phase 3
|
||||
|
||||
- 표준 테이블 생성
|
||||
- raw -> standard 정규화 파이프라인 작성
|
||||
- 멤버/프로젝트 매핑 규칙 적용
|
||||
|
||||
### Phase 4
|
||||
|
||||
- `payment.html`, `mh.html`의 파일 직접 파싱 로직을 API 기반 조회로 전환
|
||||
- 프론트는 공통 DB 기준으로만 동작
|
||||
|
||||
## Immediate Next Tasks
|
||||
|
||||
1. Postgres 스키마 초안 SQL 작성
|
||||
2. `payment.csv` import 파서 작성
|
||||
3. `MH.xlsx` import 파서 작성
|
||||
4. `organization.xlsx` import 파서 작성
|
||||
5. 멤버/프로젝트 중복 병합 규칙 구현
|
||||
170
docs/REGRESSION_CHECKLIST.md
Normal file
170
docs/REGRESSION_CHECKLIST.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 회귀 검증 체크리스트
|
||||
|
||||
## 목적
|
||||
|
||||
- 새 기능을 추가하거나 기존 기능을 수정할 때, 이전에 되던 핵심 기능이 깨졌는지 빠르게 확인한다.
|
||||
- `8081` 작업용에서 검증한 결과를 신뢰할 수 있도록 `환경`, `데이터`, `핵심 시나리오`를 고정한다.
|
||||
- 완료 판단을 감이 아니라 반복 가능한 체크 절차로 바꾼다.
|
||||
|
||||
## 적용 원칙
|
||||
|
||||
- 코드 수정은 먼저 `8081`에서 수행한다.
|
||||
- 데이터 기준은 항상 `8080` 공개용 DB를 따른다.
|
||||
- 검증 전에는 작업 범위에 맞는 DB 동기화를 먼저 수행한다.
|
||||
- 기능 수정 후에는 관련 화면만 보지 말고, 이 문서의 핵심 시나리오를 함께 확인한다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
|
||||
- [INFRA_VALIDATION_CHECKLIST.md](INFRA_VALIDATION_CHECKLIST.md)
|
||||
|
||||
## 작업 시작 전
|
||||
|
||||
### 1. 서버 상태 확인
|
||||
|
||||
- `8081` 작업용 접속 확인
|
||||
- `8080` 공개용 접속 확인
|
||||
- `docker compose ps`에서 `backend`, `frontend`, `proxy`, `db`가 정상인지 확인
|
||||
- `8081`은 기본적으로 `./scripts/start_8081.sh` 또는 `./scripts/prepare_dev_worktree.sh` 후 `.dev-worktree-8081` 에서 `docker compose -p mh-dashboard-organization-dev --env-file .env -f docker-compose.8081.yml up -d --build` 로 기동
|
||||
- `8081` 기동 후 `docker inspect mh-dashboard-organization-dev-backend-1`에서 마운트 경로가 `.dev-worktree-8081/...`인지 확인
|
||||
- 구조 정리, 라우트 분리, 기본 원본 API 변경 후에는 먼저 `./scripts/check_8081_smoke.sh` 를 실행한다
|
||||
|
||||
### 2. 데이터 동기화 범위 결정
|
||||
|
||||
- 조직도, 관리자모드, 자리배치도 작업 전:
|
||||
- `./scripts/sync_prod_db_to_dev.sh minimal`
|
||||
- 프로젝트별 분석, 팀/개인별 분석 작업 전:
|
||||
- `./scripts/sync_prod_db_to_dev.sh analysis`
|
||||
- 공개용 기준 전체 데이터 재검증이 필요한 경우만:
|
||||
- `./scripts/sync_prod_db_to_dev.sh full`
|
||||
|
||||
### 3. 기준 고정
|
||||
|
||||
- 어느 서버에서 재현했는지 기록
|
||||
- 어떤 데이터 동기화 범위로 검증했는지 기록
|
||||
- 브라우저 캐시 영향을 피하려면 강력 새로고침 후 확인
|
||||
|
||||
## 공통 회귀 시나리오
|
||||
|
||||
기능 수정 후 아래 항목을 최소한 확인한다.
|
||||
|
||||
### A. 허브 및 공통 진입
|
||||
|
||||
- 메인 허브가 정상 렌더링된다.
|
||||
- 상단 탭 이동이 정상 동작한다.
|
||||
- 로그인 상태가 비정상적으로 풀리지 않는다.
|
||||
- `DB 상태` 탭이 정상 렌더링된다.
|
||||
|
||||
### B. 조직현황
|
||||
|
||||
- 조직도 트리가 정상 표시된다.
|
||||
- 관리자모드 진입이 가능하다.
|
||||
- 대상인원 클릭 시 기본정보 모달이 열린다.
|
||||
- `+` 신규 구성원 추가 모달이 열린다.
|
||||
- 기본정보 저장이 정상 동작한다.
|
||||
|
||||
### C. 자리배치도
|
||||
|
||||
- `기술개발센터`, `한맥빌딩 6층`, `한맥빌딩 7층` 도면이 모두 열린다.
|
||||
- 미배치 인원 목록이 정상 표시된다.
|
||||
- 미배치 인원을 chair에 드래그앤드롭할 수 있다.
|
||||
- 드롭 후:
|
||||
- 미배치 목록에서 사라진다.
|
||||
- chair에 배치 상태가 표시된다.
|
||||
- 이름/직급 표기가 보인다.
|
||||
- 배치된 좌석 클릭 후 해제 또는 수정 흐름이 정상 동작한다.
|
||||
|
||||
### D. 조직도와 자리배치 연동
|
||||
|
||||
- 조직도에서 인원 클릭 시 상세 정보가 열린다.
|
||||
- 재석위치 미리보기가 표시된다.
|
||||
- 좌석이 배정된 인원은 해당 자리로 줌인된다.
|
||||
|
||||
### E. 프로젝트별 분석
|
||||
|
||||
- 월 선택이 정상 동작한다.
|
||||
- 프로젝트 목록과 합계가 비어 있지 않다.
|
||||
- `1월`, `2월` 데이터가 현재 기준값과 일치한다.
|
||||
|
||||
현재 기준 검증값:
|
||||
|
||||
- `2026-01`
|
||||
- 시간 `37,342.39`
|
||||
- 인건비 `1,391,966,625`
|
||||
- `2026-02`
|
||||
- 시간 `29,060.59`
|
||||
- 인건비 `1,078,337,651`
|
||||
|
||||
### F. 팀/개인별 분석
|
||||
|
||||
- `전체`, `GPD`, `TDC` 버튼이 순서대로 보인다.
|
||||
- `전체`에서 모든 팀이 노출된다.
|
||||
- `GPD`, `TDC` 선택 시 각 소속 범위만 버튼 기준으로 보인다.
|
||||
- 검색은 버튼 상태와 무관하게 전체 데이터를 검색한다.
|
||||
|
||||
### G. 사업관리대장 기본 원본
|
||||
|
||||
- `/api/integration/business-ledger-default` 가 `200` 이어야 한다.
|
||||
- `사업관리대장` 탭 진입 시 기본 원본이 비어 있지 않다.
|
||||
- 기본 원본 응답은 바이너리 XLSX 시그니처(`PK`)를 반환해야 한다.
|
||||
|
||||
## 작업 유형별 필수 추가 확인
|
||||
|
||||
### 조직도 / 관리자모드 수정 시
|
||||
|
||||
- 대상인원 수정 모달 레이아웃이 깨지지 않는지 확인
|
||||
- 신규 구성원 추가 모달도 같은 레이아웃으로 보이는지 확인
|
||||
- 저장 후 목록 반영이 정상인지 확인
|
||||
|
||||
### 자리배치도 수정 시
|
||||
|
||||
- viewer iframe 로드 여부 확인
|
||||
- 드래그앤드롭 이후 배치 상태가 즉시 반영되는지 확인
|
||||
- 조직도 상세 재석위치 preview까지 같이 확인
|
||||
|
||||
### 분석 로직 수정 시
|
||||
|
||||
- 작업 전에 반드시 `analysis` 또는 `full` 동기화 수행
|
||||
- 월별 합계 검증값 재확인
|
||||
- 원본 기준과 차이가 있으면 반올림, 제외 인원, 가공시간 규칙부터 점검
|
||||
|
||||
## 완료 처리 기준
|
||||
|
||||
수정 사항을 완료로 판단하려면 아래를 모두 만족해야 한다.
|
||||
|
||||
- 수정한 기능이 의도대로 동작한다.
|
||||
- 관련 공통 회귀 시나리오가 깨지지 않는다.
|
||||
- 필요한 경우 `8081`에서 검증 결과를 숫자 또는 화면 기준으로 기록한다.
|
||||
- 이후에만 `8080` 공개용 반영 여부를 판단한다.
|
||||
|
||||
## 장애 원인 분류 기준
|
||||
|
||||
문제가 생기면 먼저 아래 셋 중 어디인지 분리한다.
|
||||
|
||||
- 코드 차이
|
||||
- `8080`, `8081`의 정적 파일 또는 백엔드 로직이 다름
|
||||
- DB 차이
|
||||
- `members`, `seat_maps`, `integration_*` 등 기준 데이터가 다름
|
||||
- 캐시 또는 런타임 상태
|
||||
- 정적 파일 캐시, 컨테이너 재시작 미반영, 브라우저 세션 상태 문제
|
||||
|
||||
이 분류를 먼저 해야 원인을 잘못 짚지 않는다.
|
||||
|
||||
## 권장 기록 방식
|
||||
|
||||
작업 종료 시 아래 형식으로 남긴다.
|
||||
|
||||
```text
|
||||
작업 범위:
|
||||
- 예: 조직현황 관리자모드 기본정보 모달 레이아웃 변경
|
||||
|
||||
검증 환경:
|
||||
- 서버: 8081
|
||||
- DB 동기화: minimal / analysis / full 중 무엇을 사용했는지
|
||||
|
||||
검증 결과:
|
||||
- 조직도: 정상
|
||||
- 관리자모드 모달: 정상
|
||||
- 자리배치도 연동: 정상 또는 미검증
|
||||
- 프로젝트별 분석: 정상 또는 미검증
|
||||
```
|
||||
172
docs/TEAM_GUIDE.md
Normal file
172
docs/TEAM_GUIDE.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Team Guide
|
||||
|
||||
이 문서는 이 저장소에서 작업할 때 가장 먼저 읽는 기준 문서다.
|
||||
|
||||
목표는 세 가지다.
|
||||
|
||||
- 어디를 수정해야 하는지 바로 알 수 있게 하기
|
||||
- `8080`과 `8081`을 헷갈리지 않게 하기
|
||||
- 작업 순서와 검증 기준을 하나의 문서로 시작하게 하기
|
||||
|
||||
## 1. 먼저 이해할 구조
|
||||
|
||||
- `frontend/apps/*`
|
||||
- 화면별 source-of-truth
|
||||
- `incoming-files/served/*`
|
||||
- integration 화면의 실제 런타임 파일
|
||||
- `legacy/static/*`
|
||||
- 조직현황 레거시 런타임 파일
|
||||
- `incoming-files/reference/*`
|
||||
- 원본 참고 자산
|
||||
- `backend/app/routes/*`
|
||||
- API 엔드포인트 등록
|
||||
- `backend/app/services/*`
|
||||
- 비즈니스 로직
|
||||
- `backend/app/repositories/*`
|
||||
- DB 읽기 쿼리
|
||||
|
||||
원칙:
|
||||
|
||||
- source를 먼저 수정하고 runtime은 publish로 반영한다.
|
||||
- reference 파일은 비교/복구용이지 직접 수정 기준이 아니다.
|
||||
|
||||
## 2. 환경 원칙
|
||||
|
||||
팀 규칙 문장:
|
||||
|
||||
- `main`과 `dev`는 코드 브랜치다.
|
||||
- `8080`과 `8081`은 실행 환경이다.
|
||||
- 브랜치 이름을 포트와 같은 뜻으로 쓰지 않는다.
|
||||
- 기본 운영은 `main`을 `8080` 기준 브랜치로 본다.
|
||||
- 개발 검증은 `dev` 또는 작업 브랜치를 `8081`에서 먼저 확인한다.
|
||||
|
||||
- `8080`
|
||||
- 공개 기준 환경
|
||||
- 기준 데이터가 있는 쪽
|
||||
- `8081`
|
||||
- 개발/검증 환경
|
||||
- 먼저 수정하고 검증하는 쪽
|
||||
|
||||
중요:
|
||||
|
||||
- 새 작업은 항상 `.dev-worktree-8081` 기준으로 시작한다.
|
||||
- `8081`에서 검증되지 않은 변경을 바로 `8080`에 올리지 않는다.
|
||||
- `8081` DB는 독립 정본이 아니라 검증용 복제본처럼 다룬다.
|
||||
|
||||
자세한 DB 운영 원칙은 [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)를 따른다.
|
||||
|
||||
## 3. 작업 시작 순서
|
||||
|
||||
1. 현재 브랜치와 변경 파일을 확인한다.
|
||||
2. 연결된 이슈 또는 작업 목적을 확인한다.
|
||||
3. 이번 작업의 source 파일과 runtime 파일을 구분한다.
|
||||
4. 필요한 경우 `8081` 개발 환경을 띄운다.
|
||||
5. 필요한 DB 동기화 범위를 결정한다.
|
||||
6. 수정 후 관련 시나리오를 검증한다.
|
||||
|
||||
환경 준비:
|
||||
|
||||
- 최초 실행 전 `.env.example`을 `.env`로 복사한다.
|
||||
- `./scripts/prepare_dev_worktree.sh`는 dev worktree에 `.env`가 없으면 `.env.example`로 기본 파일을 만든다.
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- 지금 고치는 파일이 실제 source-of-truth가 맞는가?
|
||||
- 이 작업은 `8081`에서 먼저 검증해야 하는가?
|
||||
- DB 차이 때문에 생긴 문제는 아닌가?
|
||||
|
||||
## 4. 수정 원칙
|
||||
|
||||
- 한 작업은 한 기능 또는 한 버그 단위로 작게 나눈다.
|
||||
- 완료 기능은 관련 이슈 없이 함부로 건드리지 않는다.
|
||||
- 임시 우회 로직은 이유와 제거 계획이 있어야 한다.
|
||||
- 구조를 정리하더라도 기존 동작을 바꾸면 안 된다.
|
||||
|
||||
고위험 영역:
|
||||
|
||||
- `members`
|
||||
- `seat_maps`
|
||||
- `seat_slots`
|
||||
- `seat_positions`
|
||||
- `auth.*`
|
||||
- 동기화 스크립트
|
||||
- 스키마 변경
|
||||
|
||||
이 영역은 변경 이유, 영향 범위, 검증 결과를 반드시 남긴다.
|
||||
|
||||
## 5. 화면별 수정 기준
|
||||
|
||||
### 조직현황
|
||||
|
||||
- source: `frontend/apps/organization`
|
||||
- runtime: `DashBoard-organization.html`, `legacy/static/*`
|
||||
- publish: `./scripts/publish_organization_app.sh`
|
||||
|
||||
### 프로젝트별 분석
|
||||
|
||||
- source: `frontend/apps/payment/index.html`
|
||||
- runtime: `incoming-files/served/payment.html`
|
||||
- publish: `./scripts/publish_payment_app.sh`
|
||||
|
||||
### 팀/개인별 분석
|
||||
|
||||
- source: `frontend/apps/team/index.html`
|
||||
- runtime: `incoming-files/served/mh.html`
|
||||
- publish: `./scripts/publish_team_app.sh`
|
||||
|
||||
### 사업관리대장
|
||||
|
||||
- source: `frontend/apps/ledger/*`
|
||||
- runtime: `incoming-files/served/ledger/*`
|
||||
- publish: `./scripts/publish_ledger_app.sh`
|
||||
|
||||
### DB 상태
|
||||
|
||||
- source: `frontend/apps/db-status/index.html`
|
||||
- runtime: `incoming-files/served/db-status/index.html`
|
||||
- publish: `./scripts/publish_db_status_app.sh`
|
||||
|
||||
실제 서빙 책임은 [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)에서 확인한다.
|
||||
|
||||
## 6. 디자인 수정 원칙
|
||||
|
||||
- 먼저 `frontend/public/design-tokens.css`
|
||||
- 다음 `frontend/public/design-patterns.css`
|
||||
- 그 다음 [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)
|
||||
- 마지막으로 화면별 파일
|
||||
|
||||
금지:
|
||||
|
||||
- reference 파일부터 수정하기
|
||||
- 토큰/패턴으로 해결 가능한 것을 화면별 하드코딩으로 처리하기
|
||||
- 예전 색 체계를 새 기본값으로 다시 넣기
|
||||
|
||||
## 7. 검증 원칙
|
||||
|
||||
- 완료 기준은 “코드를 썼다”가 아니라 “실제 동작을 검증했다”이다.
|
||||
- 구조 정리나 라우트 분리 후에는 `./scripts/check_8081_smoke.sh`를 먼저 본다.
|
||||
- 기능 수정 후에는 관련 화면만 보지 말고 주변 연동까지 확인한다.
|
||||
|
||||
검증 세부 항목은 [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)를 따른다.
|
||||
|
||||
자주 쓰는 DB 동기화:
|
||||
|
||||
- 조직현황/멤버/자리배치: `./scripts/sync_prod_db_to_dev.sh minimal`
|
||||
- 분석 화면: `./scripts/sync_prod_db_to_dev.sh analysis`
|
||||
- 전체 재검증: `./scripts/sync_prod_db_to_dev.sh full`
|
||||
|
||||
## 8. 커밋과 PR
|
||||
|
||||
- 커밋은 한 주제만 담는다.
|
||||
- PR 본문에는 작업 목적, 변경 범위, 검증 방법, DB 영향 여부를 적는다.
|
||||
- 공용 구조 파일을 수정했으면 영향 화면을 명시한다.
|
||||
|
||||
자세한 팀 작업 규칙은 [../CONTRIBUTING.md](../CONTRIBUTING.md)를 따른다.
|
||||
|
||||
## 9. 이 문서 다음에 읽을 것
|
||||
|
||||
- 협업 방식: [../CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||
- DB 운영 원칙: [DEV_PROD_DB_PROTOCOL.md](DEV_PROD_DB_PROTOCOL.md)
|
||||
- 회귀 검증: [REGRESSION_CHECKLIST.md](REGRESSION_CHECKLIST.md)
|
||||
- 실제 서빙 책임: [architecture/8081_SERVING_MAP.md](architecture/8081_SERVING_MAP.md)
|
||||
- 디자인 기준: [architecture/DESIGN_SSOT.md](architecture/DESIGN_SSOT.md)
|
||||
@@ -1,34 +0,0 @@
|
||||
# WSL 작업 기준 가이드
|
||||
|
||||
## 1. 왜 WSL 기준으로 작업하나
|
||||
- 현재 이 프로젝트는 Ubuntu 24.04 기반 Docker 환경에서 실행되고 있습니다.
|
||||
- Windows 폴더에서 바로 작업하면 실행 경로와 편집 경로가 달라질 수 있습니다.
|
||||
- 그래서 앞으로는 `WSL Ubuntu 내부 경로`를 기준 작업공간으로 사용하는 것을 권장합니다.
|
||||
|
||||
## 2. 기준 작업 경로
|
||||
- 사용자: `hyunho`
|
||||
- 프로젝트 경로: `/home/hyunho/projects/mh-dashboard-organization`
|
||||
|
||||
## 3. VS Code에서 여는 방법
|
||||
1. VS Code 명령 팔레트를 엽니다.
|
||||
2. `Remote-WSL: Reopen Folder in WSL` 를 실행합니다.
|
||||
3. 아래 경로를 엽니다.
|
||||
- `/home/hyunho/projects/mh-dashboard-organization`
|
||||
4. 좌측 아래 상태 표시줄에 `WSL: Ubuntu-24.04` 가 보이면 정상입니다.
|
||||
|
||||
## 4. 앞으로의 작업 규칙
|
||||
- 코드 수정은 가능하면 WSL 경로 기준으로 진행합니다.
|
||||
- Docker 실행, Python 실행, 배포 테스트도 WSL 안에서 진행합니다.
|
||||
- Windows 경로는 참고용 또는 백업용으로만 보고, 실행 기준으로 사용하지 않는 것이 좋습니다.
|
||||
|
||||
## 5. 자주 쓰는 명령
|
||||
```bash
|
||||
cd /home/hyunho/projects/mh-dashboard-organization
|
||||
docker compose ps
|
||||
docker compose logs backend
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 6. 현재 확인 가능한 주소
|
||||
- 메인 화면: `http://localhost:8080`
|
||||
- API 상태 확인: `http://localhost:8080/api/health`
|
||||
118
docs/architecture/8081_SERVING_MAP.md
Normal file
118
docs/architecture/8081_SERVING_MAP.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 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`
|
||||
- URL: `/db-status.html`
|
||||
- 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html`
|
||||
- 앱 소스 기준: `frontend/apps/db-status/index.html`
|
||||
- publish 규칙: `scripts/publish_db_status_app.sh`
|
||||
|
||||
정리 원칙:
|
||||
|
||||
- `incoming-files` 아래에서는 `served/`를 실제 서빙 자산용으로 사용한다.
|
||||
- `payment`, `mh`, `ledger`, `db-status`는 사람이 직접 `served/`를 먼저 수정하지 않는다.
|
||||
- 이 4개 화면의 source-of-truth는 `frontend/apps/*`이고, publish 스크립트가 `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 파일에 반영한다.
|
||||
|
||||
## 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`
|
||||
- `reference/opayment.html`
|
||||
- `reference/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`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
||||
- `/db-status.html`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
|
||||
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||
205
docs/architecture/DB_TABLE_CATALOG.md
Normal file
205
docs/architecture/DB_TABLE_CATALOG.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# DB Table Catalog
|
||||
|
||||
## Purpose
|
||||
|
||||
이 문서는 현재 PostgreSQL 테이블을 역할별로 분류한 운영 기준 문서다.
|
||||
|
||||
핵심 원칙:
|
||||
|
||||
- 테이블 수가 많다고 바로 줄이지 않는다.
|
||||
- 먼저 `유지 / 주의 / 원본·추적 / 정리 후보`로 나눈다.
|
||||
- 실제 운영 화면과 저장 흐름에 필요한 것은 유지한다.
|
||||
- 의미가 불분명하거나 중복 역할만 하는 것은 후보로 남겨두고, 실제 삭제는 별도 검증 후 진행한다.
|
||||
|
||||
## Summary
|
||||
|
||||
- 전체 테이블: `26`
|
||||
- 유지: `16`
|
||||
- 주의: `6`
|
||||
- 원본·추적: `4`
|
||||
- 정리 후보: `0`
|
||||
|
||||
## 1. 유지
|
||||
|
||||
현재 운영 화면, 인증, 이력, 적재 흐름에서 계속 필요하다.
|
||||
|
||||
- `auth.users`
|
||||
- `auth.sessions`
|
||||
- `auth.login_audit_logs`
|
||||
- `public.members`
|
||||
- `public.member_versions`
|
||||
- `public.history_revisions`
|
||||
- `public.seat_maps`
|
||||
- `public.seat_slots`
|
||||
- `public.seat_positions`
|
||||
- `public.seat_assignment_versions`
|
||||
- `public.integration_import_batches`
|
||||
- `public.integration_projects`
|
||||
- `public.integration_work_logs`
|
||||
- `public.integration_work_log_segments`
|
||||
- `public.integration_vouchers`
|
||||
- `public.integration_binary_sources`
|
||||
|
||||
설명:
|
||||
|
||||
- `members`, `seat_*`는 조직현황/자리배치도 핵심
|
||||
- `member_versions`, `seat_assignment_versions`, `history_revisions`는 as-of 조회와 이력 비교 핵심
|
||||
- `integration_*` 표준화 결과는 프로젝트별 분석 / 팀·개인별 분석 핵심
|
||||
- `integration_binary_sources`는 사업관리대장 같은 바이너리 원본 보관용
|
||||
- `auth.*`는 로그인과 권한 운영 핵심
|
||||
|
||||
## 2. 주의
|
||||
|
||||
현재도 역할은 있지만, 실제 운영에서 얼마나 계속 필요한지 주기적으로 점검해야 한다.
|
||||
|
||||
- `public.member_overrides`
|
||||
- `public.member_retirements`
|
||||
- `public.member_aliases`
|
||||
- `public.integration_project_aliases`
|
||||
- `public.integration_project_category_mappings`
|
||||
- `public.integration_project_pm_assignments`
|
||||
|
||||
설명:
|
||||
|
||||
- 이 테이블들은 핵심 마스터라기보다 “보정/매핑/예외 처리” 성격이 강하다.
|
||||
- 운영상 필요할 수 있지만, 남용되면 기준 데이터가 흐려진다.
|
||||
- 사용 규칙과 관리 책임을 분명히 해야 한다.
|
||||
|
||||
## 3. 원본·추적
|
||||
|
||||
원본 적재와 검증을 위해 필요하다. 직접 서비스 화면의 주 출력원이 아니라, 적재 근거와 추적용이다.
|
||||
|
||||
- `public.integration_raw_organization_rows`
|
||||
- `public.integration_raw_mh_rows`
|
||||
- `public.integration_raw_mh_pm_rows`
|
||||
- `public.integration_raw_payment_rows`
|
||||
|
||||
설명:
|
||||
|
||||
- 원본 파일을 바로 표준화 테이블에만 넣으면, 나중에 적재 오류를 추적하기 어렵다.
|
||||
- raw row 보관은 import 검증과 재현성 측면에서 의미가 있다.
|
||||
- 단, 장기 보관 정책과 용량 관리는 별도 필요하다.
|
||||
|
||||
## 4. 정리 후보
|
||||
|
||||
현재 기준 정리 후보 테이블은 없다.
|
||||
|
||||
## Domain Map
|
||||
|
||||
### 인증
|
||||
|
||||
- `auth.users`
|
||||
- `auth.sessions`
|
||||
- `auth.login_audit_logs`
|
||||
|
||||
### 조직 / 구성원
|
||||
|
||||
- `public.members`
|
||||
- `public.member_overrides`
|
||||
- `public.member_retirements`
|
||||
- `public.member_aliases`
|
||||
|
||||
### 자리배치도
|
||||
|
||||
- `public.seat_maps`
|
||||
- `public.seat_slots`
|
||||
- `public.seat_positions`
|
||||
|
||||
### 이력
|
||||
|
||||
- `public.history_revisions`
|
||||
- `public.member_versions`
|
||||
- `public.seat_assignment_versions`
|
||||
|
||||
### integration 표준화
|
||||
|
||||
- `public.integration_import_batches`
|
||||
- `public.integration_projects`
|
||||
- `public.integration_project_aliases`
|
||||
- `public.integration_project_category_mappings`
|
||||
- `public.integration_project_pm_assignments`
|
||||
- `public.integration_work_logs`
|
||||
- `public.integration_work_log_segments`
|
||||
- `public.integration_vouchers`
|
||||
- `public.integration_binary_sources`
|
||||
|
||||
### integration raw
|
||||
|
||||
- `public.integration_raw_organization_rows`
|
||||
- `public.integration_raw_mh_rows`
|
||||
- `public.integration_raw_mh_pm_rows`
|
||||
- `public.integration_raw_payment_rows`
|
||||
|
||||
## Product View
|
||||
|
||||
운영자가 DB를 볼 때는 물리 테이블 수보다 아래 5개 묶음으로 보는 편이 더 이해하기 쉽다.
|
||||
|
||||
### 탭 데이터
|
||||
|
||||
- `public.members`
|
||||
- `public.seat_maps`
|
||||
- `public.seat_slots`
|
||||
- `public.seat_positions`
|
||||
- `public.integration_projects`
|
||||
- `public.integration_work_logs`
|
||||
- `public.integration_work_log_segments`
|
||||
- `public.integration_vouchers`
|
||||
- `public.integration_binary_sources`
|
||||
|
||||
### 로그인·권한
|
||||
|
||||
- `auth.users`
|
||||
- `auth.sessions`
|
||||
- `auth.login_audit_logs`
|
||||
|
||||
### 히스토리
|
||||
|
||||
- `public.history_revisions`
|
||||
- `public.member_versions`
|
||||
- `public.seat_assignment_versions`
|
||||
|
||||
### 로우데이터·적재
|
||||
|
||||
- `public.integration_import_batches`
|
||||
- `public.integration_raw_organization_rows`
|
||||
- `public.integration_raw_mh_rows`
|
||||
- `public.integration_raw_mh_pm_rows`
|
||||
- `public.integration_raw_payment_rows`
|
||||
|
||||
### 보정·보조
|
||||
|
||||
- `public.member_overrides`
|
||||
- `public.member_retirements`
|
||||
- `public.member_aliases`
|
||||
- `public.integration_project_aliases`
|
||||
- `public.integration_project_category_mappings`
|
||||
- `public.integration_project_pm_assignments`
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
### 바로 줄이지 말아야 하는 것
|
||||
|
||||
- `integration_raw_*`
|
||||
- `member_versions`
|
||||
- `seat_assignment_versions`
|
||||
- `auth.*`
|
||||
|
||||
이건 지금 구조상 “많아 보여도 필요한 층”이다.
|
||||
|
||||
### 먼저 점검할 것
|
||||
|
||||
- `member_overrides`, `member_aliases`, `project_aliases`의 실제 운영 빈도
|
||||
- `seat_maps`의 과거 실험 도면 정리 기준
|
||||
|
||||
### 정리 원칙
|
||||
|
||||
1. 테이블을 없애기 전에 실제 읽는 API/화면/스크립트를 확인한다.
|
||||
2. 원본 추적용 테이블은 운영 출력용 테이블과 구분해서 판단한다.
|
||||
3. 테이블 삭제보다 먼저 “사용 안 함” 상태를 문서화한다.
|
||||
4. 삭제는 백업과 검증 절차가 준비된 뒤에만 한다.
|
||||
|
||||
## Recommended Next Checks
|
||||
|
||||
1. `seat_maps` 과거 DXF 시도본 정리 기준 수립
|
||||
2. `주의` 그룹 테이블의 입력/수정 주체 명확화
|
||||
3. `DB 상태` 화면에서 이 분류를 기준으로 계속 설명 유지
|
||||
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](../../incoming-files/sample%20style.css)
|
||||
- Runtime token file: [design-tokens.css](../../frontend/public/design-tokens.css)
|
||||
- Runtime pattern file: [design-patterns.css](../../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
|
||||
7
frontend/apps/db-status/README.md
Normal file
7
frontend/apps/db-status/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## DB Status App
|
||||
|
||||
- 수정 원본: `frontend/apps/db-status/index.html`
|
||||
- 실제 서빙: `incoming-files/served/db-status/index.html`
|
||||
- publish: `./scripts/publish_db_status_app.sh`
|
||||
|
||||
`#2` 이슈용 관리자 화면으로, 현재 DB 저장 구조와 적재 상태를 사람이 읽을 수 있게 보여준다.
|
||||
918
frontend/apps/db-status/index.html
Normal file
918
frontend/apps/db-status/index.html
Normal file
@@ -0,0 +1,918 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DB 상태</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||
color: var(--ds-ink, #2f2419);
|
||||
}
|
||||
.page {
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: rgba(76, 58, 35, 0.82);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.kpi {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 252, 247, 0.92);
|
||||
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||
}
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(112, 84, 41, 0.72);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #3d2e1d;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 251, 245, 0.96);
|
||||
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.panel-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.panel-body {
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel-body.tight {
|
||||
padding-top: 0;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 236, 196, 0.8);
|
||||
color: #7a5923;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 10px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: rgba(104, 79, 40, 0.76);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: rgba(250, 240, 213, 0.34);
|
||||
}
|
||||
.domain-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(90, 122, 94, 0.14);
|
||||
color: #456b4c;
|
||||
}
|
||||
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||
.group-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(240, 231, 214, 0.95);
|
||||
color: #674d27;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||
.table-title {
|
||||
font-weight: 800;
|
||||
color: #2f2419;
|
||||
}
|
||||
.table-trigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: #2f2419;
|
||||
font-weight: 800;
|
||||
}
|
||||
.table-trigger:hover {
|
||||
color: #80591f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.table-desc {
|
||||
margin-top: 5px;
|
||||
color: rgba(98, 75, 42, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.view-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.view-pill {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(86, 119, 93, 0.12);
|
||||
color: #456b4c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.toolbar-input {
|
||||
width: min(360px, 100%);
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(132, 102, 54, 0.16);
|
||||
background: rgba(255, 251, 244, 0.96);
|
||||
color: #2f2419;
|
||||
font: inherit;
|
||||
}
|
||||
.toolbar-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(129, 88, 31, 0.34);
|
||||
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.action-button {
|
||||
border: 1px solid rgba(128, 98, 48, 0.14);
|
||||
background: rgba(250, 240, 213, 0.62);
|
||||
color: #6c4a1a;
|
||||
border-radius: 999px;
|
||||
padding: 9px 14px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-button:hover {
|
||||
background: rgba(244, 228, 186, 0.8);
|
||||
}
|
||||
.action-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.notes {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: rgba(84, 65, 38, 0.84);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.mapping-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.mapping-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 249, 239, 0.92);
|
||||
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||
}
|
||||
.mapping-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.mapping-card p {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(98, 75, 42, 0.74);
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mapping-table-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.preview-meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px 18px 0;
|
||||
}
|
||||
.preview-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 231, 214, 0.9);
|
||||
color: #634a25;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.column-pill em {
|
||||
font-style: normal;
|
||||
color: rgba(99, 74, 37, 0.68);
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.sticky-head th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(255, 248, 236, 0.98);
|
||||
z-index: 1;
|
||||
}
|
||||
.muted {
|
||||
color: rgba(110, 86, 50, 0.72);
|
||||
}
|
||||
.empty {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(44, 31, 16, 0.42);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal-card {
|
||||
width: min(1600px, 100%);
|
||||
max-height: min(88vh, 980px);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 250, 243, 0.98);
|
||||
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 16px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.modal-close {
|
||||
border: 0;
|
||||
background: rgba(240, 229, 206, 0.9);
|
||||
color: #6d5127;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(225, 208, 174, 0.96);
|
||||
}
|
||||
.modal-body {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 16px; }
|
||||
.hero { padding: 22px 20px; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
th, td { padding: 10px 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||
<p>
|
||||
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||
</p>
|
||||
<p>
|
||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||
</p>
|
||||
<p>
|
||||
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="overview" class="overview"></section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>전체 테이블 현황</h2>
|
||||
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
|
||||
<div class="toolbar-actions">
|
||||
<span id="table-count" class="meta-chip">0 / 0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도메인</th>
|
||||
<th>테이블</th>
|
||||
<th>Rows</th>
|
||||
<th>최근 갱신</th>
|
||||
<th>연결 화면</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div style="display:grid; gap:20px;">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>원본 import 배치</h2>
|
||||
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Rows</th>
|
||||
<th>Imported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batch-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>바이너리 원본 보관</h2>
|
||||
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>파일</th>
|
||||
<th>크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="binary-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 메모</h2>
|
||||
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ol id="notes" class="notes"></ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 분류</h2>
|
||||
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div id="group-summary"></div>
|
||||
<div id="product-summary" style="margin-top:18px;"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>화면별 데이터 소스</h2>
|
||||
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
|
||||
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="preview-meta" class="preview-meta"></div>
|
||||
<div class="panel-body tight">
|
||||
<div class="preview-table-wrap">
|
||||
<table>
|
||||
<thead id="preview-head" class="sticky-head"></thead>
|
||||
<tbody id="preview-body">
|
||||
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allTables = [];
|
||||
let currentPreview = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
const countTarget = document.getElementById("table-count");
|
||||
if (countTarget) {
|
||||
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
|
||||
}
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||
<td>
|
||||
<div style="margin-bottom:8px;">
|
||||
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||
</div>
|
||||
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.last_event_at)}</td>
|
||||
<td>
|
||||
<div class="view-list">
|
||||
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatches(items) {
|
||||
const target = document.getElementById("batch-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.imported_at)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderBinarySources(items) {
|
||||
const target = document.getElementById("binary-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(item.filename)}</td>
|
||||
<td>${formatBytes(item.byte_size)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
const target = document.getElementById("notes");
|
||||
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||
}
|
||||
|
||||
function renderGroupSummary(summary) {
|
||||
const target = document.getElementById("group-summary");
|
||||
const groups = [
|
||||
["유지", "keep"],
|
||||
["주의", "caution"],
|
||||
["원본·추적", "trace"],
|
||||
["정리 후보", "cleanup"],
|
||||
];
|
||||
target.innerHTML = groups.map(([label, klass]) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderProductSummary(summary) {
|
||||
const target = document.getElementById("product-summary");
|
||||
const groups = [
|
||||
"탭 데이터",
|
||||
"로그인·권한",
|
||||
"히스토리",
|
||||
"로우데이터·적재",
|
||||
"보정·보조",
|
||||
];
|
||||
target.innerHTML = groups.map((label) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderScreenMap(items) {
|
||||
const target = document.getElementById("screen-map");
|
||||
if (!items || !items.length) {
|
||||
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<article class="mapping-card">
|
||||
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||
<div class="mapping-table-list">
|
||||
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||
</div>
|
||||
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTablePreview(payload) {
|
||||
currentPreview = payload;
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const downloadButton = document.getElementById("preview-download");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
|
||||
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||
if (downloadButton) {
|
||||
downloadButton.disabled = !(payload.rows && payload.rows.length);
|
||||
}
|
||||
previewMeta.innerHTML = `
|
||||
<div>
|
||||
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||
</div>
|
||||
<div class="preview-columns">
|
||||
${(payload.columns || []).map((column) => `
|
||||
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const columns = payload.columns || [];
|
||||
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||
if (!payload.rows || !payload.rows.length) {
|
||||
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
return;
|
||||
}
|
||||
previewBody.innerHTML = payload.rows.map((row) => `
|
||||
<tr>
|
||||
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||
</tr>
|
||||
`).join("");
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
async function loadTablePreview(schema, table) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
previewTitle.textContent = `${table}`;
|
||||
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||
previewMeta.innerHTML = "";
|
||||
previewHead.innerHTML = "";
|
||||
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
renderTablePreview(payload);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
allTables = payload.tables || [];
|
||||
document.getElementById("generated-at").textContent = payload.generated_at
|
||||
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||
: "갱신 시각 없음";
|
||||
renderOverview(payload.overview || {});
|
||||
renderTables(allTables);
|
||||
renderBatches(payload.import_batches || []);
|
||||
renderBinarySources(payload.binary_sources || []);
|
||||
renderNotes(payload.notes || []);
|
||||
renderGroupSummary(payload.group_summary || {});
|
||||
renderProductSummary(payload.product_summary || {});
|
||||
renderScreenMap(payload.screen_map || []);
|
||||
}
|
||||
|
||||
function toCsvValue(value) {
|
||||
const text = String(value ?? "");
|
||||
if (!/[",\n]/.test(text)) return text;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function downloadPreviewCsv() {
|
||||
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
|
||||
const headers = currentPreview.columns.map((column) => column.name);
|
||||
const lines = [
|
||||
headers.map(toCsvValue).join(","),
|
||||
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
|
||||
];
|
||||
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function applyTableSearch(query) {
|
||||
const normalized = String(query || "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
renderTables(allTables);
|
||||
return;
|
||||
}
|
||||
const filtered = allTables.filter((item) => {
|
||||
const haystack = [
|
||||
item.label,
|
||||
item.table_ref,
|
||||
item.description,
|
||||
...(item.related_views || []),
|
||||
item.domain,
|
||||
item.group,
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
renderTables(filtered);
|
||||
}
|
||||
|
||||
document.getElementById("preview-close").addEventListener("click", () => {
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
|
||||
document.getElementById("table-search").addEventListener("input", (event) => {
|
||||
applyTableSearch(event.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||
if (event.target.id !== "preview-modal") return;
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/apps/ledger/README.md
Normal file
26
frontend/apps/ledger/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Ledger App Source
|
||||
|
||||
`사업관리대장` 화면의 앱 구조 source-of-truth 디렉터리다.
|
||||
|
||||
현재 원칙:
|
||||
|
||||
- 실제 runtime 응답은 여전히 `incoming-files/served/ledger/`를 사용한다.
|
||||
- 하지만 HTML/CSS/JS 수정 원본은 이 디렉터리에서 먼저 관리한다.
|
||||
- 변경 후에는 `scripts/publish_ledger_app.sh`로 `served/ledger/`에 반영한다.
|
||||
|
||||
구성:
|
||||
|
||||
- `index.html`: ledger 엔트리 HTML 원본 템플릿
|
||||
- `assets/MH 통합 대시보드_260320.css`: ledger base stylesheet
|
||||
- `assets/ledger-override.css`: 8081 ledger 스타일 확장
|
||||
- `assets/ledger-override.js`: 8081 ledger UI/상호작용 확장
|
||||
|
||||
주의:
|
||||
|
||||
- `index.html`은 runtime 경로를 직접 하드코딩하지 않는다.
|
||||
- `__LEDGER_HEAD_ASSETS__`, `__LEDGER_BODY_SCRIPTS__` placeholder는 publish 시 실제 `/integrations/ledger-assets/*` 경로로 치환된다.
|
||||
|
||||
범위:
|
||||
|
||||
- 이 디렉터리는 `#21` 이후 `사업관리대장`을 화면별 앱 구조로 승격하기 위한 첫 단계다.
|
||||
- 아직 프레임워크 앱은 아니고, 독립 관리되는 정식 화면 소스 디렉터리다.
|
||||
1377
frontend/apps/ledger/assets/MH 통합 대시보드_260320.css
Normal file
1377
frontend/apps/ledger/assets/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
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%;
|
||||
}
|
||||
}
|
||||
661
frontend/apps/ledger/assets/ledger-override.js
Normal file
661
frontend/apps/ledger/assets/ledger-override.js
Normal file
@@ -0,0 +1,661 @@
|
||||
(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 bgNormalizeText(value) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
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 normalizedCategory(row) {
|
||||
var category = bgNormalizeText(row && row.cat);
|
||||
if (category.indexOf("가족사") >= 0) return "가족사";
|
||||
var corp = bgNormalizeText(row && row.corp);
|
||||
if (corp && corp !== "바론") return "가족사";
|
||||
return "바론";
|
||||
}
|
||||
|
||||
function isSupportServiceRow(row) {
|
||||
return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
|
||||
}
|
||||
|
||||
function projectTypeLabel(row) {
|
||||
if (isSupportServiceRow(row)) return "기술지원서비스";
|
||||
return normalizedCategory(row);
|
||||
}
|
||||
|
||||
function projectTypeRank(row) {
|
||||
var label = projectTypeLabel(row);
|
||||
if (label === "바론") return 0;
|
||||
if (label === "가족사") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function normalizeStatusLabel(status) {
|
||||
var value = bgNormalizeText(status);
|
||||
if (!value) return "-";
|
||||
if (value === "완료") return "준공";
|
||||
if (value === "진행") return "과업진행중";
|
||||
if (value === "대기") return "계약대기";
|
||||
if (value === "중지") return "과업중지";
|
||||
return value;
|
||||
}
|
||||
|
||||
function rowStatusLabel(row) {
|
||||
return normalizeStatusLabel(row && row.status);
|
||||
}
|
||||
|
||||
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 rowStatusLabel(row) === "과업진행중";
|
||||
}
|
||||
|
||||
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 rowStatusLabel(row) === "준공" && 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 isBaronProjectRow(row) {
|
||||
return projectTypeLabel(row) === "바론";
|
||||
}
|
||||
|
||||
function isSoftwareProjectRow(row) {
|
||||
var name = bgNormalizeText(row && row.name).toLowerCase();
|
||||
if (!name) return false;
|
||||
return [
|
||||
"프로그램",
|
||||
"소프트웨어",
|
||||
"software",
|
||||
" sw",
|
||||
"sw ",
|
||||
"erp",
|
||||
"tova",
|
||||
"ipipe",
|
||||
"eg-bim",
|
||||
"cad"
|
||||
].some(function (keyword) {
|
||||
return name.indexOf(keyword) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSinkProjectName(row) {
|
||||
var name = bgNormalizeText(row && row.name);
|
||||
return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
|
||||
}
|
||||
|
||||
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 = activeRows.filter(isSupportServiceRow);
|
||||
var baronActiveRows = activeRows.filter(isBaronProjectRow);
|
||||
return {
|
||||
targetYear: targetYear,
|
||||
activeRows: activeRows,
|
||||
newProjectRows: newProjectRows,
|
||||
completedRows: completedRows,
|
||||
managementRows: managementRows,
|
||||
managementTotals: bgTotals(managementRows),
|
||||
baronActiveRows: baronActiveRows,
|
||||
baronProjectTotals: bgTotals(baronActiveRows),
|
||||
baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
|
||||
};
|
||||
}
|
||||
|
||||
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 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 startYear = Number(projectYear(row) || 0);
|
||||
if (!startYear) return 9998;
|
||||
return startYear;
|
||||
}
|
||||
|
||||
function tableGroupLabel(row) {
|
||||
var startYear = projectYear(row);
|
||||
if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
|
||||
return "미지정 " + projectTypeLabel(row);
|
||||
}
|
||||
|
||||
function compareDashboardRows(a, b) {
|
||||
var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
|
||||
if (typeRankDiff !== 0) return typeRankDiff;
|
||||
var groupDiff = groupSortRank(a) - groupSortRank(b);
|
||||
if (groupDiff !== 0) return groupDiff;
|
||||
var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
|
||||
if (sinkDiff !== 0) return sinkDiff;
|
||||
return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
|
||||
}
|
||||
|
||||
function filterCategoryLabel(row) {
|
||||
return projectTypeLabel(row);
|
||||
}
|
||||
|
||||
function filterClientLabel(row) {
|
||||
if (typeof normalizeClientDisplay === "function") {
|
||||
return normalizeClientDisplay(row && row.client);
|
||||
}
|
||||
return bgNormalizeText(row && row.client) || "-";
|
||||
}
|
||||
|
||||
function filterOrderLabel(row) {
|
||||
return bgNormalizeText(row && row.order) || "-";
|
||||
}
|
||||
|
||||
function receivableFilterLabel(row) {
|
||||
var amount = Number((row && row.recv) || 0);
|
||||
if (amount <= 0) return "미수 없음";
|
||||
if (amount < 10000000) return "1천만 미만";
|
||||
if (amount < 100000000) return "1천만 이상";
|
||||
return "1억 이상";
|
||||
}
|
||||
|
||||
function refreshFilterDom() {
|
||||
E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
|
||||
return [el.dataset.filter, el];
|
||||
}));
|
||||
E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
|
||||
return [el.dataset.filter, el];
|
||||
}));
|
||||
}
|
||||
|
||||
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(compareDashboardRows);
|
||||
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 ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</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("");
|
||||
refreshFilterDom();
|
||||
if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
|
||||
}
|
||||
|
||||
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 totals = summary.baronProjectTotals;
|
||||
var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 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: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
|
||||
{ label: "계약금 (VAT별도)", 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);
|
||||
});
|
||||
S.rows.sort(compareDashboardRows);
|
||||
render();
|
||||
};
|
||||
|
||||
filterDefinitions = function () {
|
||||
return [
|
||||
{ key: "cat", map: filterCategoryLabel },
|
||||
{ key: "code", map: function (r) { return r.code || "-"; } },
|
||||
{ key: "name", map: function (r) { return r.name || "-"; } },
|
||||
{ key: "client", map: filterClientLabel },
|
||||
{ key: "order", map: filterOrderLabel },
|
||||
{ key: "status", map: rowStatusLabel },
|
||||
{ key: "amount", map: amountFilterLabel },
|
||||
{ key: "outsource", map: outsourceFilterLabel },
|
||||
{ key: "receivable", map: receivableFilterLabel },
|
||||
{ key: "collected", map: collectedFilterLabel },
|
||||
{ key: "rate", map: rateFilterLabel }
|
||||
];
|
||||
};
|
||||
|
||||
updateFilterButtons = function () {
|
||||
Object.keys(E.filterButtons || {}).forEach(function (key) {
|
||||
var btn = E.filterButtons[key];
|
||||
if (!btn) return;
|
||||
var active = !!S.filters[key];
|
||||
btn.classList.toggle("active", active);
|
||||
btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
|
||||
var mark = btn.querySelector(".th-mark");
|
||||
if (mark) mark.textContent = active ? "•" : "";
|
||||
});
|
||||
};
|
||||
|
||||
syncColumnFilters = function (rows) {
|
||||
filterDefinitions().forEach(function (def) {
|
||||
var 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();
|
||||
};
|
||||
|
||||
matchesColumnFilters = function (r) {
|
||||
if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
|
||||
if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
|
||||
if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
|
||||
if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
|
||||
if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
|
||||
if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
|
||||
if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
|
||||
if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
|
||||
if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) 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;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
var panel = document.querySelector(".panel");
|
||||
if (panel && !panel.dataset.ledgerFilterBound) {
|
||||
panel.dataset.ledgerFilterBound = "true";
|
||||
panel.addEventListener("click", function (event) {
|
||||
var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
|
||||
if (trigger) {
|
||||
refreshFilterDom();
|
||||
event.stopPropagation();
|
||||
toggleFilterMenu(trigger.dataset.filter);
|
||||
return;
|
||||
}
|
||||
var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
|
||||
var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
|
||||
if (option && menu) {
|
||||
event.stopPropagation();
|
||||
setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
16
frontend/apps/organization/README.md
Normal file
16
frontend/apps/organization/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Organization App
|
||||
|
||||
조직현황 탭 전용 소스 디렉터리다.
|
||||
|
||||
- 편집 원본:
|
||||
- `index.html`
|
||||
- `assets/common.css`
|
||||
- `assets/organization.css`
|
||||
- `assets/organization.js`
|
||||
- 실제 서빙 대상:
|
||||
- `DashBoard-organization.html`
|
||||
- `legacy/static/common.css`
|
||||
- `legacy/static/organization.css`
|
||||
- `legacy/static/organization.js`
|
||||
|
||||
수정 후에는 `scripts/publish_organization_app.sh`를 실행해 서빙 파일로 반영한다.
|
||||
110
frontend/apps/organization/assets/common.css
Normal file
110
frontend/apps/organization/assets/common.css
Normal file
@@ -0,0 +1,110 @@
|
||||
@import url("/design-tokens.css?v=20260401-01");
|
||||
@import url("/design-patterns.css?v=20260401-01");
|
||||
|
||||
:root {
|
||||
--font-sans: var(--ds-font-sans);
|
||||
|
||||
--color-bg: var(--ds-bg);
|
||||
--color-bg-soft: var(--ds-bg-soft);
|
||||
--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-header: var(--ds-brand);
|
||||
--color-header-soft: var(--ds-brand-soft);
|
||||
--color-accent: var(--ds-accent);
|
||||
--color-accent-soft: var(--ds-accent-soft);
|
||||
--color-accent-strong: var(--ds-accent-strong);
|
||||
|
||||
--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);
|
||||
|
||||
--space-1: var(--ds-space-1);
|
||||
--space-2: var(--ds-space-2);
|
||||
--space-3: var(--ds-space-3);
|
||||
--space-4: var(--ds-space-4);
|
||||
--space-5: var(--ds-space-5);
|
||||
--space-6: var(--ds-space-6);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text);
|
||||
background: var(--ds-bg-gradient);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--ds-bg-gradient);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
background: var(--color-surface-soft);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.ui-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.ui-button-primary {
|
||||
border: none;
|
||||
color: #fff;
|
||||
background: var(--color-accent);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.ui-button-secondary {
|
||||
border: 1px solid var(--color-border-soft);
|
||||
color: var(--color-text);
|
||||
background: var(--ds-surface-tint);
|
||||
}
|
||||
|
||||
.ui-input {
|
||||
border: 1px solid var(--color-border-soft);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--ds-surface-tint-strong);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ui-input:focus {
|
||||
border-color: rgba(47, 153, 115, 0.45);
|
||||
box-shadow: 0 0 0 4px rgba(47, 153, 115, 0.1);
|
||||
}
|
||||
1744
frontend/apps/organization/assets/organization.css
Normal file
1744
frontend/apps/organization/assets/organization.css
Normal file
File diff suppressed because it is too large
Load Diff
1979
frontend/apps/organization/assets/organization.js
Normal file
1979
frontend/apps/organization/assets/organization.js
Normal file
File diff suppressed because it is too large
Load Diff
65
frontend/apps/organization/index.html
Normal file
65
frontend/apps/organization/index.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MH 조직현황 관리</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
|
||||
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
|
||||
</head>
|
||||
<body>
|
||||
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
||||
|
||||
<div class="search-section">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="relative flex items-center w-full">
|
||||
<span class="search-icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
</span>
|
||||
<input type="text" id="search-input" placeholder="이름 또는 조직 검색" class="search-input" />
|
||||
</div>
|
||||
<div id="dept-tabs" class="dept-tabs-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
|
||||
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header">
|
||||
<h2 class="stats-title text-xs font-black text-slate-800 flex items-center gap-2">인원 현황 통계 <span id="total-count-badge" class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
|
||||
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);">▼</span>
|
||||
</div>
|
||||
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="tree-root" class="org-canvas">
|
||||
<div class="text-slate-400 font-bold mt-20 text-xs text-center">서버에서 조직 데이터를 불러오는 중입니다.</div>
|
||||
</div>
|
||||
|
||||
<button id="admin-mode-btn" class="admin-mode-btn" data-label="관리자 모드 전환">🔐</button>
|
||||
|
||||
<div id="last-updated" class="fixed bottom-4 left-5 text-[10px] text-slate-400 font-bold z-[4000] pointer-events-none" style="letter-spacing: 0.02em; opacity: 0.8;"></div>
|
||||
|
||||
<div class="fab-container" id="fab-container">
|
||||
<button class="fab-main text-white" id="fab-main">+</button>
|
||||
<div class="fab-menu" id="fab-menu"></div>
|
||||
</div>
|
||||
|
||||
<div id="modal">
|
||||
<div class="modal-content">
|
||||
<div id="modal-header-area">
|
||||
<h2 id="modal-title" class="text-xl font-black mb-6 text-slate-800 border-b pb-4">구성원 정보 수정</h2>
|
||||
</div>
|
||||
<div id="modal-fields" class="grid grid-cols-2 gap-x-8 gap-y-5"></div>
|
||||
<div id="modal-footer-area" class="flex gap-4 mt-10">
|
||||
<button id="modal-cancel-btn" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
||||
<button id="btn-save" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/legacy/static/organization.js?v=20260402-02"></script>
|
||||
</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
2050
frontend/public/app.js
Executable file → Normal file
2050
frontend/public/app.js
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
918
frontend/public/db-status.html
Normal file
918
frontend/public/db-status.html
Normal file
@@ -0,0 +1,918 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DB 상태</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||
color: var(--ds-ink, #2f2419);
|
||||
}
|
||||
.page {
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: rgba(76, 58, 35, 0.82);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.kpi {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 252, 247, 0.92);
|
||||
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||
}
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(112, 84, 41, 0.72);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #3d2e1d;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 251, 245, 0.96);
|
||||
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.panel-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.panel-body {
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel-body.tight {
|
||||
padding-top: 0;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 236, 196, 0.8);
|
||||
color: #7a5923;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 10px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: rgba(104, 79, 40, 0.76);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: rgba(250, 240, 213, 0.34);
|
||||
}
|
||||
.domain-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(90, 122, 94, 0.14);
|
||||
color: #456b4c;
|
||||
}
|
||||
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||
.group-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(240, 231, 214, 0.95);
|
||||
color: #674d27;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||
.table-title {
|
||||
font-weight: 800;
|
||||
color: #2f2419;
|
||||
}
|
||||
.table-trigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: #2f2419;
|
||||
font-weight: 800;
|
||||
}
|
||||
.table-trigger:hover {
|
||||
color: #80591f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.table-desc {
|
||||
margin-top: 5px;
|
||||
color: rgba(98, 75, 42, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.view-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.view-pill {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(86, 119, 93, 0.12);
|
||||
color: #456b4c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.toolbar-input {
|
||||
width: min(360px, 100%);
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(132, 102, 54, 0.16);
|
||||
background: rgba(255, 251, 244, 0.96);
|
||||
color: #2f2419;
|
||||
font: inherit;
|
||||
}
|
||||
.toolbar-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(129, 88, 31, 0.34);
|
||||
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.action-button {
|
||||
border: 1px solid rgba(128, 98, 48, 0.14);
|
||||
background: rgba(250, 240, 213, 0.62);
|
||||
color: #6c4a1a;
|
||||
border-radius: 999px;
|
||||
padding: 9px 14px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-button:hover {
|
||||
background: rgba(244, 228, 186, 0.8);
|
||||
}
|
||||
.action-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.notes {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: rgba(84, 65, 38, 0.84);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.mapping-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.mapping-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 249, 239, 0.92);
|
||||
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||
}
|
||||
.mapping-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.mapping-card p {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(98, 75, 42, 0.74);
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mapping-table-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.preview-meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px 18px 0;
|
||||
}
|
||||
.preview-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 231, 214, 0.9);
|
||||
color: #634a25;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.column-pill em {
|
||||
font-style: normal;
|
||||
color: rgba(99, 74, 37, 0.68);
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.sticky-head th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(255, 248, 236, 0.98);
|
||||
z-index: 1;
|
||||
}
|
||||
.muted {
|
||||
color: rgba(110, 86, 50, 0.72);
|
||||
}
|
||||
.empty {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(44, 31, 16, 0.42);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal-card {
|
||||
width: min(1600px, 100%);
|
||||
max-height: min(88vh, 980px);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 250, 243, 0.98);
|
||||
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 16px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.modal-close {
|
||||
border: 0;
|
||||
background: rgba(240, 229, 206, 0.9);
|
||||
color: #6d5127;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(225, 208, 174, 0.96);
|
||||
}
|
||||
.modal-body {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 16px; }
|
||||
.hero { padding: 22px 20px; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
th, td { padding: 10px 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||
<p>
|
||||
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||
</p>
|
||||
<p>
|
||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||
</p>
|
||||
<p>
|
||||
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="overview" class="overview"></section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>전체 테이블 현황</h2>
|
||||
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
|
||||
<div class="toolbar-actions">
|
||||
<span id="table-count" class="meta-chip">0 / 0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도메인</th>
|
||||
<th>테이블</th>
|
||||
<th>Rows</th>
|
||||
<th>최근 갱신</th>
|
||||
<th>연결 화면</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div style="display:grid; gap:20px;">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>원본 import 배치</h2>
|
||||
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Rows</th>
|
||||
<th>Imported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batch-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>바이너리 원본 보관</h2>
|
||||
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>파일</th>
|
||||
<th>크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="binary-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 메모</h2>
|
||||
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ol id="notes" class="notes"></ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 분류</h2>
|
||||
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div id="group-summary"></div>
|
||||
<div id="product-summary" style="margin-top:18px;"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>화면별 데이터 소스</h2>
|
||||
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
|
||||
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="preview-meta" class="preview-meta"></div>
|
||||
<div class="panel-body tight">
|
||||
<div class="preview-table-wrap">
|
||||
<table>
|
||||
<thead id="preview-head" class="sticky-head"></thead>
|
||||
<tbody id="preview-body">
|
||||
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allTables = [];
|
||||
let currentPreview = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
const countTarget = document.getElementById("table-count");
|
||||
if (countTarget) {
|
||||
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
|
||||
}
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||
<td>
|
||||
<div style="margin-bottom:8px;">
|
||||
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||
</div>
|
||||
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.last_event_at)}</td>
|
||||
<td>
|
||||
<div class="view-list">
|
||||
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatches(items) {
|
||||
const target = document.getElementById("batch-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.imported_at)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderBinarySources(items) {
|
||||
const target = document.getElementById("binary-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(item.filename)}</td>
|
||||
<td>${formatBytes(item.byte_size)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
const target = document.getElementById("notes");
|
||||
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||
}
|
||||
|
||||
function renderGroupSummary(summary) {
|
||||
const target = document.getElementById("group-summary");
|
||||
const groups = [
|
||||
["유지", "keep"],
|
||||
["주의", "caution"],
|
||||
["원본·추적", "trace"],
|
||||
["정리 후보", "cleanup"],
|
||||
];
|
||||
target.innerHTML = groups.map(([label, klass]) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderProductSummary(summary) {
|
||||
const target = document.getElementById("product-summary");
|
||||
const groups = [
|
||||
"탭 데이터",
|
||||
"로그인·권한",
|
||||
"히스토리",
|
||||
"로우데이터·적재",
|
||||
"보정·보조",
|
||||
];
|
||||
target.innerHTML = groups.map((label) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderScreenMap(items) {
|
||||
const target = document.getElementById("screen-map");
|
||||
if (!items || !items.length) {
|
||||
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<article class="mapping-card">
|
||||
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||
<div class="mapping-table-list">
|
||||
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||
</div>
|
||||
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTablePreview(payload) {
|
||||
currentPreview = payload;
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const downloadButton = document.getElementById("preview-download");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
|
||||
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||
if (downloadButton) {
|
||||
downloadButton.disabled = !(payload.rows && payload.rows.length);
|
||||
}
|
||||
previewMeta.innerHTML = `
|
||||
<div>
|
||||
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||
</div>
|
||||
<div class="preview-columns">
|
||||
${(payload.columns || []).map((column) => `
|
||||
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const columns = payload.columns || [];
|
||||
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||
if (!payload.rows || !payload.rows.length) {
|
||||
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
return;
|
||||
}
|
||||
previewBody.innerHTML = payload.rows.map((row) => `
|
||||
<tr>
|
||||
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||
</tr>
|
||||
`).join("");
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
async function loadTablePreview(schema, table) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
previewTitle.textContent = `${table}`;
|
||||
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||
previewMeta.innerHTML = "";
|
||||
previewHead.innerHTML = "";
|
||||
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
renderTablePreview(payload);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
allTables = payload.tables || [];
|
||||
document.getElementById("generated-at").textContent = payload.generated_at
|
||||
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||
: "갱신 시각 없음";
|
||||
renderOverview(payload.overview || {});
|
||||
renderTables(allTables);
|
||||
renderBatches(payload.import_batches || []);
|
||||
renderBinarySources(payload.binary_sources || []);
|
||||
renderNotes(payload.notes || []);
|
||||
renderGroupSummary(payload.group_summary || {});
|
||||
renderProductSummary(payload.product_summary || {});
|
||||
renderScreenMap(payload.screen_map || []);
|
||||
}
|
||||
|
||||
function toCsvValue(value) {
|
||||
const text = String(value ?? "");
|
||||
if (!/[",\n]/.test(text)) return text;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function downloadPreviewCsv() {
|
||||
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
|
||||
const headers = currentPreview.columns.map((column) => column.name);
|
||||
const lines = [
|
||||
headers.map(toCsvValue).join(","),
|
||||
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
|
||||
];
|
||||
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function applyTableSearch(query) {
|
||||
const normalized = String(query || "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
renderTables(allTables);
|
||||
return;
|
||||
}
|
||||
const filtered = allTables.filter((item) => {
|
||||
const haystack = [
|
||||
item.label,
|
||||
item.table_ref,
|
||||
item.description,
|
||||
...(item.related_views || []),
|
||||
item.domain,
|
||||
item.group,
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
renderTables(filtered);
|
||||
}
|
||||
|
||||
document.getElementById("preview-close").addEventListener("click", () => {
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
|
||||
document.getElementById("table-search").addEventListener("input", (event) => {
|
||||
applyTableSearch(event.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||
if (event.target.id !== "preview-modal") return;
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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;
|
||||
}
|
||||
@@ -3,12 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MH 조직현황 대시보드</title>
|
||||
<title>MH 대시보드-공개용</title>
|
||||
<script>
|
||||
document.title = window.location.port === '8081'
|
||||
? 'MH 대시보드-작업용'
|
||||
: 'MH 대시보드-공개용';
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;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="/styles.css">
|
||||
<!-- Keep login and common hub defaults aligned with 8080. -->
|
||||
<link rel="stylesheet" href="/styles.css?v=20260402-01">
|
||||
<!-- 8081-only hub overrides must not restyle the login screen. -->
|
||||
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
|
||||
</head>
|
||||
<body>
|
||||
<section id="login-panel" class="login-screen">
|
||||
@@ -37,37 +47,185 @@
|
||||
|
||||
<section id="dashboard-panel" class="dashboard-shell hidden">
|
||||
<header class="dashboard-header">
|
||||
<div class="brand-block">
|
||||
<p class="eyebrow">MH Dashboard</p>
|
||||
<h2 id="current-view-title">조직 현황</h2>
|
||||
<div class="header-left">
|
||||
<div class="brand-block">
|
||||
<p class="eyebrow">MH Dashboard</p>
|
||||
<h2 id="current-view-title">조직 현황</h2>
|
||||
</div>
|
||||
|
||||
<div id="global-date-controls" class="header-date-controls hidden">
|
||||
<span class="header-date-label">기간</span>
|
||||
<label class="header-date-field">
|
||||
<input id="global-start-date" type="date" aria-label="시작일">
|
||||
</label>
|
||||
<span class="header-date-sep">~</span>
|
||||
<label class="header-date-field">
|
||||
<input id="global-end-date" type="date" aria-label="종료일">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="organization-history-controls" class="header-date-controls hidden">
|
||||
<span class="header-date-label">조직 기준월</span>
|
||||
<label class="header-date-field">
|
||||
<select id="organization-month-select" aria-label="조직 기준월"></select>
|
||||
</label>
|
||||
<button id="organization-compare-btn" class="ghost-button ghost-button-soft hidden" type="button">조직도 변경사항 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<button class="nav-pill" type="button" data-view="ledger">사업관리대장</button>
|
||||
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
|
||||
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
|
||||
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-center">
|
||||
<button class="nav-pill" type="button" data-view="ledger">사업관리대장</button>
|
||||
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
|
||||
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
|
||||
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
|
||||
<button class="nav-pill" type="button" data-view="db-status">DB 상태</button>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
|
||||
<div id="user-popover" class="user-popover hidden"></div>
|
||||
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M15 3h-4a2 2 0 0 0-2 2v3" />
|
||||
<path d="M10 17v2a2 2 0 0 0 2 2h3" />
|
||||
<path d="M21 12H9" />
|
||||
<path d="m16 7 5 5-5 5" />
|
||||
<path d="M3 5h8v14H3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
|
||||
<div id="user-popover" class="user-popover hidden"></div>
|
||||
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<div class="stage-frame">
|
||||
<iframe id="organization-frame" src="/legacy/organization?v=20260325-2" data-src="/legacy/organization?v=20260325-2" title="조직도 메인 화면"></iframe>
|
||||
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
|
||||
<iframe id="organization-frame" src="/legacy/organization?v=20260402-02" data-src="/legacy/organization?v=20260402-02" title="조직도 메인 화면"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section id="project-stage" class="main-stage" hidden>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<section id="team-stage" class="main-stage" hidden>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<section id="db-status-stage" class="main-stage" hidden>
|
||||
<div class="stage-frame">
|
||||
<iframe id="db-status-frame" src="/db-status.html?v=20260401-02" data-src="/db-status.html?v=20260401-02" title="DB 상태 화면"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section id="seatmap-admin-stage" class="main-stage" hidden>
|
||||
<div class="seatmap-layout">
|
||||
<div class="seatmap-topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Seat Layout</p>
|
||||
<h3 id="seatmap-admin-name">자리배치도</h3>
|
||||
</div>
|
||||
<div id="seatmap-admin-office-tabs" class="seatmap-office-tabs"></div>
|
||||
<div class="seatmap-actions" id="seatmap-admin-actions">
|
||||
<button id="seatmap-admin-save-btn" class="ghost-button" type="button" hidden disabled>저장</button>
|
||||
<button id="seatmap-admin-exit-btn" class="ghost-button ghost-button-soft" type="button" hidden>나가기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="seatmap-admin-status" class="seatmap-status" role="status"></p>
|
||||
|
||||
<div class="seatmap-content">
|
||||
<div class="seatmap-board-panel">
|
||||
<div id="seatmap-admin-empty" class="seatmap-empty hidden"></div>
|
||||
<div id="seatmap-admin-board-wrap" class="seatmap-board-wrap hidden">
|
||||
<div id="seatmap-admin-board" class="seatmap-board"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="seatmap-sidebar">
|
||||
<section id="seatmap-admin-settings-panel" class="seatmap-panel hidden">
|
||||
<div class="seatmap-panel-head">
|
||||
<h4>도면 설정</h4>
|
||||
<p>현재는 기술개발센터 고정 도면을 사용합니다.</p>
|
||||
</div>
|
||||
<form id="seatmap-admin-settings-form" class="seatmap-form">
|
||||
<label>
|
||||
<span>도면 이름</span>
|
||||
<input id="seatmap-admin-form-name" name="name" type="text" placeholder="예: 기술개발센터" required>
|
||||
</label>
|
||||
<div>
|
||||
<span>DXF 파일</span>
|
||||
<label class="seatmap-file-input" for="seatmap-admin-form-image">
|
||||
<input id="seatmap-admin-form-image" name="image" type="file" accept=".dxf" required>
|
||||
<span class="seatmap-file-button">DXF 선택</span>
|
||||
<strong id="seatmap-admin-file-name" class="seatmap-file-name">선택된 파일 없음</strong>
|
||||
</label>
|
||||
</div>
|
||||
<button id="seatmap-admin-settings-submit" type="submit">DXF 업로드</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="seatmap-panel">
|
||||
<div class="seatmap-panel-head">
|
||||
<h4 id="seatmap-admin-sidebar-title">전체 인원</h4>
|
||||
<p id="seatmap-admin-sidebar-desc">미배치 인원은 상단, 배치 완료 인원은 하단에 표시됩니다.</p>
|
||||
</div>
|
||||
<label class="seatmap-search">
|
||||
<span class="hidden">구성원 검색</span>
|
||||
<input id="seatmap-admin-search" type="search" placeholder="이름 또는 부서 검색">
|
||||
</label>
|
||||
<div id="seatmap-admin-context" class="seatmap-context-panel hidden"></div>
|
||||
<div id="seatmap-admin-unassigned" class="seatmap-member-list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="seatmap-readonly-stage" class="main-stage" hidden>
|
||||
<div class="seatmap-layout">
|
||||
<div class="seatmap-topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Seat Layout</p>
|
||||
<h3 id="seatmap-readonly-name">자리배치도</h3>
|
||||
</div>
|
||||
<div id="seatmap-readonly-office-tabs" class="seatmap-office-tabs"></div>
|
||||
<div class="seatmap-actions" id="seatmap-readonly-actions">
|
||||
<button id="seatmap-readonly-exit-btn" class="ghost-button ghost-button-soft" type="button" hidden>나가기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="seatmap-readonly-status" class="seatmap-status" role="status"></p>
|
||||
|
||||
<div class="seatmap-content">
|
||||
<div class="seatmap-board-panel">
|
||||
<div id="seatmap-readonly-empty" class="seatmap-empty hidden"></div>
|
||||
<div id="seatmap-readonly-board-wrap" class="seatmap-board-wrap hidden">
|
||||
<div id="seatmap-readonly-board" class="seatmap-board"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="seatmap-sidebar">
|
||||
<section class="seatmap-panel">
|
||||
<div class="seatmap-panel-head">
|
||||
<h4 id="seatmap-readonly-sidebar-title">배치 인원 검색</h4>
|
||||
<p id="seatmap-readonly-sidebar-desc">이름이나 부서를 검색하고 클릭하면 해당 좌석으로 바로 확대 이동합니다.</p>
|
||||
</div>
|
||||
<label class="seatmap-search">
|
||||
<span class="hidden">구성원 검색</span>
|
||||
<input id="seatmap-readonly-search" type="search" placeholder="이름 또는 부서 검색">
|
||||
</label>
|
||||
<div id="seatmap-readonly-context" class="seatmap-context-panel hidden"></div>
|
||||
<div id="seatmap-readonly-unassigned" class="seatmap-member-list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="empty-stage" class="main-stage" hidden>
|
||||
@@ -76,6 +234,6 @@
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
<script src="/app.js?v=20260401-02"></script>
|
||||
</body>
|
||||
</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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
BIN
incoming-files/1.png
Normal file
BIN
incoming-files/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 748 KiB |
2598
incoming-files/260320.html
Normal file
2598
incoming-files/260320.html
Normal file
File diff suppressed because one or more lines are too long
BIN
incoming-files/MH.xlsx
Normal file
BIN
incoming-files/MH.xlsx
Normal file
Binary file not shown.
36
incoming-files/README.md
Normal file
36
incoming-files/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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/*`만 읽는다.
|
||||
- 다만 `payment`, `mh`, `ledger`, `db-status`는 이제 앱 소스가 따로 있으므로, 실제 수정은 `frontend/apps/*`에서 하고 publish 스크립트로 `served/`에 반영한다.
|
||||
- 즉 `served/`는 runtime 기준이고, 사람이 직접 먼저 수정하는 source-of-truth는 아니다.
|
||||
|
||||
## Reference
|
||||
|
||||
- 원본 참고 자산
|
||||
- 복구 비교용 자산
|
||||
- 직접 서빙하지 않는 파일
|
||||
|
||||
예:
|
||||
|
||||
- 원본 `xlsx`, `csv`
|
||||
- 샘플 스타일 파일
|
||||
- 원본/백업 HTML
|
||||
- 디자인 비교용 파일
|
||||
- `reference/omh.html`
|
||||
- `reference/opayment.html`
|
||||
- `reference/ledger/MH 통합 대시보드_260320.html`
|
||||
- `reference/ledger/MH 통합 대시보드_260320.css`
|
||||
- `reference/ledger/사업관리대장-1.xlsx`
|
||||
BIN
incoming-files/organization.xlsx
Normal file
BIN
incoming-files/organization.xlsx
Normal file
Binary file not shown.
865
incoming-files/payment.csv
Normal file
865
incoming-files/payment.csv
Normal file
@@ -0,0 +1,865 @@
|
||||
상신회사,청구일,발행일,발행월,계정코드,관리계정코드,각사 계정명,프로젝트코드,사업명,사업명(표출PJT),사업명(인트라넷기준),사업분야,세부분야,기획/개발/영업,대분류,중분류,소분류,부서명,팀명,거래처,적요,차변공급가,대변공급가,지출,수입,특이사항,구분,프로젝트성격,,,,,,,,,
|
||||
바론,2025-12-08,2026-01-02,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"31,590",0,"31,590",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-08,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"31,590",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-05,2026-01-02,1월,10111101,REV-101,용역미수금(계산서발행),Y25040,보은국토 도로유지보수분야 통합건설사업관리용역(한맥/동성/동명)-인쇄편집,보은국토도로 사업관리,보은국토도로 사업관리,직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,(주)동성엔지니어링,보은국토도로유지보수분야통합건설사업관리용역(한맥/동성/동명)-인쇄편집,"1,815,000",0,0,"1,815,000",,수입,일반,,,,,,,,,
|
||||
바론,2025-12-05,2026-01-02,1월,40110501,REV-101,개발수입,Y25040,보은국토 도로유지보수분야 통합건설사업관리용역(한맥/동성/동명)-인쇄편집,보은국토도로 사업관리,보은국토도로 사업관리,직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,(주)동성엔지니어링,보은국토도로유지보수분야통합건설사업관리용역(한맥/동성/동명)-인쇄편집,0,"1,650,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2025-12-05,2026-01-02,1월,20112901,LIA-101,매출세액,Y25040,보은국토 도로유지보수분야 통합건설사업관리용역(한맥/동성/동명)-인쇄편집,보은국토도로 사업관리,보은국토도로 사업관리,직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,(주)동성엔지니어링,"1,650,000*10%",0,"165,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-02,1월,10111101,REV-101,용역미수금(계산서발행),Y25008,계양-강화간 고속도로 건설공사 기본설계단계 교량 BIM 설계(제5공구),계양~강화 고속도로(5공구),계양-강화 고속도로 건설 기본 및 실시(5공구),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)동해종합기술공사,계양-강화간고속도로건설공사기본설계단계교량BIM설계(제5공구),"28,500,000",0,0,"28,500,000",,수입,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-02,1월,40110501,REV-101,개발수입,Y25008,계양-강화간 고속도로 건설공사 기본설계단계 교량 BIM 설계(제5공구),계양~강화 고속도로(5공구),계양-강화 고속도로 건설 기본 및 실시(5공구),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)동해종합기술공사,계양-강화간고속도로건설공사기본설계단계교량BIM설계(제5공구),0,"25,909,091",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-02,1월,20112901,LIA-101,매출세액,Y25008,계양-강화간 고속도로 건설공사 기본설계단계 교량 BIM 설계(제5공구),계양~강화 고속도로(5공구),계양-강화 고속도로 건설 기본 및 실시(5공구),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)동해종합기술공사,"25,909,091*10%",0,"2,590,909",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-15,2026-01-02,1월,10111101,REV-101,용역미수금(계산서발행),Y25007,ERP시스템 구축,ERP시스템구축(한종),ERP(한국종합엔지니어링),직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,(주)한국종합엔지니어링,ERP시스템구축,"55,000,000",0,0,"55,000,000",,수입,일반,,,,,,,,,
|
||||
바론,2025-12-15,2026-01-02,1월,40110501,REV-101,개발수입,Y25007,ERP시스템 구축,ERP시스템구축(한종),ERP(한국종합엔지니어링),직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,(주)한국종합엔지니어링,ERP시스템구축,0,"50,000,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2025-12-15,2026-01-02,1월,20112901,LIA-101,매출세액,Y25007,ERP시스템 구축,ERP시스템구축(한종),ERP(한국종합엔지니어링),직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,(주)한국종합엔지니어링,"50,000,000*10%",0,"5,000,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-29,2026-01-02,1월,10111101,REV-101,용역미수금(계산서발행),Y25067,EG-BIM 사용 계약,EG-BIM (파이프텍코리아),EG-BIM (파이프텍코리아),직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,파이프텍코리아,EG-BIM사용계약,"1,100,000",0,0,"1,100,000",,수입,일반,,,,,,,,,
|
||||
바론,2025-12-29,2026-01-02,1월,40110601,REV-101,유지관리수입,Y25067,EG-BIM 사용 계약,EG-BIM (파이프텍코리아),EG-BIM (파이프텍코리아),직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,파이프텍코리아,EG-BIM사용계약,0,"1,000,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2025-12-29,2026-01-02,1월,20112901,LIA-101,매출세액,Y25067,EG-BIM 사용 계약,EG-BIM (파이프텍코리아),EG-BIM (파이프텍코리아),직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,파이프텍코리아,"1,000,000*10%",0,"100,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,총괄기획실 세미나 후 회식,0,"570,500",0,0,공통 → 인사교육,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,60115705,WF-101,복리후생비(회식대),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,총괄기획실 세미나 후 회식,"570,500",0,"570,500",0,공통 → 인사교육,복리후생비,일반,,,,,,,,,
|
||||
바론,2025-12-22,2026-01-02,1월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비,"30,400",0,"30,400",0,,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-22,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비 - 김원식,0,"30,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-02,1월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비,"106,200",0,"106,200",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비 - 김원식,0,"106,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,총괄기획실,김원식,시내교통비,0,"44,900",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,50152705,WF-201,원가)여비교통비(시내교통비),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,총괄기획실,김원식,시내교통비,"44,900",0,"44,900",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,사장단회의 다과(1월 2일),0,"181,840",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-07,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,"바론 인감증명서, 등기부등본 발급수수료","6,000",0,"6,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,우편요금 반송 수수료,0,"2,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-05,2026-01-15,1월,60114398,OP-203,소모품비(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,바론 입찰용 사용인감 제작,"78,800",0,"78,800",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,(주)교보문고,기술개발센터도서구매(12월),0,"253,260",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-05,2026-01-15,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,"바론 입찰용 등기부등본, 인감증명서 발급","25,000",0,"25,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,김지영A,외부인사 초청 세미나(유니스트 교수) 다과,0,"26,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,송파구청,바론 2026 등록면허세 납부(측량업),"27,000",0,"27,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,"노션 구독료(25.12~25.12, 사내가이드)",0,"187,420",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,송파구청,바론 2026 등록면허세 납부(정보통신공사업),"54,000",0,"54,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,마천사무실 주차 할인권 구매,0,"60,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,송파구청,바론 2026 등록면허세 납부(초경량비행장치업),"40,500",0,"40,500",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 설치용 USB 오피스 파우치 구매(2개),0,"31,700",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-29,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,법인 서류 등기,360,0,360,0,,구매,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-02,1월,20111105,LIA-101,미지급금(KB국민카드),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,,국민카드(3866),스타벅스 기프트카드 구매,0,"1,000,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-29,1월,60114301,OP-203,소모품비(사무용품비),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,날인용 도장(7개),"20,900",0,"20,900",0,,구매,일반,,,,,,,,,
|
||||
바론,2025-12-16,2026-01-02,1월,10111101,REV-101,용역미수금(계산서발행),Y25042,예산국토 제2권역 도로유지보수사업 통합건설사업관리용역(한맥/동성/장맥)-인쇄/편집,예산국토 제2권역,예산국토 제2권역,직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,장맥ENG,예산국토제2권역도로유지보수사업통합건설사업관리용역(한맥/동성/장맥)-인쇄/편집,"1,512,500",0,0,"1,512,500",,수입,일반,,,,,,,,,
|
||||
바론,2025-12-16,2026-01-02,1월,40110501,REV-101,개발수입,Y25042,예산국토 제2권역 도로유지보수사업 통합건설사업관리용역(한맥/동성/장맥)-인쇄/편집,예산국토 제2권역,예산국토 제2권역,직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,장맥ENG,예산국토제2권역도로유지보수사업통합건설사업관리용역(한맥/동성/장맥)-인쇄/편집,0,"1,375,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2025-12-16,2026-01-02,1월,20112901,LIA-101,매출세액,Y25042,예산국토 제2권역 도로유지보수사업 통합건설사업관리용역(한맥/동성/장맥)-인쇄/편집,예산국토 제2권역,예산국토 제2권역,직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,장맥ENG,"1,375,000*10%",0,"137,500",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,정성호,출장비,"52,600",0,"52,600",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,정성호,출장비 - 정성호,0,"52,600",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"23,630",0,"23,630",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"23,630",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-05,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,(주)미래엔서해에너지,당진 숙소 임대 종료에 따른 도시가스 정산,0,"41,870",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-29,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,장헌산업 등기관련 인감증명서 수수료,"5,400",0,"5,400",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,장헌 사내이사 서류 발급 수수료,"2,200",0,"2,200",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,"바론 인감증명서, 등기부등본 발급수수료",0,"6,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,"법인명변경/법인분할 등기 관련 서류 발급 수수료(인감증명서 4, 등본 2, 초본 2)","4,000",0,"4,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2025-12-19,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),X21012,XR기반 건설 설계 혁신 시스템,XR기반 건설설계 혁신시스템,XR기반 건설설계 혁신시스템,직접매출,R&D,,매출,가족사 프로젝트,R&D,총괄기획실,경영기획팀,임민경,출장비,"24,000",0,"24,000",0,,출장비,R&D,,,,,,,,,
|
||||
바론,2025-12-19,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X21012,XR기반 건설 설계 혁신 시스템,XR기반 건설설계 혁신시스템,XR기반 건설설계 혁신시스템,직접매출,R&D,,매출,가족사 프로젝트,R&D,총괄기획실,경영기획팀,임민경,출장비 - 임민경,0,"24,000",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,주완기,총괄기획실 외빈 선물용 와인 구매(5EA),0,"230,000",0,0,,제외,일반,,,,,,,,,
|
||||
장헌산업,2026-01-22,2026-01-22,1월,831,OUT-201,지급수수료,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,법무사 조수호,주식회사 장헌산업 상호변경 공과금납부,"465,000",,"465,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),X25020,TOVA,TOVA,TOVA,S/W개발,교통,기획,비매출,S/W 개발,교통,총괄기획실,기술기획팀,황동환,출장비,"47,300",0,"47,300",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25020,TOVA,TOVA,TOVA,S/W개발,교통,,비매출,S/W 개발,교통,총괄기획실,기술기획팀,김원기,출장비 - 김원기,0,"39,300",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25020,TOVA,TOVA,TOVA,S/W개발,교통,,비매출,S/W 개발,교통,총괄기획실,기술기획팀,황동환,출장비 - 황동환,0,"8,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),X25037,ERP: 장헌산업,ERP:장헌산업,ERP: 장헌산업,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,총괄기획실,ERP기획팀,권오재,출장비,"190,040",0,"190,040",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25037,ERP: 장헌산업,ERP:장헌산업,ERP: 장헌산업,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,총괄기획실,ERP기획팀,류호성,출장비 - 류호성,0,"89,310",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25037,ERP: 장헌산업,ERP:장헌산업,ERP: 장헌산업,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,총괄기획실,ERP기획팀,권오재,출장비 - 권오재,0,"100,730",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,25년 12월 4대보험료 (전체),0,"58,112,940",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,50152527,HR-204,원가)복리후생비(산재보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,근로복지공단,25년 12월 4대보험료(산재보험/회사부담),"2,725,290",0,"2,725,290",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,50152521,HR-202,원가)복리후생비(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,25년 12월 4대보험료(건강보험/회사부담),"12,218,640",0,"12,218,640",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,20111505,HR-202,예수금(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,25년 12월 4대보험료(건강보험/직원부담),"12,218,640",0,"12,218,640",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,50152521,HR-202,원가)복리후생비(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,25년 12월 4대보험료(요양보험/회사부담),"1,581,980",0,"1,581,980",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,20111505,HR-202,예수금(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,25년 12월 4대보험료(요양보험),"1,581,980",0,"1,581,980",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,50151719,HR-201,원가)세금과공과(국민연금),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민연금관리공단,25년 12월 4대보험료(국민연금/회사부담),"10,816,770",0,"10,816,770",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,20111507,HR-201,예수금(국민연금),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민연금관리공단,25년 12월 4대보험료(국민연금/직원부담),"10,816,770",0,"10,816,770",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,50152529,HR-203,원가)복리후생비(고용보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,근로복지공단,25년 12월 4대보험료(고용보험/회사부담),"3,467,490",0,"3,467,490",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-07,1월,20111509,HR-203,예수금(고용보험료(직원)),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,근로복지공단,25년 12월 4대보험료(고용보험/직원부담),"2,685,380",0,"2,685,380",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-09,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,인프라 BIM1팀,안효원,삼안 소속 경조금 선지급 (안효원 선임연구원 본인 결혼),0,"500,000",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
장헌산업,2026-01-27,2026-01-27,1월,831,OUT-201,지급수수료,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,법무사 조수호,주식회사 장헌파트너스 분할 관련 공과금 납부,"6,680,000",,"6,680,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-12,1월,10111101,REV-101,용역미수금(계산서발행),Y25041,GAIA 기능 개선 계약,GAIA 기능 개선 계약,GAIA 기능 개선 계약,직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,(주)지오메카이엔지,GAIA앱 기능추가 개발,"3,850,000",0,0,"3,850,000",,수입,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-12,1월,40110501,REV-101,개발수입,Y25041,GAIA 기능 개선 계약,GAIA 기능 개선 계약,GAIA 기능 개선 계약,직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,(주)지오메카이엔지,GAIA앱 기능추가 개발,0,"3,500,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-12,1월,20112901,LIA-101,매출세액,Y25041,GAIA 기능 개선 계약,GAIA 기능 개선 계약,GAIA 기능 개선 계약,직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,(주)지오메카이엔지,"3,500,000*10%",0,"350,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-12,1월,10111101,REV-101,용역미수금(계산서발행),Y25041,GAIA 기능 개선 계약,GAIA 기능 개선 계약,GAIA 기능 개선 계약,직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,(주)지오메카이엔지,GAIA앱 기능추가 개발,0,"3,850,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-12,1월,10110501,AST-101,보통예금,Y25041,GAIA 기능 개선 계약,GAIA 기능 개선 계약,GAIA 기능 개선 계약,직접매출,S/W판매,,매출,바론계약,판매,기술개발센터,,국민(주거래),GAIA앱 기능추가 개발,"3,850,000",0,0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-14,1월,20110101,LIA-101,외상매입금,Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,주식회사 비젼테크아이엔씨,서산-아산 건설사업단 대회의실 긴급 수리 비용,0,"253,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-14,1월,50151399,OP-203,원가)소모품비(기타),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,,대덕테크놀로지 주식회사,드론 사진측량 실무 프로젝트 수행을 위한 공구(드릴) 구매의 건,"754,909",0,"754,909",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-14,1월,10115301,AST-106,매입세액,Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,,대덕테크놀로지 주식회사,드론 사진측량 실무 프로젝트 수행을 위한 공구(드릴) 구매의 건,"75,491",0,"75,491",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-11,2026-01-14,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,소모품 (창고 물품보관용가방) 구입,0,"13,970",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-21,1월,50151399,OP-203,원가)소모품비(기타),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,,(주)코세코,드론 사진측량 실무 프로젝트 수행을 위한 장비 구매의 건,"349,910",0,"349,910",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-14,1월,20110101,LIA-101,외상매입금,Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,,대덕테크놀로지 주식회사,드론 사진측량 실무 프로젝트 수행을 위한 공구(드릴) 구매의 건,0,"830,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-21,1월,10115301,AST-106,매입세액,Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,,(주)코세코,드론 사진측량 실무 프로젝트 수행을 위한 장비 구매의 건,"34,990",0,"34,990",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-20,1월,50153199,IT-301,원가)지급수수료(기타),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,Infra Solution 개발팀,최정우,파견인력 변경 관련 서류 발급수수료,"47,400",0,"47,400",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-15,1월,20110101,LIA-101,외상매입금,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,몬스타 주식회사,인프라 BIM 1팀 측량 결과물 3D 모델링을 위한 워크스테이션 긴급 수리,0,"600,000",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2026-01-12,2026-01-14,1월,60114599,WF-105,도서인쇄비(기타),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,,,대산당진 시공BIM 수행/도서인쇄비,"306,240",,"306,240",0,,구매,일반,,,,,,,,,
|
||||
한맥,2026-01-12,2026-01-14,1월,50151309,OP-203,원가)소모품비(안전관리장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,,,대산당진 시공BIM 안전장비 구매,"390,940",,"390,940",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-11,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,관리실 방문 음료,0,"47,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-14,1월,50153199,IT-301,원가)지급수수료(기타),Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,주식회사 비젼테크아이엔씨,서산-아산 건설사업단 대회의실 긴급 수리 비용,"230,000",0,"230,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20110101,LIA-101,외상매입금,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 대표전화 통신비 26년 01월,0,"23,780",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-14,1월,10115301,AST-106,매입세액,Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,주식회사 비젼테크아이엔씨,서산-아산 건설사업단 대회의실 긴급 수리 비용,"23,000",0,"23,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-21,1월,60116199,OP-204,통신비(기타),Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,SK브로드밴드(주),서산-아산 건설사업단 BIG ROOM VPN 26년 01월 결제의 건,"86,000",0,"86,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-21,1월,10115301,AST-106,매입세액,Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,SK브로드밴드(주),서산-아산 건설사업단 BIG ROOM VPN 26년 01월 결제의 건,"8,600",0,"8,600",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,70111501,REV-501,잡이익,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 대표전화 통신비 26년 01월,0,8,0,0,,수입,일반,,,,,,,,,
|
||||
바론,2026-01-05,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,"바론 입찰용 사용인감 제작, 등기부등본 발급수수료",0,"103,800",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-09,1월,50152511,WF-106,원가)복리후생비(경조금),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,인프라 BIM1팀,안효원,삼안 소속 경조금 선지급 (안효원 선임연구원 본인 결혼),"500,000",0,"500,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-19,1월,60116399,IT-301,지급수수료(기타),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,임원 CHAT GPT 구독료(1월),"29,000",0,"29,000",0,공통 → 경영진,구매,기타,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-19,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,임원 CHAT GPT 구독료(1월),0,"29,000",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-27,1월,60115798,WF-106,복리후생비(공통),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,한상연,건강검진 비용 청구(한상연 부사장),"200,000",0,"200,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-19,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,업무 회의 후 식사,0,"55,000",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-19,1월,60115705,WF-101,복리후생비(회식대),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,업무 회의 후 식사,"55,000",0,"55,000",0,공통 → 경영진,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,주식회사 헬셀,"KB 드론책임배상보험 신규 가입의 건(총 2건 / Matrice 300 RTK, Air2s)",0,"844,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-28,1월,60115798,WF-106,복리후생비(공통),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,서울용한치과의원,임직원 건강검진(치아) 비용 정산의 건(센터 및 총괄 39명),"195,000",0,"195,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"32,240",0,"32,240",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"32,240",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/07),0,"59,100",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-01-29,1월,60116399,IT-301,지급수수료(기타),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,임원 AI 구독료(1월),"29,000",0,"29,000",0,공통 → 경영진,구매,기타,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,20111105,LIA-101,미지급금(KB국민카드),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,,국민카드(3866),스타벅스 기프트카드 구매,0,"700,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,60115798,WF-106,복리후생비(공통),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,서울용한치과의원,임직원 건강검진(치아) 비용 정산의 건(바론소속 3명),"15,000",0,"15,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25059,ERP: 장헌,ERP:장헌,ERP: (주)장헌,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,총괄기획실,기술기획팀,김원기,출장비,"186,300",0,"186,300",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25059,ERP: 장헌,ERP:장헌,ERP: (주)장헌,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,총괄기획실,기술기획팀,김원기,출장비 - 김원기,0,"126,300",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25059,ERP: 장헌,ERP:장헌,ERP: (주)장헌,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,총괄기획실,기술기획팀,홍아름,출장비 - 홍아름,0,"30,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25059,ERP: 장헌,ERP:장헌,ERP: (주)장헌,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,총괄기획실,기술기획팀,김혜인,출장비 - 김혜인,0,"30,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"46,400",0,"46,400",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"46,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,출장비,"90,830",0,"90,830",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,출장비 - 류원준,0,"90,830",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-15,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"36,470",0,"36,470",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-15,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"36,470",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,EG-BIM 각부서 실사용자와 개발자 미팅(삼안),0,"39,820",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,EG-BIM 각부서 실사용자와 개발자 미팅(삼안),"39,820",0,"39,820",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,송파구청,바론 2026 등록면허세 납부(측량업),0,"27,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-29,1월,60116399,IT-301,지급수수료(기타),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,경영기획팀,임민경,건설기술인 연회비(조용민) 경력증명서 2부(정희욱) 수수료,"23,000",0,"23,000",0,,구매,공통,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,송파구청,바론 2026 등록면허세 납부(정보통신공사업),0,"54,000",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2025-12-15,2026-01-08,1월,50152599,WF-106,원가)복리후생비(기타),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,,2025년 건강검진비,"200,000",,"200,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,송파구청,바론 2026 등록면허세 납부(초경량비행장치업),0,"40,500",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2025-12-31,2026-01-05,1월,50152599,WF-106,원가)복리후생비(기타),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,마천사무실/커피원두 구매_25년 12월,"660,000",,"660,000",0,X,구매,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,개발소프트웨어 영업(01/9),0,"45,400",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2025-12-31,2026-01-05,1월,50151309,OP-203,원가)소모품비(안전관리장비),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,마천사무실/페이퍼타올 구매(25년 12월),"140,800",,"140,800",0,X,구매,공통,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,시연행사 다과 구입,0,"50,000",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2025-12-31,2026-01-05,1월,50151101,OP-101,원가)지급임차료(월세),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,,,월세 : 대산-당진(제2공구) 시공BIM 용역 현장 직원 숙소 계약,"3,479,500",,"3,479,500",0,공통 → 대산당진BIM,구매,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"39,290",0,"39,290",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"39,290",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/09),0,"52,300",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2026-01-07,2026-01-08,1월,50153103,OUT-102,원가)지급수수료(용역수수료),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,기술개발센터 ADT캡스 월정료_26년 1월,"99,000",,"99,000",0,X,구매,공통,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,"과천(삼안), 홍천(내경), 일산(서영)",0,"129,158",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,"과천(삼안), 홍천(내경), 일산(서영)","129,158",0,"129,158",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/15),0,"22,200",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2026-01-08,2026-01-08,1월,60115707,WF-106,복리후생비(경조금),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,,기술개발센터 안효원 선임연구원 결혼 축의금,"300,000",,"300,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-01-15,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,ERP기획팀,송대일,이지빔 도메인 연장 2건 (3년),0,"141,020",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-14,1월,60116101,IT-401,"통신비(전화,TV,인터넷)",ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,마천사무실/통신요금(26년 01월),"615,055",,"615,055",0,X,구매,공통,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-20,1월,20111109,LIA-101,미지급금(하나카드),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,매일경제신문사(매경이코노미),매일경제 구독료(12월),0,"25,000",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2025-12-31,2026-01-06,1월,60115707,WF-106,복리후생비(경조금),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,,정활상 부친상/중앙대장례식장1호실,"300,000",,"300,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"21,220",0,"21,220",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"21,220",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"29,960",0,"29,960",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"29,960",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-15,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25043,CivilEngineeringLab,CivilEngineeringLab,CivilEngineeringLab,기획/관리,기획&관리,,비매출,S/W 개발,운영S/W,총괄기획실,ERP기획팀,송대일,rightcivilengineering 도메인 구입(3년),0,"68,970",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115798,WF-106,복리후생비(공통),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 임직원 체력단련비(김흥제),"601,400",0,"601,400",0,,구매,공통,,,,,,,,,
|
||||
바론,2026-01-15,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,김원기,"문서자동화 기획 AI구입(chatGPT, NotebookLM)",0,"61,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,60116199,OP-204,통신비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,SK텔레콤(주),12월 휴대폰 사용요금(최대선),"37,633",0,"37,633",0,,구매,공통,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-20,1월,20110101,LIA-101,외상매입금,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,씨앤피,11월 바론 명함 대금,0,"99,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,10115301,AST-106,매입세액,OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,SK텔레콤(주),12월 휴대폰 사용요금(최대선),"3,762",0,"3,762",0,,구매,공통,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,60116199,OP-204,통신비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,SK텔레콤(주),12월 휴대폰 사용요금(최대선),"102,885",0,"102,885",0,,구매,공통,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-20,1월,20110101,LIA-101,외상매입금,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 ERP서버 인터넷 회선 통신비 26년 01월 결제의 건,0,"561,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-02,1월,50151399,OP-203,원가)소모품비(기타),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,프로젝트마스터 클라우드 스토리지 12월 금액,"19,474",0,"19,474",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-02,1월,50152999,OP-204,원가)통신비(기타),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,LTE 태블릿 통신비 청구(11월사용),"35,200",0,"35,200",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-20,1월,20110101,LIA-101,외상매입금,X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,기술개발센터,,The Intellicad Technology Consortium,EG-BIM 개발 및 사용을 위한 ITC (IntelliCAD) 로열티 지급의 건(외화),0,"51,569,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-09,1월,50151399,OP-203,원가)소모품비(기타),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,프로젝트마스터 클라우드 서버 12월 사용 금액,"290,556",0,"290,556",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-21,1월,20110101,LIA-101,외상매입금,Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,,(주)코세코,드론 사진측량 실무 프로젝트 수행을 위한 장비 구매의 건,0,"384,900",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,50151399,OP-203,원가)소모품비(기타),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,프로젝트마스터 클라우드 스토리지 01월 금액,"23,705",0,"23,705",0,"원가)소모품비(기타) 코드가 두개임 50151309, 50151399 (하나로 통일 필요+계산을 위해 임의로 50151399로 변경)",구매,일반,,,,,,,,,
|
||||
바론,2026-01-15,2026-01-20,1월,50151399,OP-203,원가)소모품비(기타),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,김원기,"문서자동화 기획 AI구입(chatGPT, NotebookLM)","61,200",0,"61,200",0,원가)소모품비(기타) 코드가 두개임,구매,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-21,1월,20110101,LIA-101,외상매입금,Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,SK브로드밴드(주),서산-아산 건설사업단 BIG ROOM VPN 26년 01월 결제의 건,0,"94,600",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2026-01-02,2026-01-08,1월,60114733,IT-301,경상시험연구비(지급수수료),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,총괄기획실,,,스마트건설10/특허등록,"2,057,000",,"2,057,000",0,,구매,R&D,,,,,,,,,
|
||||
한맥,2026-01-15,2026-01-15,1월,60114733,IT-301,경상시험연구비(지급수수료),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,총괄기획실,,,스마트건설10/사무용품 구매,"1,469,050",,"1,469,050",0,,구매,R&D,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-21,1월,20110101,LIA-101,외상매입금,X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,구글클라우드 Ai 사용료(12월),0,"115,624",0,0,,제외,일반,,,,,,,,,
|
||||
장헌산업,2026-01-15,2026-01-20,1월,823,OUT-101,연구개발비,X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,드림디포,스마트건설/사무용품 구매(일부법인지급),"937,530",,"937,530",0,관리계정=연구개발/ 실질은 비품,구매,R&D,,,,,,,,,
|
||||
장헌산업,2026-01-20,2026-01-20,1월,823,OUT-101,연구개발비,X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,(사)국토교통과학기술진흥원,스마트건설/4분기(10월~12월) 부가세,"264,000",,"264,000",0,관리계정=연구개발/ 실질은 부가세,구매,R&D,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20110101,LIA-101,외상매입금,X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,구글클라우드 Ai 사용료(11월),0,589,0,0,,제외,일반,,,,,,,,,
|
||||
피티씨,2026-01-05,2026-01-15,1월,823,OUT-101,연구개발비,X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,드림디포거여점,[스마트건설] 12월 사무용품비(일부법인지급),"1,908,170",,"1,908,170",0,관리계정=연구개발/ 실질은 비품,구매,R&D,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,60115798,WF-106,복리후생비(공통),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,사장단회의 다과(1월 2일),"181,840",0,"181,840",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20111109,LIA-101,미지급금(하나카드),X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,하나카드(6669),Ai셀 Cloudflare 사용료(12월),0,"8,375",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,60116399,IT-301,지급수수료(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,우편요금 반송 수수료,"2,400",0,"2,400",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,60110101,HR-101,급여(임.직원),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,총괄/장종찬,26년 1월 급여(바론),"388,889,600",0,"388,889,600",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20111501,LIA-101,예수금(근로소득세),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,26년1월 급여 근로소득세(바론),0,"29,627,210",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20111503,LIA-101,예수금(근로소득주민세),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,26년1월 급여 근로소득주민세(바론),0,"2,962,370",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20111509,HR-203,예수금(고용보험료(직원)),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,26년1월 급여 고용보험(바론),0,"3,260,540",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20111505,HR-202,예수금(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,"26년1월 급여 의료보험(건강+요양, 바론)",0,"14,146,850",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20111507,HR-201,예수금(국민연금),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,26년1월 급여 국민연금(바론),0,"11,756,650",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20111599,LIA-101,예수금(기타),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,26년1월 식대 및 기타 공제(바론),0,"1,988,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,총괄/장종찬,26년1월 급여(바론),0,"325,147,580",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-22,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,1월 업무관련 유류대,0,"311,750",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-22,1월,60115999,WF-201,여비교통비(기타),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,1월 업무관련 유류대,"311,750",0,"311,750",0,공통 → 경영진,출장비,기타,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-22,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,업무 회의 후 식사,0,"27,000",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-22,1월,60115705,WF-101,복리후생비(회식대),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,업무 회의 후 식사,"27,000",0,"27,000",0,공통 → 경영진,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-22,1월,20110101,LIA-101,외상매입금,X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,주식회사 케이지에듀원,바론컨설턴트 2025년 법정필수 및 산업안전보건교육,0,"1,332,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,60114503,WF-105,도서인쇄비(도서구입비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,(주)교보문고,기술개발센터도서구매(12월),"253,260",0,"253,260",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,한맥기술,2026경영및기술지원서비스(한맥기술),"122,916,200",0,0,"122,916,200",,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,40110401,REV-101,관리용역수입,Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,한맥기술,2026경영및기술지원서비스(한맥기술),0,"111,742,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,20112901,LIA-101,매출세액,Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,한맥기술,"111,742,000*10%",0,"11,174,200",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2025-12-10,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,경영및기술지원서비스(장헌산업),"28,270,000",0,0,"28,270,000",,수입,기타,,,,,,,,,
|
||||
바론,2025-12-10,2026-01-23,1월,40110401,REV-101,관리용역수입,Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,경영및기술지원서비스(장헌산업),0,"25,700,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2025-12-10,2026-01-23,1월,20112901,LIA-101,매출세액,Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,"25,700,000*10%",0,"2,570,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,삼안,2026경영및기술지원서비스(삼안),"294,607,500",0,0,"294,607,500",,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,40110401,REV-101,관리용역수입,Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,삼안,2026경영및기술지원서비스(삼안),0,"267,825,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,20112901,LIA-101,매출세액,Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,삼안,"267,825,000*10%",0,"26,782,500",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,삼안,2026경영및기술지원서비스(삼안),0,"294,607,500",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10110501,AST-101,보통예금,Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(삼안),"294,607,500",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,경영및기술지원서비스(장헌산업),0,"28,270,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10110501,AST-101,보통예금,Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,국민(주거래),경영및기술지원서비스(장헌산업),"28,270,000",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,한맥기술,2026경영및기술지원서비스(한맥기술),0,"122,916,200",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10110501,AST-101,보통예금,Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(한맥기술),"122,916,200",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,한라산업개발,2026경영및기술지원서비스(한라산업개발),0,"10,285,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10110501,AST-101,보통예금,Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(한라산업개발),"10,285,000",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,피티씨,2026경영및기술지원서비스(피티씨)1월,0,"23,760,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10110501,AST-101,보통예금,Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(피티씨)1월,"23,760,000",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26013,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(도화30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,도화ENG,한강교량보수보강공사감독권한대행등건설사업관리용역(2공구)(도화30%)-인쇄/편집,0,"1,815,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-23,1월,10110501,AST-101,보통예금,Y26013,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(도화30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,국민(주거래),한강교량보수보강공사감독권한대행등건설사업관리용역(2공구)(도화30%)-인쇄/편집,"1,815,000",0,0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-23,1월,60115903,WF-201,여비교통비(국내출장비),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,최근혜,출장비,"82,000",0,"82,000",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-23,1월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,최근혜,출장비 - 최근혜,0,"82,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-23,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,노트북가방 구매(01/18),0,"100,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-02,1월,60115798,WF-106,복리후생비(공통),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,김지영A,외부인사 초청 세미나(유니스트 교수) 다과,"26,200",0,"26,200",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26013,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(도화30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,도화ENG,한강교량보수보강공사감독권한대행등건설사업관리용역(2공구)(도화30%)-인쇄/편집,"1,815,000",0,0,"1,815,000",,수입,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-23,1월,40110501,REV-101,개발수입,Y26013,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(도화30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,도화ENG,한강교량보수보강공사감독권한대행등건설사업관리용역(2공구)(도화30%)-인쇄/편집,0,"1,650,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-23,1월,20112901,LIA-101,매출세액,Y26013,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(도화30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,도화ENG,"1,650,000*10%",0,"165,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,피티씨,2026경영및기술지원서비스(피티씨)1월,"23,760,000",0,0,"23,760,000",,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,40110401,REV-101,관리용역수입,Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,피티씨,2026경영및기술지원서비스(피티씨)1월,0,"21,600,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,20112901,LIA-101,매출세액,Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,피티씨,"21,600,000*10%",0,"2,160,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,10111101,REV-101,용역미수금(계산서발행),Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,한라산업개발,2026경영및기술지원서비스(한라산업개발),"10,285,000",0,0,"10,285,000",,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,40110401,REV-101,관리용역수입,Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,한라산업개발,2026경영및기술지원서비스(한라산업개발),0,"9,350,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-23,1월,20112901,LIA-101,매출세액,Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,한라산업개발,"9,350,000*10%",0,"935,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-27,1월,20111103,LIA-101,미지급금(일반미지급),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,김원기,통합로그인 회의 후 회식비,0,"154,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-27,1월,60115705,WF-101,복리후생비(회식대),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,김원기,통합로그인 회의 후 회식비,"154,400",0,"154,400",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-27,1월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민은행,퇴직연금(국민) 2026년 1월분 (43명),0,"18,331,070",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-27,1월,60111101,HR-103,퇴직금,X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민은행,퇴직연금(국민) 2026년 1월분 (43명),"18,331,070",0,"18,331,070",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-27,1월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,신한은행,퇴직연금(신한) 2026년 1월분 (6명),0,"2,182,210",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-27,1월,60111101,HR-103,퇴직금,X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,신한은행,퇴직연금(신한) 2026년 1월분 (6명),"2,182,210",0,"2,182,210",0,X,제외,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-27,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,한상연,건강검진 비용 청구(한상연 부사장),0,"200,000",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-02,1월,60114301,OP-203,소모품비(사무용품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,마천사무실 주차 할인권 구매,"60,000",0,"60,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-28,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,서울용한치과의원,임직원 건강검진(치아) 비용 정산의 건(센터 및 총괄 39명),0,"195,000",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
바론,2026-01-05,2026-01-07,1월,60113105,OP-103,수도광열비(상하수도),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,(주)미래엔서해에너지,11월 당진 숙소 도시가스 요금,"40,770",0,"40,770",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/15),0,"9,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-05,2026-01-07,1월,60113105,OP-103,수도광열비(상하수도),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,(주)미래엔서해에너지,12월 당진 숙소 도시가스 요금,"1,100",0,"1,100",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,"법인 등기 관련 비용(도장, 등기)",0,"21,260",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-07,1월,60114398,OP-203,소모품비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,주완기,총괄기획실 외빈 선물용 와인 구매(5EA),"230,000",0,"230,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-11,2026-01-14,1월,60114301,OP-203,소모품비(사무용품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,소모품 (창고 물품보관용가방) 구입,"13,970",0,"13,970",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 홍보목적 브로슈어 클리어파일 구매(100매),0,"21,700",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-15,1월,60113707,IT-205,수선비(전산),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,몬스타 주식회사,인프라 BIM 1팀 측량 결과물 3D 모델링을 위한 워크스테이션 긴급 수리,"545,455",0,"545,455",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,출장(당진 공장 등록 관련),"30,000",0,"30,000",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,출장비 - 김윤재,0,"30,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,ERP기획팀,송대일,eg-bim 홈페이지 호스팅 업그레이드,0,"38,140",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-14,2026-01-15,1월,10115301,AST-106,매입세액,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,몬스타 주식회사,인프라 BIM 1팀 측량 결과물 3D 모델링을 위한 워크스테이션 긴급 수리,"54,545",0,"54,545",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,ERP기획팀,송대일,egbim 홈페이지 트래픽 초기화,0,"2,750",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-11,2026-01-15,1월,50152599,WF-106,원가)복리후생비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,관리실 방문 음료,"47,000",0,"47,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,한라대 ( 원주 ) : 강원 BIM 아카데미 강의지원,0,"168,330",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,한라대 ( 원주 ) : 강원 BIM 아카데미 강의지원,"168,330",0,"168,330",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,임원 AI 구독료(1월),0,"29,000",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60116199,OP-204,통신비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 대표전화 통신비 26년 01월,"1,626",0,"1,626",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,김우진,회계법인 업무미팅 후 식사,0,"15,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김우진A,회계법인 업무미팅 후 식사,"15,000",0,"15,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"64,030",0,"64,030",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"64,030",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"25,120",0,"25,120",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"25,120",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"99,830",0,"99,830",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"99,830",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111105,LIA-101,미지급금(KB국민카드),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,국민카드(8824),12월 업무관련 교통비(김흥제),0,"568,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"108,000",0,"108,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"74,000",0,"74,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"10,000",0,"10,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"100,000",0,"100,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"5,000",0,"5,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"94,000",0,"94,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"48,000",0,"48,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"11,000",0,"11,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 교통비(김흥제),"118,000",0,"118,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/09),0,"6,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60116199,OP-204,통신비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 대표전화 단말기할부금(부가세대상),"22,000",0,"22,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,서울용한치과의원,임직원 건강검진(치아) 비용 정산의 건(바론소속 3명),0,"15,000",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,10115301,AST-106,매입세액,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),공급가액*10%,162,0,162,0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,20111109,LIA-101,미지급금(하나카드),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,매일경제신문사(매경이코노미),매일경제 구독료(26년 1월),0,"25,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-20,1월,50151999,OP-201,원가)보험료(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,주식회사 헬셀,"KB 드론책임배상보험 신규 가입의 건(총 2건 / Matrice 300 RTK, Air2s)","844,000",0,"844,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"21,440",0,"21,440",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"21,440",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,2026 강원 BIM 아카데미 강의 지원(원주),"87,920",0,"87,920",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,2026 강원 BIM 아카데미 강의 지원(원주),0,"87,920",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111105,LIA-101,미지급금(KB국민카드),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,국민카드(8824),12월 업무관련 식대(김흥제),0,"988,600",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 식대(김흥제),"200,000",0,"200,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 식대(김흥제),"61,000",0,"61,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 식대(김흥제),"161,000",0,"161,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 식대(김흥제),"35,500",0,"35,500",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 식대(김흥제),"14,100",0,"14,100",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 식대(김흥제),"67,000",0,"67,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 식대(김흥제),"250,000",0,"250,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,12월 업무관련 식대(김흥제),"200,000",0,"200,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,경영기획팀,임민경,건설기술인 연회비(조용민) 경력증명서 2부(정희욱) 수수료,0,"23,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-20,1월,60114301,OP-203,소모품비(사무용품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,시연행사 다과 구입,"50,000",0,"50,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,김지영A,1월 총괄기획실 물품 및 다과 구매,0,"162,220",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-20,1월,60114501,WF-301,도서인쇄비(신문및정기간행물),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,매일경제신문사(매경이코노미),매일경제 구독료(12월),"25,000",0,"25,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111105,LIA-101,미지급금(KB국민카드),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,국민카드(8824),12월 임직원 체력단련비(김흥제),0,"601,400",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-20,1월,50151507,MK-101,원가)도서인쇄비(인쇄비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,씨앤피,11월 바론 명함 대금,"90,000",0,"90,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111109,LIA-101,미지급금(하나카드),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,하나카드(7726),12월 업무관련 유류대(최대선),0,"493,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,12월 업무관련 유류대(최대선),"71,000",0,"71,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,12월 업무관련 유류대(최대선),"72,000",0,"72,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,12월 업무관련 유류대(최대선),"114,000",0,"114,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,12월 업무관련 유류대(최대선),"76,000",0,"76,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,12월 업무관련 유류대(최대선),"76,000",0,"76,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,12월 업무관련 유류대(최대선),"84,000",0,"84,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,장헌산업 등기관련 인감증명서 수수료,0,"5,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-20,1월,10115301,AST-106,매입세액,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,씨앤피,11월 바론 명함 대금,"9,000",0,"9,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111105,LIA-101,미지급금(KB국민카드),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,국민카드(8824),1월 업무관련 교통비(김흥제),0,"272,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 교통비(김흥제),"50,000",0,"50,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 교통비(김흥제),"5,000",0,"5,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 교통비(김흥제),"13,000",0,"13,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 교통비(김흥제),"7,000",0,"7,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 교통비(김흥제),"7,000",0,"7,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 교통비(김흥제),"50,000",0,"50,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 교통비(김흥제),"50,000",0,"50,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115999,WF-201,여비교통비(기타),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 교통비(김흥제),"90,000",0,"90,000",0,,출장비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,장헌 사내이사 서류 발급 수수료,0,"2,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-20,1월,60116199,OP-204,통신비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 ERP서버 인터넷 회선 통신비 26년 01월 결제의 건,"510,000",0,"510,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111105,LIA-101,미지급금(KB국민카드),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,국민카드(8824),1월 업무관련 식대(김흥제),0,"1,717,300",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"62,500",0,"62,500",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"28,000",0,"28,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"200,000",0,"200,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"221,000",0,"221,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"25,000",0,"25,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"250,000",0,"250,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"315,000",0,"315,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"242,000",0,"242,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"241,000",0,"241,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"100,000",0,"100,000",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,김흥제,1월 업무관련 식대(김흥제),"32,800",0,"32,800",0,,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-29,1월,10111101,REV-101,용역미수금(계산서발행),Y25017,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(광터교차로) BIM 설계,국도42 원주~흥업,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(삼안),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)진화기술공사,국도42호선원주흥업사제외1개소교차로개선공사실시설계용역(광터교차로)BIM설,"14,520,000",0,0,"14,520,000",,수입,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-29,1월,40110501,REV-101,개발수입,Y25017,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(광터교차로) BIM 설계,국도42 원주~흥업,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(삼안),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)진화기술공사,국도42호선원주흥업사제외1개소교차로개선공사실시설계용역(광터교차로)BIM설,0,"13,200,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-29,1월,20112901,LIA-101,매출세액,Y25017,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(광터교차로) BIM 설계,국도42 원주~흥업,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(삼안),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)진화기술공사,"13,200,000*10%",0,"1,320,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,홍아름,CEL회의 후 점심식대 (7명),0,"100,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,홍아름,CEL회의 후 점심식대 (7명),"100,000",0,"100,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,김윤재,"법인명변경/법인분할 등기 관련 서류 발급 수수료(인감증명서 4, 등본 2, 초본 2)",0,"4,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-20,1월,10115301,AST-106,매입세액,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 ERP서버 인터넷 회선 통신비 26년 01월 결제의 건,"51,000",0,"51,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),OBOBOB,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,SK텔레콤(주),12월 휴대폰 사용요금(최대선),0,"144,280",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,60114501,WF-301,도서인쇄비(신문및정기간행물),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,매일경제신문사(매경이코노미),매일경제 구독료(26년 1월),"25,000",0,"25,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115798,WF-106,복리후생비(공통),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,김지영A,1월 총괄기획실 물품 및 다과 구매,"162,220",0,"162,220",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,60114398,OP-203,소모품비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,인프라 BIM1팀,안효원,GNSS 측량 테스트 안테나 구매,"300,500",0,"300,500",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"43,340",0,"43,340",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"43,340",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"28,350",0,"28,350",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"28,350",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-01-30,1월,10111101,REV-101,용역미수금(계산서발행),Y25017,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(광터교차로) BIM 설계,국도42 원주~흥업,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(삼안),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)진화기술공사,국도42호선원주흥업사제외1개소교차로개선공사실시설계용역(광터교차로)BIM설,0,"14,520,000",0,0,,수입,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-01-30,1월,10110501,AST-101,보통예금,Y25017,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(광터교차로) BIM 설계,국도42 원주~흥업,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(삼안),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,국민(주거래),국도42호선원주흥업사제외1개소교차로개선공사실시설계용역(광터교차로)BIM설,"14,520,000",0,0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-02,1월,20110101,LIA-101,외상매입금,X25055,GIS장비 개발,GIS 장비개발,GIS장비 개발,기획/관리,기획&관리,,비매출,기획/제안,기획,기술개발센터,,엔비텍,SatPointer 하드웨어 개발,0,"8,800,000",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2026-01-20,2026-01-20,1월,50151309,OP-203,원가)소모품비(안전관리장비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,마천사무실/페이퍼타올 구매(25년 12월),"199,100",,"199,100",0,X,구매,일반,,,,,,,,,
|
||||
삼안,2026-01-20,2026-01-20,1월,60114733,IT-301,경상시험연구비(지급수수료),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,드론배상책임보험 환급금 과다지급에 따른 차액 지급의 건,"101,000",,"101,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 기획그룹장,양병홍,12월 유류비,0,"125,000",0,0,공통 → 그룹장,제외,기타,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,60115903,WF-201,여비교통비(국내출장비),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 기획그룹장,양병홍,12월 유류비,"125,000",0,"125,000",0,공통 → 그룹장,출장비,기타,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,인프라 BIM1팀,안효원,GNSS 측량 테스트 안테나 구매,0,"300,500",0,0,,제외,일반,,,,,,,,,
|
||||
삼안,2026-01-28,2026-01-27,1월,60114739,IT-201,경상시험연구비(소모품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,기술개발센터/신사무실 커피원두 구매(1월_정기구매),"660,000",,"660,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 기획그룹장,양병홍,업무회의 후 식대,0,"185,300",0,0,공통 → 그룹장,제외,기타,,,,,,,,,
|
||||
바론,2025-12-31,2026-01-02,1월,60115705,WF-101,복리후생비(회식대),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 기획그룹장,양병홍,업무회의 후 식대,"185,300",0,"185,300",0,공통 → 그룹장,복리후생비,기타,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,프로젝트마스터 클라우드 스토리지 12월 청구금액,0,"19,474",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-02,1월,60116399,IT-301,지급수수료(기타),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,류원준,"노션 구독료(25.12~25.12, 사내가이드)","187,420",0,"187,420",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-02,1월,20111103,LIA-101,미지급금(일반미지급),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,통신비,0,"35,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-22,1월,60117301,WF-301,교육훈련비,X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,주식회사 케이지에듀원,바론컨설턴트 2025년 법정필수 및 산업안전보건교육,"1,332,000",0,"1,332,000",0,외주,외주,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25008,WayPrimal,WayPrimal,WayPrimal,S/W개발,도로,,비매출,S/W 개발,도로,기술개발센터,Infra Solution 개발팀,류한솔,InfraSolution 회식비,0,"499,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,60115705,WF-101,복리후생비(회식대),X25008,WayPrimal,WayPrimal,WayPrimal,S/W개발,도로,개발,비매출,S/W 개발,도로,기술개발센터,Infra Solution 개발팀,류한솔,InfraSolution 회식비,"499,000",0,"499,000",0,X,복리후생비,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25032,단가/공정 solution,단가/공정 solution,단가/공정 solution,S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,C.C.팀,정요한,팀 회식비,0,"333,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-07,1월,60115705,WF-101,복리후생비(회식대),X25032,단가/공정 solution,단가/공정 solution,단가/공정 solution,S/W개발,솔루션,기획,비매출,S/W 개발,솔루션,기술개발센터,C.C.팀,정요한,Construction Control 팀 회식비,"333,000",0,"333,000",0,X,복리후생비,일반,,,,,,,,,
|
||||
바론,2025-12-17,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),25061,인천발 KTX 직결교량 시공,인천발 KTX 시공,인천발 KTX 직결교량 시공,직접매출,시공,,매출,가족사 프로젝트,시공,기술개발센터,프리팹 디비전장,심영표,출장비,"98,240",0,"98,240",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-17,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),25061,인천발 KTX 직결교량 시공,인천발 KTX 시공,인천발 KTX 직결교량 시공,직접매출,시공,,매출,가족사 프로젝트,시공,기술개발센터,프리팹 디비전장,심영표,출장비 - 심영표,0,"98,240",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25010,WayDraw,WayDraw,WayDraw,S/W개발,도로,,비매출,S/W 개발,도로,기술개발센터,Infra Solution 디비전장,김정훈,4분기 세미나 후 회식,0,"505,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,60115705,WF-101,복리후생비(회식대),X25010,WayDraw,WayDraw,WayDraw,S/W개발,도로,기획,비매출,S/W 개발,도로,기술개발센터,Infra Solution 디비전장,김정훈,4분기 세미나 후 회식,"505,000",0,"505,000",0,X,복리후생비,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),25061,인천발 KTX 직결교량 시공,인천발 KTX 시공,인천발 KTX 직결교량 시공,직접매출,시공,,매출,가족사 프로젝트,시공,기술개발센터,실물실증팀,이수문,출장비,"1,089,770",0,"1,089,770",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),25061,인천발 KTX 직결교량 시공,인천발 KTX 시공,인천발 KTX 직결교량 시공,직접매출,시공,,매출,가족사 프로젝트,시공,기술개발센터,실물실증팀,이수문,출장비 - 이수문,0,"1,089,770",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25030,GSIM,GSIM,GSIM,S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,팀 회식비,0,"313,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-07,1월,60115705,WF-101,복리후생비(회식대),X25030,GSIM,GSIM,GSIM,S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,GSIM개발팀 회식비,"313,200",0,"313,200",0,X,복리후생비,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,기술개발센터,인프라 BIM1팀,김이훈,출장비,"425,620",0,"425,620",0,,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,기술개발센터,인프라 BIM1팀,안효원,출장비 - 안효원,0,"240,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,기술개발센터,인프라 BIM1팀,김이훈,출장비 - 김이훈,0,"185,620",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,한지아,Cursor AI Agent 사용료(디자인퍼블리싱),0,"137,666",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,50153199,IT-301,원가)지급수수료(기타),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,한지아,Cursor AI Agent 사용료(디자인퍼블리싱),"137,666",0,"137,666",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25021,LifeLine-Water,LifeLine-Water,LifeLine-Water,S/W개발,수리/수문,,비매출,S/W 개발,수리/수문,기술개발센터,상하수도팀,박현수,팀 세미나 회의비,0,"42,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-07,1월,50154701,WF-103,원가)회의비,X25021,LifeLine-Water,LifeLine-Water,LifeLine-Water,S/W개발,수리/수문,기획,비매출,S/W 개발,수리/수문,기술개발센터,상하수도팀,박현수,물관리&단지설계개발 자체 세미나 회의비,"42,200",0,"42,200",0,X,복리후생비,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25022,강우강도산정 프로그램,강우강도산정 프로그램,강우강도산정 프로그램,S/W개발,수리/수문,,비매출,S/W 개발,수리/수문,기술개발센터,수자원팀,이은구,수자원팀 회식비,0,"95,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-07,1월,60115705,WF-101,복리후생비(회식대),X25022,강우강도산정 프로그램,강우강도산정 프로그램,강우강도산정 프로그램,S/W개발,수리/수문,기획,비매출,S/W 개발,수리/수문,기술개발센터,수자원팀,이은구,꼬꼬잭치킨 거여마천점 수자원팀 회식비,"95,000",0,"95,000",0,X,복리후생비,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,60115903,WF-201,여비교통비(국내출장비),Z25058,서산~명천 도로건설공사 기본 및 실시설계,서산~명천 기본 및 실시설계,서산~명천 도로건설공사 기본 및 실시설계,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,인프라 BIM1팀,이배승,출장비,"96,264",0,"96,264",0,,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-23,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),Z25058,서산~명천 도로건설공사 기본 및 실시설계,서산~명천 기본 및 실시설계,서산~명천 도로건설공사 기본 및 실시설계,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,인프라 BIM1팀,이배승,출장비 - 이배승,0,"96,264",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-07,1월,20111103,LIA-101,미지급금(일반미지급),X25024,HmEG(HmDraw),HmEG(HmDraw),HmEG(HmDraw),S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,기술개발센터,그래픽스 개발팀,백승민,셀 회식비,0,"201,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-26,2026-01-07,1월,60115705,WF-101,복리후생비(회식대),X25024,HmEG(HmDraw),HmEG(HmDraw),HmEG(HmDraw),S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,기술개발센터,그래픽스 개발팀,백승민,HmEG셀 회식비,"201,000",0,"201,000",0,X,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-09,1월,20111103,LIA-101,미지급금(일반미지급),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,프로젝트마스터 클라우드 서버 12월 사용 금액,0,"290,556",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-29,1월,60116399,IT-301,지급수수료(기타),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,,굿어스데이터 주식회사,센터 가상화서비스 12월 서비스 통합,"362,300",0,"362,300",0,,구매,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-09,1월,60115903,WF-201,여비교통비(국내출장비),Y25008,계양-강화간 고속도로 건설공사 기본설계단계 교량 BIM 설계(제5공구),계양~강화 고속도로(5공구),계양-강화 고속도로 건설 기본 및 실시(5공구),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,인프라 BIM2팀,표종진,출장비,"142,310",0,"142,310",0,X,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-30,2026-01-09,1월,20111103,LIA-101,미지급금(일반미지급),Y25008,계양-강화간 고속도로 건설공사 기본설계단계 교량 BIM 설계(제5공구),계양~강화 고속도로(5공구),계양-강화 고속도로 건설 기본 및 실시(5공구),직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,인프라 BIM2팀,표종진,출장비 - 표종진,0,"142,310",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2025-12-05,2026-01-09,1월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,인프라 BIM1팀,이광태,출장비,"60,000",0,"60,000",0,,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-05,2026-01-09,1월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,인프라 BIM1팀,이광태,출장비,0,"60,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115903,WF-201,여비교통비(국내출장비),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,차량유류대/업무협의,"397,000",0,"397,000",0,HSJ,출장비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,차량유류대/업무협의,0,"397,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115903,WF-201,여비교통비(국내출장비),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,차량유류대/업무협의,"327,000",0,"327,000",0,HSJ,출장비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,차량유류대/업무협의,0,"327,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2025-12-22,2026-01-15,1월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,인프라 BIM1팀,이광태,출장비,"8,000",0,"8,000",0,,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-22,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,인프라 BIM1팀,이광태,출장비 - 이광태,0,"8,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,인프라 BIM1팀,이광태,출장비,"16,000",0,"16,000",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,인프라 BIM1팀,이광태,출장비,0,"16,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,그룹장 업무협의 식대,"267,000",0,"267,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,그룹장 업무협의 식대,0,"267,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,Infra Solution팀 과업회의 식대,"283,800",0,"283,800",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,Infra Solution팀 과업회의 식대,0,"283,800",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,엔지니어링 개발그룹 성과회의 식대,"485,000",0,"485,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,엔지니어링 개발그룹 성과회의 식대,0,"485,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,Modeler셀 업무협의 식대,"309,000",0,"309,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,Modeler셀 업무협의 식대,0,"309,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115903,WF-201,여비교통비(국내출장비),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,차량유류대/업무협의,"334,000",0,"334,000",0,HSJ,출장비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,차량유류대/업무협의,0,"334,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,Strana팀 업무협의 식대,"284,800",0,"284,800",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,Strana팀 업무협의 식대,0,"284,800",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,EG-BIM 셀 팀회식,"266,000",0,"266,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,EG-BIM 셀 팀회식,0,"266,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,60115903,WF-201,여비교통비(국내출장비),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,한형관,차량유류대/업무협의,"338,000",0,"338,000",0,HSJ,출장비,기타,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-15,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,한형관,차량유류대/업무협의,0,"338,000",0,0,,제외,공통,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 개발그룹장,장계석,업무회의 간 식대 및 음료대,0,"88,100",0,0,공통 → 그룹장,제외,기타,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,50152507,WF-101,원가)복리후생비(회식대),X25002,KNGIL,KNGIL,KNGIL,S/W개발,기초조사및GIS,개발,비매출,S/W 개발,기초조사 및 GIS,기술개발센터,엔지니어링 개발그룹장,장계석,통합인증(회원관리) 회의 간 식대,"34,700",0,"34,700",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,50152507,WF-101,원가)복리후생비(회식대),X25002,KNGIL,KNGIL,KNGIL,S/W개발,기초조사및GIS,개발,비매출,S/W 개발,기초조사 및 GIS,기술개발센터,엔지니어링 개발그룹장,장계석,통합인증(회원관리) 회의 간 음료대,"9,500",0,"9,500",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,50152507,WF-101,원가)복리후생비(회식대),X25029,bCMf,bCMf,bCMf,S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,엔지니어링 개발그룹장,장계석,BCMF 회의 후 식사,"20,000",0,"20,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,50152507,WF-101,원가)복리후생비(회식대),X25042,ERP: 바론,ERP:바론,ERP: 바론,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,엔지니어링 개발그룹장,장계석,산하종합기술 미팅 간 식대,"6,500",0,"6,500",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,50152507,WF-101,원가)복리후생비(회식대),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,저가형 GNSS 계약 간 음료대,"17,400",0,"17,400",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2025-12-22,2026-01-20,1월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,구조물계획팀,김일태,출장비,"16,500",0,"16,500",0,,출장비,일반,,,,,,,,,
|
||||
바론,2025-12-22,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,구조물계획팀,김일태,출장비 - 김일태,0,"16,500",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-13,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,Infra Solution 개발팀,최정우,서류 발급수수료,0,"47,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-29,1월,10115301,AST-106,매입세액,X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,,굿어스데이터 주식회사,"362,300*10%","36,230",0,"36,230",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-20,1월,20111103,LIA-101,미지급금(일반미지급),X25012,WallZainer,WallZainer,WallZainer,S/W개발,구조,,비매출,S/W 개발,구조물,기술개발센터,구조물 S/W개발팀,정계완,구조물SW개발팀 회식비,0,"103,600",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-07,2026-01-20,1월,60115705,WF-101,복리후생비(회식대),X25012,WallZainer,WallZainer,WallZainer,S/W개발,구조,개발,비매출,S/W 개발,구조물,기술개발센터,구조물 S/W개발팀,정계완,구조물SW개발팀 회식비,"103,600",0,"103,600",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-23,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 개발그룹장,장계석,업무회의 간 식대 및 음료대,0,"43,000",0,0,공통 → 그룹장,제외,기타,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-23,1월,50152507,WF-101,원가)복리후생비(회식대),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 개발그룹장,장계석,필리핀 지사장 업무회의 음료대,"5,000",0,"5,000",0,공통 → 그룹장,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-23,1월,50152507,WF-101,원가)복리후생비(회식대),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 개발그룹장,장계석,업무회의 간 식사,"32,000",0,"32,000",0,공통 → 그룹장,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-23,1월,50152507,WF-101,원가)복리후생비(회식대),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 개발그룹장,장계석,업무회의 간 음료대,"6,000",0,"6,000",0,공통 → 그룹장,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25053,수자원 해외사업,수자원 해외사업,수자원 해외사업,기획/관리,기획&관리,,비매출,기획/제안,기획,기술개발센터,수자원팀,이은구,수자원팀 인원 충원후 회식,0,"147,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),X25053,수자원 해외사업,수자원 해외사업,수자원 해외사업,기획/관리,기획&관리,,비매출,기획/제안,기획,기술개발센터,수자원팀,이은구,제일수산 수자원팀 회식비,"147,000",0,"147,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25038,ERP: PTC,ERP:PTC,ERP: PTC,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,김윤하,PTC안전 업무인수인계 후 식사,0,"36,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),X25038,ERP: PTC,ERP:PTC,ERP: PTC,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,김윤하,PTC안전 업무인수인계 후 식사,"36,000",0,"36,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25029,bCMf,bCMf,bCMf,S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,Web Solution팀,정명준,셀 회식,0,"65,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),X25029,bCMf,bCMf,bCMf,S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,Web Solution팀,정명준,BCMF 업무인수 인계 후 식사,"65,000",0,"65,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25009,WayConfirm,WayConfirm,WayConfirm,S/W개발,도로,,비매출,S/W 개발,도로,기술개발센터,Infra Solution 개발팀,이가연,인프라솔루션 비탈면셀 회식비,0,"120,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),X25009,WayConfirm,WayConfirm,WayConfirm,S/W개발,도로,개발,비매출,S/W 개발,도로,기술개발센터,Infra Solution 개발팀,이가연,인프라솔루션 비탈면셀 회식비,"120,000",0,"120,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25033,WatchBIM,WatchBIM,WatchBIM,S/W개발,도로,,비매출,S/W 개발,솔루션,기술개발센터,Infra Solution 개발팀,김재현,WatchBIM 셀 회식,0,"140,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-27,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),X25033,WatchBIM,WatchBIM,WatchBIM,S/W개발,도로,개발,비매출,S/W 개발,솔루션,기술개발센터,Infra Solution 개발팀,김재현,WatchBIM 셀 회식,"140,000",0,"140,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),Y25004,"가평군하수관망계측시스템유지관리용역(프로그램 개발, 중앙제어실 및 서버유지관리)",가평군 하수관망시스템,가평군 하수관망 계측시스템 유지관리 용역,직접매출,콘텐츠제작,,매출,바론계약,기술용역,기술개발센터,Web Solution팀,이병권,출장비,"158,000",0,"158,000",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),Y25004,"가평군하수관망계측시스템유지관리용역(프로그램 개발, 중앙제어실 및 서버유지관리)",가평군 하수관망시스템,가평군 하수관망 계측시스템 유지관리 용역,직접매출,콘텐츠제작,,매출,바론계약,기술용역,기술개발센터,엔지니어링 개발그룹장,장계석,출장비 - 장계석,0,"36,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),Y25004,"가평군하수관망계측시스템유지관리용역(프로그램 개발, 중앙제어실 및 서버유지관리)",가평군 하수관망시스템,가평군 하수관망 계측시스템 유지관리 용역,직접매출,콘텐츠제작,,매출,바론계약,기술용역,기술개발센터,Web Solution팀,신지호,출장비 - 신지호,0,"113,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),Y25004,"가평군하수관망계측시스템유지관리용역(프로그램 개발, 중앙제어실 및 서버유지관리)",가평군 하수관망시스템,가평군 하수관망 계측시스템 유지관리 용역,직접매출,콘텐츠제작,,매출,바론계약,기술용역,기술개발센터,Web Solution팀,이병권,출장비 - 이병권,0,"9,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,프로젝트마스터 클라우드 스토리지 01월 청구금액,0,"23,705",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2025-12-22,2026-01-08,1월,50153103,OUT-102,원가)지급수수료(용역수수료),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,,,방화벽 유지보수 12월,"55,000",,"55,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25030,GSIM,GSIM,GSIM,S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,통신비,0,"26,110",0,0,,제외,일반,,,,,,,,,
|
||||
한맥,2025-12-23,2026-01-08,1월,50153103,OUT-102,원가)지급수수료(용역수수료),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,,,통합 유지보수(서버외) 12월,"770,000",,"770,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25044,PQ시스템,PQ시스템,PQ시스템,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,신지호,PQ프로그램 개선 회의 음료,0,"10,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115705,WF-101,복리후생비(회식대),X25044,PQ시스템,PQ시스템,PQ시스템,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,신지호,PQ프로그램 개선 회의 음료,"10,000",0,"10,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-21,1월,50153199,IT-301,원가)지급수수료(기타),X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,구글클라우드 Ai 사용료(12월),"105,113",0,"105,113",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-21,1월,10115301,AST-106,매입세액,X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,구글클라우드 Ai 사용료(12월),"10,511",0,"10,511",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-19,2026-01-29,1월,20110101,LIA-101,외상매입금,X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,,굿어스데이터 주식회사,센터 가상화서비스 12월 서비스 통합,0,"398,530",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,대전 kickoff 미팅,0,"47,400",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,60115903,WF-201,여비교통비(국내출장비),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,대전 kickoff 미팅 간 교통비,"47,400",0,"47,400",0,,출장비,R&D,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 개발그룹장,장계석,업무미팅 간 식대,0,"111,600",0,0,공통 → 그룹장,제외,기타,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,50152507,WF-101,원가)복리후생비(회식대),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,대전 kickoff 미팅 간 음료대,"11,700",0,"11,700",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,50152507,WF-101,원가)복리후생비(회식대),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,ETRI 회의 및 식사,"47,100",0,"47,100",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,50152507,WF-101,원가)복리후생비(회식대),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,ETRI 회의 및 식사,"15,500",0,"15,500",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,50152507,WF-101,원가)복리후생비(회식대),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,ETRI 회의 및 식사,"32,300",0,"32,300",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,50152507,WF-101,원가)복리후생비(회식대),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 개발그룹장,장계석,유지보수 업체 미팅,"5,000",0,"5,000",0,공통 → 그룹장,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,50153199,IT-301,원가)지급수수료(기타),X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,구글클라우드 Ai 사용료(11월),535,0,535,0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,10115301,AST-106,매입세액,X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,구글클라우드 Ai 사용료(11월),54,0,54,0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-01-21,1월,50153199,IT-301,원가)지급수수료(기타),X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,Cloudflare Inc.,Ai셀 Cloudflare 사용료(12월),"8,375",0,"8,375",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-15,2026-01-20,1월,50153199,IT-301,원가)지급수수료(기타),X25043,CivilEngineeringLab,CivilEngineeringLab,CivilEngineeringLab,기획/관리,기획&관리,,비매출,S/W 개발,운영S/W,총괄기획실,ERP기획팀,송대일,rightcivilengineering 도메인 구입(3년),"68,970",0,"68,970",0,,구매,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-02,1월,50151301,OP-203,원가)소모품비(사무용품비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 설치용 USB 오피스 파우치 구매(2개),"31,700",0,"31,700",0,X,구매,일반,,,,,,,,,
|
||||
바론,2025-12-24,2026-01-02,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,스타벅스 기프트카드 구매,"1,000,000",0,"1,000,000",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/07),"59,100",0,"59,100",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-01-20,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,스타벅스 기프트카드 구매,"700,000",0,"700,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-09,2026-01-20,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,개발소프트웨어 영업(01/09),"45,400",0,"45,400",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-12,2026-01-20,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/09),"52,300",0,"52,300",0,,구매,일반,,,,,,,,,
|
||||
한맥,2026-01-13,2026-01-14,1월,60114744,WF-103,경상시험연구비(회의비(연구)),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,총괄기획실,,,스마트건설10/회의 후 식대(12/02),"87,000",,"87,000",0,,복리후생비,R&D,,,,,,,,,
|
||||
한맥,2026-01-13,2026-01-14,1월,60114744,WF-103,경상시험연구비(회의비(연구)),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,총괄기획실,,,스마트건설10/회의 후 식대(12/23),"355,000",,"355,000",0,,복리후생비,R&D,,,,,,,,,
|
||||
한맥,2026-01-13,2026-01-14,1월,60114744,WF-103,경상시험연구비(회의비(연구)),Y23113,실도로 기반 Lv4. 자율주행차량 운전능력 평가기술 개발,,,,,,,,,총괄기획실,,,실도로기반LV.4/회의 후 식대(12/12),"108,000",,"108,000",0,,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-13,2026-01-14,1월,60114744,WF-103,경상시험연구비(회의비(연구)),Y23113,실도로 기반 Lv4. 자율주행차량 운전능력 평가기술 개발,,,,,,,,,총괄기획실,,,실도로기반LV.4/회의 후 식대(12/16),"218,000",,"218,000",0,,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-13,2026-01-14,1월,60114744,WF-103,경상시험연구비(회의비(연구)),Y23113,실도로 기반 Lv4. 자율주행차량 운전능력 평가기술 개발,,,,,,,,,총괄기획실,,,실도로기반LV.4/회의 후 식대(12/23),"355,000",,"355,000",0,,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-16,2026-01-20,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/15),"22,200",0,"22,200",0,,구매,일반,,,,,,,,,
|
||||
한맥,2026-01-15,2026-01-15,1월,10113701,AST-104,선급금(일반),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,총괄기획실,,,스마트건설10세부/부가세 환입(12월),"327,444",,0,"327,444",,수입,R&D,,,,,,,,,
|
||||
한맥,2026-01-15,2026-01-15,1월,10113701,AST-104,선급금(일반),Y23113,실도로 기반 Lv4. 자율주행차량 운전능력 평가기술 개발,,,,,,,,,총괄기획실,,,실도로기반LV.4/부가세 환입(10~12월),"132,230",,0,"132,230",,수입,기타,,,,,,,,,
|
||||
한맥,2026-01-20,2026-01-20,1월,50152705,WF-201,원가)여비교통비(시내교통비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,마천사무실/11월 주차정기권 일할계산분,0,,0,0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-15,2026-01-20,1월,50153199,IT-301,원가)지급수수료(기타),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,총괄기획실,ERP기획팀,송대일,이지빔 도메인 연장 2건 (3년),"141,020",0,"141,020",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-20,1월,10352101,IT-101,소프트웨어,X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,총괄기획실,,The Intellicad Technology Consortium,EG-BIM 개발 및 사용을 위한 ITC (IntelliCAD) 로열티 지급의 건(외화),"51,569,000",0,"51,569,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-23,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,노트북가방 구매(01/18),"100,000",0,"100,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-20,2026-01-29,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/15),"9,000",0,"9,000",0,,구매,일반,,,,,,,,,
|
||||
한맥,2026-01-05,2026-01-14,1월,60115305,MK-201,접대비(경조금),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,,이종훈 아들결혼/26.1.11 일요일 12:30/영등포구 국회대로 612 더베르지,"500,000",,"500,000",0,"공통 → 교육훈련,참석",복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,지방도647호 대호대교/현장점검 후 회식,"283,000",,"283,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,모징지구 등 3개 지구단위계획 업무협의,"266,200",,"266,200",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,서산-명천 도로공사 팀회식,"65,000",,"65,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,새대천항 북방파제 부서회의,"265,200",,"265,200",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,서울남현 공공주택지구 현장점검,"284,000",,"284,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,2023년 아산 도시관리계획 업무협의,"241,000",,"241,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,용산국제업무지구 현장조사,"216,000",,"216,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,천안 에코밸리일반산단 진입도로 현장조사,"212,000",,"212,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,연산-두마 감리현장 업무협의,"153,000",,"153,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-09,2026-01-15,1월,60115705,WF-101,복리후생비(회식대),HSJHSJ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,새만금권역(지방하천) 기본기획 업무협의,"223,000",,"223,000",0,HSJ,복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-13,2026-01-15,1월,60115305,MK-201,접대비(경조금),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,,삼보 엄인섭사장 빙모상(전주삼성장례문화원 301호),"100,000",,"100,000",0,"공통 → 교육훈련,참석",복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-01-22,2026-01-23,1월,60115305,MK-201,접대비(경조금),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,,임명기 경조금,"100,000",,"100,000",0,"공통 → 교육훈련,참석",복리후생비,기타,,,,,,,,,
|
||||
삼안,2026-01-09,2026-01-09,1월,20111539,LIA-102,예수금(연구비),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,,스마트건설10세부 연구비 부가세 환급,"469,000",,0,"469,000",부가세환급(수입)을 구분하기위해 LIA-102코드로 변경,수입,R&D,,,,,,,,,
|
||||
삼안,2026-01-09,2026-01-09,1월,20111539,LIA-102,예수금(연구비),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,,디지털1핵심/연구비 부가세 환급(10월~12월),"682,394",,0,"682,394",부가세환급(수입)을 구분하기위해 LIA-102코드로 변경,수입,R&D,,,,,,,,,
|
||||
삼안,2026-01-09,2026-01-09,1월,20111539,LIA-102,예수금(연구비),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,,2핵심/연구비 부가세 환급(10월~12월),"300,000",,0,"300,000",부가세환급(수입)을 구분하기위해 LIA-102코드로 변경,수입,R&D,,,,,,,,,
|
||||
삼안,2026-01-19,2026-01-19,1월,20111539,LIA-102,예수금(연구비),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,,스마트건설10세부 연구비 부가세 환급,"42,454",,0,"42,454",부가세환급(수입)을 구분하기위해 LIA-102코드로 변경,수입,R&D,,,,,,,,,
|
||||
삼안,2026-01-19,2026-01-19,1월,20111539,LIA-102,예수금(연구비),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,,디지털1핵심/연구비 부가세 환급(10월~12월),"48,380",,0,"48,380",부가세환급(수입)을 구분하기위해 LIA-102코드로 변경,수입,R&D,,,,,,,,,
|
||||
바론,2026-01-22,2026-01-29,1월,50151301,OP-203,원가)소모품비(사무용품비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 홍보목적 브로슈어 클리어파일 구매(100매),"21,700",0,"21,700",0,,구매,일반,,,,,,,,,
|
||||
삼안,2026-01-22,2026-01-22,1월,20111539,LIA-102,예수금(연구비),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,,스마트건설10세부 연구비 부가세 환급,"6,364",,0,"6,364",부가세환급(수입)을 구분하기위해 LIA-102코드로 변경,수입,R&D,,,,,,,,,
|
||||
삼안,2026-01-22,2026-01-22,1월,20111539,LIA-102,예수금(연구비),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,,디지털1핵심/연구비 부가세 환급(10월~12월),"45,411",,0,"45,411",부가세환급(수입)을 구분하기위해 LIA-102코드로 변경,수입,R&D,,,,,,,,,
|
||||
삼안,2026-01-23,2026-01-23,1월,20111539,LIA-102,예수금(연구비),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,,디지털1핵심/연구비 부가세 환급(10월~12월),"3,545",,0,"3,545",부가세환급(수입)을 구분하기위해 LIA-102코드로 변경,수입,R&D,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,50153199,IT-301,원가)지급수수료(기타),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,총괄기획실,ERP기획팀,송대일,eg-bim 홈페이지 호스팅 업그레이드,"38,140",0,"38,140",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-23,2026-01-29,1월,50153199,IT-301,원가)지급수수료(기타),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,총괄기획실,ERP기획팀,송대일,egbim 홈페이지 트래픽 초기화,"2,750",0,"2,750",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-26,2026-01-29,1월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/09),"6,000",0,"6,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-02,1월,50170199,OUT-102,원가)기술협력비(기타외주비),X25055,GIS장비 개발,GIS 장비개발,GIS장비 개발,기획/관리,기획&관리,,비매출,기획/제안,기획,기술개발센터,,엔비텍,SatPointer 하드웨어 개발,"8,000,000",0,"8,000,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-02,2026-01-02,1월,10115301,AST-106,매입세액,X25055,GIS장비 개발,GIS 장비개발,GIS장비 개발,기획/관리,기획&관리,,비매출,기획/제안,기획,기술개발센터,,엔비텍,SatPointer 하드웨어 개발,"800,000",0,"800,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-01-29,1월,50152999,OP-204,원가)통신비(기타),X25030,GSIM,GSIM,GSIM,S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,LTE 태블릿 통신비 청구(12월사용),"26,110",0,"26,110",0,,구매,일반,,,,,,,,,
|
||||
삼안,2026-01-28,2026-01-28,1월,60114739,IT-201,경상시험연구비(소모품비),X25024,HmEG(HmDraw),HmEG(HmDraw),HmEG(HmDraw),S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,기술개발센터,,,"(USD4,500*1,426.20)-소프트웨어 개발을 위한 ODA SDK 구매","6,417,900",,"6,417,900",0,X,구매,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-02-05,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 기획그룹장,양병홍,1월 유류비,0,"160,000",0,0,공통 → 그룹장,제외,기타,,,,,,,,,
|
||||
바론,2026-01-28,2026-02-05,2월,60115903,WF-201,여비교통비(국내출장비),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 기획그룹장,양병홍,1월 유류비,"160,000",0,"160,000",0,공통 → 그룹장,출장비,기타,,,,,,,,,
|
||||
바론,2026-01-28,2026-02-05,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 기획그룹장,양병홍,업무회의 후 식대,0,"151,000",0,0,공통 → 그룹장,제외,기타,,,,,,,,,
|
||||
바론,2026-01-28,2026-02-05,2월,60115705,WF-101,복리후생비(회식대),ZZZZZZ,공통(그룹장),공통(그룹장),공통(그룹장),기획/관리,공통,,공통,공통,공통,기술개발센터,엔지니어링 기획그룹장,양병홍,업무회의 후 식대,"151,000",0,"151,000",0,공통 → 그룹장,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-01-26,2026-02-05,2월,60115903,WF-201,여비교통비(국내출장비),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,이동원A,출장비,"76,800",0,"76,800",0,,출장비,R&D,,,,,,,,,
|
||||
바론,2026-01-26,2026-02-05,2월,20111103,LIA-101,미지급금(일반미지급),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,일반구조물 디비전장,이동원,출장비 - 이동원,0,"76,800",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,기술개발센터,그래픽스 개발팀,김민성,"AI LLM SubScription 12월, 1월",0,"625,388",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-06,2월,60116399,IT-301,지급수수료(기타),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,기술개발센터,그래픽스 개발팀,김민성,"AI LLM SubScription 12월, 1월","625,388",0,"625,388",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25008,WayPrimal,WayPrimal,WayPrimal,S/W개발,도로,,비매출,S/W 개발,도로,공통,Infra Solution 개발팀,장용섭,"WayDraw, WayPrimal 파트 회식",0,"192,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-06,2월,60115705,WF-101,복리후생비(회식대),X25008,WayPrimal,WayPrimal,WayPrimal,S/W개발,도로,개발,비매출,S/W 개발,도로,공통,Infra Solution 개발팀,장용섭,"항아리보쌈 WayDraw, WayPrimal 파트 회식","192,000",0,"192,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,프로젝트마스터 클라우드 서버 1월 사용 금액,0,"167,818",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-06,2월,50151399,OP-203,원가)소모품비(기타),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,프로젝트마스터 클라우드 서버 1월 사용 금액,"167,818",0,"167,818",0,계정코드 수정(50151309 확인불가),구매,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25027,StrAna,StrAna,StrAna,S/W개발,구조해석,,비매출,S/W 개발,그래픽&구조해석,기술개발센터,Strana 개발팀,이호경,팀 회식비,0,"150,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-06,2월,60115705,WF-101,복리후생비(회식대),X25027,StrAna,StrAna,StrAna,S/W개발,구조해석,개발,비매출,S/W 개발,그래픽&구조해석,기술개발센터,Strana 개발팀,이호경,Strana 개발팀 회식비,"150,000",0,"150,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25012,WallZainer,WallZainer,WallZainer,S/W개발,구조,,비매출,S/W 개발,구조물,기술개발센터,구조물 S/W개발팀,정계완,구조물SW개발팀 회식비,0,"161,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-06,2월,60115705,WF-101,복리후생비(회식대),X25012,WallZainer,WallZainer,WallZainer,S/W개발,구조,개발,비매출,S/W 개발,구조물,기술개발센터,구조물 S/W개발팀,정계완,구조물SW개발팀 회식비,"161,400",0,"161,400",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25002,KNGIL,KNGIL,KNGIL,S/W개발,기초조사및GIS,,비매출,S/W 개발,기초조사 및 GIS,기술개발센터,천지인팀,손원일,팀회식비,0,"161,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X25002,KNGIL,KNGIL,KNGIL,S/W개발,기초조사및GIS,개발,비매출,S/W 개발,기초조사 및 GIS,기술개발센터,천지인팀,손원일,단지설계셀 회식비,"161,000",0,"161,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25009,WayConfirm,WayConfirm,WayConfirm,S/W개발,도로,,비매출,S/W 개발,도로,기술개발센터,인프라 BIM2팀,이동호,팀 회식비,0,"221,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X25009,WayConfirm,WayConfirm,WayConfirm,S/W개발,도로,기획,비매출,S/W 개발,도로,기술개발센터,인프라 BIM2팀,이동호,BIM2팀 회식비,"221,000",0,"221,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,,이동원A,출장비,"88,520",0,"88,520",0,,출장비,R&D,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25057,디지털 국토정보 기술개발사업 1핵심과제(삼안),디지털 국토정보(1핵심),디지털 국토정보 기술개발사업,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,일반구조물 디비전장,이동원,,0,"88,520",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,(재)건설기술교육원,건설기술인승급교육,0,"200,000",0,0,시공BIM → 교육훈련,제외,기타,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,50153301,WF-301,원가)교육훈련비,ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,(재)건설기술교육원,건설기술인승급교육,"200,000",0,"200,000",0,시공BIM → 교육훈련,구매,기타,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,인프라 BIM1팀,황은식,경부선 철도사업 측량분야 수행 회의 후 저녁식사,0,"119,000",0,0,영수증(수도권) → 팀 기준,제외,기타,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,인프라 BIM1팀,황은식,경부선 철도사업 측량분야 수행 회의 후 저녁식사,"119,000",0,"119,000",0,영수증(수도권) → 팀 기준,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,인프라 BIM1팀,황은식,경부선 드론측량 수행 관련 회의 후 식대,0,"35,500",0,0,영수증(수도권) → 팀 기준,제외,기타,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,인프라 BIM1팀,황은식,경부선 드론측량 수행 관련 회의 후 식대,"35,500",0,"35,500",0,영수증(수도권) → 팀 기준,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25008,WayPrimal,WayPrimal,WayPrimal,S/W개발,도로,,비매출,S/W 개발,도로,기술개발센터,인프라 BIM1팀,황은식,BIM 1팀 Way셀 회식,0,"241,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X25008,WayPrimal,WayPrimal,WayPrimal,S/W개발,도로,기획,비매출,S/W 개발,도로,기술개발센터,인프라 BIM1팀,황은식,BIM 1팀 Way셀 회식,"241,000",0,"241,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),Z25059,울산~외곽순환 고속도로(1공구),울산외곽순환(1공구),울산~외곽순환 고속도로(1공구),직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,터널팀,이화영,출장비,"33,200",0,"33,200",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),Z25059,울산~외곽순환 고속도로(1공구),울산외곽순환(1공구),울산~외곽순환 고속도로(1공구),직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,터널팀,이화영,출장비,0,"33,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,이병권,업무미팅 간 식대,0,"8,500",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,50152507,WF-101,원가)복리후생비(회식대),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,이병권,유지보수 업체 미팅,"8,500",0,"8,500",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,(재)건설기술교육원,건설기술인최초교육,0,"200,000",0,0,시공BIM → 교육훈련,제외,기타,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,50153301,WF-301,원가)교육훈련비,ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,(재)건설기술교육원,건설기술인최초교육,"200,000",0,"200,000",0,시공BIM → 교육훈련,구매,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,스마트건설팀,복진훈,스마트건설 사전점검 평가 후 회식,0,"505,000",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,스마트건설팀,복진훈,스마트건설 사전점검 평가 후 회식,"505,000",0,"505,000",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,스마트건설팀,복진훈,10세부 최종평가 사전점검 회의 후 점심식대,0,"113,000",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X25056,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,스마트건설팀,복진훈,10세부 최종평가 사전점검 회의 후 점심식대,"113,000",0,"113,000",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25008,WayPrimal,WayPrimal,WayPrimal,S/W개발,도로,,비매출,S/W 개발,도로,기술개발센터,인프라 BIM2팀,표종진,팀 회식비,0,"190,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X25008,WayPrimal,WayPrimal,WayPrimal,S/W개발,도로,기획,비매출,S/W 개발,도로,기술개발센터,인프라 BIM2팀,표종진,BIM2팀 회식비,"190,000",0,"190,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25025,EG-BIM Modeler,EG-BIM Modeler,EG-BIM Modeler,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,기술개발센터,그래픽스 개발팀,김민성,팀장 회식비,0,"100,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X25025,EG-BIM Modeler,EG-BIM Modeler,EG-BIM Modeler,S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,기술개발센터,그래픽스 개발팀,김민성,그래픽스개발팀 셀장 회식비,"100,000",0,"100,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,이동원A,출장비,"172,710",0,"172,710",0,사전기획 → 경부선(평택-직지사),출장비,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,일반구조물 디비전장,이동원,출장비 - 이동원,0,"172,710",0,0,사전기획 → 경부선(평택-직지사),제외,기타,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,쓰리디랩스 드론 실증관련 업무협의,0,"61,800",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,50152507,WF-101,원가)복리후생비(회식대),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,쓰리디랩스 드론 실증관련 업무협의 간 식대,"61,800",0,"61,800",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-13,2월,20111103,LIA-101,미지급금(일반미지급),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,팀 회식비,0,"347,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-13,2월,60115705,WF-101,복리후생비(회식대),X25028,문서관리시스템(PM),문서관리시스템(PM),문서관리시스템(PM),S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,GSIM 개발팀,이호성,GSIM개발팀 회식비,"347,000",0,"347,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-13,2월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,하부구조팀,최창인,출장비,"146,828",0,"146,828",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-13,2월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,기술개발센터,하부구조팀,최창인,출장비 - 최창인,0,"146,828",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-20,2월,60116399,IT-301,지급수수료(기타),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,,굿어스데이터 주식회사,센터 가상화서비스 01월 서비스 통합,"361,900",0,"361,900",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-20,2월,10115301,AST-106,매입세액,X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,,굿어스데이터 주식회사,"361,900*10%","36,190",0,"36,190",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-20,2월,20110101,LIA-101,외상매입금,X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,,굿어스데이터 주식회사,센터 가상화서비스 01월 서비스 통합,0,"398,090",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,(재)건설기술교육원,건설기술인승급교육,0,"200,000",0,0,시공BIM → 교육훈련,제외,기타,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,50153301,WF-301,원가)교육훈련비,ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,(재)건설기술교육원,건설기술인승급교육,"200,000",0,"200,000",0,시공BIM → 교육훈련,구매,기타,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,AbutZainer,AbutZainer,AbutZainer,S/W개발,구조,,비매출,S/W 개발,구조물,기술개발센터,하부구조팀,김승호,회식비 템플릿,0,"141,000",0,0,공통 → AbutZainer,제외,일반,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-20,2월,60115705,WF-101,복리후생비(회식대),ZZZZZZ,AbutZainer,AbutZainer,AbutZainer,S/W개발,구조,기획,비매출,S/W 개발,구조물,기술개발센터,하부구조팀,김승호,깡돈식당 하부구조팀 회식비,"141,000",0,"141,000",0,공통 → AbutZainer,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-20,2월,60115903,WF-201,여비교통비(국내출장비),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,인프라 BIM1팀,김이훈,출장비,"147,490",0,"147,490",0,,출장비,기타,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,인프라 BIM1팀,김이훈,출장비 - 김이훈,0,"147,490",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),X25027,StrAna,StrAna,StrAna,S/W개발,구조해석,,비매출,S/W 개발,그래픽&구조해석,기술개발센터,Strana 개발팀,이호경,팀 회식비,0,"132,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-20,2월,60115705,WF-101,복리후생비(회식대),X25027,StrAna,StrAna,StrAna,S/W개발,구조해석,개발,비매출,S/W 개발,그래픽&구조해석,기술개발센터,Strana 개발팀,이호경,Strana 개발팀 회식비,"132,000",0,"132,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,구글클라우드 코리아 유한회사,경부선 드론촬영 원본데이터 보관 목적 웹하드 구매,0,"59,500",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-20,2월,60114398,OP-203,소모품비(기타),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,구글클라우드 코리아 유한회사,경부선 드론촬영 원본데이터 보관 목적 웹하드 구매,"59,500",0,"59,500",0,,구매,기타,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-25,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,경영지원부,,이용운,건강검진 비용 청구,0,"266,700",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-25,2월,60115798,WF-106,복리후생비(공통),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,경영지원부,,이용운,건강검진 비용 청구,"266,700",0,"266,700",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25033,WatchBIM,WatchBIM,WatchBIM,S/W개발,도로,,비매출,S/W 개발,솔루션,기술개발센터,Infra Solution 개발팀,김재현,셀 회식비,0,"112,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-27,2월,60115705,WF-101,복리후생비(회식대),X25033,WatchBIM,WatchBIM,WatchBIM,S/W개발,도로,개발,비매출,S/W 개발,솔루션,기술개발센터,Infra Solution 개발팀,김재현,셀 회식비,"112,000",0,"112,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25025,EG-BIM Modeler,EG-BIM Modeler,EG-BIM Modeler,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,기술개발센터,그래픽스 개발팀,이재원,팀 회식비,0,"164,500",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,60115705,WF-101,복리후생비(회식대),X25025,EG-BIM Modeler,EG-BIM Modeler,EG-BIM Modeler,S/W개발,그래픽,개발,비매출,S/W 개발,그래픽&구조해석,기술개발센터,그래픽스 개발팀,이재원,그래픽스 개발팀 Modeler셀 회식비,"164,500",0,"164,500",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2025-12-15,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),Y26017,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,엔지니어링 개발그룹장,장계석,출장 간 하이패스 비용,0,"26,500",0,0,가평+경부선 출장비 합산 청구 → 가평 근무기록(1/30) → 경부선 통합 청구,제외,기타,,,,,,,,,
|
||||
바론,2025-12-15,2026-02-27,2월,60115903,WF-201,여비교통비(국내출장비),Y26017,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,엔지니어링 개발그룹장,장계석,출장 간 하이패스 비용,"26,500",0,"26,500",0,가평+경부선 출장비 합산 청구 → 가평 근무기록(1/30) → 경부선 통합 청구,출장비,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),Y26017,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,엔지니어링 개발그룹장,장계석,철도 드론 촬영 간 식대 및 음료대,0,"176,000",0,0,영수증 내역(충북) → 직지사,제외,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,50152507,WF-101,원가)복리후생비(회식대),Y26017,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,엔지니어링 개발그룹장,장계석,철도 드론 촬영 간 식대,"2,000",0,"2,000",0,영수증 내역(충북) → 직지사,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,50152507,WF-101,원가)복리후생비(회식대),Y26017,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,엔지니어링 개발그룹장,장계석,철도 드론 촬영 간 식대,"81,000",0,"81,000",0,영수증 내역(충북) → 직지사,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,50152507,WF-101,원가)복리후생비(회식대),Y26017,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,엔지니어링 개발그룹장,장계석,철도 드론 촬영 간 식대,"24,000",0,"24,000",0,영수증 내역(충북) → 직지사,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,50152507,WF-101,원가)복리후생비(회식대),Y26017,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,엔지니어링 개발그룹장,장계석,철도 드론 촬영 간 식대,"69,000",0,"69,000",0,영수증 내역(충북) → 직지사,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,국토정보 2핵심 국토부 보고 간 교통비,0,"120,330",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,60115903,WF-201,여비교통비(국내출장비),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,국토정보 2핵심 국토부 보고 간 교통비,"120,330",0,"120,330",0,,출장비,R&D,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,국토정보 국토부 보고 간 식대 및 음료대,0,"36,800",0,0,,제외,R&D,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,50152507,WF-101,원가)복리후생비(회식대),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,국토정보 국토부 보고 간 식대,"10,000",0,"10,000",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,50152507,WF-101,원가)복리후생비(회식대),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,국토정보 국토부 보고 간 음료대,"17,500",0,"17,500",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,50152507,WF-101,원가)복리후생비(회식대),X25058,디지털 국토정보 기술개발사업 2핵심과제(삼안),디지털 국토정보(2핵심),국토정보 2핵심,직접매출,R&D,,매출,가족사 프로젝트,R&D,기술개발센터,엔지니어링 개발그룹장,장계석,국토정보 국토부 보고 간 음료대,"9,300",0,"9,300",0,,복리후생비,R&D,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,신지호,전산 유지보수 신규 업체 미팅 간 음료대,0,"7,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,50152507,WF-101,원가)복리후생비(회식대),X25046,전산운영관리,전산운영관리,전산운영관리,기획/관리,운영 S/W,,비매출,S/W 개발,운영S/W,기술개발센터,Web Solution팀,신지호,전산 유지보수 신규 업체 미팅 간 음료대,"7,200",0,"7,200",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25012,WallZainer,WallZainer,WallZainer,S/W개발,구조,,비매출,S/W 개발,구조물,기술개발센터,구조물 S/W개발팀,김세열,AI LLM 구독(초과분 API) 25년9월 ~ 26년1월 사용분,0,"522,590",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,60116399,IT-301,지급수수료(기타),X25012,WallZainer,WallZainer,WallZainer,S/W개발,구조,개발,비매출,S/W 개발,구조물,기술개발센터,구조물 S/W개발팀,김세열,AI LLM 구독(초과분 API) 25년9월 ~ 26년1월 사용분,"522,590",0,"522,590",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-27,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25029,bCMf,bCMf,bCMf,S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,Web Solution팀,김윤하,사업단 BCMF시연준비후 식사,0,"179,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-27,2026-02-27,2월,60115705,WF-101,복리후생비(회식대),X25029,bCMf,bCMf,bCMf,S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,Web Solution팀,김윤하,사업단 BCMF시연준비후 식사,"179,000",0,"179,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-02,2월,20110101,LIA-101,외상매입금,X25051,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,(주)지케이측량시스템,측량장비(GNSS) CHCNAV i85 구매의 건,0,"5,500,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-02,2월,50151399,OP-203,원가)소모품비(기타),X25051,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,(주)지케이측량시스템,측량장비(GNSS) CHCNAV i85 구매의 건,"5,000,000",0,"5,000,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-02,2월,10115301,AST-106,매입세액,X25051,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,(주)지케이측량시스템,측량장비(GNSS) CHCNAV i85 구매의 건,"500,000",0,"500,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-04,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,월드번역통역,영문홍보물 제작을 위한 번역(가족사&바론브로슈어),0,"426,930",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-04,2월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,월드번역통역,영문홍보물 제작을 위한 번역(가족사&바론브로슈어),"388,119",0,"388,119",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-04,2월,10115301,AST-106,매입세액,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,월드번역통역,"매입세액(388,119*0.1)","38,811",0,"38,811",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-05,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,협업증진팀,한승민,"1월 한맥 농구동호회 대관비 (2회분, 1/14, 28) / 대관장소 사정으로 2회분 동일 결제",0,"320,000",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-05,2월,60115798,WF-106,복리후생비(공통),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,협업증진팀,한승민,"1월 한맥 농구동호회 대관비 (2회분, 1/14, 28) / 대관장소 사정으로 2회분 동일 결제","320,000",0,"320,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-05,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,외부회의 음료,0,"35,100",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-05,2월,60115705,WF-101,복리후생비(회식대),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,외부회의 음료,"35,100",0,"35,100",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-05,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,외부식사 (건설기술인협회),0,"37,500",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-05,2월,60115705,WF-101,복리후생비(회식대),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,외부식사 (건설기술인협회),"37,500",0,"37,500",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-02-06,2월,60115903,WF-201,여비교통비(국내출장비),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,출장비,"8,000",0,"8,000",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-21,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,출장비 - 임민경,0,"8,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,김우진,(주)장헌산업 사업자등록 신청,0,"19,500",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-06,2월,60115903,WF-201,여비교통비(국내출장비),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,김우진,(주)장헌산업 사업자등록 신청,"19,500",0,"19,500",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-02-06,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"30,960",0,"30,960",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-28,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"30,960",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-06,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"113,850",0,"113,850",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,출장비 - 권혁진,0,"30,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-29,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"83,850",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/29),0,"65,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-06,2월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/29),"65,000",0,"65,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-02-06,2월,60115903,WF-201,여비교통비(국내출장비),X26006,업무/사업관리,업무/사업관리,업무/사업관리,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,출장비,"8,000",0,"8,000",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X26006,업무/사업관리,업무/사업관리,업무/사업관리,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,임민경,출장비 - 임민경,0,"8,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,김지영A,1월 총괄기획실 물품 및 다과 구매(추가),0,"58,128",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-06,2월,60115798,WF-106,복리후생비(공통),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,김지영A,1월 총괄기획실 물품 및 다과 구매(추가),"58,128",0,"58,128",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,26년 1월 4대보험료 (전체),0,"62,685,600",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,50152527,HR-204,원가)복리후생비(산재보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,근로복지공단,26년 1월 4대보험료(산재보험/회사부담),"3,083,300",0,"3,083,300",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,50152521,HR-202,원가)복리후생비(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,26년 1월 4대보험료(건강보험/회사부담),"12,661,000",0,"12,661,000",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,20111505,HR-202,예수금(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,26년 1월 4대보험료(건강보험/직원부담),"12,661,000",0,"12,661,000",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,50152521,HR-202,원가)복리후생비(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,26년 1월 4대보험료(요양보험/회사부담),"1,663,310",0,"1,663,310",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,20111505,HR-202,예수금(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,26년 1월 4대보험료(요양보험),"1,663,310",0,"1,663,310",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,50151719,HR-201,원가)세금과공과(국민연금),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민연금관리공단,26년 1월 4대보험료(국민연금/회사부담),"12,096,220",0,"12,096,220",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,20111507,HR-201,예수금(국민연금),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민연금관리공단,26년 1월 4대보험료(국민연금/직원부담),"12,096,220",0,"12,096,220",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,50152529,HR-203,원가)복리후생비(고용보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,근로복지공단,26년 1월 4대보험료(고용보험/회사부담),"3,808,750",0,"3,808,750",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-06,2월,20111509,HR-203,예수금(고용보험료(직원)),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,근로복지공단,26년 1월 4대보험료(고용보험/직원부담),"2,952,490",0,"2,952,490",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-06,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 설치용 USB구매 (16GB*10개),0,"79,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-06,2월,60114301,OP-203,소모품비(사무용품비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 설치용 USB구매 (16GB*10개),"79,000",0,"79,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-09,2월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,주완기,임원실 소형테이블 모니터 설치 부가 물품 구매,0,"142,290",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-09,2월,60114305,IT-201,소모품비(전산용품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,주완기,미니PC 브라켓 구매,"16,500",0,"16,500",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-09,2월,60114305,IT-201,소모품비(전산용품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,주완기,멀티탭 보관함 구매,"16,790",0,"16,790",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-02,2026-02-09,2월,60114305,IT-201,소모품비(전산용품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,주완기,무선 마우스 구매(로지텍),"109,000",0,"109,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-10,2월,10111101,REV-101,용역미수금(계산서발행),Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,경영및기술지원서비스(장헌산업),0,"28,270,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-10,2월,10110501,AST-101,보통예금,Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,국민(주거래),경영및기술지원서비스(장헌산업),"28,270,000",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,10111101,REV-101,용역미수금(계산서발행),Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,2026경영및기술지원서비스(장헌산업),0,"18,241,300",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,10110501,AST-101,보통예금,Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(장헌산업),"18,241,300",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,10111101,REV-101,용역미수금(계산서발행),Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,피티씨,2026경영및기술지원서비스(피티씨),0,"23,760,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,10110501,AST-101,보통예금,Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(피티씨),"23,760,000",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-15,2026-02-10,2월,10111101,REV-101,용역미수금(계산서발행),Y26014,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(진우30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,(주)진우엔지니어링,한강교량보수보강공사감독권한대행등건설사업관리용역(2공구)(진우30%)-인쇄/편집,"1,815,000",0,0,"1,815,000",,수입,기타,,,,,,,,,
|
||||
바론,2026-01-15,2026-02-10,2월,40110501,REV-101,개발수입,Y26014,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(진우30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,(주)진우엔지니어링,한강교량보수보강공사감독권한대행등건설사업관리용역(2공구)(진우30%)-인쇄/편집,0,"1,650,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-15,2026-02-10,2월,20112901,LIA-101,매출세액,Y26014,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(진우30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,(주)진우엔지니어링,"1,650,000*10%",0,"165,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,10111101,REV-101,용역미수금(계산서발행),Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,2026경영및기술지원서비스(장헌산업),"18,241,300",0,0,"18,241,300",,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,40110401,REV-101,관리용역수입,Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,2026경영및기술지원서비스(장헌산업),0,"16,583,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,20112901,LIA-101,매출세액,Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,"16,583,000*10%",0,"1,658,300",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,10111101,REV-101,용역미수금(계산서발행),Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,피티씨,2026경영및기술지원서비스(피티씨),"23,760,000",0,0,"23,760,000",,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,40110401,REV-101,관리용역수입,Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,피티씨,2026경영및기술지원서비스(피티씨),0,"21,600,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,20112901,LIA-101,매출세액,Y26010,2026 경영 및 기술지원 서비스(피티씨),,,,,,,,,총괄기획실,,피티씨,"21,600,000*10%",0,"2,160,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-02-10,2월,10111101,REV-101,용역미수금(계산서발행),Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,2026경영및기술지원서비스(장헌산업),"18,241,300",0,0,"18,241,300",,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-02-10,2월,40110401,REV-101,관리용역수입,Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,2026경영및기술지원서비스(장헌산업),0,"16,583,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-01-13,2026-02-10,2월,20112901,LIA-101,매출세액,Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,"16,583,000*10%",0,"1,658,300",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,10111101,REV-101,용역미수금(계산서발행),Y26014,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(진우30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,(주)진우엔지니어링,한강교량보수보강공사감독권한대행등건설사업관리용역(2공구)(진우30%)-인쇄/편집,0,"1,815,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-10,2월,10110501,AST-101,보통예금,Y26014,한강교량 보수보강공사 감독권한대행등 건설사업관리용역(2공구)(진우30%)-인쇄/편집,한강교량 사업관리(2공구),한강교량 사업관리(2공구),직접매출,콘텐츠제작,,매출,바론계약,콘텐츠 제작,기술개발센터,,국민(주거래),한강교량보수보강공사감독권한대행등건설사업관리용역(2공구)(진우30%)-인쇄/편집,"1,815,000",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2025-07-10,2026-02-11,2월,10111101,REV-101,용역미수금(계산서발행),Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,경영및기술지원서비스(장헌산업),"28,270,000",0,0,"28,270,000",,수입,기타,,,,,,,,,
|
||||
바론,2025-07-10,2026-02-11,2월,40110401,REV-101,관리용역수입,Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,경영및기술지원서비스(장헌산업),0,"25,700,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2025-07-10,2026-02-11,2월,20112901,LIA-101,매출세액,Y25039,경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,"25,700,000*10%",0,"2,570,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,기술기획팀,최현호,경조금 (최현호 선임연구원 자녀 출생),0,"300,000",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,50152511,WF-106,원가)복리후생비(경조금),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,기술기획팀,최현호,경조금 (최현호 선임연구원 자녀 출생),"300,000",0,"300,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,20111109,LIA-101,미지급금(하나카드),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,,하나카드(6669),SW 설치용 USB 대량 구매(4GB*100),0,"392,810",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,50151301,OP-203,원가)소모품비(사무용품비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 설치용 USB 대량 구매(4GB*100),"392,810",0,"392,810",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"67,020",0,"67,020",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-04,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"67,020",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,법무사이승희사무소,바론 목적변경 취득세 및 기타 수수료,0,"67,240",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,법무사이승희사무소,바론 목적변경 취득세 및 기타 수수료,"67,240",0,"67,240",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,대한고주파,홍보 전단지 제작,0,"1,232,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,60114599,WF-105,도서인쇄비(기타),X25006,GAIA,GAIA,GAIA,S/W개발,기초조사및GIS,영업,비매출,S/W 개발,기초조사 및 GIS,총괄기획실,,대한고주파,GAIA (홍보전단지 제작),"210,000",0,"210,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,10115301,AST-106,매입세액,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,대한고주파,"매입세액 (210,000*0.1)","21,000",0,"21,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,60114599,WF-105,도서인쇄비(기타),Y25013,iPipe 프로그램 구매 (CAD용),iPipe 프로그램 구매 (CAD용),iPipe 프로그램 구매 (CAD용),S/W개발,수리/수문,영업,비매출,S/W 개발,수리/수문,총괄기획실,,대한고주파,iPipeS (홍보전단지 제작),"210,000",0,"210,000",0,,구매,기타,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,10115301,AST-106,매입세액,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,대한고주파,"매입세액 (210,000*0.1)","21,000",0,"21,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,60114599,WF-105,도서인쇄비(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,대한고주파,디자인포트폴리오 (홍보전단지 제작),"70,000",0,"70,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,10115301,AST-106,매입세액,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,대한고주파,"매입세액 (70,000*0.1)","7,000",0,"7,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,60114599,WF-105,도서인쇄비(기타),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,,대한고주파,EG-BIM (홍보전단지 제작),"210,000",0,"210,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,10115301,AST-106,매입세액,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,대한고주파,"매입세액 (210,000*0.1)","21,000",0,"21,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,60114599,WF-105,도서인쇄비(기타),X25002,KNGIL,KNGIL,KNGIL,S/W개발,기초조사및GIS,영업,비매출,S/W 개발,기초조사 및 GIS,총괄기획실,,대한고주파,KNGIL (홍보전단지 제작),"210,000",0,"210,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,10115301,AST-106,매입세액,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,대한고주파,"매입세액 (210,000*0.1)","21,000",0,"21,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,60114599,WF-105,도서인쇄비(기타),X25020,TOVA,TOVA,TOVA,S/W개발,교통,영업,비매출,S/W 개발,교통,총괄기획실,,대한고주파,TOVQ (홍보전단지 제작),"210,000",0,"210,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,10115301,AST-106,매입세액,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,대한고주파,"매입세액 (210,000*0.1)","21,000",0,"21,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"58,020",0,"58,020",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-09,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"58,020",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"189,900",0,"189,900",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,0,"189,900",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/04),0,"10,300",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/04),"10,300",0,"10,300",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,20110101,LIA-101,외상매입금,Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)경수,경부선 평택-직지사구간 실시설계 측량 분야(외주착수금),0,"26,950,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,50170199,OUT-102,원가)기술협력비(기타외주비),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)경수,경부선 평택-직지사구간 실시설계 측량 분야(외주착수금),"24,500,000",0,"24,500,000",0,외주,외주,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,10115301,AST-106,매입세액,Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,(주)경수,경부선 평택-직지사구간 실시설계 측량 분야(외주착수금),"2,450,000",0,"2,450,000",0,외주,외주,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,20110101,LIA-101,외상매입금,Y26017,경부선 노량진-평택구간 작업자 이동통로 확보를 위한 기술조사 및 실시설계용역,경부선 (노량진-평택) 측량,경부선 노량진-평택구간 작업자 이동통로 확보를 위한 기술조사 및 실시설계용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,둠둠 주식회사,경부선 노량진~직지사구간 작업자 이동통로 확보 기술조사 및 실시설계용역(외주착수금),0,"25,575,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,50170199,OUT-102,원가)기술협력비(기타외주비),Y26017,경부선 노량진-평택구간 작업자 이동통로 확보를 위한 기술조사 및 실시설계용역,경부선 (노량진-평택) 측량,경부선 노량진-평택구간 작업자 이동통로 확보를 위한 기술조사 및 실시설계용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,둠둠 주식회사,경부선 노량진~직지사구간 작업자 이동통로 확보 기술조사 및 실시설계용역(외주착수금),"23,250,000",0,"23,250,000",0,외주,외주,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,10115301,AST-106,매입세액,Y26017,경부선 노량진-평택구간 작업자 이동통로 확보를 위한 기술조사 및 실시설계용역,경부선 (노량진-평택) 측량,경부선 노량진-평택구간 작업자 이동통로 확보를 위한 기술조사 및 실시설계용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,둠둠 주식회사,경부선 노량진~직지사구간 작업자 이동통로 확보 기술조사 및 실시설계용역(외주착수금),"2,325,000",0,"2,325,000",0,외주,외주,기타,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/30),0,"24,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-03,2026-02-12,2월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(01/30),"24,000",0,"24,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,20110101,LIA-101,외상매입금,Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,둠둠 주식회사,경부선 평택~직지사구간 작업자 이동통로 확보 기술조사 및 실시설계용역(외주착수금),0,"9,762,500",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,50170199,OUT-102,원가)기술협력비(기타외주비),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,둠둠 주식회사,경부선 평택~직지사구간 작업자 이동통로 확보 기술조사 및 실시설계용역(외주착수금),"8,875,000",0,"8,875,000",0,외주,외주,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-12,2월,10115301,AST-106,매입세액,Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,기술개발센터,,둠둠 주식회사,경부선 평택~직지사구간 작업자 이동통로 확보 기술조사 및 실시설계용역(외주착수금),"887,500",0,"887,500",0,외주,외주,기타,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,최근혜,출장비,"79,760",0,"79,760",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-06,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,최근혜,출장비 - 최근혜,0,"79,760",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,바론 입찰제출용 법인등기 발급 수수료,0,"10,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,바론 입찰제출용 법인등기 발급 수수료,"10,000",0,"10,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"48,640",0,"48,640",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"48,640",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,20110101,LIA-101,외상매입금,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 전화 통신비 26년 02월,0,"3,300",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,60116199,OP-204,통신비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 전화 통신비 26년 02월,"3,000",0,"3,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,10115301,AST-106,매입세액,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),공급가액*10%,300,0,300,0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,법무사이승희사무소,바론 목적변경등기(법무사수수료),0,"253,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,법무사이승희사무소,바론 목적변경등기(법무사수수료),"230,000",0,"230,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-12,2월,10115301,AST-106,매입세액,X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,법무사이승희사무소,"매입세액(230,000*0.1)","23,000",0,"23,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,20111105,LIA-101,미지급금(KB국민카드),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민카드(3866),삼안 경영전략본부 회의 후 식사,0,"338,000",0,0,공통 → 인사교육,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,최근혜,삼안 경영전략본부 회의 후 식사,"338,000",0,"338,000",0,공통 → 인사교육,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,권혁진,업무용 산업의 역군 서비스 이용료(1월),0,"99,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,권혁진,업무용 산업의 역군 서비스 이용료(1월),"99,000",0,"99,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,김원기,"기술기획팀 AI기획에 따른 AI결제(chatGPT, NotebookLM)",0,"61,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,60114398,OP-203,소모품비(기타),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,김원기,"기술기획팀 AI기획에 따른 AI결제(chatGPT, NotebookLM)","61,400",0,"61,400",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,김원기,기술기획팀 점심 회식비(최현호 출산휴가),0,"200,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,60115705,WF-101,복리후생비(회식대),X25051,사전기획,사전기획,사전기획,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,기술기획팀,김원기,기술기획팀 점심 회식비(최현호 출산휴가),"200,000",0,"200,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,이지빔 영업(02/11) 수원&강남 단기 렌트카,0,"40,430",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-12,2월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,이지빔 영업(02/11) 수원&강남 단기 렌트카,"40,430",0,"40,430",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-01-31,2026-02-12,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"87,460",0,"87,460",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-31,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"87,460",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,김지영A,(주)장헌 브로슈어 당진 택배발송 비용,0,"12,100",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-05,2026-02-12,2월,50153199,IT-301,원가)지급수수료(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,솔루션통합팀,김지영A,(주)장헌 브로슈어 당진 택배발송 비용,"12,100",0,"12,100",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,20110101,LIA-101,외상매입금,Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,SK브로드밴드(주),서산-아산 건설사업단 BIG ROOM VPN 26년 02월 결제의 건,0,"94,600",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,60116199,OP-204,통신비(기타),Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,SK브로드밴드(주),서산-아산 건설사업단 BIG ROOM VPN 26년 02월 결제의 건,"86,000",0,"86,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,10115301,AST-106,매입세액,Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,SK브로드밴드(주),서산-아산 건설사업단 BIG ROOM VPN 26년 02월 결제의 건,"8,600",0,"8,600",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,최근혜,외빈 선물 구매,0,"179,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,60114398,OP-203,소모품비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,최근혜,외빈 선물 구매,"100,000",0,"100,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,60114398,OP-203,소모품비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,최근혜,외빈 선물 구매,"79,000",0,"79,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-13,2월,20111103,LIA-101,미지급금(일반미지급),X25035,Domainer,Domainer,Domainer,S/W개발,솔루션,,비매출,S/W 개발,솔루션,총괄기획실,협업증진팀,성형일,협업증진팀 식대,0,"112,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-13,2월,60115705,WF-101,복리후생비(회식대),X25035,Domainer,Domainer,Domainer,S/W개발,솔루션,기획,비매출,S/W 개발,솔루션,총괄기획실,협업증진팀,성형일,팀 기획업무회의 후 식사,"112,000",0,"112,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,20110101,LIA-101,외상매입금,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 ERP서버 인터넷 회선 통신비 26년 02월 결제의 건,0,"561,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,60116199,OP-204,통신비(기타),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 ERP서버 인터넷 회선 통신비 26년 02월 결제의 건,"510,000",0,"510,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,10115301,AST-106,매입세액,X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,SK브로드밴드(주),바론 ERP서버 인터넷 회선 통신비 26년 02월 결제의 건,"51,000",0,"51,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,20111109,LIA-101,미지급금(하나카드),Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,하나카드(6669),서산-아산 건설사업단 화상회의 S/W(Zoom) 1년 구독의 건,0,"246,070",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-13,2월,50153199,IT-301,원가)지급수수료(기타),Y25019,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구) 통합 스마트상황실 구축 및 운영,인주~염치(2공구)빅룸,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,,Zoom Video Communications Inc.,서산-아산 건설사업단 화상회의 S/W(Zoom) 1년 구독의 건,"246,070",0,"246,070",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-13,2월,10111101,REV-101,용역미수금(계산서발행),Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,한맥기술,2026경영및기술지원서비스(한맥기술)2월,"122,916,200",0,0,"122,916,200",,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-13,2월,40110401,REV-101,관리용역수입,Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,한맥기술,2026경영및기술지원서비스(한맥기술)2월,0,"111,742,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-13,2월,20112901,LIA-101,매출세액,Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,한맥기술,"111,742,000*10%",0,"11,174,200",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-13,2월,10111101,REV-101,용역미수금(계산서발행),Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,한맥기술,2026경영및기술지원서비스(한맥기술)2월,0,"122,916,200",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-13,2월,10110501,AST-101,보통예금,Y26008,2026 경영 및 기술지원 서비스(한맥기술),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(한맥기술)2월,"122,916,200",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-19,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,임원 CHAT GPT 구독료(2월),0,"29,000",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-19,2월,60116399,IT-301,지급수수료(기타),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,임원 CHAT GPT 구독료(2월),"29,000",0,"29,000",0,공통 → 경영진,구매,기타,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-19,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,2월 업무관련 유류대(장종찬사장),0,"324,500",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-19,2월,60115999,WF-201,여비교통비(기타),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,2월 업무관련 유류대(장종찬사장),"324,500",0,"324,500",0,공통 → 경영진,출장비,기타,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,솔루션통합팀,권혁진,건설기술인 승급 ( 중급 : 스마트건설 - BIM ) 교육 ( 설계시공 > 안전관리 > 건설안전 ),0,"200,000",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,60117301,WF-301,교육훈련비,ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,솔루션통합팀,권혁진,건설기술인 승급 ( 중급 : 스마트건설 - BIM ) 교육 ( 설계시공 > 안전관리 > 건설안전 ),"200,000",0,"200,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,20111109,LIA-101,미지급금(하나카드),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,하나카드(7726),1월 업무관련 식대(최대선),0,"380,000",0,0,공통 → OB,제외,공통,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,50152507,WF-101,원가)복리후생비(회식대),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,1월 업무관련 식대(최대선),"380,000",0,"380,000",0,공통 → OB,복리후생비,공통,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,20111109,LIA-101,미지급금(하나카드),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,하나카드(7726),1월 업무관련 유류대(최대선),0,"196,000",0,0,공통 → OB,제외,공통,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,60115999,WF-201,여비교통비(기타),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,1월 업무관련 유류대(최대선),"66,000",0,"66,000",0,공통 → OB,출장비,공통,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,60115999,WF-201,여비교통비(기타),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,1월 업무관련 유류대(최대선),"60,000",0,"60,000",0,공통 → OB,출장비,공통,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,60115999,WF-201,여비교통비(기타),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,최대선,1월 업무관련 유류대(최대선),"70,000",0,"70,000",0,공통 → OB,출장비,공통,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,시내교통비,0,"12,700",0,0,공통 → 대산당진BIM,제외,일반,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,50152705,WF-201,원가)여비교통비(시내교통비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,업무협의,"12,700",0,"12,700",0,공통 → 대산당진BIM,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,"업체방문 석식대 ( 바우컨설탄트, 총 6명 )",0,"189,100",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-19,2026-02-20,2월,60115705,WF-101,복리후생비(회식대),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,"업체방문 석식대 ( 바우컨설탄트, 총 6명 )","189,100",0,"189,100",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-02-20,2월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비,"93,200",0,"93,200",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-08,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비 - 김원식,0,"93,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-02-20,2월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비,"113,300",0,"113,300",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-16,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비 - 김원식,0,"113,300",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-20,2월,60115903,WF-201,여비교통비(국내출장비),Z25059,울산~외곽순환 고속도로(1공구),울산외곽순환(1공구),울산~외곽순환 고속도로(1공구),직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비,"106,400",0,"106,400",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-01-30,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),Z25059,울산~외곽순환 고속도로(1공구),울산외곽순환(1공구),울산~외곽순환 고속도로(1공구),직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비 - 김원식,0,"106,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-20,2월,60115903,WF-201,여비교통비(국내출장비),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비,"97,000",0,"97,000",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-12,2026-02-20,2월,20111103,LIA-101,미지급금(일반미지급),Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,총괄기획실,김원식,출장비 - 김원식,0,"97,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111103,LIA-101,미지급금(일반미지급),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,인프라 BIM1팀,김원기A,경부선 드론 기반 조사 촬영에 필요한 현장 운영비(전도금),0,"2,000,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,10114903,AST-105,전도금(가불금),Y26018,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역,경부선 (평택-직지사) 측량,경부선 평택-직지사구간 작업자 이동통로 확보 기술조사 및 실시설계 용역-드론촬영/현장조사자료작성,직접매출,기술용역계약,,매출,바론계약,기술용역,총괄기획실,인프라 BIM1팀,김원기A,경부선 드론 기반 조사 촬영에 필요한 현장 운영비(전도금),"2,000,000",0,"2,000,000",0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,60110101,HR-101,급여(임.직원),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,총괄/장종찬,2026년 2월 급여(바론),"393,784,100",0,"393,784,100",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111503,LIA-101,예수금(근로소득주민세),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,2025년 연말정산 (주민세),0,"-6,354,890",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111507,HR-201,예수금(국민연금),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,김예슬 국민연금 정산,0,"-688,020",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111501,LIA-101,예수금(근로소득세),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,근로복지공단,2026년 2월 급여 근로소득세(바론),0,"26,421,200",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111503,LIA-101,예수금(근로소득주민세),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,근로복지공단,2026년 2월 급여 근로소득주민세(바론),0,"2,641,790",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111509,HR-203,예수금(고용보험료(직원)),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,고용노동부,2026년 2월 급여 고용보험(바론),0,"3,271,310",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111505,HR-202,예수금(건강보험료),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민건강보험공단,"2026년 2월 급여 의료보험(건강+요양, 바론)",0,"14,670,700",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111507,HR-201,예수금(국민연금),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민연금관리공단,2026년 2월 급여 국민연금(바론),0,"11,935,950",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111599,LIA-101,예수금(기타),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,2026년 2월 식대 및 기타 공제(바론),0,"1,796,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,총괄/장종찬,2026년 2월 급여(바론),0,"403,645,110",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-23,2월,20111501,LIA-101,예수금(근로소득세),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,,2025년 연말정산 (소득세),0,"-63,555,450",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-20,2026-02-24,2월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,조태희,인재성장팀 회식비(2026년 2월),0,"126,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-20,2026-02-24,2월,60115705,WF-101,복리후생비(회식대),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,조태희,인재성장팀 회식비(2026년 2월),"126,000",0,"126,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-24,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,SK텔레콤(주),2월 휴대폰 사용요금(최대선),0,"144,330",0,0,공통 → OB,제외,공통,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-24,2월,60116199,OP-204,통신비(기타),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,SK텔레콤(주),2월 휴대폰 사용요금(최대선),"37,633",0,"37,633",0,공통 → OB,구매,공통,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-24,2월,10115301,AST-106,매입세액,ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,SK텔레콤(주),2월 휴대폰 사용요금(최대선),"3,762",0,"3,762",0,공통 → OB,구매,공통,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-24,2월,60116199,OP-204,통신비(기타),ZZZZZZ,공통(OB),공통(OB),공통,기획/관리,공통,,공통,공통,공통,임원실,,SK텔레콤(주),2월 휴대폰 사용요금(최대선),"102,935",0,"102,935",0,공통 → OB,구매,공통,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-25,2월,10111101,REV-101,용역미수금(계산서발행),Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,한라산업개발,2026경영및기술지원서비스(한라산업개발),0,"10,285,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-25,2월,10110501,AST-101,보통예금,Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(한라산업개발),"10,285,000",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-25,2월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민은행,퇴직연금(국민) 2026년 2월분 (44명),0,"19,360,430",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-25,2월,60111101,HR-103,퇴직금,X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,국민은행,퇴직연금(국민) 2026년 2월분 (44명),"19,360,430",0,"19,360,430",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-25,2월,20111103,LIA-101,미지급금(일반미지급),X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,신한은행,퇴직연금(신한) 2026년 2월분 (6명),0,"2,159,060",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-25,2월,60111101,HR-103,퇴직금,X26003,인사/교육,인사/교육,인사/교육,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,신한은행,퇴직연금(신한) 2026년 2월분 (6명),"2,159,060",0,"2,159,060",0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-25,2월,10111101,REV-101,용역미수금(계산서발행),Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,삼안,2026경영및기술지원서비스(삼안)2월,"294,607,500",0,0,"294,607,500",,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-25,2월,40110401,REV-101,관리용역수입,Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,삼안,2026경영및기술지원서비스(삼안)2월,0,"267,825,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-25,2월,20112901,LIA-101,매출세액,Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,삼안,"267,825,000*10%",0,"26,782,500",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-25,2월,10111101,REV-101,용역미수금(계산서발행),Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,한라산업개발,2026경영및기술지원서비스(한라산업개발),"10,285,000",0,0,"10,285,000",,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-25,2월,40110401,REV-101,관리용역수입,Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,한라산업개발,2026경영및기술지원서비스(한라산업개발),0,"9,350,000",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-10,2026-02-25,2월,20112901,LIA-101,매출세액,Y26012,2026 경영 및 기술지원 서비스(한라산업개발),,,,,,,,,총괄기획실,,한라산업개발,"9,350,000*10%",0,"935,000",0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-25,2월,10111101,REV-101,용역미수금(계산서발행),Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,삼안,2026경영및기술지원서비스(삼안)2월,0,"294,607,500",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-25,2월,10110501,AST-101,보통예금,Y26011,2026 경영 및 기술지원 서비스(삼안),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(삼안)2월,"294,607,500",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-26,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,업무 회의 후 식사,0,"37,000",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-26,2월,60115705,WF-101,복리후생비(회식대),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,업무 회의 후 식사,"37,000",0,"37,000",0,공통 → 경영진,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-26,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,임원 AI 구독료(2월),0,"29,000",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-26,2월,60116399,IT-301,지급수수료(기타),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,임원 AI 구독료(2월),"29,000",0,"29,000",0,공통 → 경영진,구매,기타,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-26,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,업무 회의 후 식사,0,"59,900",0,0,공통 → 경영진,제외,기타,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-26,2월,60115705,WF-101,복리후생비(회식대),ZZZZZZ,경영진,경영진,경영진,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,총괄기획실 사장,장종찬,업무 회의 후 식사,"59,900",0,"59,900",0,공통 → 경영진,복리후생비,기타,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,협업증진팀,한승민,"2월 한맥 농구동호회 대관비 (2회분, 2/11, 25)",0,"320,000",0,0,"공통 → 교육훈련,참석",제외,기타,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,60115798,WF-106,복리후생비(공통),ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,협업증진팀,한승민,"2월 한맥 농구동호회 대관비 (2회분, 2/11, 25)","320,000",0,"320,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-27,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"142,370",0,"142,370",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,출장비 - 권혁진,0,"8,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-11,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"134,370",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-27,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"69,920",0,"69,920",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-13,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"69,920",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,김우진,변리사 업무미팅 후 식사(IP 이전),0,"36,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,60115705,WF-101,복리후생비(회식대),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,김우진,변리사 업무미팅 후 식사(IP 이전),"36,000",0,"36,000",0,,복리후생비,일반,,,,,,,,,
|
||||
바론,2026-02-20,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/13),0,"34,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-20,2026-02-27,2월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/13),"34,000",0,"34,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-20,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/13),0,"3,400",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-20,2026-02-27,2월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/13),"3,400",0,"3,400",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,바론 입찰제출용 법인인감증명서 발급수수료,0,"20,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-23,2026-02-27,2월,60116399,IT-301,지급수수료(기타),X26002,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,경영기획팀,국혜림,바론 입찰제출용 법인인감증명서 발급수수료,"20,000",0,"20,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,DB손해보험,바론컨설턴트 법인차량(168러 2890) 자동차보험 가입의 건,0,"743,590",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,60115103,OP-201,보험료(자동차보험료),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,DB손해보험,바론컨설턴트 법인차량(168러 2890) 자동차보험 가입의 건,"743,590",0,"743,590",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-27,2월,60115903,WF-201,여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비,"111,950",0,"111,950",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,권혁진,출장비 - 권혁진,0,"30,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-24,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,출장비 - 염승호,0,"81,950",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/24),0,"34,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/24),"34,000",0,"34,000",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,디자인기획팀,신혜영,디자인팀 공용 피그마 사용료(2026.2월~2027.2월사용),0,"625,209",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,60114399,OP-203,소모품비(기타(공통)),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,디자인기획팀,신혜영,디자인팀 공용 피그마 사용료(2026.2월~2027.2월사용),"625,209",0,"625,209",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,20111109,LIA-101,미지급금(하나카드),X25030,GSIM,GSIM,GSIM,S/W개발,솔루션,,비매출,S/W 개발,솔루션,기술개발센터,,하나카드(6669),GSIM팀 AWS 사용료(26년 1월),0,"182,932",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,50153199,IT-301,원가)지급수수료(기타),X25030,GSIM,GSIM,GSIM,S/W개발,솔루션,개발,비매출,S/W 개발,솔루션,기술개발센터,,Amazon Web Services Inc.,GSIM팀 AWS 사용료(26년 1월),"182,932",0,"182,932",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-27,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/26),0,"31,700",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-27,2026-02-27,2월,60115798,WF-106,복리후생비(공통),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,염승호,이지빔 영업(02/26),"31,700",0,"31,700",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-27,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,건국대학교 강의 지원,0,"16,940",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-27,2026-02-27,2월,50152701,WF-201,원가)여비교통비(국내출장비),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,건국대학교 강의 지원,"16,940",0,"16,940",0,,출장비,일반,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 홍보 브로슈어 업체 발송,0,"165,440",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,60116399,IT-301,지급수수료(기타),X25026,EG-BIM Drawer,EG-BIM Drawer,EG-BIM Drawer,S/W개발,그래픽,영업,비매출,S/W 개발,그래픽&구조해석,총괄기획실,솔루션통합팀,김지영A,SW 홍보 브로슈어 업체 발송,"165,440",0,"165,440",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-27,2026-02-27,2월,10111101,REV-101,용역미수금(계산서발행),Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,장헌산업,2026경영및기술지원서비스(장헌산업),0,"18,241,300",0,0,,수입,기타,,,,,,,,,
|
||||
바론,2026-02-27,2026-02-27,2월,10110501,AST-101,보통예금,Y26009,2026 경영 및 기술지원 서비스(장헌산업),,,,,,,,,총괄기획실,,국민(주거래),2026경영및기술지원서비스(장헌산업),"18,241,300",0,0,0,,제외,기타,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,20111109,LIA-101,미지급금(하나카드),X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,하나카드(6669),Ai셀 Cloudflare 사용료(26년 1월),0,"8,319",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,50153199,IT-301,원가)지급수수료(기타),X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,Cloudflare Inc.,Ai셀 Cloudflare 사용료(26년 1월),"8,319",0,"8,319",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,20110101,LIA-101,외상매입금,X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,Ai셀 구글클라우드 Ai 사용료(26년 1월),0,"327,153",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,50153199,IT-301,원가)지급수수료(기타),X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,Ai셀 구글클라우드 Ai 사용료(26년 1월),"297,412",0,"297,412",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-26,2026-02-27,2월,10115301,AST-106,매입세액,X25054,AI,AI,AI,기획/관리,기획&관리,,비매출,기획/제안,기획,총괄기획실,,구글클라우드 코리아 유한회사,Ai셀 구글클라우드 Ai 사용료(26년 1월),"29,741",0,"29,741",0,,구매,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,20111103,LIA-101,미지급금(일반미지급),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,주완기,기술개발센터 공용 무선 랜카드 구매,0,"34,000",0,0,,제외,일반,,,,,,,,,
|
||||
바론,2026-02-25,2026-02-27,2월,60114301,OP-203,소모품비(사무용품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,인재성장팀,주완기,기술개발센터 공용 무선 랜카드 구매,"34,000",0,"34,000",0,,구매,일반,,,,,,,,,
|
||||
한맥,2026-02-10,2026-02-13,2월,50152599,WF-106,원가)복리후생비,ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,,방재보수교육 김승호 수석,"220,000",,"220,000",0,"공통 → 교육훈련,참석",구매,기타,,,,,,,,,
|
||||
한맥,2026-02-19,2026-02-19,2월,10113701,AST-104,선급금,ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,기술개발센터,,,대학원등록금 기업분담금(급여차감예정),"528,500",,0,"528,500",장헌 전상현JM (명지대 등록금건) → 제외,수입,공통,,,,,,,,,
|
||||
한맥,2026-02-26,2026-02-26,2월,60115305,MK-201,접대비,ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,기술개발센터,,,우종일 모친상/보훈병원장례식장,"100,000",,"100,000",0,"공통 → 교육훈련,참석",복리후생비,기타,,,,,,,,,
|
||||
한맥,2026-02-05,2026-02-13,2월,50151101,OP-101,원가)지급임차료,Z25056,고속국도 제 30호 서산-영덕선(대산-당진) 건설공사 전환 및 시공 BIM 수행 용역(제 2공구),대산~당진(2공구) 시공BIM,대산~당진 시공2공구 시공BIM 수행,직접매출,내부BIM설계지원,,매출,가족사 프로젝트,BIM 설계,총괄기획실,,,월세(2월) : 대산-당진(제2공구) 시공BIM 용역 현장 직원 숙소,"370,000",,"370,000",0,,구매,일반,,,,,,,,,
|
||||
한맥,2026-02-10,2026-02-13,2월,50153103,OUT-102,원가)지급수수료,ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,마천사무실/1분기 카페트 청소,"308,000",,"308,000",0,,구매,공통,,,,,,,,,
|
||||
한맥,2026-02-10,2026-02-13,2월,50153103,OUT-102,원가)지급수수료,ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,기술개발센터 ADT캡스 월정료_26년 2월,"99,000",,"99,000",0,,구매,공통,,,,,,,,,
|
||||
한맥,2026-02-11,2026-02-13,2월,50151109,OP-202,원가)지급임차료,ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,마천사무실/정수기 4대 26년 2월 청구분,"137,400",,"137,400",0,,구매,공통,,,,,,,,,
|
||||
한맥,2026-02-11,2026-02-13,2월,50152705,WF-201,원가)여비교통비,ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,마천사무실/2월 주차정기권,"99,000",,"99,000",0,,출장비,공통,,,,,,,,,
|
||||
한맥,2026-02-11,2026-02-13,2월,60116101,IT-401,통신비,ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,마천사무실/통신요금(26년 02월),"614,900",,"614,900",0,,구매,공통,,,,,,,,,
|
||||
한맥,2026-02-11,2026-02-13,2월,50152705,WF-201,원가)여비교통비,ZZZZZZ,공통,공통(기타),공통,기획/관리,공통,,공통,공통,공통,총괄기획실,,,마천사무실/2월 주차정기권,"33,000",,"33,000",0,,출장비,공통,,,,,,,,,
|
||||
한맥,2026-02-19,2026-02-23,2월,60114743,WF-103,경상시험연구비,ZZZZZZ,"교육훈련,참석","교육훈련, 참석","교육훈련,참석",기획/관리,공통,,공통,공통,공통,총괄기획실,,,기술개발센터 세미나 진행 관련 식사 및 다과 구입,"732,000",,"732,000",0,"공통 → 교육훈련,참석",복리후생비,기타,,,,,,,,,
|
||||
삼안,2026-02-10,2026-01-29,1월,60114739,IT-201,경상시험연구비(소모품비),X26005,운영지원(총무),운영지원(총무),운영지원(총무),기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,기술개발센터,,,센터 소모품 및 다과 구매,"4,128,674",,"4,128,674",0,,구매,일반,,,,,,,,,
|
||||
장헌산업,2025-12-16,2026-01-20,1월,823,OUT-201,연구개발비,20-교영-18,스마트건설기술개발사업 10과제(한맥/삼안/장헌/피티씨),스마트건설기술(10과제),스마트건설(10과제),직접매출,R&D,,매출,가족사 프로젝트,R&D,,,MDPI,스마트건설/MDPI 게재료(SCI),"4,870,413",,"4,870,413",0,,구매,R&D,,,,,,,,,
|
||||
장헌산업,2026-01-30,2026-02-04,2월,831,OUT-201,지급수수료,26-관리-06,경영기획/전략,경영기획/전략,경영기획/전략,기획/관리,총괄기획실,,총괄기획실,총괄기획실,총괄기획실,총괄기획실,,법무사 조수호,주식회사 장헌파트너스 분할 관련 공과금 추가납부(공증료),"121,000",,"121,000",0,,구매,일반,,,,,,,,,
|
||||
|
90
incoming-files/ptj.csv
Normal file
90
incoming-files/ptj.csv
Normal file
@@ -0,0 +1,90 @@
|
||||
매출/비매출,분야,세부분야,프로젝트명,
|
||||
매출,바론계약,판매,EG-BIM (파이프텍코리아),
|
||||
매출,바론계약,판매,ERP(한국종합엔지니어링),
|
||||
매출,바론계약,판매,GAIA 기능 개선 계약,
|
||||
매출,바론계약,기술용역,계양-강화 고속도로 건설 기본 및 실시(5공구),
|
||||
매출,바론계약,기술용역,국도42호선 원주 흥업 사제 외 1개소 교차로 개선공사 실시설계용역(삼안),
|
||||
매출,바론계약,기술용역,인주-염치(제1공구) 회의 시스템 설치,
|
||||
매출,바론계약,기술용역,고속국도 제32호 당진~청주선 인주~염치간 건설공사(제2공구),
|
||||
매출,바론계약,기술용역,대산-당진 1공구 Conference Platform(BigRoom)구축,
|
||||
매출,바론계약,기술용역,대산-당진 2공구 Conference Platform(BigRoom)구축,
|
||||
매출,바론계약,기술용역,대산-당진 3-4공구 Conference Platform(BigRoom)구축,
|
||||
매출,바론계약,기술용역,공학용 사이니지 시스템 구축,2월 추가
|
||||
매출,바론계약,콘텐츠 제작,보은국토도로 사업관리,
|
||||
매출,바론계약,콘텐츠 제작,한강교량 사업관리(2공구),
|
||||
매출,바론계약,콘텐츠 제작,예산국토 제2권역,
|
||||
매출,바론계약,콘텐츠 제작,가평군 하수관망 계측시스템 유지관리 용역,
|
||||
매출,바론계약,콘텐츠 제작,PQ(지오메카이엔지),
|
||||
매출,가족사 프로젝트,BIM 설계,대산~당진 시공2공구 시공BIM 수행,
|
||||
매출,가족사 프로젝트,BIM 설계,서산~명천 도로건설공사 기본 및 실시설계,
|
||||
매출,가족사 프로젝트,BIM 설계,울산~외곽순환 고속도로(1공구),
|
||||
매출,가족사 프로젝트,BIM 설계,충남논산스마트팜 조감도,
|
||||
매출,가족사 프로젝트,BIM 설계,원효대교북단 조감도,
|
||||
매출,가족사 프로젝트,BIM 설계,사천시 향촌지구 우수유출저감시설 PQ 문서편집,
|
||||
매출,가족사 프로젝트,시공,인천발 KTX 직결교량 시공,
|
||||
매출,가족사 프로젝트,디자인,가족사 보고서 템플릿 제작_PQ,
|
||||
매출,가족사 프로젝트,디자인,(주)삼안 2026년 경영기획서 편집디자인,2월 추가
|
||||
매출,가족사 프로젝트,R&D,디지털 국토정보 기술개발사업,
|
||||
매출,가족사 프로젝트,R&D,국토정보 2핵심,
|
||||
매출,가족사 프로젝트,R&D,스마트건설(10과제),
|
||||
매출,가족사 프로젝트,R&D,XR기반 건설설계 혁신시스템,
|
||||
비매출,S/W 개발,기초조사 및 GIS,KNGIL,
|
||||
비매출,S/W 개발,기초조사 및 GIS,GIS Mapper,
|
||||
비매출,S/W 개발,기초조사 및 GIS,천지인,
|
||||
비매출,S/W 개발,기초조사 및 GIS,Surveyor,
|
||||
비매출,S/W 개발,기초조사 및 GIS,GAIA,
|
||||
비매출,S/W 개발,도로,WayPrimal,
|
||||
비매출,S/W 개발,도로,WayConfirm,
|
||||
비매출,S/W 개발,도로,WayDraw,
|
||||
비매출,S/W 개발,도로,WayShop,
|
||||
비매출,S/W 개발,구조물,WallZainer,
|
||||
비매출,S/W 개발,구조물,Bridge planner,
|
||||
비매출,S/W 개발,구조물,AbutZainer,
|
||||
비매출,S/W 개발,구조물,BriZainer-DR,
|
||||
비매출,S/W 개발,구조물,BriZainer-Nodular,
|
||||
비매출,S/W 개발,구조물,TunnelZainer,
|
||||
비매출,S/W 개발,구조,BoxZainer,2월 추가
|
||||
비매출,S/W 개발,교통,TOVA,
|
||||
비매출,S/W 개발,수리/수문,LifeLine-Water,
|
||||
비매출,S/W 개발,수리/수문,강우강도산정 프로그램,
|
||||
비매출,S/W 개발,그래픽&구조해석,HmEG(HmDraw),
|
||||
비매출,S/W 개발,그래픽&구조해석,EG-BIM Modeler,
|
||||
비매출,S/W 개발,그래픽&구조해석,EG-BIM Drawer,
|
||||
비매출,S/W 개발,그래픽&구조해석,StrAna,
|
||||
비매출,S/W 개발,솔루션,문서관리시스템(PM),
|
||||
비매출,S/W 개발,솔루션,bCMf,
|
||||
비매출,S/W 개발,솔루션,GSIM,
|
||||
비매출,S/W 개발,솔루션,CCP,
|
||||
비매출,S/W 개발,솔루션,단가/공정 solution,
|
||||
비매출,S/W 개발,솔루션,WatchBIM,
|
||||
비매출,S/W 개발,솔루션,Twin Highway,
|
||||
비매출,S/W 개발,솔루션,Domainer,
|
||||
비매출,S/W 개발,솔루션,Cadaster,2월 추가
|
||||
비매출,S/W 개발,운영S/W,입찰정보(용역/공사) 조회 시스템,
|
||||
비매출,S/W 개발,운영S/W,ERP: 장헌산업,
|
||||
비매출,S/W 개발,운영S/W,ERP: PTC,
|
||||
비매출,S/W 개발,운영S/W,ERP: 한라,
|
||||
비매출,S/W 개발,운영S/W,ERP: 삼안,
|
||||
비매출,S/W 개발,운영S/W,ERP: 한맥,
|
||||
비매출,S/W 개발,운영S/W,ERP: 바론,
|
||||
비매출,S/W 개발,운영S/W,ERP: (주)장헌,
|
||||
비매출,S/W 개발,운영S/W,ERP: 산하종합기술,
|
||||
비매출,S/W 개발,운영S/W,CivilEngineeringLab,
|
||||
비매출,S/W 개발,운영S/W,PQ시스템,
|
||||
비매출,S/W 개발,운영S/W,BEPs,
|
||||
비매출,S/W 개발,운영S/W,전산운영관리,
|
||||
비매출,S/W 개발,운영S/W,한맥가족 배움터,
|
||||
비매출,기획/제안,기획,사전기획,
|
||||
비매출,기획/제안,기획,청용천교 업무지원,
|
||||
비매출,기획/제안,기획,수자원 해외사업,
|
||||
비매출,기획/제안,기획,AI,
|
||||
비매출,기획/제안,기획,GIS장비 개발,
|
||||
비매출,기획/제안,기획,프리캐스트 조립식 박스 교량,
|
||||
비매출,기획/제안,기획,인덕원~동탄 복선전철 스마트상황실 구축 및 운영안,
|
||||
비매출,기획/제안,기획,바론 SW 포탈,2월 추가
|
||||
총괄기획실,총괄기획실,총괄기획실,경영기획/전략,
|
||||
총괄기획실,총괄기획실,총괄기획실,인사/교육,
|
||||
총괄기획실,총괄기획실,총괄기획실,운영지원(총무),
|
||||
총괄기획실,총괄기획실,총괄기획실,업무/사업관리,
|
||||
공통,공통,공통,"교육훈련,참석",
|
||||
공통,공통,공통,공통,
|
||||
|
24
incoming-files/reference/README.md
Normal file
24
incoming-files/reference/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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
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
22
incoming-files/reference/ledger/README.md
Normal file
22
incoming-files/reference/ledger/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# reference/ledger
|
||||
|
||||
이 디렉터리는 `사업관리대장` 원본 참고 자산만 둔다.
|
||||
|
||||
원칙:
|
||||
|
||||
- 직접 서빙하지 않는다.
|
||||
- 비교, 복구, 기준 확인이 필요할 때만 본다.
|
||||
- 실제 수정 원본은 `frontend/apps/ledger/*`다.
|
||||
- 실제 runtime 응답은 `incoming-files/served/ledger/*`다.
|
||||
|
||||
현재 포함:
|
||||
|
||||
- 원본 HTML/CSS
|
||||
- 원본 XLSX
|
||||
- 과거 override 참고 파일
|
||||
- 단일 canonical reference set만 유지
|
||||
|
||||
주의:
|
||||
|
||||
- `reference/ledger` 아래에 다시 `ledger/` 같은 중첩 복사본을 만들지 않는다.
|
||||
- 원본 정리가 필요하면 이 디렉터리에서만 구조를 맞춘다.
|
||||
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);
|
||||
});
|
||||
})();
|
||||
BIN
incoming-files/reference/ledger/사업관리대장-1.xlsx
Normal file
BIN
incoming-files/reference/ledger/사업관리대장-1.xlsx
Normal file
Binary file not shown.
3308
incoming-files/reference/omh.html
Normal file
3308
incoming-files/reference/omh.html
Normal file
File diff suppressed because it is too large
Load Diff
1571
incoming-files/reference/opayment.html
Normal file
1571
incoming-files/reference/opayment.html
Normal file
File diff suppressed because it is too large
Load Diff
1377
incoming-files/sample style.css
Normal file
1377
incoming-files/sample style.css
Normal file
File diff suppressed because it is too large
Load Diff
931
incoming-files/seat/center_chair_people_map(2).html
Normal file
931
incoming-files/seat/center_chair_people_map(2).html
Normal file
@@ -0,0 +1,931 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>center chair people map</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
button.alt {
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mapper {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(94vw, 1320px);
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 239, 247, 0.95);
|
||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hidden-off {
|
||||
display: none !important;
|
||||
}
|
||||
.mapper-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||
font-size: 12px;
|
||||
color: #51607a;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.mapper-head strong {
|
||||
display: block;
|
||||
color: #17243b;
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mapper-head .alt {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-chart {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.org-top {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
.org-top-title {
|
||||
background: #1e2f4d;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
padding: 16px 12px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.org-top-members {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.org-teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.org-team {
|
||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.95);
|
||||
min-width: 0;
|
||||
}
|
||||
.org-team h4 {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
color: #21324e;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||
background: rgba(240, 245, 252, 0.96);
|
||||
}
|
||||
.org-members {
|
||||
padding: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.org-person {
|
||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-person.active {
|
||||
border-color: rgba(15,118,110,0.6);
|
||||
background: rgba(15,118,110,0.11);
|
||||
}
|
||||
.org-person.assigned {
|
||||
border-color: rgba(37,99,235,0.5);
|
||||
background: rgba(37,99,235,0.1);
|
||||
}
|
||||
.org-person strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #15233a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.org-person small {
|
||||
display: block;
|
||||
color: #5a6a86;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.mapper {
|
||||
top: 72px;
|
||||
width: min(96vw, 920px);
|
||||
max-height: 58vh;
|
||||
}
|
||||
.viewer-actions {
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mapper-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.org-top-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.org-teams {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
min-width: 170px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17,24,39,0.94);
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(12px, 12px);
|
||||
transition: opacity 120ms ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||
</div>
|
||||
<aside class="mapper hidden-off">
|
||||
<div class="mapper-head">
|
||||
<div id="mapper-status">
|
||||
<strong>조직 현황</strong>
|
||||
<span>선택 인원 없음</span>
|
||||
</div>
|
||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||
</div>
|
||||
<div class="org-chart" id="org-chart"></div>
|
||||
</aside>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload.js?v=20260330a"></script>
|
||||
<script>
|
||||
const DATA = window.CHAIR_MAP_DATA;
|
||||
function decodeSegments(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Int32Array(bytes.buffer);
|
||||
}
|
||||
const bgTileRanges = DATA.bgTileRanges;
|
||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||
key, name, kind, start, count
|
||||
}));
|
||||
const meta = DATA.meta;
|
||||
const world = meta.headerBounds;
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
const scaleChip = document.getElementById("scale-chip");
|
||||
const hoverChip = document.getElementById("hover-chip");
|
||||
const STORAGE_KEY = "ptc-chair-selection";
|
||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||
const orgChartEl = document.getElementById("org-chart");
|
||||
const mapperStatus = document.getElementById("mapper-status");
|
||||
// Prevent stale auto-highlights from previous sessions.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||
const placed = new Set();
|
||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||
let chairAssignments = {};
|
||||
let activePersonId = null;
|
||||
const ORG_TEMPLATE = {
|
||||
top: {
|
||||
name: "총괄기획실",
|
||||
count: 53,
|
||||
members: [
|
||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||
],
|
||||
};
|
||||
const chairGeometry = chairs.map((chair) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const path = new Path2D();
|
||||
const hitSegments = new Float32Array(chair.count * 4);
|
||||
let segCursor = 0;
|
||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = chairSegValues[offset] / 10;
|
||||
const y1 = chairSegValues[offset + 1] / 10;
|
||||
const x2 = chairSegValues[offset + 2] / 10;
|
||||
const y2 = chairSegValues[offset + 3] / 10;
|
||||
path.moveTo(x1, y1);
|
||||
path.lineTo(x2, y2);
|
||||
hitSegments[segCursor] = x1;
|
||||
hitSegments[segCursor + 1] = y1;
|
||||
hitSegments[segCursor + 2] = x2;
|
||||
hitSegments[segCursor + 3] = y2;
|
||||
segCursor += 4;
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return {
|
||||
...chair,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||
path,
|
||||
hitSegments,
|
||||
};
|
||||
});
|
||||
function renumberChairKeys(chairItems) {
|
||||
if (!chairItems.length) return;
|
||||
const heights = chairItems
|
||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||
.sort((a, b) => a - b);
|
||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||
|
||||
const sorted = [...chairItems].sort((a, b) => {
|
||||
const ay = (a.minY + a.maxY) * 0.5;
|
||||
const by = (b.minY + b.maxY) * 0.5;
|
||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||
const ax = (a.minX + a.maxX) * 0.5;
|
||||
const bx = (b.minX + b.maxX) * 0.5;
|
||||
return ax - bx; // left -> right
|
||||
});
|
||||
|
||||
sorted.forEach((chair, index) => {
|
||||
chair.key = String(index + 1);
|
||||
chair.seatNo = index + 1;
|
||||
});
|
||||
}
|
||||
renumberChairKeys(chairGeometry);
|
||||
const PICK_GRID_SIZE = 1800;
|
||||
const chairPickGrid = new Map();
|
||||
function pickGridKey(gx, gy) {
|
||||
return `${gx},${gy}`;
|
||||
}
|
||||
chairGeometry.forEach((chair, index) => {
|
||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const key = pickGridKey(gx, gy);
|
||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||
chairPickGrid.get(key).push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
let pixelRatio = window.devicePixelRatio || 1;
|
||||
let pointer = { x: 0, y: 0 };
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let hovered = null;
|
||||
let rafPending = false;
|
||||
|
||||
function normalizePeople(raw) {
|
||||
return raw
|
||||
.map((person, index) => {
|
||||
if (!person || !person.name) return null;
|
||||
return {
|
||||
id: person.id || `person-${index + 1}`,
|
||||
name: String(person.name).trim(),
|
||||
dept: String(person.dept || "").trim(),
|
||||
title: String(person.title || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createTemplatePeople() {
|
||||
const generated = [];
|
||||
let seq = 1;
|
||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name: member.name,
|
||||
dept: member.dept,
|
||||
title: member.title,
|
||||
});
|
||||
});
|
||||
ORG_TEMPLATE.teams.forEach((team) => {
|
||||
team.members.forEach((name) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name,
|
||||
dept: team.name,
|
||||
title: "선임",
|
||||
});
|
||||
});
|
||||
});
|
||||
return generated;
|
||||
}
|
||||
|
||||
people = normalizePeople(people);
|
||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||
if (!templateReady) {
|
||||
people = createTemplatePeople();
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||
chairAssignments = Object.fromEntries(
|
||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||
))
|
||||
);
|
||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||
|
||||
function persistPeople() {
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
|
||||
function persistAssignments() {
|
||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||
}
|
||||
|
||||
function persistActivePerson() {
|
||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||
}
|
||||
|
||||
function assignmentCount() {
|
||||
return Object.keys(chairAssignments).length;
|
||||
}
|
||||
|
||||
function getPersonById(id) {
|
||||
return people.find((person) => person.id === id) || null;
|
||||
}
|
||||
|
||||
function getChairByPerson(personId) {
|
||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||
if (assignedPersonId === personId) return chairKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPeopleList() {
|
||||
const activePerson = getPersonById(activePersonId);
|
||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||
|
||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||
const personCard = (person, roleText) => {
|
||||
if (!person) return "";
|
||||
const chairKey = getChairByPerson(person.id);
|
||||
const assignedClass = chairKey ? " assigned" : "";
|
||||
const activeClass = person.id === activePersonId ? " active" : "";
|
||||
return `
|
||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||
<strong>${person.name}</strong>
|
||||
<small>${person.title || roleText || "-"}</small>
|
||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||
</article>
|
||||
`;
|
||||
};
|
||||
|
||||
const topHtml = ORG_TEMPLATE.top.members
|
||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||
.join("");
|
||||
|
||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||
const membersHtml = team.members
|
||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||
.join("");
|
||||
return `
|
||||
<section class="org-team">
|
||||
<h4>${team.name} (${team.count})</h4>
|
||||
<div class="org-members">${membersHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
orgChartEl.innerHTML = `
|
||||
<section class="org-top">
|
||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||
<div class="org-top-members">${topHtml}</div>
|
||||
</section>
|
||||
<section class="org-teams">${teamsHtml}</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return {
|
||||
x: x * camera.scale + camera.offsetX,
|
||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function screenToWorld(x, y) {
|
||||
return {
|
||||
x: (x - camera.offsetX) / camera.scale,
|
||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * pixelRatio);
|
||||
canvas.height = Math.round(rect.height * pixelRatio);
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
fit();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = world.maxX - world.minX;
|
||||
const height = world.maxY - world.minY;
|
||||
const pad = 36;
|
||||
const scaleX = (rect.width - pad * 2) / width;
|
||||
const scaleY = (rect.height - pad * 2) / height;
|
||||
camera.scale = Math.min(scaleX, scaleY);
|
||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function drawGrid(width, height) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 120; x < width; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 120; y < height; y += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pickChair(screenX, screenY) {
|
||||
const threshold = 12;
|
||||
const pointerWorld = screenToWorld(screenX, screenY);
|
||||
const thresholdWorld = threshold / camera.scale;
|
||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const candidateIndexes = [];
|
||||
const seen = new Set();
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||
if (!candidates) continue;
|
||||
for (const index of candidates) {
|
||||
if (seen.has(index)) continue;
|
||||
seen.add(index);
|
||||
candidateIndexes.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = null;
|
||||
for (const index of candidateIndexes) {
|
||||
const chair = chairGeometry[index];
|
||||
if (
|
||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||
pointerWorld.y > chair.maxY + thresholdWorld
|
||||
) continue;
|
||||
let distSq = Infinity;
|
||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||
const x1 = chair.hitSegments[i];
|
||||
const y1 = chair.hitSegments[i + 1];
|
||||
const x2 = chair.hitSegments[i + 2];
|
||||
const y2 = chair.hitSegments[i + 3];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let segDistSq;
|
||||
if (len2 === 0) {
|
||||
const px = pointerWorld.x - x1;
|
||||
const py = pointerWorld.y - y1;
|
||||
segDistSq = px * px + py * py;
|
||||
} else {
|
||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const lx = x1 + t * dx;
|
||||
const ly = y1 + t * dy;
|
||||
const px = pointerWorld.x - lx;
|
||||
const py = pointerWorld.y - ly;
|
||||
segDistSq = px * px + py * py;
|
||||
}
|
||||
if (segDistSq < distSq) distSq = segDistSq;
|
||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||
}
|
||||
if (distSq > thresholdWorldSq) continue;
|
||||
const dist = Math.sqrt(distSq) * camera.scale;
|
||||
|
||||
if (!best) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
const distGap = dist - best.dist;
|
||||
if (distGap < -0.75) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(distGap) <= 2) {
|
||||
const areaGap = chair.area - best.chair.area;
|
||||
if (areaGap < -1) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(areaGap) <= 1 &&
|
||||
chair.kind === "block" &&
|
||||
best.chair.kind !== "block"
|
||||
) {
|
||||
best = { chair, dist };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.chair : null;
|
||||
}
|
||||
|
||||
function renderTooltip() {
|
||||
if (!hovered) {
|
||||
tooltip.classList.remove("visible");
|
||||
hoverChip.textContent = "chair hover: none";
|
||||
return;
|
||||
}
|
||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||
tooltip.innerHTML = `
|
||||
<strong>${hovered.name}</strong>
|
||||
<div>chair key: ${hovered.key}</div>
|
||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||
`;
|
||||
tooltip.style.left = `${pointer.x + 14}px`;
|
||||
tooltip.style.top = `${pointer.y + 14}px`;
|
||||
tooltip.classList.add("visible");
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorldTransform() {
|
||||
ctx.setTransform(
|
||||
pixelRatio * camera.scale,
|
||||
0,
|
||||
0,
|
||||
-pixelRatio * camera.scale,
|
||||
pixelRatio * camera.offsetX,
|
||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||
);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
drawGrid(rect.width, rect.height);
|
||||
const viewA = screenToWorld(0, rect.height);
|
||||
const viewB = screenToWorld(rect.width, 0);
|
||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||
ctx.lineWidth = 1 / camera.scale;
|
||||
const tileSize = meta.backgroundTileSize;
|
||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||
const range = bgTileRanges[`${tx},${ty}`];
|
||||
if (!range) continue;
|
||||
const start = range[0];
|
||||
const count = range[1];
|
||||
for (let i = start; i < start + count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = bgSegValues[offset] / 10;
|
||||
const y1 = bgSegValues[offset + 1] / 10;
|
||||
const x2 = bgSegValues[offset + 2] / 10;
|
||||
const y2 = bgSegValues[offset + 3] / 10;
|
||||
if (
|
||||
Math.max(x1, x2) < viewMinX ||
|
||||
Math.min(x1, x2) > viewMaxX ||
|
||||
Math.max(y1, y2) < viewMinY ||
|
||||
Math.min(y1, y2) > viewMaxY
|
||||
) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.lineWidth = 1.45 / camera.scale;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const chair of chairGeometry) {
|
||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||
const active = hovered && hovered.key === chair.key;
|
||||
const selected = placed.has(chair.key);
|
||||
const assignedPersonId = chairAssignments[chair.key];
|
||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||
const assigned = Boolean(assignedPersonId);
|
||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||
ctx.strokeStyle = activePersonChair
|
||||
? "rgba(234, 179, 8, 1)"
|
||||
: assigned
|
||||
? "rgba(37, 99, 235, 0.98)"
|
||||
: selected
|
||||
? "rgba(220, 38, 38, 0.98)"
|
||||
: active
|
||||
? "rgba(15, 118, 110, 0.98)"
|
||||
: chair.kind === "group"
|
||||
? "rgba(16, 134, 149, 0.74)"
|
||||
: "rgba(21, 149, 142, 0.8)";
|
||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||
ctx.stroke(chair.path);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||
renderTooltip();
|
||||
}
|
||||
|
||||
function persistPlaced() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||
canvas.classList.add("dragging");
|
||||
});
|
||||
|
||||
window.addEventListener("pointerup", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (move < 4) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (picked) {
|
||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||
else placed.add(picked.key);
|
||||
persistPlaced();
|
||||
if (activePersonId) {
|
||||
const currentChair = getChairByPerson(activePersonId);
|
||||
if (chairAssignments[picked.key] === activePersonId) {
|
||||
delete chairAssignments[picked.key];
|
||||
} else {
|
||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||
chairAssignments[picked.key] = activePersonId;
|
||||
}
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
canvas.classList.remove("dragging");
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
if (dragging && dragStart) {
|
||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||
}
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const before = screenToWorld(mx, my);
|
||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||
const after = worldToScreen(before.x, before.y);
|
||||
camera.offsetX += mx - after.x;
|
||||
camera.offsetY += my - after.y;
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||
placed.clear();
|
||||
persistPlaced();
|
||||
requestDraw();
|
||||
});
|
||||
clearAssignBtn.addEventListener("click", () => {
|
||||
chairAssignments = {};
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
orgChartEl.addEventListener("click", (event) => {
|
||||
const item = event.target.closest(".org-person[data-person-id]");
|
||||
if (!item) return;
|
||||
const personId = item.getAttribute("data-person-id");
|
||||
activePersonId = personId === activePersonId ? null : personId;
|
||||
persistActivePerson();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
window.addEventListener("resize", resize);
|
||||
renderPeopleList();
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
931
incoming-files/seat/center_chair_people_map.html
Normal file
931
incoming-files/seat/center_chair_people_map.html
Normal file
@@ -0,0 +1,931 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>center chair people map</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
button.alt {
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mapper {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(94vw, 1320px);
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 239, 247, 0.95);
|
||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hidden-off {
|
||||
display: none !important;
|
||||
}
|
||||
.mapper-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||
font-size: 12px;
|
||||
color: #51607a;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.mapper-head strong {
|
||||
display: block;
|
||||
color: #17243b;
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mapper-head .alt {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-chart {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.org-top {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
.org-top-title {
|
||||
background: #1e2f4d;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
padding: 16px 12px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.org-top-members {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.org-teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.org-team {
|
||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.95);
|
||||
min-width: 0;
|
||||
}
|
||||
.org-team h4 {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
color: #21324e;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||
background: rgba(240, 245, 252, 0.96);
|
||||
}
|
||||
.org-members {
|
||||
padding: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.org-person {
|
||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-person.active {
|
||||
border-color: rgba(15,118,110,0.6);
|
||||
background: rgba(15,118,110,0.11);
|
||||
}
|
||||
.org-person.assigned {
|
||||
border-color: rgba(37,99,235,0.5);
|
||||
background: rgba(37,99,235,0.1);
|
||||
}
|
||||
.org-person strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #15233a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.org-person small {
|
||||
display: block;
|
||||
color: #5a6a86;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.mapper {
|
||||
top: 72px;
|
||||
width: min(96vw, 920px);
|
||||
max-height: 58vh;
|
||||
}
|
||||
.viewer-actions {
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mapper-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.org-top-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.org-teams {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
min-width: 170px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17,24,39,0.94);
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(12px, 12px);
|
||||
transition: opacity 120ms ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||
</div>
|
||||
<aside class="mapper hidden-off">
|
||||
<div class="mapper-head">
|
||||
<div id="mapper-status">
|
||||
<strong>조직 현황</strong>
|
||||
<span>선택 인원 없음</span>
|
||||
</div>
|
||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||
</div>
|
||||
<div class="org-chart" id="org-chart"></div>
|
||||
</aside>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload.js"></script>
|
||||
<script>
|
||||
const DATA = window.CHAIR_MAP_DATA;
|
||||
function decodeSegments(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Int32Array(bytes.buffer);
|
||||
}
|
||||
const bgTileRanges = DATA.bgTileRanges;
|
||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||
key, name, kind, start, count
|
||||
}));
|
||||
const meta = DATA.meta;
|
||||
const world = meta.headerBounds;
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
const scaleChip = document.getElementById("scale-chip");
|
||||
const hoverChip = document.getElementById("hover-chip");
|
||||
const STORAGE_KEY = "ptc-chair-selection";
|
||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||
const orgChartEl = document.getElementById("org-chart");
|
||||
const mapperStatus = document.getElementById("mapper-status");
|
||||
// Prevent stale auto-highlights from previous sessions.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||
const placed = new Set();
|
||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||
let chairAssignments = {};
|
||||
let activePersonId = null;
|
||||
const ORG_TEMPLATE = {
|
||||
top: {
|
||||
name: "총괄기획실",
|
||||
count: 53,
|
||||
members: [
|
||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||
],
|
||||
};
|
||||
const chairGeometry = chairs.map((chair) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const path = new Path2D();
|
||||
const hitSegments = new Float32Array(chair.count * 4);
|
||||
let segCursor = 0;
|
||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = chairSegValues[offset] / 10;
|
||||
const y1 = chairSegValues[offset + 1] / 10;
|
||||
const x2 = chairSegValues[offset + 2] / 10;
|
||||
const y2 = chairSegValues[offset + 3] / 10;
|
||||
path.moveTo(x1, y1);
|
||||
path.lineTo(x2, y2);
|
||||
hitSegments[segCursor] = x1;
|
||||
hitSegments[segCursor + 1] = y1;
|
||||
hitSegments[segCursor + 2] = x2;
|
||||
hitSegments[segCursor + 3] = y2;
|
||||
segCursor += 4;
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return {
|
||||
...chair,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||
path,
|
||||
hitSegments,
|
||||
};
|
||||
});
|
||||
function renumberChairKeys(chairItems) {
|
||||
if (!chairItems.length) return;
|
||||
const heights = chairItems
|
||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||
.sort((a, b) => a - b);
|
||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||
|
||||
const sorted = [...chairItems].sort((a, b) => {
|
||||
const ay = (a.minY + a.maxY) * 0.5;
|
||||
const by = (b.minY + b.maxY) * 0.5;
|
||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||
const ax = (a.minX + a.maxX) * 0.5;
|
||||
const bx = (b.minX + b.maxX) * 0.5;
|
||||
return ax - bx; // left -> right
|
||||
});
|
||||
|
||||
sorted.forEach((chair, index) => {
|
||||
chair.key = String(index + 1);
|
||||
chair.seatNo = index + 1;
|
||||
});
|
||||
}
|
||||
renumberChairKeys(chairGeometry);
|
||||
const PICK_GRID_SIZE = 1800;
|
||||
const chairPickGrid = new Map();
|
||||
function pickGridKey(gx, gy) {
|
||||
return `${gx},${gy}`;
|
||||
}
|
||||
chairGeometry.forEach((chair, index) => {
|
||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const key = pickGridKey(gx, gy);
|
||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||
chairPickGrid.get(key).push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
let pixelRatio = window.devicePixelRatio || 1;
|
||||
let pointer = { x: 0, y: 0 };
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let hovered = null;
|
||||
let rafPending = false;
|
||||
|
||||
function normalizePeople(raw) {
|
||||
return raw
|
||||
.map((person, index) => {
|
||||
if (!person || !person.name) return null;
|
||||
return {
|
||||
id: person.id || `person-${index + 1}`,
|
||||
name: String(person.name).trim(),
|
||||
dept: String(person.dept || "").trim(),
|
||||
title: String(person.title || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createTemplatePeople() {
|
||||
const generated = [];
|
||||
let seq = 1;
|
||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name: member.name,
|
||||
dept: member.dept,
|
||||
title: member.title,
|
||||
});
|
||||
});
|
||||
ORG_TEMPLATE.teams.forEach((team) => {
|
||||
team.members.forEach((name) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name,
|
||||
dept: team.name,
|
||||
title: "선임",
|
||||
});
|
||||
});
|
||||
});
|
||||
return generated;
|
||||
}
|
||||
|
||||
people = normalizePeople(people);
|
||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||
if (!templateReady) {
|
||||
people = createTemplatePeople();
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||
chairAssignments = Object.fromEntries(
|
||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||
))
|
||||
);
|
||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||
|
||||
function persistPeople() {
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
|
||||
function persistAssignments() {
|
||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||
}
|
||||
|
||||
function persistActivePerson() {
|
||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||
}
|
||||
|
||||
function assignmentCount() {
|
||||
return Object.keys(chairAssignments).length;
|
||||
}
|
||||
|
||||
function getPersonById(id) {
|
||||
return people.find((person) => person.id === id) || null;
|
||||
}
|
||||
|
||||
function getChairByPerson(personId) {
|
||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||
if (assignedPersonId === personId) return chairKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPeopleList() {
|
||||
const activePerson = getPersonById(activePersonId);
|
||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||
|
||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||
const personCard = (person, roleText) => {
|
||||
if (!person) return "";
|
||||
const chairKey = getChairByPerson(person.id);
|
||||
const assignedClass = chairKey ? " assigned" : "";
|
||||
const activeClass = person.id === activePersonId ? " active" : "";
|
||||
return `
|
||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||
<strong>${person.name}</strong>
|
||||
<small>${person.title || roleText || "-"}</small>
|
||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||
</article>
|
||||
`;
|
||||
};
|
||||
|
||||
const topHtml = ORG_TEMPLATE.top.members
|
||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||
.join("");
|
||||
|
||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||
const membersHtml = team.members
|
||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||
.join("");
|
||||
return `
|
||||
<section class="org-team">
|
||||
<h4>${team.name} (${team.count})</h4>
|
||||
<div class="org-members">${membersHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
orgChartEl.innerHTML = `
|
||||
<section class="org-top">
|
||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||
<div class="org-top-members">${topHtml}</div>
|
||||
</section>
|
||||
<section class="org-teams">${teamsHtml}</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return {
|
||||
x: x * camera.scale + camera.offsetX,
|
||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function screenToWorld(x, y) {
|
||||
return {
|
||||
x: (x - camera.offsetX) / camera.scale,
|
||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * pixelRatio);
|
||||
canvas.height = Math.round(rect.height * pixelRatio);
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
fit();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = world.maxX - world.minX;
|
||||
const height = world.maxY - world.minY;
|
||||
const pad = 36;
|
||||
const scaleX = (rect.width - pad * 2) / width;
|
||||
const scaleY = (rect.height - pad * 2) / height;
|
||||
camera.scale = Math.min(scaleX, scaleY);
|
||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function drawGrid(width, height) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 120; x < width; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 120; y < height; y += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pickChair(screenX, screenY) {
|
||||
const threshold = 12;
|
||||
const pointerWorld = screenToWorld(screenX, screenY);
|
||||
const thresholdWorld = threshold / camera.scale;
|
||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const candidateIndexes = [];
|
||||
const seen = new Set();
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||
if (!candidates) continue;
|
||||
for (const index of candidates) {
|
||||
if (seen.has(index)) continue;
|
||||
seen.add(index);
|
||||
candidateIndexes.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = null;
|
||||
for (const index of candidateIndexes) {
|
||||
const chair = chairGeometry[index];
|
||||
if (
|
||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||
pointerWorld.y > chair.maxY + thresholdWorld
|
||||
) continue;
|
||||
let distSq = Infinity;
|
||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||
const x1 = chair.hitSegments[i];
|
||||
const y1 = chair.hitSegments[i + 1];
|
||||
const x2 = chair.hitSegments[i + 2];
|
||||
const y2 = chair.hitSegments[i + 3];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let segDistSq;
|
||||
if (len2 === 0) {
|
||||
const px = pointerWorld.x - x1;
|
||||
const py = pointerWorld.y - y1;
|
||||
segDistSq = px * px + py * py;
|
||||
} else {
|
||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const lx = x1 + t * dx;
|
||||
const ly = y1 + t * dy;
|
||||
const px = pointerWorld.x - lx;
|
||||
const py = pointerWorld.y - ly;
|
||||
segDistSq = px * px + py * py;
|
||||
}
|
||||
if (segDistSq < distSq) distSq = segDistSq;
|
||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||
}
|
||||
if (distSq > thresholdWorldSq) continue;
|
||||
const dist = Math.sqrt(distSq) * camera.scale;
|
||||
|
||||
if (!best) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
const distGap = dist - best.dist;
|
||||
if (distGap < -0.75) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(distGap) <= 2) {
|
||||
const areaGap = chair.area - best.chair.area;
|
||||
if (areaGap < -1) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(areaGap) <= 1 &&
|
||||
chair.kind === "block" &&
|
||||
best.chair.kind !== "block"
|
||||
) {
|
||||
best = { chair, dist };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.chair : null;
|
||||
}
|
||||
|
||||
function renderTooltip() {
|
||||
if (!hovered) {
|
||||
tooltip.classList.remove("visible");
|
||||
hoverChip.textContent = "chair hover: none";
|
||||
return;
|
||||
}
|
||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||
tooltip.innerHTML = `
|
||||
<strong>${hovered.name}</strong>
|
||||
<div>chair key: ${hovered.key}</div>
|
||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||
`;
|
||||
tooltip.style.left = `${pointer.x + 14}px`;
|
||||
tooltip.style.top = `${pointer.y + 14}px`;
|
||||
tooltip.classList.add("visible");
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorldTransform() {
|
||||
ctx.setTransform(
|
||||
pixelRatio * camera.scale,
|
||||
0,
|
||||
0,
|
||||
-pixelRatio * camera.scale,
|
||||
pixelRatio * camera.offsetX,
|
||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||
);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
drawGrid(rect.width, rect.height);
|
||||
const viewA = screenToWorld(0, rect.height);
|
||||
const viewB = screenToWorld(rect.width, 0);
|
||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||
ctx.lineWidth = 1 / camera.scale;
|
||||
const tileSize = meta.backgroundTileSize;
|
||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||
const range = bgTileRanges[`${tx},${ty}`];
|
||||
if (!range) continue;
|
||||
const start = range[0];
|
||||
const count = range[1];
|
||||
for (let i = start; i < start + count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = bgSegValues[offset] / 10;
|
||||
const y1 = bgSegValues[offset + 1] / 10;
|
||||
const x2 = bgSegValues[offset + 2] / 10;
|
||||
const y2 = bgSegValues[offset + 3] / 10;
|
||||
if (
|
||||
Math.max(x1, x2) < viewMinX ||
|
||||
Math.min(x1, x2) > viewMaxX ||
|
||||
Math.max(y1, y2) < viewMinY ||
|
||||
Math.min(y1, y2) > viewMaxY
|
||||
) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.lineWidth = 1.45 / camera.scale;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const chair of chairGeometry) {
|
||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||
const active = hovered && hovered.key === chair.key;
|
||||
const selected = placed.has(chair.key);
|
||||
const assignedPersonId = chairAssignments[chair.key];
|
||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||
const assigned = Boolean(assignedPersonId);
|
||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||
ctx.strokeStyle = activePersonChair
|
||||
? "rgba(234, 179, 8, 1)"
|
||||
: assigned
|
||||
? "rgba(37, 99, 235, 0.98)"
|
||||
: selected
|
||||
? "rgba(220, 38, 38, 0.98)"
|
||||
: active
|
||||
? "rgba(15, 118, 110, 0.98)"
|
||||
: chair.kind === "group"
|
||||
? "rgba(16, 134, 149, 0.74)"
|
||||
: "rgba(21, 149, 142, 0.8)";
|
||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||
ctx.stroke(chair.path);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||
renderTooltip();
|
||||
}
|
||||
|
||||
function persistPlaced() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||
canvas.classList.add("dragging");
|
||||
});
|
||||
|
||||
window.addEventListener("pointerup", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (move < 4) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (picked) {
|
||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||
else placed.add(picked.key);
|
||||
persistPlaced();
|
||||
if (activePersonId) {
|
||||
const currentChair = getChairByPerson(activePersonId);
|
||||
if (chairAssignments[picked.key] === activePersonId) {
|
||||
delete chairAssignments[picked.key];
|
||||
} else {
|
||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||
chairAssignments[picked.key] = activePersonId;
|
||||
}
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
canvas.classList.remove("dragging");
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
if (dragging && dragStart) {
|
||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||
}
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const before = screenToWorld(mx, my);
|
||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||
const after = worldToScreen(before.x, before.y);
|
||||
camera.offsetX += mx - after.x;
|
||||
camera.offsetY += my - after.y;
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||
placed.clear();
|
||||
persistPlaced();
|
||||
requestDraw();
|
||||
});
|
||||
clearAssignBtn.addEventListener("click", () => {
|
||||
chairAssignments = {};
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
orgChartEl.addEventListener("click", (event) => {
|
||||
const item = event.target.closest(".org-person[data-person-id]");
|
||||
if (!item) return;
|
||||
const personId = item.getAttribute("data-person-id");
|
||||
activePersonId = personId === activePersonId ? null : personId;
|
||||
persistActivePerson();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
window.addEventListener("resize", resize);
|
||||
renderPeopleList();
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
932
incoming-files/seat/center_chair_people_map_6f.html
Normal file
932
incoming-files/seat/center_chair_people_map_6f.html
Normal file
@@ -0,0 +1,932 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>center chair people map 6f</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
button.alt {
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mapper {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(94vw, 1320px);
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 239, 247, 0.95);
|
||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hidden-off {
|
||||
display: none !important;
|
||||
}
|
||||
.mapper-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||
font-size: 12px;
|
||||
color: #51607a;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.mapper-head strong {
|
||||
display: block;
|
||||
color: #17243b;
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mapper-head .alt {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-chart {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.org-top {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
.org-top-title {
|
||||
background: #1e2f4d;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
padding: 16px 12px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.org-top-members {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.org-teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.org-team {
|
||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.95);
|
||||
min-width: 0;
|
||||
}
|
||||
.org-team h4 {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
color: #21324e;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||
background: rgba(240, 245, 252, 0.96);
|
||||
}
|
||||
.org-members {
|
||||
padding: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.org-person {
|
||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-person.active {
|
||||
border-color: rgba(15,118,110,0.6);
|
||||
background: rgba(15,118,110,0.11);
|
||||
}
|
||||
.org-person.assigned {
|
||||
border-color: rgba(37,99,235,0.5);
|
||||
background: rgba(37,99,235,0.1);
|
||||
}
|
||||
.org-person strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #15233a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.org-person small {
|
||||
display: block;
|
||||
color: #5a6a86;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.mapper {
|
||||
top: 72px;
|
||||
width: min(96vw, 920px);
|
||||
max-height: 58vh;
|
||||
}
|
||||
.viewer-actions {
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mapper-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.org-top-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.org-teams {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
min-width: 170px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17,24,39,0.94);
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(12px, 12px);
|
||||
transition: opacity 120ms ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||
</div>
|
||||
<aside class="mapper hidden-off">
|
||||
<div class="mapper-head">
|
||||
<div id="mapper-status">
|
||||
<strong>조직 현황</strong>
|
||||
<span>선택 인원 없음</span>
|
||||
</div>
|
||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||
</div>
|
||||
<div class="org-chart" id="org-chart"></div>
|
||||
</aside>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload_6f.js"></script>
|
||||
<script>
|
||||
const DATA = window.CHAIR_MAP_DATA;
|
||||
function decodeSegments(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Int32Array(bytes.buffer);
|
||||
}
|
||||
const bgTileRanges = DATA.bgTileRanges;
|
||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||
key, name, kind, start, count
|
||||
}));
|
||||
const meta = DATA.meta;
|
||||
const world = meta.headerBounds;
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
const scaleChip = document.getElementById("scale-chip");
|
||||
const hoverChip = document.getElementById("hover-chip");
|
||||
const STORAGE_KEY = "ptc-chair-selection";
|
||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||
const orgChartEl = document.getElementById("org-chart");
|
||||
const mapperStatus = document.getElementById("mapper-status");
|
||||
// Prevent stale auto-highlights from previous sessions.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||
const placed = new Set();
|
||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||
let chairAssignments = {};
|
||||
let activePersonId = null;
|
||||
const ORG_TEMPLATE = {
|
||||
top: {
|
||||
name: "총괄기획실",
|
||||
count: 53,
|
||||
members: [
|
||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||
],
|
||||
};
|
||||
const chairGeometry = chairs.map((chair) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const path = new Path2D();
|
||||
const hitSegments = new Float32Array(chair.count * 4);
|
||||
let segCursor = 0;
|
||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = chairSegValues[offset] / 10;
|
||||
const y1 = chairSegValues[offset + 1] / 10;
|
||||
const x2 = chairSegValues[offset + 2] / 10;
|
||||
const y2 = chairSegValues[offset + 3] / 10;
|
||||
path.moveTo(x1, y1);
|
||||
path.lineTo(x2, y2);
|
||||
hitSegments[segCursor] = x1;
|
||||
hitSegments[segCursor + 1] = y1;
|
||||
hitSegments[segCursor + 2] = x2;
|
||||
hitSegments[segCursor + 3] = y2;
|
||||
segCursor += 4;
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return {
|
||||
...chair,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||
path,
|
||||
hitSegments,
|
||||
};
|
||||
});
|
||||
function renumberChairKeys(chairItems) {
|
||||
if (!chairItems.length) return;
|
||||
const heights = chairItems
|
||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||
.sort((a, b) => a - b);
|
||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||
|
||||
const sorted = [...chairItems].sort((a, b) => {
|
||||
const ay = (a.minY + a.maxY) * 0.5;
|
||||
const by = (b.minY + b.maxY) * 0.5;
|
||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||
const ax = (a.minX + a.maxX) * 0.5;
|
||||
const bx = (b.minX + b.maxX) * 0.5;
|
||||
return ax - bx; // left -> right
|
||||
});
|
||||
|
||||
sorted.forEach((chair, index) => {
|
||||
chair.key = String(index + 1);
|
||||
chair.seatNo = index + 1;
|
||||
});
|
||||
}
|
||||
renumberChairKeys(chairGeometry);
|
||||
const PICK_GRID_SIZE = 1800;
|
||||
const chairPickGrid = new Map();
|
||||
function pickGridKey(gx, gy) {
|
||||
return `${gx},${gy}`;
|
||||
}
|
||||
chairGeometry.forEach((chair, index) => {
|
||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const key = pickGridKey(gx, gy);
|
||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||
chairPickGrid.get(key).push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
let pixelRatio = window.devicePixelRatio || 1;
|
||||
let pointer = { x: 0, y: 0 };
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let hovered = null;
|
||||
let rafPending = false;
|
||||
|
||||
function normalizePeople(raw) {
|
||||
return raw
|
||||
.map((person, index) => {
|
||||
if (!person || !person.name) return null;
|
||||
return {
|
||||
id: person.id || `person-${index + 1}`,
|
||||
name: String(person.name).trim(),
|
||||
dept: String(person.dept || "").trim(),
|
||||
title: String(person.title || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createTemplatePeople() {
|
||||
const generated = [];
|
||||
let seq = 1;
|
||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name: member.name,
|
||||
dept: member.dept,
|
||||
title: member.title,
|
||||
});
|
||||
});
|
||||
ORG_TEMPLATE.teams.forEach((team) => {
|
||||
team.members.forEach((name) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name,
|
||||
dept: team.name,
|
||||
title: "선임",
|
||||
});
|
||||
});
|
||||
});
|
||||
return generated;
|
||||
}
|
||||
|
||||
people = normalizePeople(people);
|
||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||
if (!templateReady) {
|
||||
people = createTemplatePeople();
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||
chairAssignments = Object.fromEntries(
|
||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||
))
|
||||
);
|
||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||
|
||||
function persistPeople() {
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
|
||||
function persistAssignments() {
|
||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||
}
|
||||
|
||||
function persistActivePerson() {
|
||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||
}
|
||||
|
||||
function assignmentCount() {
|
||||
return Object.keys(chairAssignments).length;
|
||||
}
|
||||
|
||||
function getPersonById(id) {
|
||||
return people.find((person) => person.id === id) || null;
|
||||
}
|
||||
|
||||
function getChairByPerson(personId) {
|
||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||
if (assignedPersonId === personId) return chairKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPeopleList() {
|
||||
const activePerson = getPersonById(activePersonId);
|
||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||
|
||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||
const personCard = (person, roleText) => {
|
||||
if (!person) return "";
|
||||
const chairKey = getChairByPerson(person.id);
|
||||
const assignedClass = chairKey ? " assigned" : "";
|
||||
const activeClass = person.id === activePersonId ? " active" : "";
|
||||
return `
|
||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||
<strong>${person.name}</strong>
|
||||
<small>${person.title || roleText || "-"}</small>
|
||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||
</article>
|
||||
`;
|
||||
};
|
||||
|
||||
const topHtml = ORG_TEMPLATE.top.members
|
||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||
.join("");
|
||||
|
||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||
const membersHtml = team.members
|
||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||
.join("");
|
||||
return `
|
||||
<section class="org-team">
|
||||
<h4>${team.name} (${team.count})</h4>
|
||||
<div class="org-members">${membersHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
orgChartEl.innerHTML = `
|
||||
<section class="org-top">
|
||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||
<div class="org-top-members">${topHtml}</div>
|
||||
</section>
|
||||
<section class="org-teams">${teamsHtml}</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return {
|
||||
x: x * camera.scale + camera.offsetX,
|
||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function screenToWorld(x, y) {
|
||||
return {
|
||||
x: (x - camera.offsetX) / camera.scale,
|
||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * pixelRatio);
|
||||
canvas.height = Math.round(rect.height * pixelRatio);
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
fit();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = world.maxX - world.minX;
|
||||
const height = world.maxY - world.minY;
|
||||
const pad = 36;
|
||||
const scaleX = (rect.width - pad * 2) / width;
|
||||
const scaleY = (rect.height - pad * 2) / height;
|
||||
camera.scale = Math.min(scaleX, scaleY);
|
||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function drawGrid(width, height) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 120; x < width; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 120; y < height; y += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pickChair(screenX, screenY) {
|
||||
const threshold = 12;
|
||||
const pointerWorld = screenToWorld(screenX, screenY);
|
||||
const thresholdWorld = threshold / camera.scale;
|
||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const candidateIndexes = [];
|
||||
const seen = new Set();
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||
if (!candidates) continue;
|
||||
for (const index of candidates) {
|
||||
if (seen.has(index)) continue;
|
||||
seen.add(index);
|
||||
candidateIndexes.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = null;
|
||||
for (const index of candidateIndexes) {
|
||||
const chair = chairGeometry[index];
|
||||
if (
|
||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||
pointerWorld.y > chair.maxY + thresholdWorld
|
||||
) continue;
|
||||
let distSq = Infinity;
|
||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||
const x1 = chair.hitSegments[i];
|
||||
const y1 = chair.hitSegments[i + 1];
|
||||
const x2 = chair.hitSegments[i + 2];
|
||||
const y2 = chair.hitSegments[i + 3];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let segDistSq;
|
||||
if (len2 === 0) {
|
||||
const px = pointerWorld.x - x1;
|
||||
const py = pointerWorld.y - y1;
|
||||
segDistSq = px * px + py * py;
|
||||
} else {
|
||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const lx = x1 + t * dx;
|
||||
const ly = y1 + t * dy;
|
||||
const px = pointerWorld.x - lx;
|
||||
const py = pointerWorld.y - ly;
|
||||
segDistSq = px * px + py * py;
|
||||
}
|
||||
if (segDistSq < distSq) distSq = segDistSq;
|
||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||
}
|
||||
if (distSq > thresholdWorldSq) continue;
|
||||
const dist = Math.sqrt(distSq) * camera.scale;
|
||||
|
||||
if (!best) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
const distGap = dist - best.dist;
|
||||
if (distGap < -0.75) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(distGap) <= 2) {
|
||||
const areaGap = chair.area - best.chair.area;
|
||||
if (areaGap < -1) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(areaGap) <= 1 &&
|
||||
chair.kind === "block" &&
|
||||
best.chair.kind !== "block"
|
||||
) {
|
||||
best = { chair, dist };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.chair : null;
|
||||
}
|
||||
|
||||
function renderTooltip() {
|
||||
if (!hovered) {
|
||||
tooltip.classList.remove("visible");
|
||||
hoverChip.textContent = "chair hover: none";
|
||||
return;
|
||||
}
|
||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||
tooltip.innerHTML = `
|
||||
<strong>${hovered.name}</strong>
|
||||
<div>chair key: ${hovered.key}</div>
|
||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||
`;
|
||||
tooltip.style.left = `${pointer.x + 14}px`;
|
||||
tooltip.style.top = `${pointer.y + 14}px`;
|
||||
tooltip.classList.add("visible");
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorldTransform() {
|
||||
ctx.setTransform(
|
||||
pixelRatio * camera.scale,
|
||||
0,
|
||||
0,
|
||||
-pixelRatio * camera.scale,
|
||||
pixelRatio * camera.offsetX,
|
||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||
);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
drawGrid(rect.width, rect.height);
|
||||
const viewA = screenToWorld(0, rect.height);
|
||||
const viewB = screenToWorld(rect.width, 0);
|
||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||
ctx.lineWidth = 1 / camera.scale;
|
||||
const tileSize = meta.backgroundTileSize;
|
||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||
const range = bgTileRanges[`${tx},${ty}`];
|
||||
if (!range) continue;
|
||||
const start = range[0];
|
||||
const count = range[1];
|
||||
for (let i = start; i < start + count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = bgSegValues[offset] / 10;
|
||||
const y1 = bgSegValues[offset + 1] / 10;
|
||||
const x2 = bgSegValues[offset + 2] / 10;
|
||||
const y2 = bgSegValues[offset + 3] / 10;
|
||||
if (
|
||||
Math.max(x1, x2) < viewMinX ||
|
||||
Math.min(x1, x2) > viewMaxX ||
|
||||
Math.max(y1, y2) < viewMinY ||
|
||||
Math.min(y1, y2) > viewMaxY
|
||||
) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.lineWidth = 1.45 / camera.scale;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const chair of chairGeometry) {
|
||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||
const active = hovered && hovered.key === chair.key;
|
||||
const selected = placed.has(chair.key);
|
||||
const assignedPersonId = chairAssignments[chair.key];
|
||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||
const assigned = Boolean(assignedPersonId);
|
||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||
ctx.strokeStyle = activePersonChair
|
||||
? "rgba(234, 179, 8, 1)"
|
||||
: assigned
|
||||
? "rgba(37, 99, 235, 0.98)"
|
||||
: selected
|
||||
? "rgba(220, 38, 38, 0.98)"
|
||||
: active
|
||||
? "rgba(15, 118, 110, 0.98)"
|
||||
: chair.kind === "group"
|
||||
? "rgba(16, 134, 149, 0.74)"
|
||||
: "rgba(21, 149, 142, 0.8)";
|
||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||
ctx.stroke(chair.path);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||
renderTooltip();
|
||||
}
|
||||
|
||||
function persistPlaced() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||
canvas.classList.add("dragging");
|
||||
});
|
||||
|
||||
window.addEventListener("pointerup", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (move < 4) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (picked) {
|
||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||
else placed.add(picked.key);
|
||||
persistPlaced();
|
||||
if (activePersonId) {
|
||||
const currentChair = getChairByPerson(activePersonId);
|
||||
if (chairAssignments[picked.key] === activePersonId) {
|
||||
delete chairAssignments[picked.key];
|
||||
} else {
|
||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||
chairAssignments[picked.key] = activePersonId;
|
||||
}
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
canvas.classList.remove("dragging");
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
if (dragging && dragStart) {
|
||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||
}
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const before = screenToWorld(mx, my);
|
||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||
const after = worldToScreen(before.x, before.y);
|
||||
camera.offsetX += mx - after.x;
|
||||
camera.offsetY += my - after.y;
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||
placed.clear();
|
||||
persistPlaced();
|
||||
requestDraw();
|
||||
});
|
||||
clearAssignBtn.addEventListener("click", () => {
|
||||
chairAssignments = {};
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
orgChartEl.addEventListener("click", (event) => {
|
||||
const item = event.target.closest(".org-person[data-person-id]");
|
||||
if (!item) return;
|
||||
const personId = item.getAttribute("data-person-id");
|
||||
activePersonId = personId === activePersonId ? null : personId;
|
||||
persistActivePerson();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
window.addEventListener("resize", resize);
|
||||
renderPeopleList();
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
932
incoming-files/seat/center_chair_people_map_7f.html
Normal file
932
incoming-files/seat/center_chair_people_map_7f.html
Normal file
@@ -0,0 +1,932 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>center chair people map 7f</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
button.alt {
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mapper {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(94vw, 1320px);
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 239, 247, 0.95);
|
||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hidden-off {
|
||||
display: none !important;
|
||||
}
|
||||
.mapper-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||
font-size: 12px;
|
||||
color: #51607a;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.mapper-head strong {
|
||||
display: block;
|
||||
color: #17243b;
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mapper-head .alt {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-chart {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.org-top {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
.org-top-title {
|
||||
background: #1e2f4d;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
padding: 16px 12px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.org-top-members {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.org-teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.org-team {
|
||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.95);
|
||||
min-width: 0;
|
||||
}
|
||||
.org-team h4 {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
color: #21324e;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||
background: rgba(240, 245, 252, 0.96);
|
||||
}
|
||||
.org-members {
|
||||
padding: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.org-person {
|
||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-person.active {
|
||||
border-color: rgba(15,118,110,0.6);
|
||||
background: rgba(15,118,110,0.11);
|
||||
}
|
||||
.org-person.assigned {
|
||||
border-color: rgba(37,99,235,0.5);
|
||||
background: rgba(37,99,235,0.1);
|
||||
}
|
||||
.org-person strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #15233a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.org-person small {
|
||||
display: block;
|
||||
color: #5a6a86;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.mapper {
|
||||
top: 72px;
|
||||
width: min(96vw, 920px);
|
||||
max-height: 58vh;
|
||||
}
|
||||
.viewer-actions {
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mapper-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.org-top-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.org-teams {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
min-width: 170px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17,24,39,0.94);
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(12px, 12px);
|
||||
transition: opacity 120ms ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||
</div>
|
||||
<aside class="mapper hidden-off">
|
||||
<div class="mapper-head">
|
||||
<div id="mapper-status">
|
||||
<strong>조직 현황</strong>
|
||||
<span>선택 인원 없음</span>
|
||||
</div>
|
||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||
</div>
|
||||
<div class="org-chart" id="org-chart"></div>
|
||||
</aside>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload_7f.js"></script>
|
||||
<script>
|
||||
const DATA = window.CHAIR_MAP_DATA;
|
||||
function decodeSegments(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Int32Array(bytes.buffer);
|
||||
}
|
||||
const bgTileRanges = DATA.bgTileRanges;
|
||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||
key, name, kind, start, count
|
||||
}));
|
||||
const meta = DATA.meta;
|
||||
const world = meta.headerBounds;
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
const scaleChip = document.getElementById("scale-chip");
|
||||
const hoverChip = document.getElementById("hover-chip");
|
||||
const STORAGE_KEY = "ptc-chair-selection";
|
||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||
const orgChartEl = document.getElementById("org-chart");
|
||||
const mapperStatus = document.getElementById("mapper-status");
|
||||
// Prevent stale auto-highlights from previous sessions.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||
const placed = new Set();
|
||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||
let chairAssignments = {};
|
||||
let activePersonId = null;
|
||||
const ORG_TEMPLATE = {
|
||||
top: {
|
||||
name: "총괄기획실",
|
||||
count: 53,
|
||||
members: [
|
||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||
],
|
||||
};
|
||||
const chairGeometry = chairs.map((chair) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const path = new Path2D();
|
||||
const hitSegments = new Float32Array(chair.count * 4);
|
||||
let segCursor = 0;
|
||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = chairSegValues[offset] / 10;
|
||||
const y1 = chairSegValues[offset + 1] / 10;
|
||||
const x2 = chairSegValues[offset + 2] / 10;
|
||||
const y2 = chairSegValues[offset + 3] / 10;
|
||||
path.moveTo(x1, y1);
|
||||
path.lineTo(x2, y2);
|
||||
hitSegments[segCursor] = x1;
|
||||
hitSegments[segCursor + 1] = y1;
|
||||
hitSegments[segCursor + 2] = x2;
|
||||
hitSegments[segCursor + 3] = y2;
|
||||
segCursor += 4;
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return {
|
||||
...chair,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||
path,
|
||||
hitSegments,
|
||||
};
|
||||
});
|
||||
function renumberChairKeys(chairItems) {
|
||||
if (!chairItems.length) return;
|
||||
const heights = chairItems
|
||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||
.sort((a, b) => a - b);
|
||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||
|
||||
const sorted = [...chairItems].sort((a, b) => {
|
||||
const ay = (a.minY + a.maxY) * 0.5;
|
||||
const by = (b.minY + b.maxY) * 0.5;
|
||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||
const ax = (a.minX + a.maxX) * 0.5;
|
||||
const bx = (b.minX + b.maxX) * 0.5;
|
||||
return ax - bx; // left -> right
|
||||
});
|
||||
|
||||
sorted.forEach((chair, index) => {
|
||||
chair.key = String(index + 1);
|
||||
chair.seatNo = index + 1;
|
||||
});
|
||||
}
|
||||
renumberChairKeys(chairGeometry);
|
||||
const PICK_GRID_SIZE = 1800;
|
||||
const chairPickGrid = new Map();
|
||||
function pickGridKey(gx, gy) {
|
||||
return `${gx},${gy}`;
|
||||
}
|
||||
chairGeometry.forEach((chair, index) => {
|
||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const key = pickGridKey(gx, gy);
|
||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||
chairPickGrid.get(key).push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
let pixelRatio = window.devicePixelRatio || 1;
|
||||
let pointer = { x: 0, y: 0 };
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let hovered = null;
|
||||
let rafPending = false;
|
||||
|
||||
function normalizePeople(raw) {
|
||||
return raw
|
||||
.map((person, index) => {
|
||||
if (!person || !person.name) return null;
|
||||
return {
|
||||
id: person.id || `person-${index + 1}`,
|
||||
name: String(person.name).trim(),
|
||||
dept: String(person.dept || "").trim(),
|
||||
title: String(person.title || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createTemplatePeople() {
|
||||
const generated = [];
|
||||
let seq = 1;
|
||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name: member.name,
|
||||
dept: member.dept,
|
||||
title: member.title,
|
||||
});
|
||||
});
|
||||
ORG_TEMPLATE.teams.forEach((team) => {
|
||||
team.members.forEach((name) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name,
|
||||
dept: team.name,
|
||||
title: "선임",
|
||||
});
|
||||
});
|
||||
});
|
||||
return generated;
|
||||
}
|
||||
|
||||
people = normalizePeople(people);
|
||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||
if (!templateReady) {
|
||||
people = createTemplatePeople();
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||
chairAssignments = Object.fromEntries(
|
||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||
))
|
||||
);
|
||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||
|
||||
function persistPeople() {
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
|
||||
function persistAssignments() {
|
||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||
}
|
||||
|
||||
function persistActivePerson() {
|
||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||
}
|
||||
|
||||
function assignmentCount() {
|
||||
return Object.keys(chairAssignments).length;
|
||||
}
|
||||
|
||||
function getPersonById(id) {
|
||||
return people.find((person) => person.id === id) || null;
|
||||
}
|
||||
|
||||
function getChairByPerson(personId) {
|
||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||
if (assignedPersonId === personId) return chairKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPeopleList() {
|
||||
const activePerson = getPersonById(activePersonId);
|
||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||
|
||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||
const personCard = (person, roleText) => {
|
||||
if (!person) return "";
|
||||
const chairKey = getChairByPerson(person.id);
|
||||
const assignedClass = chairKey ? " assigned" : "";
|
||||
const activeClass = person.id === activePersonId ? " active" : "";
|
||||
return `
|
||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||
<strong>${person.name}</strong>
|
||||
<small>${person.title || roleText || "-"}</small>
|
||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||
</article>
|
||||
`;
|
||||
};
|
||||
|
||||
const topHtml = ORG_TEMPLATE.top.members
|
||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||
.join("");
|
||||
|
||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||
const membersHtml = team.members
|
||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||
.join("");
|
||||
return `
|
||||
<section class="org-team">
|
||||
<h4>${team.name} (${team.count})</h4>
|
||||
<div class="org-members">${membersHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
orgChartEl.innerHTML = `
|
||||
<section class="org-top">
|
||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||
<div class="org-top-members">${topHtml}</div>
|
||||
</section>
|
||||
<section class="org-teams">${teamsHtml}</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return {
|
||||
x: x * camera.scale + camera.offsetX,
|
||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function screenToWorld(x, y) {
|
||||
return {
|
||||
x: (x - camera.offsetX) / camera.scale,
|
||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * pixelRatio);
|
||||
canvas.height = Math.round(rect.height * pixelRatio);
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
fit();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = world.maxX - world.minX;
|
||||
const height = world.maxY - world.minY;
|
||||
const pad = 36;
|
||||
const scaleX = (rect.width - pad * 2) / width;
|
||||
const scaleY = (rect.height - pad * 2) / height;
|
||||
camera.scale = Math.min(scaleX, scaleY);
|
||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function drawGrid(width, height) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 120; x < width; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 120; y < height; y += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pickChair(screenX, screenY) {
|
||||
const threshold = 12;
|
||||
const pointerWorld = screenToWorld(screenX, screenY);
|
||||
const thresholdWorld = threshold / camera.scale;
|
||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const candidateIndexes = [];
|
||||
const seen = new Set();
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||
if (!candidates) continue;
|
||||
for (const index of candidates) {
|
||||
if (seen.has(index)) continue;
|
||||
seen.add(index);
|
||||
candidateIndexes.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = null;
|
||||
for (const index of candidateIndexes) {
|
||||
const chair = chairGeometry[index];
|
||||
if (
|
||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||
pointerWorld.y > chair.maxY + thresholdWorld
|
||||
) continue;
|
||||
let distSq = Infinity;
|
||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||
const x1 = chair.hitSegments[i];
|
||||
const y1 = chair.hitSegments[i + 1];
|
||||
const x2 = chair.hitSegments[i + 2];
|
||||
const y2 = chair.hitSegments[i + 3];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let segDistSq;
|
||||
if (len2 === 0) {
|
||||
const px = pointerWorld.x - x1;
|
||||
const py = pointerWorld.y - y1;
|
||||
segDistSq = px * px + py * py;
|
||||
} else {
|
||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const lx = x1 + t * dx;
|
||||
const ly = y1 + t * dy;
|
||||
const px = pointerWorld.x - lx;
|
||||
const py = pointerWorld.y - ly;
|
||||
segDistSq = px * px + py * py;
|
||||
}
|
||||
if (segDistSq < distSq) distSq = segDistSq;
|
||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||
}
|
||||
if (distSq > thresholdWorldSq) continue;
|
||||
const dist = Math.sqrt(distSq) * camera.scale;
|
||||
|
||||
if (!best) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
const distGap = dist - best.dist;
|
||||
if (distGap < -0.75) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(distGap) <= 2) {
|
||||
const areaGap = chair.area - best.chair.area;
|
||||
if (areaGap < -1) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(areaGap) <= 1 &&
|
||||
chair.kind === "block" &&
|
||||
best.chair.kind !== "block"
|
||||
) {
|
||||
best = { chair, dist };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.chair : null;
|
||||
}
|
||||
|
||||
function renderTooltip() {
|
||||
if (!hovered) {
|
||||
tooltip.classList.remove("visible");
|
||||
hoverChip.textContent = "chair hover: none";
|
||||
return;
|
||||
}
|
||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||
tooltip.innerHTML = `
|
||||
<strong>${hovered.name}</strong>
|
||||
<div>chair key: ${hovered.key}</div>
|
||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||
`;
|
||||
tooltip.style.left = `${pointer.x + 14}px`;
|
||||
tooltip.style.top = `${pointer.y + 14}px`;
|
||||
tooltip.classList.add("visible");
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorldTransform() {
|
||||
ctx.setTransform(
|
||||
pixelRatio * camera.scale,
|
||||
0,
|
||||
0,
|
||||
-pixelRatio * camera.scale,
|
||||
pixelRatio * camera.offsetX,
|
||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||
);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
drawGrid(rect.width, rect.height);
|
||||
const viewA = screenToWorld(0, rect.height);
|
||||
const viewB = screenToWorld(rect.width, 0);
|
||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||
ctx.lineWidth = 1 / camera.scale;
|
||||
const tileSize = meta.backgroundTileSize;
|
||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||
const range = bgTileRanges[`${tx},${ty}`];
|
||||
if (!range) continue;
|
||||
const start = range[0];
|
||||
const count = range[1];
|
||||
for (let i = start; i < start + count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = bgSegValues[offset] / 10;
|
||||
const y1 = bgSegValues[offset + 1] / 10;
|
||||
const x2 = bgSegValues[offset + 2] / 10;
|
||||
const y2 = bgSegValues[offset + 3] / 10;
|
||||
if (
|
||||
Math.max(x1, x2) < viewMinX ||
|
||||
Math.min(x1, x2) > viewMaxX ||
|
||||
Math.max(y1, y2) < viewMinY ||
|
||||
Math.min(y1, y2) > viewMaxY
|
||||
) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.lineWidth = 1.45 / camera.scale;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const chair of chairGeometry) {
|
||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||
const active = hovered && hovered.key === chair.key;
|
||||
const selected = placed.has(chair.key);
|
||||
const assignedPersonId = chairAssignments[chair.key];
|
||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||
const assigned = Boolean(assignedPersonId);
|
||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||
ctx.strokeStyle = activePersonChair
|
||||
? "rgba(234, 179, 8, 1)"
|
||||
: assigned
|
||||
? "rgba(37, 99, 235, 0.98)"
|
||||
: selected
|
||||
? "rgba(220, 38, 38, 0.98)"
|
||||
: active
|
||||
? "rgba(15, 118, 110, 0.98)"
|
||||
: chair.kind === "group"
|
||||
? "rgba(16, 134, 149, 0.74)"
|
||||
: "rgba(21, 149, 142, 0.8)";
|
||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||
ctx.stroke(chair.path);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||
renderTooltip();
|
||||
}
|
||||
|
||||
function persistPlaced() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||
canvas.classList.add("dragging");
|
||||
});
|
||||
|
||||
window.addEventListener("pointerup", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (move < 4) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (picked) {
|
||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||
else placed.add(picked.key);
|
||||
persistPlaced();
|
||||
if (activePersonId) {
|
||||
const currentChair = getChairByPerson(activePersonId);
|
||||
if (chairAssignments[picked.key] === activePersonId) {
|
||||
delete chairAssignments[picked.key];
|
||||
} else {
|
||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||
chairAssignments[picked.key] = activePersonId;
|
||||
}
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
canvas.classList.remove("dragging");
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
if (dragging && dragStart) {
|
||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||
}
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const before = screenToWorld(mx, my);
|
||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||
const after = worldToScreen(before.x, before.y);
|
||||
camera.offsetX += mx - after.x;
|
||||
camera.offsetY += my - after.y;
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||
placed.clear();
|
||||
persistPlaced();
|
||||
requestDraw();
|
||||
});
|
||||
clearAssignBtn.addEventListener("click", () => {
|
||||
chairAssignments = {};
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
orgChartEl.addEventListener("click", (event) => {
|
||||
const item = event.target.closest(".org-person[data-person-id]");
|
||||
if (!item) return;
|
||||
const personId = item.getAttribute("data-person-id");
|
||||
activePersonId = personId === activePersonId ? null : personId;
|
||||
persistActivePerson();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
window.addEventListener("resize", resize);
|
||||
renderPeopleList();
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1
incoming-files/seat/center_chair_people_payload.js
Normal file
1
incoming-files/seat/center_chair_people_payload.js
Normal file
File diff suppressed because one or more lines are too long
1
incoming-files/seat/center_chair_people_payload_6f.js
Normal file
1
incoming-files/seat/center_chair_people_payload_6f.js
Normal file
File diff suppressed because one or more lines are too long
1
incoming-files/seat/center_chair_people_payload_7f.js
Normal file
1
incoming-files/seat/center_chair_people_payload_7f.js
Normal file
File diff suppressed because one or more lines are too long
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`로 반영한다.
|
||||
- 원본 참고 파일이나 비교용 파일은 이 디렉터리에 두지 않는다.
|
||||
5
incoming-files/served/db-status/README.md
Normal file
5
incoming-files/served/db-status/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## DB Status Served Output
|
||||
|
||||
- 이 디렉터리는 `frontend/apps/db-status` publish 결과물만 둔다.
|
||||
- backend `/admin/db-status`는 여기의 `index.html`만 서빙한다.
|
||||
- 수정은 직접 여기서 하지 말고 `./scripts/publish_db_status_app.sh`를 사용한다.
|
||||
918
incoming-files/served/db-status/index.html
Normal file
918
incoming-files/served/db-status/index.html
Normal file
@@ -0,0 +1,918 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DB 상태</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||||
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||||
color: var(--ds-ink, #2f2419);
|
||||
}
|
||||
.page {
|
||||
max-width: 2000px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(134, 98, 47, 0.14);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||||
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: rgba(76, 58, 35, 0.82);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.kpi {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 252, 247, 0.92);
|
||||
border: 1px solid rgba(140, 110, 59, 0.14);
|
||||
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||||
}
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgba(112, 84, 41, 0.72);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #3d2e1d;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 251, 245, 0.96);
|
||||
border: 1px solid rgba(142, 110, 54, 0.14);
|
||||
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.panel-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.panel-body {
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel-body.tight {
|
||||
padding-top: 0;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 236, 196, 0.8);
|
||||
color: #7a5923;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 10px;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: rgba(104, 79, 40, 0.76);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: rgba(250, 240, 213, 0.34);
|
||||
}
|
||||
.domain-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(90, 122, 94, 0.14);
|
||||
color: #456b4c;
|
||||
}
|
||||
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||||
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||||
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||||
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||||
.group-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
background: rgba(240, 231, 214, 0.95);
|
||||
color: #674d27;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||||
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||||
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||||
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||||
.table-title {
|
||||
font-weight: 800;
|
||||
color: #2f2419;
|
||||
}
|
||||
.table-trigger {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: #2f2419;
|
||||
font-weight: 800;
|
||||
}
|
||||
.table-trigger:hover {
|
||||
color: #80591f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.table-desc {
|
||||
margin-top: 5px;
|
||||
color: rgba(98, 75, 42, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.view-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.view-pill {
|
||||
display: inline-flex;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(86, 119, 93, 0.12);
|
||||
color: #456b4c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.toolbar-input {
|
||||
width: min(360px, 100%);
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(132, 102, 54, 0.16);
|
||||
background: rgba(255, 251, 244, 0.96);
|
||||
color: #2f2419;
|
||||
font: inherit;
|
||||
}
|
||||
.toolbar-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(129, 88, 31, 0.34);
|
||||
box-shadow: 0 0 0 4px rgba(208, 176, 116, 0.16);
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.action-button {
|
||||
border: 1px solid rgba(128, 98, 48, 0.14);
|
||||
background: rgba(250, 240, 213, 0.62);
|
||||
color: #6c4a1a;
|
||||
border-radius: 999px;
|
||||
padding: 9px 14px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-button:hover {
|
||||
background: rgba(244, 228, 186, 0.8);
|
||||
}
|
||||
.action-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.notes {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: rgba(84, 65, 38, 0.84);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.mapping-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.mapping-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 249, 239, 0.92);
|
||||
border: 1px solid rgba(132, 102, 54, 0.12);
|
||||
}
|
||||
.mapping-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.mapping-card p {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(98, 75, 42, 0.74);
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mapping-table-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.preview-meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px 18px 0;
|
||||
}
|
||||
.preview-columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(240, 231, 214, 0.9);
|
||||
color: #634a25;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.column-pill em {
|
||||
font-style: normal;
|
||||
color: rgba(99, 74, 37, 0.68);
|
||||
font-weight: 600;
|
||||
}
|
||||
.preview-table-wrap {
|
||||
overflow: auto;
|
||||
max-height: 520px;
|
||||
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.sticky-head th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(255, 248, 236, 0.98);
|
||||
z-index: 1;
|
||||
}
|
||||
.muted {
|
||||
color: rgba(110, 86, 50, 0.72);
|
||||
}
|
||||
.empty {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: rgba(102, 77, 41, 0.72);
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(44, 31, 16, 0.42);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
.modal-card {
|
||||
width: min(1600px, 100%);
|
||||
max-height: min(88vh, 980px);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 250, 243, 0.98);
|
||||
border: 1px solid rgba(142, 110, 54, 0.18);
|
||||
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 16px;
|
||||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||||
}
|
||||
.modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.modal-close {
|
||||
border: 0;
|
||||
background: rgba(240, 229, 206, 0.9);
|
||||
color: #6d5127;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(225, 208, 174, 0.96);
|
||||
}
|
||||
.modal-body {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: 16px; }
|
||||
.hero { padding: 22px 20px; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
th, td { padding: 10px 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||||
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||||
<p>
|
||||
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||||
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||||
</p>
|
||||
<p>
|
||||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||||
</p>
|
||||
<p>
|
||||
아래 표는 전체 테이블을 보여주고, 오른쪽 패널은 화면별 데이터 소스와 저장 흐름을 운영 관점으로 묶어서 보여줍니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="overview" class="overview"></section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>전체 테이블 현황</h2>
|
||||
<p>현재 운영 DB의 전체 26개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<input id="table-search" class="toolbar-input" type="search" placeholder="테이블명, 설명, 화면명으로 검색" />
|
||||
<div class="toolbar-actions">
|
||||
<span id="table-count" class="meta-chip">0 / 0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도메인</th>
|
||||
<th>테이블</th>
|
||||
<th>Rows</th>
|
||||
<th>최근 갱신</th>
|
||||
<th>연결 화면</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div style="display:grid; gap:20px;">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>원본 import 배치</h2>
|
||||
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Rows</th>
|
||||
<th>Imported</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batch-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>바이너리 원본 보관</h2>
|
||||
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>파일</th>
|
||||
<th>크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="binary-body">
|
||||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 메모</h2>
|
||||
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<ol id="notes" class="notes"></ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>운영 분류</h2>
|
||||
<p>유지/주의/원본·추적/정리 후보 기준과 제품 관점 묶음을 같이 봅니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div id="group-summary"></div>
|
||||
<div id="product-summary" style="margin-top:18px;"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>화면별 데이터 소스</h2>
|
||||
<p>각 탭/기능이 실제로 어떤 테이블을 읽고 저장하는지 빠르게 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="screen-map" class="panel-body mapping-list"></div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||||
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button id="preview-download" class="action-button" type="button" disabled>CSV 다운로드</button>
|
||||
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="preview-meta" class="preview-meta"></div>
|
||||
<div class="panel-body tight">
|
||||
<div class="preview-table-wrap">
|
||||
<table>
|
||||
<thead id="preview-head" class="sticky-head"></thead>
|
||||
<tbody id="preview-body">
|
||||
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allTables = [];
|
||||
let currentPreview = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '<span class="muted">-</span>';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (size <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let current = size;
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function renderOverview(overview) {
|
||||
const target = document.getElementById("overview");
|
||||
target.innerHTML = [
|
||||
["핵심 테이블", overview.visible_tables],
|
||||
["전체 테이블", overview.total_tables],
|
||||
["등록 인원", overview.registered_members],
|
||||
["재직 인원", overview.active_members],
|
||||
["고정 오피스 도면", overview.fixed_office_maps],
|
||||
["현재 active 도면", overview.active_seat_maps],
|
||||
["Import 배치", overview.import_batches],
|
||||
["바이너리 원본", overview.binary_sources],
|
||||
].map(([label, value]) => `
|
||||
<article class="kpi">
|
||||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||||
<span class="kpi-value">${formatNumber(value)}</span>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTables(items) {
|
||||
const target = document.getElementById("table-body");
|
||||
const countTarget = document.getElementById("table-count");
|
||||
if (countTarget) {
|
||||
countTarget.textContent = `${formatNumber(items.length)} / ${formatNumber(allTables.length)}`;
|
||||
}
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||||
<td>
|
||||
<div style="margin-bottom:8px;">
|
||||
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||||
</div>
|
||||
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||||
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||||
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.last_event_at)}</td>
|
||||
<td>
|
||||
<div class="view-list">
|
||||
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatches(items) {
|
||||
const target = document.getElementById("batch-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${formatNumber(item.row_count)}</td>
|
||||
<td>${formatDateTime(item.imported_at)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderBinarySources(items) {
|
||||
const target = document.getElementById("binary-body");
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(item.filename)}</td>
|
||||
<td>${formatBytes(item.byte_size)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
const target = document.getElementById("notes");
|
||||
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||
}
|
||||
|
||||
function renderGroupSummary(summary) {
|
||||
const target = document.getElementById("group-summary");
|
||||
const groups = [
|
||||
["유지", "keep"],
|
||||
["주의", "caution"],
|
||||
["원본·추적", "trace"],
|
||||
["정리 후보", "cleanup"],
|
||||
];
|
||||
target.innerHTML = groups.map(([label, klass]) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderProductSummary(summary) {
|
||||
const target = document.getElementById("product-summary");
|
||||
const groups = [
|
||||
"탭 데이터",
|
||||
"로그인·권한",
|
||||
"히스토리",
|
||||
"로우데이터·적재",
|
||||
"보정·보조",
|
||||
];
|
||||
target.innerHTML = groups.map((label) => `
|
||||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||||
<div><span class="group-tag keep">${escapeHtml(label)}</span></div>
|
||||
<div class="view-list">
|
||||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderScreenMap(items) {
|
||||
const target = document.getElementById("screen-map");
|
||||
if (!items || !items.length) {
|
||||
target.innerHTML = '<div class="empty">화면별 데이터 소스 정보가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<article class="mapping-card">
|
||||
<h3>${escapeHtml(item.screen || "")}</h3>
|
||||
<div class="mapping-table-list">
|
||||
${(item.tables || []).map((table) => `<span class="view-pill">${escapeHtml(table)}</span>`).join("")}
|
||||
</div>
|
||||
<p>${escapeHtml(item.write_flow || "")}</p>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderTablePreview(payload) {
|
||||
currentPreview = payload;
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const downloadButton = document.getElementById("preview-download");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
|
||||
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||||
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||||
if (downloadButton) {
|
||||
downloadButton.disabled = !(payload.rows && payload.rows.length);
|
||||
}
|
||||
previewMeta.innerHTML = `
|
||||
<div>
|
||||
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||||
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||||
</div>
|
||||
<div class="preview-columns">
|
||||
${(payload.columns || []).map((column) => `
|
||||
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const columns = payload.columns || [];
|
||||
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||||
if (!payload.rows || !payload.rows.length) {
|
||||
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
return;
|
||||
}
|
||||
previewBody.innerHTML = payload.rows.map((row) => `
|
||||
<tr>
|
||||
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||||
</tr>
|
||||
`).join("");
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
async function loadTablePreview(schema, table) {
|
||||
const previewModal = document.getElementById("preview-modal");
|
||||
const previewMeta = document.getElementById("preview-meta");
|
||||
const previewTitle = document.getElementById("preview-title");
|
||||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||||
const previewHead = document.getElementById("preview-head");
|
||||
const previewBody = document.getElementById("preview-body");
|
||||
previewTitle.textContent = `${table}`;
|
||||
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||||
previewMeta.innerHTML = "";
|
||||
previewHead.innerHTML = "";
|
||||
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||||
previewModal.classList.add("open");
|
||||
previewModal.setAttribute("aria-hidden", "false");
|
||||
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
renderTablePreview(payload);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
allTables = payload.tables || [];
|
||||
document.getElementById("generated-at").textContent = payload.generated_at
|
||||
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||||
: "갱신 시각 없음";
|
||||
renderOverview(payload.overview || {});
|
||||
renderTables(allTables);
|
||||
renderBatches(payload.import_batches || []);
|
||||
renderBinarySources(payload.binary_sources || []);
|
||||
renderNotes(payload.notes || []);
|
||||
renderGroupSummary(payload.group_summary || {});
|
||||
renderProductSummary(payload.product_summary || {});
|
||||
renderScreenMap(payload.screen_map || []);
|
||||
}
|
||||
|
||||
function toCsvValue(value) {
|
||||
const text = String(value ?? "");
|
||||
if (!/[",\n]/.test(text)) return text;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function downloadPreviewCsv() {
|
||||
if (!currentPreview || !currentPreview.columns || !currentPreview.rows || !currentPreview.rows.length) return;
|
||||
const headers = currentPreview.columns.map((column) => column.name);
|
||||
const lines = [
|
||||
headers.map(toCsvValue).join(","),
|
||||
...currentPreview.rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
|
||||
];
|
||||
const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${currentPreview.table_name || "table-preview"}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function applyTableSearch(query) {
|
||||
const normalized = String(query || "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
renderTables(allTables);
|
||||
return;
|
||||
}
|
||||
const filtered = allTables.filter((item) => {
|
||||
const haystack = [
|
||||
item.label,
|
||||
item.table_ref,
|
||||
item.description,
|
||||
...(item.related_views || []),
|
||||
item.domain,
|
||||
item.group,
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
renderTables(filtered);
|
||||
}
|
||||
|
||||
document.getElementById("preview-close").addEventListener("click", () => {
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
document.getElementById("preview-download").addEventListener("click", downloadPreviewCsv);
|
||||
document.getElementById("table-search").addEventListener("input", (event) => {
|
||||
applyTableSearch(event.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||||
if (event.target.id !== "preview-modal") return;
|
||||
const modal = document.getElementById("preview-modal");
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||||
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||||
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1377
incoming-files/served/ledger/MH 통합 대시보드_260320.css
Normal file
1377
incoming-files/served/ledger/MH 통합 대시보드_260320.css
Normal file
File diff suppressed because it is too large
Load Diff
21
incoming-files/served/ledger/README.md
Normal file
21
incoming-files/served/ledger/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Ledger Served Assets
|
||||
|
||||
`8081` 사업관리대장 화면이 실제로 읽는 런타임 파일 모음이다.
|
||||
|
||||
source-of-truth:
|
||||
|
||||
- [frontend/apps/ledger](../../../frontend/apps/ledger)
|
||||
- 반영 스크립트: [scripts/publish_ledger_app.sh](../../../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%;
|
||||
}
|
||||
}
|
||||
661
incoming-files/served/ledger/ledger-override.js
Normal file
661
incoming-files/served/ledger/ledger-override.js
Normal file
@@ -0,0 +1,661 @@
|
||||
(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 bgNormalizeText(value) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
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 normalizedCategory(row) {
|
||||
var category = bgNormalizeText(row && row.cat);
|
||||
if (category.indexOf("가족사") >= 0) return "가족사";
|
||||
var corp = bgNormalizeText(row && row.corp);
|
||||
if (corp && corp !== "바론") return "가족사";
|
||||
return "바론";
|
||||
}
|
||||
|
||||
function isSupportServiceRow(row) {
|
||||
return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
|
||||
}
|
||||
|
||||
function projectTypeLabel(row) {
|
||||
if (isSupportServiceRow(row)) return "기술지원서비스";
|
||||
return normalizedCategory(row);
|
||||
}
|
||||
|
||||
function projectTypeRank(row) {
|
||||
var label = projectTypeLabel(row);
|
||||
if (label === "바론") return 0;
|
||||
if (label === "가족사") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function normalizeStatusLabel(status) {
|
||||
var value = bgNormalizeText(status);
|
||||
if (!value) return "-";
|
||||
if (value === "완료") return "준공";
|
||||
if (value === "진행") return "과업진행중";
|
||||
if (value === "대기") return "계약대기";
|
||||
if (value === "중지") return "과업중지";
|
||||
return value;
|
||||
}
|
||||
|
||||
function rowStatusLabel(row) {
|
||||
return normalizeStatusLabel(row && row.status);
|
||||
}
|
||||
|
||||
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 rowStatusLabel(row) === "과업진행중";
|
||||
}
|
||||
|
||||
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 rowStatusLabel(row) === "준공" && 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 isBaronProjectRow(row) {
|
||||
return projectTypeLabel(row) === "바론";
|
||||
}
|
||||
|
||||
function isSoftwareProjectRow(row) {
|
||||
var name = bgNormalizeText(row && row.name).toLowerCase();
|
||||
if (!name) return false;
|
||||
return [
|
||||
"프로그램",
|
||||
"소프트웨어",
|
||||
"software",
|
||||
" sw",
|
||||
"sw ",
|
||||
"erp",
|
||||
"tova",
|
||||
"ipipe",
|
||||
"eg-bim",
|
||||
"cad"
|
||||
].some(function (keyword) {
|
||||
return name.indexOf(keyword) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSinkProjectName(row) {
|
||||
var name = bgNormalizeText(row && row.name);
|
||||
return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
|
||||
}
|
||||
|
||||
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 = activeRows.filter(isSupportServiceRow);
|
||||
var baronActiveRows = activeRows.filter(isBaronProjectRow);
|
||||
return {
|
||||
targetYear: targetYear,
|
||||
activeRows: activeRows,
|
||||
newProjectRows: newProjectRows,
|
||||
completedRows: completedRows,
|
||||
managementRows: managementRows,
|
||||
managementTotals: bgTotals(managementRows),
|
||||
baronActiveRows: baronActiveRows,
|
||||
baronProjectTotals: bgTotals(baronActiveRows),
|
||||
baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
|
||||
};
|
||||
}
|
||||
|
||||
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 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 startYear = Number(projectYear(row) || 0);
|
||||
if (!startYear) return 9998;
|
||||
return startYear;
|
||||
}
|
||||
|
||||
function tableGroupLabel(row) {
|
||||
var startYear = projectYear(row);
|
||||
if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
|
||||
return "미지정 " + projectTypeLabel(row);
|
||||
}
|
||||
|
||||
function compareDashboardRows(a, b) {
|
||||
var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
|
||||
if (typeRankDiff !== 0) return typeRankDiff;
|
||||
var groupDiff = groupSortRank(a) - groupSortRank(b);
|
||||
if (groupDiff !== 0) return groupDiff;
|
||||
var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
|
||||
if (sinkDiff !== 0) return sinkDiff;
|
||||
return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
|
||||
}
|
||||
|
||||
function filterCategoryLabel(row) {
|
||||
return projectTypeLabel(row);
|
||||
}
|
||||
|
||||
function filterClientLabel(row) {
|
||||
if (typeof normalizeClientDisplay === "function") {
|
||||
return normalizeClientDisplay(row && row.client);
|
||||
}
|
||||
return bgNormalizeText(row && row.client) || "-";
|
||||
}
|
||||
|
||||
function filterOrderLabel(row) {
|
||||
return bgNormalizeText(row && row.order) || "-";
|
||||
}
|
||||
|
||||
function receivableFilterLabel(row) {
|
||||
var amount = Number((row && row.recv) || 0);
|
||||
if (amount <= 0) return "미수 없음";
|
||||
if (amount < 10000000) return "1천만 미만";
|
||||
if (amount < 100000000) return "1천만 이상";
|
||||
return "1억 이상";
|
||||
}
|
||||
|
||||
function refreshFilterDom() {
|
||||
E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
|
||||
return [el.dataset.filter, el];
|
||||
}));
|
||||
E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
|
||||
return [el.dataset.filter, el];
|
||||
}));
|
||||
}
|
||||
|
||||
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(compareDashboardRows);
|
||||
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 ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</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("");
|
||||
refreshFilterDom();
|
||||
if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
|
||||
}
|
||||
|
||||
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 totals = summary.baronProjectTotals;
|
||||
var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 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: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
|
||||
{ label: "계약금 (VAT별도)", 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);
|
||||
});
|
||||
S.rows.sort(compareDashboardRows);
|
||||
render();
|
||||
};
|
||||
|
||||
filterDefinitions = function () {
|
||||
return [
|
||||
{ key: "cat", map: filterCategoryLabel },
|
||||
{ key: "code", map: function (r) { return r.code || "-"; } },
|
||||
{ key: "name", map: function (r) { return r.name || "-"; } },
|
||||
{ key: "client", map: filterClientLabel },
|
||||
{ key: "order", map: filterOrderLabel },
|
||||
{ key: "status", map: rowStatusLabel },
|
||||
{ key: "amount", map: amountFilterLabel },
|
||||
{ key: "outsource", map: outsourceFilterLabel },
|
||||
{ key: "receivable", map: receivableFilterLabel },
|
||||
{ key: "collected", map: collectedFilterLabel },
|
||||
{ key: "rate", map: rateFilterLabel }
|
||||
];
|
||||
};
|
||||
|
||||
updateFilterButtons = function () {
|
||||
Object.keys(E.filterButtons || {}).forEach(function (key) {
|
||||
var btn = E.filterButtons[key];
|
||||
if (!btn) return;
|
||||
var active = !!S.filters[key];
|
||||
btn.classList.toggle("active", active);
|
||||
btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
|
||||
var mark = btn.querySelector(".th-mark");
|
||||
if (mark) mark.textContent = active ? "•" : "";
|
||||
});
|
||||
};
|
||||
|
||||
syncColumnFilters = function (rows) {
|
||||
filterDefinitions().forEach(function (def) {
|
||||
var 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();
|
||||
};
|
||||
|
||||
matchesColumnFilters = function (r) {
|
||||
if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
|
||||
if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
|
||||
if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
|
||||
if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
|
||||
if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
|
||||
if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
|
||||
if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
|
||||
if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
|
||||
if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) 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;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
var panel = document.querySelector(".panel");
|
||||
if (panel && !panel.dataset.ledgerFilterBound) {
|
||||
panel.dataset.ledgerFilterBound = "true";
|
||||
panel.addEventListener("click", function (event) {
|
||||
var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
|
||||
if (trigger) {
|
||||
refreshFilterDom();
|
||||
event.stopPropagation();
|
||||
toggleFilterMenu(trigger.dataset.filter);
|
||||
return;
|
||||
}
|
||||
var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
|
||||
var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
|
||||
if (option && menu) {
|
||||
event.stopPropagation();
|
||||
setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user