Compare commits

...
14 Commits
8 changed files with 1311 additions and 111 deletions

No files matched your search

+1 -1
View File
@@ -7,5 +7,5 @@ matching.db
csv
*.png
*.xlsx
*.txt
gitea-api.txt
data
+6
View File
@@ -17,3 +17,9 @@ Thumbs.db
# Local scratch
*.tmp
# SQLite runtime files / local recovery backups
*.db-wal
*.db-shm
*.db.bak-*
db-backups/
+21 -6
View File
@@ -2,18 +2,33 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PORT=8090
PORT=8091 \
CHROME_PATH=/usr/bin/chromium
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
chromium \
fonts-noto-cjk \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py index.html detail-view.html detail-view-project.html ./
RUN npm install --omit=dev playwright-core
# matching.db는 볼륨으로 마운트해서 데이터 보존
VOLUME ["/app/data"]
COPY mysql_preview_server.py \
index.html \
detail-view.html \
detail-view-project.html \
mysql-preview.html \
people-unified.html \
project-codes.html \
./
EXPOSE 8090
EXPOSE 8091 8092
CMD ["sh", "-c", "ln -sf /app/data/matching.db /app/matching.db 2>/dev/null || true; python3 app.py"]
CMD ["python3", "mysql_preview_server.py"]
+241
View File
@@ -0,0 +1,241 @@
# 장헌산업 업무 데이터 조회 시스템
이 저장소는 장헌산업 내부 업무 데이터를 조회하고, ERP 정보와 매칭해서 웹 화면으로 보여주는 사내용 도구입니다.
주요 목적은 아래 2가지입니다.
- `8091` 페이지: 사람별/프로젝트별 근무 데이터 조회
- `8092` 페이지: 시공 코드, 계약정보, 공사개요, 연계코드 조회
## 구성 개요
이 프로젝트는 Python 기반의 간단한 웹 서버와 SQLite DB(`matching.db`)를 사용합니다.
- 웹 서버: `mysql_preview_server.py`
- 공용 DB: `matching.db`
- 메인 대시보드 화면: `index.html`
- 사람별 근무 화면: `people-unified.html`
- 시공 코드 조회 화면: `project-codes.html`
같은 저장소 안에서 여러 HTML 화면을 제공하지만, 데이터는 공통으로 `matching.db`를 사용합니다.
## 페이지 설명
### `8091` 메인/근무 데이터 페이지
`8091` 포트는 근무 데이터와 집계 화면용입니다.
포함 내용:
- 프로젝트별 투입시간 집계
- 사람별 근무내역 조회
- 월별/기간별 프로젝트 투입 현황
- 사업관리 작업일보 기반 추가 공수 데이터 조회
관련 화면:
- `index.html`: 메인 대시보드
- `people-unified.html`: 사람별 / 프로젝트별 근무내역
- `detail-view.html`, `detail-view-project.html`: 상세 보기
### `8092` 시공 코드 조회 페이지
`8092` 포트는 ERP 시공 코드 전용 조회 화면입니다.
포함 내용:
- 시공코드 목록
- 계약정보
- 공사개요
- 교량 매칭 정보
- 공사시행계획서 기반 교량제원
- 연계코드 표시
연계코드는 아래 형식으로 표시됩니다.
- `영업/설계 약칭(코드번호)`가 아니라
- 현재는 `약칭(코드번호) · 약칭(코드번호)` 형식
예시:
- `새만금~전주간 고속도로 건설고사(제4공구) [8차](25-교영-09)`
- `새만금~전주간 고속도로 건설고사(제4공구) [8차](25-설계-05)`
관련 화면:
- `project-codes.html`
## 데이터 소스
이 시스템은 여러 데이터 소스를 함께 사용합니다.
### 1. SQLite 캐시 DB
- 파일: `matching.db`
- 저장소에 포함되어 있음
- 클론한 사람도 같은 시점의 데이터를 바로 볼 수 있음
주요 저장 내용:
- 직원 정보
- `dailyproject` 근무 데이터
- 프로젝트 약칭
- ERP 시공 코드 캐시
- ERP 계약정보 캐시
- ERP 교량 개요 캐시
- ERP 공사시행계획서 캐시
- 영업/설계/시공 연계코드 캐시
### 2. MySQL 원본 데이터
사내 MySQL에서 근무 데이터를 읽어와 SQLite로 적재합니다.
관련 환경값:
- `MYSQL_HOST`
- `MYSQL_PORT`
- `MYSQL_USER`
- `MYSQL_PASSWORD`
- `MYSQL_DB`
### 3. ERP 데이터
ERP에서 아래 정보를 가져옵니다.
- 프로젝트 약칭
- 시공 코드 목록
- 계약정보
- 공사관리 / 공사개요
- 공사시행계획서
- 영업/설계/시공 연계코드
관련 기본 URL:
- `http://erp.jangheon.co.kr/projt_mng`
## DB 관련 중요 사항
이 저장소는 **코드만이 아니라 `matching.db`도 함께 관리**합니다.
이유:
- 클론한 사람이 바로 같은 데이터를 볼 수 있어야 함
- 내부 페이지가 DB 캐시를 전제로 동작함
- ERP/MySQL 접속 없이도 기본 조회가 가능해야 함
즉, 이 저장소에서는 `matching.db`도 실제 배포/공유 자산입니다.
다만 아래 파일은 운영 중 자동 생성될 수 있습니다.
- `matching.db-wal`
- `matching.db-shm`
- `matching.db.bak-*`
이 파일들은 보조 파일/백업 파일이며, 기본 공유 대상은 `matching.db`입니다.
## 연계코드 저장 방식
시공코드 페이지의 연계코드는 DB에도 저장됩니다.
관련 테이블:
- `project_alias`
- `erp_project_alias_cache`
- `erp_linked_code_cache`
예를 들어 시공코드 하나에 대해 아래 정보를 저장합니다.
- 시공코드
- 사업코드
- 연관 영업코드
- 연관 영업 약칭
- 연관 설계코드
- 연관 설계 약칭
그래서 나중에 아래와 같은 질문에 바로 답할 수 있습니다.
- “이 시공코드의 연관 영업코드는 뭐야?”
- “이 설계코드 약칭이 뭐야?”
## 실행 방법
### 로컬 실행
Python 서버 실행:
```bash
python3 mysql_preview_server.py
```
기본 포트:
- `8091`: 메인/근무 데이터
- `8092`: 시공 코드 조회
### Docker 실행
이미지/컨테이너는 `docker-compose.yml`과 `Dockerfile`로 실행할 수 있습니다.
관련 문서:
- `DEPLOY_DOCKER.md`
## 주요 파일 설명
- `mysql_preview_server.py`: 현재 실제 기능 대부분이 들어있는 메인 서버
- `app.py`: 이전/보조 서버 코드
- `index.html`: 8091 메인 대시보드
- `people-unified.html`: 사람별/프로젝트별 근무 조회
- `project-codes.html`: 8092 시공 코드 전용 화면
- `matching.db`: 공용 SQLite 데이터베이스
- `docker-compose.yml`: Docker 배포 설정
- `DEPLOY_DOCKER.md`: Docker 배포 방법
## 사용 흐름
### 근무 데이터 흐름
1. MySQL 원본 데이터를 읽음
2. SQLite `matching.db`에 적재
3. `8091` 페이지에서 집계/상세 조회
### 시공 코드 데이터 흐름
1. ERP에서 시공코드 목록 조회
2. ERP 계약정보 / 공사개요 / 공사시행계획서 조회
3. 영업/설계/시공 연계코드와 약칭 매칭
4. SQLite `matching.db`에 캐시 저장
5. `8092` 페이지에서 빠르게 조회
## 운영 시 주의사항
- `matching.db`는 실제 운영 데이터가 들어 있으므로 함부로 초기화하면 안 됩니다.
- ERP 재조회 버튼을 누르면 캐시가 갱신될 수 있습니다.
- 운영 중에는 DB 파일이 변경될 수 있어 `matching.db-wal`, `matching.db-shm`가 생길 수 있습니다.
- 다른 사람이 같은 결과를 보려면 저장소의 `matching.db`도 함께 최신 상태여야 합니다.
## 권장 공유 방식
다른 사람이 이 저장소를 받아서 바로 확인하려면:
1. 저장소를 클론
2. `matching.db`가 포함되어 있는지 확인
3. 서버 실행
4. `8091`, `8092` 페이지 접속
이렇게 하면 ERP를 다시 긁지 않아도 커밋 시점 기준의 데이터를 바로 볼 수 있습니다.
+31 -7
View File
@@ -1,13 +1,12 @@
version: "3.9"
services:
manhour-dashboard:
jh-people-8091:
build: .
container_name: manhour-dashboard
container_name: jh-people-8091
ports:
- "8090:8090"
- "8091:8091"
environment:
PORT: "8090"
PORT: "8091"
STARTUP_MAINTENANCE: "0"
MYSQL_HOST: "${MYSQL_HOST:-172.16.42.111}"
MYSQL_PORT: "${MYSQL_PORT:-3306}"
MYSQL_USER: "${MYSQL_USER:-root}"
@@ -20,5 +19,30 @@ services:
INTRANET_LOGIN_ID: "${INTRANET_LOGIN_ID:-G25001}"
INTRANET_LOGIN_PW: "${INTRANET_LOGIN_PW:-00000}"
volumes:
- ./data:/app/data
- ./matching.db:/app/matching.db
restart: unless-stopped
jh-project-8092:
build: .
container_name: jh-project-8092
ports:
- "8092:8092"
environment:
PORT: "8092"
STARTUP_MAINTENANCE: "0"
MYSQL_HOST: "${MYSQL_HOST:-172.16.42.111}"
MYSQL_PORT: "${MYSQL_PORT:-3306}"
MYSQL_USER: "${MYSQL_USER:-root}"
MYSQL_PASSWORD: "${MYSQL_PASSWORD:-hanmacerp!}"
MYSQL_DB: "${MYSQL_DB:-jangheon_manhour}"
ERP_BASE_URL: "${ERP_BASE_URL:-http://erp.jangheon.co.kr/projt_mng}"
ERP_LOGIN_ID: "${ERP_LOGIN_ID:-g25001}"
ERP_LOGIN_PW: "${ERP_LOGIN_PW:-00000}"
INTRANET_BASE_URL: "${INTRANET_BASE_URL:-http://erp.jangheon.co.kr/intranet/}"
INTRANET_LOGIN_ID: "${INTRANET_LOGIN_ID:-G25001}"
INTRANET_LOGIN_PW: "${INTRANET_LOGIN_PW:-00000}"
volumes:
- ./matching.db:/app/matching.db
depends_on:
- jh-people-8091
restart: unless-stopped
BIN
View File
Binary file not shown.
+773 -76
View File
File diff suppressed because it is too large. Load diff
+238 -21
View File
@@ -352,6 +352,31 @@
opacity: 0.45;
cursor: default;
}
.map-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 48px;
height: 28px;
margin-top: 6px;
padding: 0 10px;
border: 1px solid rgba(15, 118, 110, 0.24);
border-radius: 999px;
background: #e9f8f5;
color: var(--accent-strong);
font-size: 12px;
font-weight: 800;
cursor: pointer;
white-space: nowrap;
}
.map-button:hover {
filter: brightness(0.96);
}
.map-location {
display: grid;
gap: 4px;
justify-items: start;
}
.modal-backdrop {
position: fixed;
inset: 0;
@@ -610,6 +635,7 @@
</div>
<script>
const LINKED_CODE_BY_BUSINESS = {"26-002":{"salesCode":"26-교영-02","designCode":"26-설계-02","constCode":""},"26-004":{"salesCode":"26-교영-04","designCode":"26-설계-03","constCode":"26-시공-03"},"25-003":{"salesCode":"25-교영-02","designCode":"25-설계-01","constCode":"25-시공-01"},"25-005":{"salesCode":"25-교영-04","designCode":"25-설계-02","constCode":"25-시공-37"},"25-008":{"salesCode":"25-교영-07","designCode":"25-설계-04","constCode":"25-시공-08"},"25-009":{"salesCode":"25-교영-09","designCode":"25-설계-05","constCode":"26-시공-02"},"25-004":{"salesCode":"25-교영-03","designCode":"25-설계-06","constCode":""},"25-032":{"salesCode":"25-교영-32","designCode":"26-설계-01","constCode":""},"25-028":{"salesCode":"25-교영-28","designCode":"26-설계-04","constCode":""},"24-035":{"salesCode":"24-교영-35","designCode":"24-설계-01","constCode":""},"24-034":{"salesCode":"24-교영-34","designCode":"24-설계-03","constCode":""},"24-026":{"salesCode":"24-교영-26","designCode":"24-설계-05","constCode":""},"24-039":{"salesCode":"24-교영-39","designCode":"24-설계-09","constCode":"25-시공-13"},"24-044":{"salesCode":"24-교영-44","designCode":"24-설계-14","constCode":"25-시공-30"},"24-056":{"salesCode":"24-교영-85","designCode":"24-설계-31","constCode":"24-시공-24"},"24-061":{"salesCode":"24-교영-63","designCode":"24-설계-20","constCode":"24-시공-25"},"24-068":{"salesCode":"24-교영-69","designCode":"24-설계-21","constCode":"24-시공-27"},"24-072":{"salesCode":"24-교영-75","designCode":"24-설계-23","constCode":""},"24-074":{"salesCode":"24-교영-77","designCode":"24-설계-24","constCode":""},"24-078":{"salesCode":"24-교영-81","designCode":"24-설계-25","constCode":"26-시공-01"},"24-079":{"salesCode":"24-교영-82","designCode":"24-설계-26","constCode":"24-시공-33"},"24-080":{"salesCode":"24-교영-83","designCode":"24-설계-27","constCode":""},"24-081":{"salesCode":"24-교영-86","designCode":"24-설계-30","constCode":""},"24-064":{"salesCode":"24-교영-65","designCode":"24-설계-32","constCode":""},"24-066":{"salesCode":"24-교영-67","designCode":"24-설계-33","constCode":""},"24-067":{"salesCode":"24-교영-68","designCode":"24-설계-34","constCode":""},"24-017":{"salesCode":"24-교영-17","designCode":"24-설계-35","constCode":""},"24-020":{"salesCode":"24-교영-20","designCode":"24-설계-36","constCode":""},"24-084":{"salesCode":"24-교영-89","designCode":"24-설계-37","constCode":""},"24-063":{"salesCode":"24-교영-64","designCode":"25-설계-03","constCode":""},"24-073":{"salesCode":"24-교영-76","designCode":"25-설계-07","constCode":"25-시공-15"},"24-024":{"salesCode":"24-교영-24","designCode":"25-설계-09","constCode":""},"24-019":{"salesCode":"24-교영-19","designCode":"25-설계-11","constCode":"25-시공-24"},"23-042":{"salesCode":"23-교영-38","designCode":"24-설계-28","constCode":""},"23-004":{"salesCode":"23-교영-04","designCode":"23-설계-07","constCode":""},"23-008":{"salesCode":"23-교영-08","designCode":"23-설계-08","constCode":"25-시공-32"},"23-003":{"salesCode":"23-교영-03","designCode":"23-설계-10","constCode":""},"23-019":{"salesCode":"23-교영-19","designCode":"23-설계-12","constCode":""},"23-020":{"salesCode":"23-교영-20","designCode":"23-설계-13","constCode":""},"23-035":{"salesCode":"23-교영-35","designCode":"23-설계-14","constCode":"24-시공-21"},"23-030":{"salesCode":"23-교영-30","designCode":"24-설계-02","constCode":""},"23-037":{"salesCode":"23-교영-37","designCode":"24-설계-18","constCode":""},"24-048":{"salesCode":"23-교영-37","designCode":"24-설계-18","constCode":""},"23-041":{"salesCode":"23-교영-39","designCode":"24-설계-29","constCode":""},"22-046":{"salesCode":"22-교영-46","designCode":"22-설계-01","constCode":""},"22-016":{"salesCode":"22-교영-16","designCode":"22-설계-02","constCode":"23-시공-27"},"22-045":{"salesCode":"22-교영-45","designCode":"22-설계-03","constCode":""},"22-017":{"salesCode":"22-교영-17","designCode":"22-설계-04","constCode":""},"22-014":{"salesCode":"22-교영-14","designCode":"22-설계-05","constCode":""},"22-044":{"salesCode":"22-교영-44","designCode":"22-설계-06","constCode":""},"22-043":{"salesCode":"22-교영-43","designCode":"22-설계-07","constCode":""},"22-011":{"salesCode":"22-교영-11","designCode":"22-설계-08","constCode":""},"22-041":{"salesCode":"22-교영-41","designCode":"22-설계-10","constCode":""},"22-033":{"salesCode":"22-교영-33","designCode":"23-설계-01","constCode":"25-시공-33"},"22-027":{"salesCode":"22-교영-27","designCode":"23-설계-02","constCode":""},"22-012":{"salesCode":"22-교영-12","designCode":"23-설계-03","constCode":""},"22-039":{"salesCode":"22-교영-39","designCode":"23-설계-04","constCode":"25-시공-31"},"22-038":{"salesCode":"22-교영-38","designCode":"23-설계-11","constCode":""},"22-036":{"salesCode":"22-교영-36"Line truncated
const state = {
rows: [],
filteredRows: [],
@@ -620,6 +646,7 @@
sourceTable: '',
sourceColumns: {},
};
const FACTORY_ADDRESS = '충청남도 당진시 고대면 성산로 464';
const searchInput = document.getElementById('searchInput');
const contractTypeFilter = document.getElementById('contractTypeFilter');
@@ -669,13 +696,56 @@
.replace(/(?<=\d)-(?=\d)/g, '-\n');
}
function extractBusinessCodeKey(value) {
const text = String(value || '').trim();
const match = text.match(/\d{2}-\d{3}/);
return match ? match[0] : text;
}
function resolveLinkedCodes(detail) {
const apiLinked = detail.linkedCodes || {};
if (apiLinked.salesCode || apiLinked.designCode) {
return apiLinked;
}
const key = extractBusinessCodeKey(detail.businessCode);
return LINKED_CODE_BY_BUSINESS[key] || { salesCode: '', designCode: '' };
}
function compactLinkedCodeName(name, fallbackName) {
const cleanName = String(name || '').trim();
const cleanFallback = String(fallbackName || '').trim();
if (!cleanName) return cleanFallback;
if (cleanFallback && (cleanName.length > 24 || cleanName.includes('[') || cleanName.includes('민간투자사업'))) {
return cleanFallback;
}
return cleanName;
}
function formatLinkedCodeLabel(code, name, fallbackName = '') {
const cleanCode = String(code || '').trim();
const cleanName = compactLinkedCodeName(name, fallbackName);
if (!cleanCode) return '-';
if (!cleanName) return cleanCode;
return `${cleanName}(${cleanCode})`;
}
function normalizeCrossbeamRows(crossbeam) {
if (Array.isArray(crossbeam)) {
return crossbeam.length ? crossbeam : [{ height: '', length: '', quantity: '', remarks: '' }];
}
if (crossbeam && typeof crossbeam === 'object') {
return [crossbeam];
}
return [{ height: '', length: '', quantity: '', remarks: '' }];
}
function renderBudgetPlanInlineTables(plan) {
if (!plan) return '-';
const inputDays = plan.inputDays || {};
const inputRebar = plan.inputRebar || {};
const girderSpecs = Array.isArray(plan.girderSpecs) ? plan.girderSpecs : [];
const predeck = plan.predeck || {};
const crossbeam = plan.crossbeam || {};
const crossbeamRows = normalizeCrossbeamRows(plan.crossbeam);
return `
<div class="inline-plan-wrap">
@@ -736,28 +806,36 @@
</tr>
`
}
</table>
<table class="plan-table plan-table-split">
<tr>
<th colspan="4">프리덱</th>
<th colspan="4">가로보</th>
</tr>
<tr>
<th rowspan="2">프리덱</th>
<th>일반부(㎡)</th>
<th>중분대(㎡)</th>
<th>방호벽부(㎡)</th>
<th>비고</th>
<th rowspan="2">가로보</th>
<th>형고(m)</th>
<th>길이(m)</th>
<th>수량(EA)</th>
<th>비고</th>
</tr>
<tr>
<td>${escapeHtml(predeck.generalArea || '-')}</td>
<td>${escapeHtml(predeck.medianArea || '-')}</td>
<td>${escapeHtml(predeck.barrierArea || '-')}</td>
<td class="plan-left">${escapeHtml(predeck.remarks || '-')}</td>
<td>${escapeHtml(crossbeam.height || '-')}</td>
<td>${escapeHtml(crossbeam.length || '-')}</td>
<td>${escapeHtml(crossbeam.quantity || '-')}</td>
<td class="plan-left">${escapeHtml(crossbeam.remarks || '-')}</td>
</tr>
${crossbeamRows.map((row, index) => `
<tr>
${index === 0 ? `
<td rowspan="${crossbeamRows.length}">${escapeHtml(predeck.generalArea || '-')}</td>
<td rowspan="${crossbeamRows.length}">${escapeHtml(predeck.medianArea || '-')}</td>
<td rowspan="${crossbeamRows.length}">${escapeHtml(predeck.barrierArea || '-')}</td>
<td rowspan="${crossbeamRows.length}" class="plan-left">${escapeHtml(predeck.remarks || '-')}</td>
` : ''}
<td>${escapeHtml(row.height || '-')}</td>
<td>${escapeHtml(row.length || '-')}</td>
<td>${escapeHtml(row.quantity || '-')}</td>
<td class="plan-left">${escapeHtml(row.remarks || '-')}</td>
</tr>
`).join('')}
</table>
</div>
`;
@@ -770,6 +848,13 @@
.filter(Boolean);
}
function compareProjectCodeDesc(a, b) {
return String(b.projectCode || '').localeCompare(String(a.projectCode || ''), 'ko', {
numeric: true,
sensitivity: 'base',
});
}
function renderRows(rows) {
if (!rows.length) {
resultBody.innerHTML = '<tr><td colspan="2" class="empty">조건에 맞는 프로젝트가 없습니다.</td></tr>';
@@ -838,11 +923,17 @@
}
function renderDetailRows(detail) {
const linkedCodes = resolveLinkedCodes(detail);
const linkedCodeText = [
formatLinkedCodeLabel(linkedCodes.salesCode, linkedCodes.salesName, detail.projectName),
formatLinkedCodeLabel(linkedCodes.designCode, linkedCodes.designName, detail.projectName),
].join(' · ');
const rows = [
['사업코드', detail.businessCode],
['약칭', detail.projectName],
['시공코드', detail.projectCode],
['현장위치', detail.siteLocation],
['연계코드', linkedCodeText],
['현장위치', renderSiteLocationCell(detail.siteLocation, detail.projectName)],
['발주처', detail.clientName],
['최종계약금액', detail.finalContractAmountText],
['계약종류', detail.contractType],
@@ -857,9 +948,32 @@
detailBody.innerHTML = rows.map(([label, value]) => `
<tr>
<th>${escapeHtml(label)}</th>
<td>${typeof value === 'string' && value.includes('<table') ? value : escapeHtml(value || '-')}</td>
<td>${typeof value === 'string' && (value.includes('<table') || value.includes('class="map-location"')) ? value : escapeHtml(value || '-')}</td>
</tr>
`).join('');
Array.from(detailBody.querySelectorAll('.map-button')).forEach((button) => {
button.addEventListener('click', () => {
openBridgeMap(button.getAttribute('data-bridge') || '', button.getAttribute('data-location') || '');
});
});
}
function renderSiteLocationCell(siteLocation, projectName) {
const location = normalizeValue(siteLocation);
const disabled = !location || location === '-';
return `
<div class="map-location">
<div>${toHtmlWithBreaks(location)}</div>
<button
class="map-button"
type="button"
data-bridge="${escapeAttr(projectName || '')}"
data-location="${escapeAttr(location)}"
${disabled ? 'disabled' : ''}
title="공장 위치와 현장 위치를 지도에서 보기"
>지도</button>
</div>
`;
}
function normalizeBridgeName(value) {
@@ -900,6 +1014,86 @@
return `${text.slice(0, splitIndex).trim()}\n${text.slice(splitIndex + 1).trim()}`;
}
function normalizeMapQuery(value) {
return String(value || '')
.replace(/\s+/g, ' ')
.replace(/\n+/g, ' ')
.trim();
}
function extractAddressForMap(value) {
const original = normalizeMapQuery(value);
const pointMatch = original.match(/\(시점\)\s*([^()]+?)(?=\s*\(종점\)|$)/);
if (pointMatch && pointMatch[1]) {
return normalizeMapQuery(pointMatch[1]);
}
const pointLabelMatch = original.match(/시점\s*[::]?\s*([^()]+?)(?=\s*종점\s*[::]?|$)/);
if (pointLabelMatch && pointLabelMatch[1]) {
return normalizeMapQuery(pointLabelMatch[1]);
}
let text = original
.replace(/^\(주\)\s*장헌\s*/, '')
.replace(/^\(주\)장헌\s*/, '')
.replace(/^\[.*?\]\s*/, '')
.replace(/\(.*?\)/g, ' ')
.replace(/\[.*?\]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const addressStart = text.search(/(서울특별시|서울시|부산광역시|부산시|대구광역시|대구시|인천광역시|인천시|광주광역시|광주시|대전광역시|대전시|울산광역시|울산시|세종특별자치시|세종시|경기도|강원특별자치도|강원도|충청북도|충북|충청남도|충남|전북특별자치도|전라북도|전북|전라남도|전남|경상북도|경북|경상남도|경남|제주특별자치도|제주도)/);
if (addressStart > 0) {
text = text.slice(addressStart).trim();
}
text = text
.split(/\s*~\s*/)[0]
.split(/\s+종점\s+/)[0]
.replace(/\s+/g, ' ')
.trim();
return text;
}
async function openBridgeMap(bridgeName, bridgeLocation) {
const site = extractAddressForMap(bridgeLocation);
if (!site || site === '-') {
alert('교량 위치 정보가 없습니다.');
return;
}
const popup = window.open(
'',
`jhBridgeRouteMap_${Date.now()}`,
'popup=yes,width=1280,height=820,left=80,top=60,resizable=yes,scrollbars=yes'
);
if (!popup) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 후 다시 눌러주세요.');
return;
}
popup.document.write('<!doctype html><meta charset="utf-8"><title>길찾기 준비 중</title><body style="font-family:sans-serif;padding:24px;">네이버 자동차 길찾기를 준비하는 중입니다...</body>');
popup.focus();
try {
const params = new URLSearchParams({
origin: FACTORY_ADDRESS,
destination: site,
});
const response = await fetch(`/api/naver-route-url?${params.toString()}`, { cache: 'no-store' });
const result = await response.json();
if (!response.ok || !result.ok || !result.url) {
throw new Error(result.error || '네이버 길찾기 URL을 만들지 못했습니다.');
}
popup.location.href = result.url;
} catch (error) {
const fallbackUrl = `https://map.naver.com/p/search/${encodeURIComponent(site)}`;
popup.document.body.innerHTML = `
<div style="font-family:sans-serif;padding:24px;line-height:1.6;">
<h2 style="margin:0 0 12px;color:#b91c1c;">네이버 자동차 길찾기를 바로 열지 못했습니다.</h2>
<p>현장 주소: <strong>${escapeHtml(site)}</strong></p>
<p style="white-space:pre-wrap;color:#7f1d1d;">${escapeHtml(error.message || String(error))}</p>
<p><a href="${escapeAttr(fallbackUrl)}" style="color:#0f766e;font-weight:700;">네이버지도에서 현장 위치 먼저 열기</a></p>
</div>
`;
}
}
function formatStatus(scaleRow, overviewRow) {
const status = overviewRow?.constructionStatus || scaleRow?.constructionStatus || '';
return status || '-';
@@ -918,7 +1112,7 @@
const inputRebar = plan.inputRebar || {};
const girderSpecs = Array.isArray(plan.girderSpecs) ? plan.girderSpecs : [];
const predeck = plan.predeck || {};
const crossbeam = plan.crossbeam || {};
const crossbeamRows = normalizeCrossbeamRows(plan.crossbeam);
planModalTitle.textContent = `${bridgeName || plan.projectName || '프로젝트'} 공사시행계획서`;
planModalBody.innerHTML = `
@@ -946,10 +1140,25 @@
</table>
<div class="plan-section-title">교량제원 - 프리덱 / 가로보</div>
<table class="plan-table">
<tr><th>프리덱 일반부</th><td>${escapeHtml(predeck.generalArea || '-')}</td><th>가로보 형고</th><td>${escapeHtml(crossbeam.height || '-')}</td></tr>
<tr><th>프리덱 중분대</th><td>${escapeHtml(predeck.medianArea || '-')}</td><th>가로보 길이</th><td>${escapeHtml(crossbeam.length || '-')}</td></tr>
<tr><th>프리덱 방호벽부</th><td>${escapeHtml(predeck.barrierArea || '-')}</td><th>가로보 수량</th><td>${escapeHtml(crossbeam.quantity || '-')}</td></tr>
<tr><th>프리덱 비고</th><td>${escapeHtml(predeck.remarks || '-')}</td><th>가로보 비고</th><td>${escapeHtml(crossbeam.remarks || '-')}</td></tr>
<tr><th colspan="2">프리덱</th><th>형고(m)</th><th>길이(m)</th><th>수량(EA)</th><th>비고</th></tr>
${crossbeamRows.map((row, index) => `
<tr>
${index === 0 ? `<th>일반부(㎡)</th><td>${escapeHtml(predeck.generalArea || '-')}</td>` : ''}
${index === 1 ? `<th>중분대(㎡)</th><td>${escapeHtml(predeck.medianArea || '-')}</td>` : ''}
${index === 2 ? `<th>방호벽부(㎡)</th><td>${escapeHtml(predeck.barrierArea || '-')}</td>` : ''}
${index >= 3 ? `<th>비고</th><td>${escapeHtml(predeck.remarks || '-')}</td>` : ''}
<td>${escapeHtml(row.height || '-')}</td>
<td>${escapeHtml(row.length || '-')}</td>
<td>${escapeHtml(row.quantity || '-')}</td>
<td>${escapeHtml(row.remarks || '-')}</td>
</tr>
`).join('')}
${crossbeamRows.length < 4 ? `
${crossbeamRows.length <= 0 ? '' : ''}
${crossbeamRows.length < 2 ? `<tr><th>중분대(㎡)</th><td>${escapeHtml(predeck.medianArea || '-')}</td><td colspan="4"></td></tr>` : ''}
${crossbeamRows.length < 3 ? `<tr><th>방호벽부(㎡)</th><td>${escapeHtml(predeck.barrierArea || '-')}</td><td colspan="4"></td></tr>` : ''}
${crossbeamRows.length < 4 ? `<tr><th>비고</th><td>${escapeHtml(predeck.remarks || '-')}</td><td colspan="4"></td></tr>` : ''}
` : ''}
</table>
`;
}
@@ -1000,6 +1209,7 @@
crossbeamCount: scaleRow.crossbeamCount || '-',
panelCount: scaleRow.panelCount || '-',
rebarCount: scaleRow.rebarCount || '-',
rawBridgeLocation: overviewRow.bridgeLocation || '',
bridgeLocation: formatBridgeLocation(overviewRow.bridgeLocation),
remarks: overviewRow.remarks || '-',
};
@@ -1008,6 +1218,8 @@
function renderMergedBridgeRows(scaleRows, overviews, budgetPlan) {
const mergedRows = mergeBridgeRows(scaleRows, overviews, budgetPlan);
const siteLocation = normalizeValue(state.detail?.siteLocation || state.selectedRow?.siteLocation || '');
const hasSiteLocation = siteLocation && siteLocation !== '-';
bridgeMeta.textContent = mergedRows.length
? `공사규모 ${scaleRows.length}건 · 공사개요 ${overviews.length}건을 교량명 기준으로 매칭했습니다.`
: '연결된 공사규모나 공사개요 데이터가 없습니다.';
@@ -1035,7 +1247,11 @@
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.crossbeamCount))}</td>
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.panelCount))}</td>
<td class="cell-num bridge-summary">${toHtmlWithBreaks(formatCellValue(row.rebarCount))}</td>
<td class="bridge-summary cell-wide">${toHtmlWithBreaks(row.bridgeLocation)}</td>
<td class="bridge-summary cell-wide">
<div class="map-location">
<div>${toHtmlWithBreaks(row.bridgeLocation)}</div>
</div>
</td>
<td>
<button
class="remark-button"
@@ -1100,6 +1316,7 @@
if (selectedApplicationType) {
state.filteredRows = state.filteredRows.filter((row) => splitApplicationTypes(row.applicationType).includes(selectedApplicationType));
}
state.filteredRows.sort(compareProjectCodeDesc);
countChip.textContent = `총 ${state.rows.length.toLocaleString()}건 / 표시 ${state.filteredRows.length.toLocaleString()}건`;
statusText.textContent = keyword