2516 lines
96 KiB
HTML
2516 lines
96 KiB
HTML
<!doctype html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>레미콘 배합/단가 통합 관리</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap');
|
||
|
||
:root {
|
||
--bg: #f1f5f9;
|
||
--card: #ffffff;
|
||
--text: #334155;
|
||
--muted: #64748b;
|
||
--line: #e2e8f0;
|
||
--primary: #1e40af;
|
||
--primary-hover: #1d4ed8;
|
||
--header: #ffffff;
|
||
--danger: #b91c1c;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
padding: 22px;
|
||
font-family: "Pretendard", -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", "Noto Sans KR", sans-serif;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
}
|
||
|
||
.wrap { max-width: 1460px; margin: 0 auto; display: grid; gap: 14px; }
|
||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05); overflow: hidden; }
|
||
|
||
.top {
|
||
background: rgba(255, 255, 255, 0.95);
|
||
color: #0f172a;
|
||
padding: 16px 18px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
border-bottom: 1px solid var(--line);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.top h1 { margin: 0; font-size: 20px; }
|
||
.top p { margin: 5px 0 0; color: #64748b; font-size: 13px; }
|
||
|
||
.nav { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.content { padding: 16px; display: grid; gap: 12px; }
|
||
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||
.grid2 { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||
.panel { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: #fff; }
|
||
.panel h2 { margin: 0; padding: 11px 12px; font-size: 16px; border-bottom: 1px solid var(--line); background: #f8fafc; color: #334155; font-weight: 700; }
|
||
|
||
.page { display: none; }
|
||
.page.active { display: block; }
|
||
|
||
.tag { background: #f8fafc; color: #475569; border: 1px solid #e2e8f0; border-radius: 999px; padding: 2px 8px; font-size: 12px; display: inline-block; }
|
||
.empty { color: var(--muted); font-size: 13px; padding: 12px; }
|
||
|
||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||
.field label { font-size: 12px; color: var(--muted); }
|
||
|
||
.form-grid { padding: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||
.full { grid-column: 1 / -1; }
|
||
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
th, td { border-bottom: 1px solid #e2e8f0; padding: 10px 9px; text-align: left; vertical-align: middle; }
|
||
th { background: #f8fafc; color: #475569; font-weight: 700; }
|
||
#historyArea { overflow-x: auto; }
|
||
.history-table { width: 100%; table-layout: auto; }
|
||
.history-table th, .history-table td {
|
||
white-space: nowrap;
|
||
line-height: 1.35;
|
||
padding: 6px 8px;
|
||
}
|
||
.history-table th {
|
||
text-align: center;
|
||
vertical-align: middle;
|
||
}
|
||
.history-table td {
|
||
text-align: center;
|
||
vertical-align: middle;
|
||
}
|
||
.history-table tbody tr {
|
||
height: 34px;
|
||
}
|
||
.history-table .kind-cell {
|
||
text-align: center;
|
||
vertical-align: middle;
|
||
font-weight: 600;
|
||
min-width: 52px;
|
||
padding: 0 8px;
|
||
line-height: 1;
|
||
}
|
||
.history-table .kind-cell .kind-label {
|
||
display: block;
|
||
height: 100%;
|
||
line-height: 1.2;
|
||
}
|
||
.history-table th:first-child,
|
||
.history-table td:first-child {
|
||
width: 48px;
|
||
min-width: 48px;
|
||
padding-left: 14px;
|
||
padding-right: 6px;
|
||
}
|
||
.history-table td[rowspan] {
|
||
vertical-align: middle;
|
||
}
|
||
.history-table .prod-cell,
|
||
.history-table .manage-cell {
|
||
vertical-align: middle;
|
||
text-align: center;
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
}
|
||
.history-table .action-cell {
|
||
display: flex;
|
||
gap: 4px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
vertical-align: middle;
|
||
}
|
||
.history-table .action-cell button {
|
||
height: 28px;
|
||
padding: 0 8px;
|
||
font-size: 12px;
|
||
border-radius: 8px;
|
||
}
|
||
.manage-wrap { display: grid; gap: 6px; justify-items: center; }
|
||
.pick-row { font-size: 12px; color: #334155; display: flex; gap: 4px; align-items: center; justify-content: center; }
|
||
.pick-row input[type="checkbox"] {
|
||
width: 13px;
|
||
height: 13px;
|
||
transform: scale(0.95);
|
||
margin: 0;
|
||
}
|
||
.history-tools { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; }
|
||
.history-tools .count { font-size: 12px; color: #475569; }
|
||
.history-tools .sub {
|
||
height: 30px;
|
||
padding: 0 10px;
|
||
font-size: 12px;
|
||
border-radius: 8px;
|
||
}
|
||
.line-less-table th,
|
||
.line-less-table td {
|
||
border-bottom: none;
|
||
}
|
||
.spec-table th,
|
||
.spec-table td {
|
||
text-align: center;
|
||
vertical-align: middle;
|
||
}
|
||
.spec-table .kind-cell {
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
min-width: 52px;
|
||
padding: 0 8px;
|
||
}
|
||
.spec-table .kind-cell .kind-label {
|
||
display: block;
|
||
height: 32px;
|
||
line-height: 32px;
|
||
}
|
||
.spec-table th:first-child,
|
||
.spec-table td:first-child {
|
||
padding-left: 16px;
|
||
padding-right: 10px;
|
||
}
|
||
.compare-chart-wrap {
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
}
|
||
.compare-chart-title {
|
||
font-size: 13px;
|
||
color: #334155;
|
||
margin-bottom: 6px;
|
||
font-weight: 600;
|
||
}
|
||
#priceCompareChart {
|
||
width: 100%;
|
||
height: 260px;
|
||
display: block;
|
||
}
|
||
.mix-compare-body { padding: 14px; max-height: 76vh; overflow: auto; }
|
||
#mixCompareChart { width: 100%; height: 300px; display: block; }
|
||
.chart-tooltip {
|
||
position: fixed;
|
||
z-index: 9999;
|
||
pointer-events: none;
|
||
background: rgba(15, 23, 42, 0.95);
|
||
color: #fff;
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
padding: 6px 8px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.22);
|
||
white-space: nowrap;
|
||
display: none;
|
||
}
|
||
.mini-table { border-collapse: collapse; font-size: 12px; }
|
||
.mini-table td { border: none; padding: 2px 6px; white-space: nowrap; text-align: center; }
|
||
.combined-wrap { display: flex; align-items: flex-start; gap: 10px; }
|
||
.combined-meta { min-width: 180px; font-size: 12px; color: #334155; text-align: left; line-height: 1.5; }
|
||
.combined-meta strong { color: #0f172a; }
|
||
|
||
input, select {
|
||
height: 38px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
padding: 0 10px;
|
||
font-size: 14px;
|
||
min-width: 120px;
|
||
}
|
||
|
||
button { height: 38px; border: none; border-radius: 10px; padding: 0 12px; font-size: 14px; cursor: pointer; }
|
||
.primary { background: var(--primary); color: #fff; }
|
||
.primary:hover { background: var(--primary-hover); }
|
||
.sub { background: #f1f5f9; color: #0f172a; border: 1px solid #cbd5e1; }
|
||
.danger { background: #fee2e2; color: var(--danger); }
|
||
#mainPage .content .row:first-child label { font-size: 13px; }
|
||
#mainPage .content .row:first-child select {
|
||
height: 34px;
|
||
min-width: 170px;
|
||
font-size: 13px;
|
||
border-radius: 9px;
|
||
}
|
||
#mainPage .content .row:first-child button {
|
||
height: 34px;
|
||
font-size: 13px;
|
||
padding: 0 10px;
|
||
border-radius: 9px;
|
||
}
|
||
#pricePage .content .row label { font-size: 13px; }
|
||
#pricePage .content .row select {
|
||
height: 34px;
|
||
min-width: 170px;
|
||
font-size: 13px;
|
||
border-radius: 9px;
|
||
}
|
||
#pricePage .content .row button {
|
||
height: 34px;
|
||
font-size: 13px;
|
||
padding: 0 10px;
|
||
border-radius: 9px;
|
||
}
|
||
#pricePage table th:first-child,
|
||
#pricePage table td:first-child {
|
||
padding-left: 22px;
|
||
}
|
||
|
||
.spec-btn {
|
||
border: 0;
|
||
background: #ecfeff;
|
||
color: #155e75;
|
||
padding: 4px 8px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
height: auto;
|
||
}
|
||
|
||
.modal {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.modal.open { display: flex; }
|
||
.modal-card { width: min(920px, 100%); background: #fff; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); }
|
||
.modal-head { background: #0f172YT; color: #fff; padding: 12px 14px; display: flex; justify-content: space-between; align-items: center; }
|
||
.modal-body { padding: 14px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||
.modal-foot { padding: 12px 14px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; }
|
||
.price-modal-body { padding: 14px; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||
.price-modal-body .field input { width: 100%; }
|
||
.pair-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.pair-grid input { width: 100%; }
|
||
.calc-body { padding: 14px; max-height: 70vh; overflow: auto; }
|
||
.calc-body table { font-size: 12px; }
|
||
.calc-body td, .calc-body th { white-space: normal; }
|
||
#calcModal .modal-head { padding: 10px 12px; }
|
||
#calcModal .modal-head strong { font-size: 16px; }
|
||
#calcModal .calc-body { font-size: 12px; }
|
||
|
||
@media (max-width: 960px) {
|
||
.form-grid { grid-template-columns: 1fr 1fr; }
|
||
.modal-body { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.form-grid, .modal-body { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="card top">
|
||
<div>
|
||
<h1>레미콘 배합/단가 통합 관리</h1>
|
||
<p>자재 7종 고정: 시멘트, 고로슬래그, 세척사, 부순모래, 25mm, 20mm, 혼화제</p>
|
||
</div>
|
||
<div class="nav">
|
||
<button class="primary" id="goMainBtn">메인 (배합 이력)</button>
|
||
<button class="sub" id="goPriceBtn">단가 데이터 관리</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="mainPage" class="page active">
|
||
<div class="card content">
|
||
<div class="row">
|
||
<label for="priceSetSelect">적용 단가</label>
|
||
<select id="priceSetSelect"></select>
|
||
<button class="sub" id="refreshBtn">새로고침</button>
|
||
<button class="primary" id="newMixBtn">배합표 등록 (팝업)</button>
|
||
</div>
|
||
<div class="row" id="statusRow"></div>
|
||
</div>
|
||
|
||
<div class="grid2">
|
||
<section class="panel">
|
||
<h2>규격 목록 (최신 배합)</h2>
|
||
<div id="specListArea"></div>
|
||
</section>
|
||
<section class="panel">
|
||
<h2>선택 규격 배합 이력</h2>
|
||
<div id="historyArea" class="empty">규격을 클릭하면 이력이 표시됩니다.</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="pricePage" class="page">
|
||
<section class="card">
|
||
<div class="content">
|
||
<div class="row">
|
||
<button class="primary" id="openPriceModalBtn">단가 입력 (팝업)</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div id="priceListArea"></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="content">
|
||
<div class="row">
|
||
<label for="compareBaseSelect">기준 단가</label>
|
||
<select id="compareBaseSelect"></select>
|
||
<label for="compareTargetSelect">비교 단가</label>
|
||
<select id="compareTargetSelect"></select>
|
||
<button class="sub" id="swapCompareBtn">기준/비교 바꾸기</button>
|
||
</div>
|
||
<div id="priceCompareArea" class="empty">비교할 단가를 선택하세요.</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal" id="mixModal">
|
||
<div class="modal-card">
|
||
<div class="modal-head">
|
||
<strong id="mixModalTitle">배합표 등록</strong>
|
||
<button class="sub" id="closeModalBtn">닫기</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="field full">
|
||
<label for="mSpec">규격명</label>
|
||
<select id="mSpec"></select>
|
||
<div class="row" style="margin-top:6px;">
|
||
<input id="mNewSpec" placeholder="신규 규격 입력 (예: 20-55-600)" />
|
||
<button type="button" class="sub" id="addSpecBtn">규격 추가</button>
|
||
</div>
|
||
</div>
|
||
<div class="field full">
|
||
<label for="mPriceSetId">적용 단가</label>
|
||
<select id="mPriceSetId"></select>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="mWaterCementRatio">물시멘트비 (W/B)%</label>
|
||
<input id="mWaterCementRatio" type="number" min="0" step="0.01" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="mFineAggRatio">잔골재율 (S/a)%</label>
|
||
<input id="mFineAggRatio" type="number" min="0" step="0.01" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="mWater">물 (W)kg</label>
|
||
<input id="mWater" type="number" min="0" step="0.01" />
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="mCement">시멘트 (C1)kg</label>
|
||
<input id="mCement" type="number" min="0" step="0.01" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="mSlag">고로슬래그 (C2)kg</label>
|
||
<input id="mSlag" type="number" min="0" step="0.01" />
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="mWashedSand">세척사 (S1)kg</label>
|
||
<input id="mWashedSand" type="number" min="0" step="0.01" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="mCrushedSand">부순모래 (S2)kg</label>
|
||
<input id="mCrushedSand" type="number" min="0" step="0.01" />
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="mMm25">골재 25mm (G1)kg</label>
|
||
<input id="mMm25" type="number" min="0" step="0.01" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="mMm20">골재 20mm (G2)kg</label>
|
||
<input id="mMm20" type="number" min="0" step="0.01" />
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="mAdmixture">혼화제 (AD)kg</label>
|
||
<input id="mAdmixture" type="number" min="0" step="0.01" />
|
||
</div>
|
||
|
||
<div class="field full">
|
||
<label for="mNote">변경 사유/메모 (선택)</label>
|
||
<input id="mNote" placeholder="예: 골재 수급 변경 반영" />
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="sub" id="cancelBtn">취소</button>
|
||
<button class="primary" id="saveMixBtn">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal" id="priceModal">
|
||
<div class="modal-card">
|
||
<div class="modal-head">
|
||
<strong id="priceModalTitle">단가 데이터 입력</strong>
|
||
<button class="sub" id="closePriceModalBtn">닫기</button>
|
||
</div>
|
||
<div class="price-modal-body">
|
||
<div class="field">
|
||
<label for="pYear">단가인상 년</label>
|
||
<input id="pYear" type="number" min="2000" max="2100" placeholder="예: 2026" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="pMonth">단가인상 월</label>
|
||
<input id="pMonth" type="number" min="1" max="12" placeholder="예: 2" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="pCementPrice">시멘트 단가(ton/원)</label>
|
||
<input id="pCementPrice" type="number" min="0" step="0.01" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="pSlagPrice">슬래그 단가(ton/원)</label>
|
||
<input id="pSlagPrice" type="number" min="0" step="0.01" />
|
||
</div>
|
||
<div class="field">
|
||
<label>세척사 단가입력 - 단위중량</label>
|
||
<div class="pair-grid">
|
||
<input id="pWashedSandPrice" type="number" min="0" step="0.01" placeholder="단가(m3/원)" />
|
||
<input id="pWashedSandDensity" type="number" min="1" step="1" placeholder="단위중량" />
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>부순모래 단가입력 - 단위중량</label>
|
||
<div class="pair-grid">
|
||
<input id="pCrushedSandPrice" type="number" min="0" step="0.01" placeholder="단가(m3/원)" />
|
||
<input id="pCrushedSandDensity" type="number" min="1" step="1" placeholder="단위중량" />
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>25mm 단가입력 - 단위중량</label>
|
||
<div class="pair-grid">
|
||
<input id="pMm25Price" type="number" min="0" step="0.01" placeholder="단가(m3/원)" />
|
||
<input id="pMm25Density" type="number" min="1" step="1" placeholder="단위중량" />
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>20mm 단가입력 - 단위중량</label>
|
||
<div class="pair-grid">
|
||
<input id="pMm20Price" type="number" min="0" step="0.01" placeholder="단가(m3/원)" />
|
||
<input id="pMm20Density" type="number" min="1" step="1" placeholder="단위중량" />
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="pAdmixturePrice">혼화제 단가(kg/원)</label>
|
||
<input id="pAdmixturePrice" type="number" min="0" step="0.01" />
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="sub" id="clearPriceBtn">입력 초기화</button>
|
||
<button class="primary" id="savePriceBtn">단가 저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal" id="calcModal">
|
||
<div class="modal-card">
|
||
<div class="modal-head">
|
||
<strong>계산 상세 확인</strong>
|
||
<button class="sub" id="closeCalcModalBtn">닫기</button>
|
||
</div>
|
||
<div class="calc-body" id="calcDetailBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal" id="mixCompareModal">
|
||
<div class="modal-card" style="width:min(1200px,100%);">
|
||
<div class="modal-head">
|
||
<strong>선택 배합 금액 비교</strong>
|
||
<button class="sub" id="closeMixCompareModalBtn">닫기</button>
|
||
</div>
|
||
<div class="mix-compare-body" id="mixCompareBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const MATERIALS = [
|
||
{ key: "cement", label: "시멘트 (C1)kg", short: "시멘트 (C1)kg" },
|
||
{ key: "slag", label: "고로슬래그 (C2)kg", short: "고로슬래그 (C2)kg" },
|
||
{ key: "washedSand", label: "세척사 (S1)kg", short: "세척사 (S1)kg" },
|
||
{ key: "crushedSand", label: "부순모래 (S2)kg", short: "부순모래 (S2)kg" },
|
||
{ key: "mm25", label: "골재 25mm (G1)kg", short: "골재 25mm (G1)kg" },
|
||
{ key: "mm20", label: "골재 20mm (G2)kg", short: "골재 20mm (G2)kg" },
|
||
{ key: "admixture", label: "혼화제 (AD)kg", short: "혼화제 (AD)kg" }
|
||
];
|
||
|
||
const PRICE_SETS_KEY = "remicon_price_sets_v1";
|
||
const MIX_HISTORY_KEY = "remicon_mix_history_v1";
|
||
const SELECTED_PRICE_SET_KEY = "remicon_selected_price_set_v1";
|
||
const MIX_DRAFT_KEY = "remicon_mix_draft_v1";
|
||
const PRICE_DRAFT_KEY = "remicon_price_draft_v1";
|
||
const CUSTOM_SPECS_KEY = "remicon_custom_specs_v1";
|
||
const EMBEDDED_DATA_REV_KEY = "remicon_embedded_data_rev_v1";
|
||
const EMBEDDED_DATA_REV = "2026-03-03-fixed-v1";
|
||
const PRESET_SPECS = [
|
||
"25-27-600",
|
||
"25-30-600",
|
||
"25-35-600",
|
||
"20-40-600",
|
||
"20-45-600",
|
||
"20-50-600",
|
||
"20-60-600"
|
||
];
|
||
|
||
const DEFAULT_PRICE_SETS = [
|
||
{
|
||
id: "ps_2025_01",
|
||
year: 2025,
|
||
month: 1,
|
||
name: "2025년 1월 단가인상",
|
||
prices: {
|
||
cement: 112000,
|
||
slag: 73000,
|
||
washedSand: 28000,
|
||
crushedSand: 20900,
|
||
mm25: 18400,
|
||
mm20: 21500,
|
||
admixture: 1200,
|
||
washedSandDensity: 1600,
|
||
crushedSandDensity: 1600,
|
||
mm25Density: 2300,
|
||
mm20Density: 2300,
|
||
cementAdj: 1,
|
||
slagAdj: 1,
|
||
washedSandAdj: 1,
|
||
crushedSandAdj: 1,
|
||
mm20Adj: 1,
|
||
mm25Adj: 1,
|
||
admixtureAdj: 1
|
||
},
|
||
createdAt: "2026-02-27T11:34:40+09:00"
|
||
},
|
||
{
|
||
id: "ps_2026_02",
|
||
year: 2026,
|
||
month: 2,
|
||
name: "2026년 2월 단가인상",
|
||
prices: {
|
||
cement: 112000,
|
||
slag: 73000,
|
||
washedSand: 32000,
|
||
crushedSand: 20900,
|
||
mm25: 18400,
|
||
mm20: 21500,
|
||
admixture: 1200,
|
||
washedSandDensity: 1600,
|
||
crushedSandDensity: 1600,
|
||
mm25Density: 2300,
|
||
mm20Density: 2300,
|
||
cementAdj: 1,
|
||
slagAdj: 1,
|
||
washedSandAdj: 1,
|
||
crushedSandAdj: 1,
|
||
mm20Adj: 1,
|
||
mm25Adj: 1,
|
||
admixtureAdj: 1
|
||
},
|
||
createdAt: "2026-02-26T10:09:54+09:00"
|
||
}
|
||
];
|
||
|
||
const DEFAULT_MIX_HISTORY = [
|
||
{
|
||
id: "mix_25_27_600_2026",
|
||
spec: "25-27-600",
|
||
mixMeta: { waterCementRatio: 46, fineAggRatio: 48.5, water: 170 },
|
||
materials: { cement: 277, slag: 92, washedSand: 501, crushedSand: 338, mm25: 901, mm20: 0, admixture: 3.32 },
|
||
priceSetId: "ps_2026_02",
|
||
note: "",
|
||
createdAt: "2026-02-26T10:50:24+09:00",
|
||
updatedAt: "2026-02-26T10:50:24+09:00"
|
||
},
|
||
{
|
||
id: "mix_25_30_600_2026",
|
||
spec: "25-30-600",
|
||
mixMeta: { waterCementRatio: 42, fineAggRatio: 46.5, water: 170 },
|
||
materials: { cement: 304, slag: 101, washedSand: 472, crushedSand: 319, mm25: 920, mm20: 0, admixture: 3.36 },
|
||
priceSetId: "ps_2026_02",
|
||
note: "",
|
||
createdAt: "2026-02-27T11:40:07+09:00",
|
||
updatedAt: "2026-02-27T11:40:07+09:00"
|
||
},
|
||
{
|
||
id: "mix_25_35_600_2025",
|
||
spec: "25-35-600",
|
||
mixMeta: { waterCementRatio: 38, fineAggRatio: 45.5, water: 170 },
|
||
materials: { cement: 314, slag: 134, washedSand: 452, crushedSand: 305, mm25: 917, mm20: 0, admixture: 4.02 },
|
||
priceSetId: "ps_2025_01",
|
||
note: "",
|
||
createdAt: "2026-02-27T11:40:41+09:00",
|
||
updatedAt: "2026-02-27T11:40:41+09:00"
|
||
}
|
||
];
|
||
|
||
let priceSets = [];
|
||
let mixHistory = [];
|
||
let selectedSpec = "";
|
||
let editingMixId = null;
|
||
let editingPriceId = null;
|
||
let selectedMixCompareIds = new Set();
|
||
let densitySnapshot = {
|
||
washedSand: 1600,
|
||
crushedSand: 1600,
|
||
mm20: 1500,
|
||
mm25: 1500
|
||
};
|
||
|
||
const mixModal = document.getElementById("mixModal");
|
||
const priceModal = document.getElementById("priceModal");
|
||
const calcModal = document.getElementById("calcModal");
|
||
const mixCompareModal = document.getElementById("mixCompareModal");
|
||
const calcDetailBody = document.getElementById("calcDetailBody");
|
||
const mixCompareBody = document.getElementById("mixCompareBody");
|
||
const mixModalTitle = document.getElementById("mixModalTitle");
|
||
const priceModalTitle = document.getElementById("priceModalTitle");
|
||
const saveMixBtn = document.getElementById("saveMixBtn");
|
||
const savePriceBtnEl = document.getElementById("savePriceBtn");
|
||
const priceSetSelect = document.getElementById("priceSetSelect");
|
||
const statusRow = document.getElementById("statusRow");
|
||
const compareBaseSelect = document.getElementById("compareBaseSelect");
|
||
const compareTargetSelect = document.getElementById("compareTargetSelect");
|
||
const chartHoverHandlers = new WeakMap();
|
||
let chartTooltipEl = null;
|
||
|
||
const PRICE_COMPARE_FIELDS = [
|
||
{ key: "cement", label: "시멘트 단가" },
|
||
{ key: "slag", label: "고로슬래그 단가" },
|
||
{ key: "washedSand", label: "세척사 단가" },
|
||
{ key: "crushedSand", label: "부순모래 단가" },
|
||
{ key: "mm25", label: "골재 25mm 단가" },
|
||
{ key: "mm20", label: "골재 20mm 단가" },
|
||
{ key: "admixture", label: "혼화제 단가" }
|
||
];
|
||
|
||
function toNum(v) {
|
||
const n = Number(String(v ?? "").replace(/,/g, "").trim());
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
|
||
function loadJsonArray(key) {
|
||
const raw = localStorage.getItem(key);
|
||
if (!raw) return [];
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch (error) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveJsonArray(key, value) {
|
||
localStorage.setItem(key, JSON.stringify(value));
|
||
}
|
||
|
||
function saveDraft(key, value) {
|
||
localStorage.setItem(key, JSON.stringify(value));
|
||
}
|
||
|
||
function loadDraft(key) {
|
||
const raw = localStorage.getItem(key);
|
||
if (!raw) return null;
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getCustomSpecs() {
|
||
return loadJsonArray(CUSTOM_SPECS_KEY)
|
||
.map((x) => String(x || "").trim())
|
||
.filter((x) => x.length > 0);
|
||
}
|
||
|
||
function saveCustomSpecs(specs) {
|
||
localStorage.setItem(CUSTOM_SPECS_KEY, JSON.stringify(specs));
|
||
}
|
||
|
||
function formatDateTime(iso) {
|
||
if (!iso) return "-";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "-";
|
||
return d.toLocaleString("ko-KR");
|
||
}
|
||
|
||
function formatDateOnly(iso) {
|
||
if (!iso) return "-";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "-";
|
||
const y = d.getFullYear();
|
||
const m = d.getMonth() + 1;
|
||
const day = d.getDate();
|
||
return `${y}. ${m}. ${day}.`;
|
||
}
|
||
|
||
function priceSetLabel(set) {
|
||
if (!set) return "단가 미지정";
|
||
return `${set.year}년 ${set.month}월 단가인상`;
|
||
}
|
||
|
||
function getPriceSetMap() {
|
||
const map = new Map();
|
||
priceSets.forEach((p) => map.set(p.id, p));
|
||
return map;
|
||
}
|
||
|
||
function formatMaterials(materials) {
|
||
return MATERIALS.map((m) => `${m.short}:${toNum(materials[m.key])}`).join(" / ");
|
||
}
|
||
|
||
function formatPriceSetTableHTML(prices) {
|
||
const p = prices || {};
|
||
const cells = [
|
||
["시멘트<br>(C1)kg", toNum(p.cement)],
|
||
["고로슬래그<br>(C2)kg", toNum(p.slag)],
|
||
["세척사<br>(S1)kg", toNum(p.washedSand)],
|
||
["부순모래<br>(S2)kg", toNum(p.crushedSand)],
|
||
["골재 25mm<br>(G1)kg", toNum(p.mm25)],
|
||
["골재 20mm<br>(G2)kg", toNum(p.mm20)],
|
||
["혼화제<br>(AD)kg", toNum(p.admixture)]
|
||
];
|
||
let html = '<table class="mini-table"><tr><td></td>';
|
||
cells.forEach(([k]) => {
|
||
html += `<td>${k}</td>`;
|
||
});
|
||
html += '</tr><tr><td>단가</td>';
|
||
cells.forEach(([, v]) => {
|
||
html += `<td>${fmt(v)}</td>`;
|
||
});
|
||
html += '</tr></table>';
|
||
return html;
|
||
}
|
||
|
||
function formatMixFront(meta) {
|
||
if (!meta) return "-";
|
||
const wc = toNum(meta.waterCementRatio);
|
||
const fa = toNum(meta.fineAggRatio);
|
||
const water = toNum(meta.water);
|
||
return `물시멘트비 (W/B)%:${wc} / 잔골재율 (S/a)%:${fa} / 물 (W)kg:${water}`;
|
||
}
|
||
|
||
function formatMixDisplay(entry) {
|
||
return `${formatMixFront(entry.mixMeta)} | ${formatMaterials(entry.materials || {})}`;
|
||
}
|
||
|
||
function formatMixTableHTML(entry) {
|
||
const meta = entry.mixMeta || {};
|
||
const mat = entry.materials || {};
|
||
const cells = [
|
||
["물시멘트비<br>(W/B)%", toNum(meta.waterCementRatio)],
|
||
["잔골재율<br>(S/a)%", toNum(meta.fineAggRatio)],
|
||
["물<br>(W)kg", toNum(meta.water)],
|
||
["시멘트<br>(C1)kg", toNum(mat.cement)],
|
||
["고로슬래그<br>(C2)kg", toNum(mat.slag)],
|
||
["세척사<br>(S1)kg", toNum(mat.washedSand)],
|
||
["부순모래<br>(S2)kg", toNum(mat.crushedSand)],
|
||
["골재 25mm<br>(G1)kg", toNum(mat.mm25)],
|
||
["골재 20mm<br>(G2)kg", toNum(mat.mm20)],
|
||
["혼화제<br>(AD)kg", toNum(mat.admixture)]
|
||
];
|
||
let html = '<table class="mini-table"><tr>';
|
||
cells.forEach(([k, v]) => {
|
||
html += `<td>${k}</td>`;
|
||
});
|
||
html += "</tr><tr>";
|
||
cells.forEach(([, v]) => {
|
||
html += `<td>${v}</td>`;
|
||
});
|
||
html += "</tr></table>";
|
||
return html;
|
||
}
|
||
|
||
function calcMaterialCosts(mat, prices) {
|
||
const wsDensity = toNum(prices.washedSandDensity) || 1600;
|
||
const csDensity = toNum(prices.crushedSandDensity) || 1600;
|
||
const mm20Density = toNum(prices.mm20Density) || 1500;
|
||
const mm25Density = toNum(prices.mm25Density) || 1500;
|
||
return {
|
||
cement: (toNum(mat.cement) / 1000) * toNum(prices.cement),
|
||
slag: (toNum(mat.slag) / 1000) * toNum(prices.slag),
|
||
// For m3-based items, convert kg to m3 using unit weight (kg/m3).
|
||
washedSand: (toNum(mat.washedSand) / wsDensity) * toNum(prices.washedSand),
|
||
crushedSand: (toNum(mat.crushedSand) / csDensity) * toNum(prices.crushedSand),
|
||
mm20: (toNum(mat.mm20) / mm20Density) * toNum(prices.mm20),
|
||
mm25: (toNum(mat.mm25) / mm25Density) * toNum(prices.mm25),
|
||
admixture: toNum(mat.admixture) * toNum(prices.admixture)
|
||
};
|
||
}
|
||
|
||
function sumProductionCosts(costs) {
|
||
// Production unit cost is the sum of all 7 material costs.
|
||
return (
|
||
toNum(costs.cement) +
|
||
toNum(costs.slag) +
|
||
toNum(costs.washedSand) +
|
||
toNum(costs.crushedSand) +
|
||
toNum(costs.mm20) +
|
||
toNum(costs.mm25) +
|
||
toNum(costs.admixture)
|
||
);
|
||
}
|
||
|
||
function formatCostBreakdown(costs) {
|
||
const items = [
|
||
{ label: "시멘트", value: toNum(costs.cement) },
|
||
{ label: "고로슬래그", value: toNum(costs.slag) },
|
||
{ label: "세척사", value: toNum(costs.washedSand) },
|
||
{ label: "부순모래", value: toNum(costs.crushedSand) },
|
||
{ label: "골재 25mm", value: toNum(costs.mm25) },
|
||
{ label: "골재 20mm", value: toNum(costs.mm20) },
|
||
{ label: "혼화제", value: toNum(costs.admixture) }
|
||
];
|
||
const visible = items.filter((x) => Math.round(x.value) !== 0);
|
||
return visible.map((x) => `${x.label}:${Math.round(x.value).toLocaleString("ko-KR")}`).join(" / ");
|
||
}
|
||
|
||
function formatCostTableHTML(costs) {
|
||
if (!costs) return "-";
|
||
const items = [
|
||
["시멘트", toNum(costs.cement)],
|
||
["고로슬래그", toNum(costs.slag)],
|
||
["세척사", toNum(costs.washedSand)],
|
||
["부순모래", toNum(costs.crushedSand)],
|
||
["골재 25mm", toNum(costs.mm25)],
|
||
["골재 20mm", toNum(costs.mm20)],
|
||
["혼화제", toNum(costs.admixture)]
|
||
].filter(([, v]) => Math.round(v) !== 0);
|
||
let html = '<table class="mini-table"><tr>';
|
||
items.forEach(([k]) => {
|
||
html += `<td>${k}</td>`;
|
||
});
|
||
html += "</tr><tr>";
|
||
items.forEach(([, v]) => {
|
||
html += `<td>${Math.round(v).toLocaleString("ko-KR")}</td>`;
|
||
});
|
||
html += "</tr></table>";
|
||
return html;
|
||
}
|
||
|
||
function formatCombinedMixCostTableHTML(entry, costs, prices, setText) {
|
||
const meta = entry.mixMeta || {};
|
||
const mat = entry.materials || {};
|
||
const unit = prices || {};
|
||
const cells = [
|
||
["물시멘트비<br>(W/B)%", toNum(meta.waterCementRatio), null, null],
|
||
["잔골재율<br>(S/a)%", toNum(meta.fineAggRatio), null, null],
|
||
["물<br>(W)kg", toNum(meta.water), null, null],
|
||
["시멘트<br>(C1)kg", toNum(mat.cement), toNum(costs?.cement), toNum(unit.cement)],
|
||
["고로슬래그<br>(C2)kg", toNum(mat.slag), toNum(costs?.slag), toNum(unit.slag)],
|
||
["세척사<br>(S1)kg", toNum(mat.washedSand), toNum(costs?.washedSand), toNum(unit.washedSand)],
|
||
["부순모래<br>(S2)kg", toNum(mat.crushedSand), toNum(costs?.crushedSand), toNum(unit.crushedSand)],
|
||
["골재 25mm<br>(G1)kg", toNum(mat.mm25), toNum(costs?.mm25), toNum(unit.mm25)],
|
||
["골재 20mm<br>(G2)kg", toNum(mat.mm20), toNum(costs?.mm20), toNum(unit.mm20)],
|
||
["혼화제<br>(AD)kg", toNum(mat.admixture), toNum(costs?.admixture), toNum(unit.admixture)]
|
||
];
|
||
|
||
let html = '<div class="combined-wrap">';
|
||
html += `<div class="combined-meta"><div><strong>등록일</strong> ${formatDateOnly(entry.createdAt)}</div><div><strong>적용단가</strong> ${setText}</div></div>`;
|
||
html += '<table class="mini-table"><tr><td></td>';
|
||
cells.forEach(([k]) => {
|
||
html += `<td>${k}</td>`;
|
||
});
|
||
html += '</tr><tr><td>배합</td>';
|
||
cells.forEach(([, mixVal]) => {
|
||
html += `<td>${mixVal}</td>`;
|
||
});
|
||
html += '</tr><tr><td>단가</td>';
|
||
cells.forEach(([, , , unitVal], idx) => {
|
||
if (idx < 3 || !prices) html += '<td>-</td>';
|
||
else html += `<td>${fmt(unitVal)}</td>`;
|
||
});
|
||
html += '</tr><tr><td>원가</td>';
|
||
cells.forEach(([, , costVal], idx) => {
|
||
if (idx < 3 || !costs) html += '<td>-</td>';
|
||
else html += `<td>${Math.round(costVal).toLocaleString("ko-KR")}</td>`;
|
||
});
|
||
html += '</tr></table></div>';
|
||
return html;
|
||
}
|
||
|
||
function formatSpecMixCostTableHTML(entry, prices, costs) {
|
||
const meta = entry.mixMeta || {};
|
||
const mat = entry.materials || {};
|
||
const hasPrice = !!prices;
|
||
const unit = prices || {};
|
||
|
||
let html = '<table class="mini-table"><tr><td></td><td>물시멘트비<br>(W/B)%</td><td>잔골재율<br>(S/a)%</td><td>물<br>(W)kg</td><td>시멘트<br>(C1)kg</td><td>고로슬래그<br>(C2)kg</td><td>세척사<br>(S1)kg</td><td>부순모래<br>(S2)kg</td><td>골재 25mm<br>(G1)kg</td><td>골재 20mm<br>(G2)kg</td><td>혼화제<br>(AD)kg</td></tr>';
|
||
html += `<tr><td>배합</td><td>${toNum(meta.waterCementRatio)}</td><td>${toNum(meta.fineAggRatio)}</td><td>${toNum(meta.water)}</td><td>${toNum(mat.cement)}</td><td>${toNum(mat.slag)}</td><td>${toNum(mat.washedSand)}</td><td>${toNum(mat.crushedSand)}</td><td>${toNum(mat.mm25)}</td><td>${toNum(mat.mm20)}</td><td>${toNum(mat.admixture)}</td></tr>`;
|
||
html += `<tr><td>단가</td><td>-</td><td>-</td><td>-</td><td>${hasPrice ? fmt(unit.cement) : "-"}</td><td>${hasPrice ? fmt(unit.slag) : "-"}</td><td>${hasPrice ? fmt(unit.washedSand) : "-"}</td><td>${hasPrice ? fmt(unit.crushedSand) : "-"}</td><td>${hasPrice ? fmt(unit.mm25) : "-"}</td><td>${hasPrice ? fmt(unit.mm20) : "-"}</td><td>${hasPrice ? fmt(unit.admixture) : "-"}</td></tr>`;
|
||
html += `<tr><td>원가</td><td>-</td><td>-</td><td>-</td><td>${costs ? Math.round(toNum(costs.cement)).toLocaleString("ko-KR") : "-"}</td><td>${costs ? Math.round(toNum(costs.slag)).toLocaleString("ko-KR") : "-"}</td><td>${costs ? Math.round(toNum(costs.washedSand)).toLocaleString("ko-KR") : "-"}</td><td>${costs ? Math.round(toNum(costs.crushedSand)).toLocaleString("ko-KR") : "-"}</td><td>${costs ? Math.round(toNum(costs.mm25)).toLocaleString("ko-KR") : "-"}</td><td>${costs ? Math.round(toNum(costs.mm20)).toLocaleString("ko-KR") : "-"}</td><td>${costs ? Math.round(toNum(costs.admixture)).toLocaleString("ko-KR") : "-"}</td></tr>`;
|
||
html += "</table>";
|
||
return html;
|
||
}
|
||
|
||
function costOf(entry, priceSetMap) {
|
||
const p = priceSetMap.get(entry.priceSetId);
|
||
if (!p) return null;
|
||
const mat = entry.materials || {};
|
||
const prices = p.prices || {};
|
||
const costs = calcMaterialCosts(mat, prices);
|
||
return sumProductionCosts(costs);
|
||
}
|
||
|
||
function seedDefaultDataIfEmpty() {
|
||
const savedRev = localStorage.getItem(EMBEDDED_DATA_REV_KEY);
|
||
const mustApplyEmbedded = savedRev !== EMBEDDED_DATA_REV;
|
||
if (mustApplyEmbedded) {
|
||
saveJsonArray(PRICE_SETS_KEY, DEFAULT_PRICE_SETS);
|
||
saveJsonArray(MIX_HISTORY_KEY, DEFAULT_MIX_HISTORY);
|
||
localStorage.setItem(SELECTED_PRICE_SET_KEY, "ps_2026_02");
|
||
localStorage.setItem(CUSTOM_SPECS_KEY, JSON.stringify([]));
|
||
localStorage.setItem(EMBEDDED_DATA_REV_KEY, EMBEDDED_DATA_REV);
|
||
return;
|
||
}
|
||
|
||
const hasPriceSets = loadJsonArray(PRICE_SETS_KEY).length > 0;
|
||
const hasMixHistory = loadJsonArray(MIX_HISTORY_KEY).length > 0;
|
||
if (!hasPriceSets) saveJsonArray(PRICE_SETS_KEY, DEFAULT_PRICE_SETS);
|
||
if (!hasMixHistory) saveJsonArray(MIX_HISTORY_KEY, DEFAULT_MIX_HISTORY);
|
||
if (!localStorage.getItem(SELECTED_PRICE_SET_KEY)) {
|
||
localStorage.setItem(SELECTED_PRICE_SET_KEY, "ps_2026_02");
|
||
}
|
||
}
|
||
|
||
function hydrate() {
|
||
seedDefaultDataIfEmpty();
|
||
priceSets = loadJsonArray(PRICE_SETS_KEY).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||
mixHistory = loadJsonArray(MIX_HISTORY_KEY).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||
}
|
||
|
||
function switchPage(page) {
|
||
document.getElementById("mainPage").classList.toggle("active", page === "main");
|
||
document.getElementById("pricePage").classList.toggle("active", page === "price");
|
||
document.getElementById("goMainBtn").className = page === "main" ? "primary" : "sub";
|
||
document.getElementById("goPriceBtn").className = page === "price" ? "primary" : "sub";
|
||
if (page === "main") renderMain();
|
||
if (page === "price") renderPriceList();
|
||
}
|
||
|
||
function renderPriceSetSelect() {
|
||
priceSetSelect.innerHTML = "";
|
||
if (priceSets.length === 0) {
|
||
const opt = document.createElement("option");
|
||
opt.value = "";
|
||
opt.textContent = "등록된 단가 없음";
|
||
priceSetSelect.appendChild(opt);
|
||
priceSetSelect.disabled = true;
|
||
localStorage.removeItem(SELECTED_PRICE_SET_KEY);
|
||
return;
|
||
}
|
||
|
||
priceSetSelect.disabled = false;
|
||
const saved = localStorage.getItem(SELECTED_PRICE_SET_KEY);
|
||
const validSaved = saved && priceSets.some((p) => p.id === saved);
|
||
const initial = validSaved ? saved : priceSets[0].id;
|
||
|
||
priceSets.forEach((set) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = set.id;
|
||
opt.textContent = priceSetLabel(set);
|
||
if (set.id === initial) opt.selected = true;
|
||
priceSetSelect.appendChild(opt);
|
||
});
|
||
|
||
localStorage.setItem(SELECTED_PRICE_SET_KEY, initial);
|
||
}
|
||
|
||
function renderMixPriceSetSelect(selectedId) {
|
||
const mixPriceSetSelect = document.getElementById("mPriceSetId");
|
||
mixPriceSetSelect.innerHTML = "";
|
||
if (priceSets.length === 0) {
|
||
const opt = document.createElement("option");
|
||
opt.value = "";
|
||
opt.textContent = "등록된 단가 없음";
|
||
mixPriceSetSelect.appendChild(opt);
|
||
mixPriceSetSelect.disabled = true;
|
||
return;
|
||
}
|
||
|
||
mixPriceSetSelect.disabled = false;
|
||
const fallbackId = localStorage.getItem(SELECTED_PRICE_SET_KEY) || priceSets[0].id;
|
||
const targetId = selectedId || fallbackId;
|
||
let hasTarget = false;
|
||
|
||
priceSets.forEach((set) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = set.id;
|
||
opt.textContent = priceSetLabel(set);
|
||
if (set.id === targetId) {
|
||
opt.selected = true;
|
||
hasTarget = true;
|
||
}
|
||
mixPriceSetSelect.appendChild(opt);
|
||
});
|
||
|
||
if (!hasTarget) {
|
||
const missing = document.createElement("option");
|
||
missing.value = targetId;
|
||
missing.textContent = "삭제된/미지정 단가";
|
||
missing.selected = true;
|
||
mixPriceSetSelect.appendChild(missing);
|
||
}
|
||
}
|
||
|
||
function renderStatus() {
|
||
statusRow.innerHTML = "";
|
||
const setId = localStorage.getItem(SELECTED_PRICE_SET_KEY);
|
||
const active = priceSets.find((p) => p.id === setId);
|
||
|
||
const a = document.createElement("span");
|
||
a.className = "tag";
|
||
a.textContent = active ? `현재 단가: ${priceSetLabel(active)}` : "현재 단가: 없음";
|
||
statusRow.appendChild(a);
|
||
|
||
const b = document.createElement("span");
|
||
b.className = "tag";
|
||
b.textContent = `등록 배합 이력: ${mixHistory.length}건`;
|
||
statusRow.appendChild(b);
|
||
}
|
||
|
||
function uniqueLatestSpecs() {
|
||
const map = new Map();
|
||
mixHistory.forEach((entry) => {
|
||
if (!entry.spec) return;
|
||
if (!map.has(entry.spec)) map.set(entry.spec, entry);
|
||
});
|
||
return [...map.entries()]
|
||
.map(([spec, entry]) => ({ spec, latest: entry }))
|
||
.sort((a, b) => a.spec.localeCompare(b.spec, "ko-KR", { numeric: true, sensitivity: "base" }));
|
||
}
|
||
|
||
function renderSpecDataList(selectedValue = "") {
|
||
const specSelect = document.getElementById("mSpec");
|
||
if (!specSelect) return;
|
||
specSelect.innerHTML = "";
|
||
const dynamicSpecs = uniqueLatestSpecs().map(({ spec }) => spec);
|
||
const customSpecs = getCustomSpecs();
|
||
const mergedSpecs = [...new Set([...PRESET_SPECS, ...customSpecs, ...dynamicSpecs])]
|
||
.sort((a, b) => a.localeCompare(b, "ko-KR", { numeric: true, sensitivity: "base" }));
|
||
const placeholder = document.createElement("option");
|
||
placeholder.value = "";
|
||
placeholder.textContent = "규격 선택";
|
||
specSelect.appendChild(placeholder);
|
||
mergedSpecs.forEach((spec) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = spec;
|
||
opt.textContent = spec;
|
||
specSelect.appendChild(opt);
|
||
});
|
||
const target = selectedValue || "";
|
||
if (target && !mergedSpecs.includes(target)) {
|
||
const custom = document.createElement("option");
|
||
custom.value = target;
|
||
custom.textContent = target;
|
||
specSelect.appendChild(custom);
|
||
}
|
||
specSelect.value = target;
|
||
}
|
||
|
||
function addCustomSpec() {
|
||
const input = document.getElementById("mNewSpec");
|
||
if (!input) return;
|
||
const next = String(input.value || "").trim();
|
||
if (!next) {
|
||
alert("추가할 규격을 입력하세요.");
|
||
return;
|
||
}
|
||
const all = [...new Set([...PRESET_SPECS, ...getCustomSpecs(), ...uniqueLatestSpecs().map((x) => x.spec)])];
|
||
if (all.includes(next)) {
|
||
renderSpecDataList(next);
|
||
input.value = "";
|
||
return;
|
||
}
|
||
const updated = [...getCustomSpecs(), next]
|
||
.filter((x) => x)
|
||
.sort((a, b) => a.localeCompare(b, "ko-KR", { numeric: true, sensitivity: "base" }));
|
||
saveCustomSpecs(updated);
|
||
renderSpecDataList(next);
|
||
document.getElementById("mSpec").value = next;
|
||
saveDraft(MIX_DRAFT_KEY, getMixDraftPayload());
|
||
input.value = "";
|
||
}
|
||
|
||
function renderSpecList() {
|
||
const area = document.getElementById("specListArea");
|
||
const items = uniqueLatestSpecs();
|
||
|
||
if (items.length === 0) {
|
||
area.innerHTML = '<div class="empty">배합표 이력이 없습니다. "배합표 등록"으로 시작하세요.</div>';
|
||
document.getElementById("historyArea").className = "empty";
|
||
document.getElementById("historyArea").textContent = "규격을 선택하세요.";
|
||
return;
|
||
}
|
||
|
||
const priceSetMap = getPriceSetMap();
|
||
let html = `
|
||
<table class="spec-table">
|
||
<thead>
|
||
<tr>
|
||
<th rowspan="2">규격</th>
|
||
<th colspan="11">최신 배합</th>
|
||
<th rowspan="2">최신 원가</th>
|
||
<th rowspan="2">최종 수정</th>
|
||
</tr>
|
||
<tr>
|
||
<th></th>
|
||
<th>물시멘트비<br>(W/B)%</th>
|
||
<th>잔골재율<br>(S/a)%</th>
|
||
<th>물<br>(W)kg</th>
|
||
<th>시멘트<br>(C1)kg</th>
|
||
<th>고로슬래그<br>(C2)kg</th>
|
||
<th>세척사<br>(S1)kg</th>
|
||
<th>부순모래<br>(S2)kg</th>
|
||
<th>골재 25mm<br>(G1)kg</th>
|
||
<th>골재 20mm<br>(G2)kg</th>
|
||
<th>혼화제<br>(AD)kg</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
items.forEach(({ spec, latest }) => {
|
||
const set = priceSetMap.get(latest.priceSetId);
|
||
const prices = set ? (set.prices || {}) : null;
|
||
const costs = set ? calcMaterialCosts(latest.materials || {}, prices) : null;
|
||
const cost = costOf(latest, priceSetMap);
|
||
const costText = cost === null ? "단가 미연결" : `${Math.round(cost).toLocaleString("ko-KR")}m3/원`;
|
||
const meta = latest.mixMeta || {};
|
||
const mat = latest.materials || {};
|
||
const unitVals = {
|
||
cement: set ? fmt(prices.cement) : "-",
|
||
slag: set ? fmt(prices.slag) : "-",
|
||
washedSand: set ? fmt(prices.washedSand) : "-",
|
||
crushedSand: set ? fmt(prices.crushedSand) : "-",
|
||
mm25: set ? fmt(prices.mm25) : "-",
|
||
mm20: set ? fmt(prices.mm20) : "-",
|
||
admixture: set ? fmt(prices.admixture) : "-"
|
||
};
|
||
const costVals = {
|
||
cement: costs ? Math.round(toNum(costs.cement)).toLocaleString("ko-KR") : "-",
|
||
slag: costs ? Math.round(toNum(costs.slag)).toLocaleString("ko-KR") : "-",
|
||
washedSand: costs ? Math.round(toNum(costs.washedSand)).toLocaleString("ko-KR") : "-",
|
||
crushedSand: costs ? Math.round(toNum(costs.crushedSand)).toLocaleString("ko-KR") : "-",
|
||
mm25: costs ? Math.round(toNum(costs.mm25)).toLocaleString("ko-KR") : "-",
|
||
mm20: costs ? Math.round(toNum(costs.mm20)).toLocaleString("ko-KR") : "-",
|
||
admixture: costs ? Math.round(toNum(costs.admixture)).toLocaleString("ko-KR") : "-"
|
||
};
|
||
|
||
html += `
|
||
<tr>
|
||
<td rowspan="3"><button class="spec-btn" data-spec="${spec}">${spec}</button></td>
|
||
<td class="kind-cell"><span class="kind-label">배합</span></td>
|
||
<td>${toNum(meta.waterCementRatio)}</td>
|
||
<td>${toNum(meta.fineAggRatio)}</td>
|
||
<td>${toNum(meta.water)}</td>
|
||
<td>${toNum(mat.cement)}</td>
|
||
<td>${toNum(mat.slag)}</td>
|
||
<td>${toNum(mat.washedSand)}</td>
|
||
<td>${toNum(mat.crushedSand)}</td>
|
||
<td>${toNum(mat.mm25)}</td>
|
||
<td>${toNum(mat.mm20)}</td>
|
||
<td>${toNum(mat.admixture)}</td>
|
||
<td rowspan="3">${costText}</td>
|
||
<td rowspan="3">${formatDateOnly(latest.createdAt)}</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="kind-cell"><span class="kind-label">단가</span></td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>${unitVals.cement}</td>
|
||
<td>${unitVals.slag}</td>
|
||
<td>${unitVals.washedSand}</td>
|
||
<td>${unitVals.crushedSand}</td>
|
||
<td>${unitVals.mm25}</td>
|
||
<td>${unitVals.mm20}</td>
|
||
<td>${unitVals.admixture}</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="kind-cell"><span class="kind-label">원가</span></td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>${costVals.cement}</td>
|
||
<td>${costVals.slag}</td>
|
||
<td>${costVals.washedSand}</td>
|
||
<td>${costVals.crushedSand}</td>
|
||
<td>${costVals.mm25}</td>
|
||
<td>${costVals.mm20}</td>
|
||
<td>${costVals.admixture}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += "</tbody></table>";
|
||
area.innerHTML = html;
|
||
|
||
area.querySelectorAll(".spec-btn").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
selectedSpec = btn.dataset.spec || "";
|
||
renderHistory(selectedSpec);
|
||
});
|
||
});
|
||
|
||
if (items.length > 0 && !items.some((x) => x.spec === selectedSpec)) selectedSpec = "";
|
||
if (selectedSpec) renderHistory(selectedSpec);
|
||
else {
|
||
const history = document.getElementById("historyArea");
|
||
history.className = "empty";
|
||
history.textContent = "규격을 클릭하면 이력이 표시됩니다.";
|
||
}
|
||
}
|
||
|
||
function renderHistory(spec) {
|
||
const area = document.getElementById("historyArea");
|
||
if (!spec) {
|
||
area.className = "empty";
|
||
area.textContent = "규격을 선택하세요.";
|
||
return;
|
||
}
|
||
|
||
const rows = mixHistory.filter((x) => x.spec === spec);
|
||
if (rows.length === 0) {
|
||
area.className = "empty";
|
||
area.textContent = "선택한 규격의 이력이 없습니다.";
|
||
return;
|
||
}
|
||
|
||
const priceSetMap = getPriceSetMap();
|
||
const rowIdSet = new Set(rows.map((r) => r.id));
|
||
selectedMixCompareIds = new Set([...selectedMixCompareIds].filter((id) => rowIdSet.has(id)));
|
||
let html = '<table class="history-table"><thead><tr><th>선택</th><th>적용단가</th><th></th><th>물시멘트비<br>(W/B)%</th><th>잔골재율<br>(S/a)%</th><th>물<br>(W)kg</th><th>시멘트<br>(C1)kg</th><th>고로슬래그<br>(C2)kg</th><th>세척사<br>(S1)kg</th><th>부순모래<br>(S2)kg</th><th>골재 25mm<br>(G1)kg</th><th>골재 20mm<br>(G2)kg</th><th>혼화제<br>(AD)kg</th><th>생산단가</th><th>관리</th></tr></thead><tbody>';
|
||
html = `<div class="history-tools"><button class="sub" id="openMixCompareBtn">체크 배합 비교</button><span class="count">선택 ${selectedMixCompareIds.size}건</span></div>` + html;
|
||
|
||
rows.forEach((entry) => {
|
||
const set = priceSetMap.get(entry.priceSetId);
|
||
const setText = set ? priceSetLabel(set) : "삭제된/미지정 단가";
|
||
const cost = costOf(entry, priceSetMap);
|
||
const prices = set ? (set.prices || {}) : {};
|
||
const costs = set ? calcMaterialCosts(entry.materials || {}, prices) : null;
|
||
const costText = cost === null ? "-" : `${Math.round(cost).toLocaleString("ko-KR")}m3/원`;
|
||
const meta = entry.mixMeta || {};
|
||
const mat = entry.materials || {};
|
||
|
||
const unitVals = {
|
||
cement: set ? fmt(prices.cement) : "-",
|
||
slag: set ? fmt(prices.slag) : "-",
|
||
washedSand: set ? fmt(prices.washedSand) : "-",
|
||
crushedSand: set ? fmt(prices.crushedSand) : "-",
|
||
mm20: set ? fmt(prices.mm20) : "-",
|
||
mm25: set ? fmt(prices.mm25) : "-",
|
||
admixture: set ? fmt(prices.admixture) : "-"
|
||
};
|
||
|
||
const costVals = {
|
||
cement: costs ? Math.round(toNum(costs.cement)).toLocaleString("ko-KR") : "-",
|
||
slag: costs ? Math.round(toNum(costs.slag)).toLocaleString("ko-KR") : "-",
|
||
washedSand: costs ? Math.round(toNum(costs.washedSand)).toLocaleString("ko-KR") : "-",
|
||
crushedSand: costs ? Math.round(toNum(costs.crushedSand)).toLocaleString("ko-KR") : "-",
|
||
mm20: costs ? Math.round(toNum(costs.mm20)).toLocaleString("ko-KR") : "-",
|
||
mm25: costs ? Math.round(toNum(costs.mm25)).toLocaleString("ko-KR") : "-",
|
||
admixture: costs ? Math.round(toNum(costs.admixture)).toLocaleString("ko-KR") : "-"
|
||
};
|
||
|
||
html += `
|
||
<tr>
|
||
<td rowspan="3"><label class="pick-row"><input type="checkbox" data-pick-mix="${entry.id}" ${selectedMixCompareIds.has(entry.id) ? "checked" : ""}></label></td>
|
||
<td rowspan="3">${setText}</td>
|
||
<td class="kind-cell"><span class="kind-label">배합</span></td>
|
||
<td>${toNum(meta.waterCementRatio)}</td>
|
||
<td>${toNum(meta.fineAggRatio)}</td>
|
||
<td>${toNum(meta.water)}</td>
|
||
<td>${toNum(mat.cement)}</td>
|
||
<td>${toNum(mat.slag)}</td>
|
||
<td>${toNum(mat.washedSand)}</td>
|
||
<td>${toNum(mat.crushedSand)}</td>
|
||
<td>${toNum(mat.mm25)}</td>
|
||
<td>${toNum(mat.mm20)}</td>
|
||
<td>${toNum(mat.admixture)}</td>
|
||
<td class="prod-cell" rowspan="3">${costText}</td>
|
||
<td class="manage-cell" rowspan="3"><div class="manage-wrap"><div class="action-cell"><button class="sub" data-view-calc="${entry.id}">계산</button><button class="sub" data-edit-mix="${entry.id}">수정</button><button class="danger" data-del-mix="${entry.id}">삭제</button></div></div></td>
|
||
</tr>
|
||
<tr>
|
||
<td class="kind-cell"><span class="kind-label">단가</span></td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>${unitVals.cement}</td>
|
||
<td>${unitVals.slag}</td>
|
||
<td>${unitVals.washedSand}</td>
|
||
<td>${unitVals.crushedSand}</td>
|
||
<td>${unitVals.mm25}</td>
|
||
<td>${unitVals.mm20}</td>
|
||
<td>${unitVals.admixture}</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="kind-cell"><span class="kind-label">원가</span></td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
<td>${costVals.cement}</td>
|
||
<td>${costVals.slag}</td>
|
||
<td>${costVals.washedSand}</td>
|
||
<td>${costVals.crushedSand}</td>
|
||
<td>${costVals.mm25}</td>
|
||
<td>${costVals.mm20}</td>
|
||
<td>${costVals.admixture}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += "</tbody></table>";
|
||
area.className = "";
|
||
area.innerHTML = html;
|
||
const openBtn = document.getElementById("openMixCompareBtn");
|
||
if (openBtn) openBtn.addEventListener("click", () => openMixComparePopup(spec));
|
||
area.querySelectorAll("input[data-pick-mix]").forEach((input) => {
|
||
input.addEventListener("change", () => {
|
||
const id = input.dataset.pickMix;
|
||
if (!id) return;
|
||
if (input.checked) selectedMixCompareIds.add(id);
|
||
else selectedMixCompareIds.delete(id);
|
||
const countEl = area.querySelector(".history-tools .count");
|
||
if (countEl) countEl.textContent = `선택 ${selectedMixCompareIds.size}건`;
|
||
});
|
||
});
|
||
area.querySelectorAll("button[data-view-calc]").forEach((btn) => {
|
||
btn.addEventListener("click", () => openCalcDetail(btn.dataset.viewCalc));
|
||
});
|
||
area.querySelectorAll("button[data-edit-mix]").forEach((btn) => {
|
||
btn.addEventListener("click", () => editMixEntry(btn.dataset.editMix));
|
||
});
|
||
area.querySelectorAll("button[data-del-mix]").forEach((btn) => {
|
||
btn.addEventListener("click", () => deleteMixEntry(btn.dataset.delMix));
|
||
});
|
||
}
|
||
|
||
function openCalcDetail(entryId) {
|
||
const entry = mixHistory.find((x) => x.id === entryId);
|
||
if (!entry) return;
|
||
const priceSetMap = getPriceSetMap();
|
||
const set = priceSetMap.get(entry.priceSetId);
|
||
if (!set) {
|
||
calcDetailBody.innerHTML = '<div class="empty">연결된 단가 데이터가 없습니다.</div>';
|
||
calcModal.classList.add("open");
|
||
return;
|
||
}
|
||
|
||
const mat = entry.materials || {};
|
||
const prices = set.prices || {};
|
||
const wsDensity = toNum(prices.washedSandDensity) || 1600;
|
||
const csDensity = toNum(prices.crushedSandDensity) || 1600;
|
||
const mm20Density = toNum(prices.mm20Density) || 1500;
|
||
const mm25Density = toNum(prices.mm25Density) || 1500;
|
||
|
||
const calc = {
|
||
cement: (toNum(mat.cement) / 1000) * toNum(prices.cement),
|
||
slag: (toNum(mat.slag) / 1000) * toNum(prices.slag),
|
||
washedSand: (toNum(mat.washedSand) / wsDensity) * toNum(prices.washedSand),
|
||
crushedSand: (toNum(mat.crushedSand) / csDensity) * toNum(prices.crushedSand),
|
||
mm20: (toNum(mat.mm20) / mm20Density) * toNum(prices.mm20),
|
||
mm25: (toNum(mat.mm25) / mm25Density) * toNum(prices.mm25),
|
||
admixture: toNum(mat.admixture) * toNum(prices.admixture)
|
||
};
|
||
const production = sumProductionCosts(calc);
|
||
|
||
const rows = [
|
||
{
|
||
name: "시멘트",
|
||
formula: `${toNum(mat.cement)}kg × ${fmt(prices.cement)}원`,
|
||
val: calc.cement
|
||
},
|
||
{
|
||
name: "슬래그",
|
||
formula: `${toNum(mat.slag)}kg × ${fmt(prices.slag)}원`,
|
||
val: calc.slag
|
||
},
|
||
{
|
||
name: "세척사",
|
||
formula: `(${toNum(mat.washedSand)}kg / ${wsDensity}m3/kg) × ${fmt(prices.washedSand)}원`,
|
||
val: calc.washedSand
|
||
},
|
||
{
|
||
name: "부순모래",
|
||
formula: `(${toNum(mat.crushedSand)}kg / ${csDensity}m3/kg) × ${fmt(prices.crushedSand)}원`,
|
||
val: calc.crushedSand
|
||
},
|
||
{
|
||
name: "골재 25mm<br>(G1)kg",
|
||
formula: `(${toNum(mat.mm25)}kg / ${mm25Density}m3/kg) × ${fmt(prices.mm25)}원`,
|
||
val: calc.mm25
|
||
},
|
||
{
|
||
name: "골재 20mm<br>(G2)kg",
|
||
formula: `(${toNum(mat.mm20)}kg / ${mm20Density}m3/kg) × ${fmt(prices.mm20)}원`,
|
||
val: calc.mm20
|
||
},
|
||
{
|
||
name: "혼화제<br>(AD)kg",
|
||
formula: `${toNum(mat.admixture)}kg × ${fmt(prices.admixture)}원`,
|
||
val: calc.admixture
|
||
}
|
||
];
|
||
|
||
let html = `
|
||
<div><strong>규격:</strong> ${entry.spec}</div>
|
||
<div><strong>적용 단가:</strong> ${priceSetLabel(set)}</div>
|
||
<table>
|
||
<thead><tr><th>항목</th><th>계산식</th><th>계산값(m3/원)</th></tr></thead>
|
||
<tbody>
|
||
`;
|
||
rows.forEach((r) => {
|
||
html += `<tr><td>${r.name}</td><td>${r.formula}</td><td>${fmt(r.val)}m3/원</td></tr>`;
|
||
});
|
||
html += `
|
||
</tbody>
|
||
</table>
|
||
<div><strong>생산단가 합계:</strong> ${fmt(production)}m3/원</div>
|
||
`;
|
||
calcDetailBody.innerHTML = html;
|
||
calcModal.classList.add("open");
|
||
}
|
||
|
||
function editMixEntry(id) {
|
||
const entry = mixHistory.find((x) => x.id === id);
|
||
if (!entry) return;
|
||
editingMixId = id;
|
||
mixModalTitle.textContent = "배합표 수정";
|
||
saveMixBtn.textContent = "수정 저장";
|
||
|
||
renderSpecDataList(entry.spec || "");
|
||
document.getElementById("mSpec").value = entry.spec || "";
|
||
document.getElementById("mWaterCementRatio").value = toNum(entry.mixMeta?.waterCementRatio);
|
||
document.getElementById("mFineAggRatio").value = toNum(entry.mixMeta?.fineAggRatio);
|
||
document.getElementById("mWater").value = toNum(entry.mixMeta?.water);
|
||
document.getElementById("mCement").value = toNum(entry.materials?.cement);
|
||
document.getElementById("mSlag").value = toNum(entry.materials?.slag);
|
||
document.getElementById("mWashedSand").value = toNum(entry.materials?.washedSand);
|
||
document.getElementById("mCrushedSand").value = toNum(entry.materials?.crushedSand);
|
||
document.getElementById("mMm20").value = toNum(entry.materials?.mm20);
|
||
document.getElementById("mMm25").value = toNum(entry.materials?.mm25);
|
||
document.getElementById("mAdmixture").value = toNum(entry.materials?.admixture);
|
||
document.getElementById("mNote").value = entry.note || "";
|
||
renderMixPriceSetSelect(entry.priceSetId || "");
|
||
|
||
mixModal.classList.add("open");
|
||
document.getElementById("mSpec").focus();
|
||
}
|
||
|
||
function deleteMixEntry(id) {
|
||
if (!confirm("이 배합표 이력을 삭제하시겠습니까?")) return;
|
||
mixHistory = mixHistory.filter((x) => x.id !== id);
|
||
saveJsonArray(MIX_HISTORY_KEY, mixHistory);
|
||
renderMain();
|
||
}
|
||
|
||
function openModal() {
|
||
editingMixId = null;
|
||
mixModalTitle.textContent = "배합표 등록";
|
||
saveMixBtn.textContent = "저장";
|
||
clearModalFields();
|
||
renderSpecDataList(selectedSpec || "");
|
||
document.getElementById("mSpec").value = selectedSpec || "";
|
||
renderMixPriceSetSelect(localStorage.getItem(SELECTED_PRICE_SET_KEY) || "");
|
||
applyMixDraft();
|
||
mixModal.classList.add("open");
|
||
document.getElementById("mSpec").focus();
|
||
}
|
||
|
||
function closeModal() {
|
||
mixModal.classList.remove("open");
|
||
editingMixId = null;
|
||
mixModalTitle.textContent = "배합표 등록";
|
||
saveMixBtn.textContent = "저장";
|
||
}
|
||
|
||
function openPriceModal() {
|
||
editingPriceId = null;
|
||
priceModalTitle.textContent = "단가 데이터 입력";
|
||
savePriceBtnEl.textContent = "단가 저장";
|
||
applyPriceDraft();
|
||
priceModal.classList.add("open");
|
||
syncDensitySnapshotFromInputs();
|
||
document.getElementById("pYear").focus();
|
||
}
|
||
|
||
function closePriceModal() {
|
||
priceModal.classList.remove("open");
|
||
editingPriceId = null;
|
||
priceModalTitle.textContent = "단가 데이터 입력";
|
||
savePriceBtnEl.textContent = "단가 저장";
|
||
}
|
||
|
||
function syncDensitySnapshotFromInputs() {
|
||
densitySnapshot.washedSand = toNum(document.getElementById("pWashedSandDensity").value) || 1600;
|
||
densitySnapshot.crushedSand = toNum(document.getElementById("pCrushedSandDensity").value) || 1600;
|
||
densitySnapshot.mm20 = toNum(document.getElementById("pMm20Density").value) || 1500;
|
||
densitySnapshot.mm25 = toNum(document.getElementById("pMm25Density").value) || 1500;
|
||
}
|
||
|
||
function bindDensityPriceAutoAdjust() {
|
||
const bindings = [
|
||
{ densityId: "pWashedSandDensity", priceId: "pWashedSandPrice", key: "washedSand" },
|
||
{ densityId: "pCrushedSandDensity", priceId: "pCrushedSandPrice", key: "crushedSand" },
|
||
{ densityId: "pMm20Density", priceId: "pMm20Price", key: "mm20" },
|
||
{ densityId: "pMm25Density", priceId: "pMm25Price", key: "mm25" }
|
||
];
|
||
|
||
bindings.forEach(({ densityId, priceId, key }) => {
|
||
const densityInput = document.getElementById(densityId);
|
||
const priceInput = document.getElementById(priceId);
|
||
densityInput.addEventListener("input", () => {
|
||
const prev = toNum(densitySnapshot[key]);
|
||
const next = toNum(densityInput.value);
|
||
const price = toNum(priceInput.value);
|
||
if (prev > 0 && next > 0 && price > 0) {
|
||
const adjusted = price * (next / prev);
|
||
priceInput.value = adjusted.toFixed(2);
|
||
}
|
||
if (next > 0) densitySnapshot[key] = next;
|
||
});
|
||
});
|
||
}
|
||
|
||
function clearModalFields() {
|
||
["mWaterCementRatio", "mFineAggRatio", "mWater", "mCement", "mSlag", "mWashedSand", "mCrushedSand", "mMm20", "mMm25", "mAdmixture", "mNote"].forEach((id) => {
|
||
document.getElementById(id).value = "";
|
||
});
|
||
}
|
||
|
||
function getMixDraftPayload() {
|
||
return {
|
||
mSpec: document.getElementById("mSpec").value,
|
||
mPriceSetId: document.getElementById("mPriceSetId").value,
|
||
mWaterCementRatio: document.getElementById("mWaterCementRatio").value,
|
||
mFineAggRatio: document.getElementById("mFineAggRatio").value,
|
||
mWater: document.getElementById("mWater").value,
|
||
mCement: document.getElementById("mCement").value,
|
||
mSlag: document.getElementById("mSlag").value,
|
||
mWashedSand: document.getElementById("mWashedSand").value,
|
||
mCrushedSand: document.getElementById("mCrushedSand").value,
|
||
mMm20: document.getElementById("mMm20").value,
|
||
mMm25: document.getElementById("mMm25").value,
|
||
mAdmixture: document.getElementById("mAdmixture").value,
|
||
mNote: document.getElementById("mNote").value
|
||
};
|
||
}
|
||
|
||
function applyMixDraft() {
|
||
const draft = loadDraft(MIX_DRAFT_KEY);
|
||
if (!draft) return;
|
||
const draftSpec = String(draft.mSpec || "").trim();
|
||
if (draftSpec) {
|
||
const specSelect = document.getElementById("mSpec");
|
||
if (specSelect && ![...specSelect.options].some((o) => o.value === draftSpec)) {
|
||
const opt = document.createElement("option");
|
||
opt.value = draftSpec;
|
||
opt.textContent = draftSpec;
|
||
specSelect.appendChild(opt);
|
||
}
|
||
}
|
||
Object.entries(draft).forEach(([id, val]) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = val ?? "";
|
||
});
|
||
}
|
||
|
||
function clearMixDraft() {
|
||
localStorage.removeItem(MIX_DRAFT_KEY);
|
||
}
|
||
|
||
function saveMixEntry() {
|
||
const spec = document.getElementById("mSpec").value.trim();
|
||
if (!spec) {
|
||
alert("규격명을 입력하세요.");
|
||
return;
|
||
}
|
||
|
||
const selectedPriceSetId = document.getElementById("mPriceSetId").value || localStorage.getItem(SELECTED_PRICE_SET_KEY);
|
||
if (!selectedPriceSetId) {
|
||
alert("먼저 단가 데이터를 등록하세요.");
|
||
return;
|
||
}
|
||
|
||
const entry = {
|
||
id: editingMixId || crypto.randomUUID(),
|
||
spec,
|
||
mixMeta: {
|
||
waterCementRatio: toNum(document.getElementById("mWaterCementRatio").value),
|
||
fineAggRatio: toNum(document.getElementById("mFineAggRatio").value),
|
||
water: toNum(document.getElementById("mWater").value)
|
||
},
|
||
materials: {
|
||
cement: toNum(document.getElementById("mCement").value),
|
||
slag: toNum(document.getElementById("mSlag").value),
|
||
washedSand: toNum(document.getElementById("mWashedSand").value),
|
||
crushedSand: toNum(document.getElementById("mCrushedSand").value),
|
||
mm20: toNum(document.getElementById("mMm20").value),
|
||
mm25: toNum(document.getElementById("mMm25").value),
|
||
admixture: toNum(document.getElementById("mAdmixture").value)
|
||
},
|
||
priceSetId: selectedPriceSetId,
|
||
note: document.getElementById("mNote").value.trim(),
|
||
createdAt: editingMixId
|
||
? (mixHistory.find((x) => x.id === editingMixId)?.createdAt || new Date().toISOString())
|
||
: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
|
||
if (editingMixId) {
|
||
mixHistory = mixHistory.map((x) => (x.id === editingMixId ? entry : x));
|
||
} else {
|
||
mixHistory.unshift(entry);
|
||
}
|
||
saveJsonArray(MIX_HISTORY_KEY, mixHistory);
|
||
clearMixDraft();
|
||
selectedSpec = "";
|
||
clearModalFields();
|
||
closeModal();
|
||
renderMain();
|
||
}
|
||
|
||
function clearPriceForm() {
|
||
["pYear", "pMonth", "pCementPrice", "pSlagPrice", "pWashedSandPrice", "pCrushedSandPrice", "pMm20Price", "pMm25Price", "pAdmixturePrice"].forEach((id) => {
|
||
document.getElementById(id).value = "";
|
||
});
|
||
["pWashedSandDensity", "pCrushedSandDensity", "pMm20Density", "pMm25Density"].forEach((id) => {
|
||
document.getElementById(id).value = "";
|
||
});
|
||
syncDensitySnapshotFromInputs();
|
||
localStorage.removeItem(PRICE_DRAFT_KEY);
|
||
}
|
||
|
||
function getPriceDraftPayload() {
|
||
return {
|
||
pYear: document.getElementById("pYear").value,
|
||
pMonth: document.getElementById("pMonth").value,
|
||
pCementPrice: document.getElementById("pCementPrice").value,
|
||
pSlagPrice: document.getElementById("pSlagPrice").value,
|
||
pWashedSandPrice: document.getElementById("pWashedSandPrice").value,
|
||
pCrushedSandPrice: document.getElementById("pCrushedSandPrice").value,
|
||
pMm20Price: document.getElementById("pMm20Price").value,
|
||
pMm25Price: document.getElementById("pMm25Price").value,
|
||
pAdmixturePrice: document.getElementById("pAdmixturePrice").value,
|
||
pWashedSandDensity: document.getElementById("pWashedSandDensity").value,
|
||
pCrushedSandDensity: document.getElementById("pCrushedSandDensity").value,
|
||
pMm20Density: document.getElementById("pMm20Density").value,
|
||
pMm25Density: document.getElementById("pMm25Density").value
|
||
};
|
||
}
|
||
|
||
function applyPriceDraft() {
|
||
const draft = loadDraft(PRICE_DRAFT_KEY);
|
||
if (!draft) return;
|
||
Object.entries(draft).forEach(([id, val]) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = val ?? "";
|
||
});
|
||
syncDensitySnapshotFromInputs();
|
||
}
|
||
|
||
function clearPriceDraft() {
|
||
localStorage.removeItem(PRICE_DRAFT_KEY);
|
||
}
|
||
|
||
function applyTargetPricePreset() {
|
||
hydrate();
|
||
if (!selectedSpec) {
|
||
alert("먼저 메인 페이지에서 기준 규격을 클릭해 선택하세요.");
|
||
return;
|
||
}
|
||
const baseEntry = mixHistory.find((x) => x.spec === selectedSpec) || null;
|
||
|
||
if (!baseEntry) {
|
||
alert("선택한 규격의 배합 이력이 없어 목표값 반영을 할 수 없습니다.");
|
||
return;
|
||
}
|
||
|
||
const mat = baseEntry.materials || {};
|
||
const cementKg = toNum(mat.cement);
|
||
const slagKg = toNum(mat.slag);
|
||
const wsKg = toNum(mat.washedSand);
|
||
const csKg = toNum(mat.crushedSand);
|
||
const mm25Kg = toNum(mat.mm25);
|
||
const admixtureKg = toNum(mat.admixture);
|
||
|
||
const wsDensity = toNum(document.getElementById("pWashedSandDensity").value) || 1600;
|
||
const csDensity = toNum(document.getElementById("pCrushedSandDensity").value) || 1600;
|
||
const mm25Density = toNum(document.getElementById("pMm25Density").value) || 1500;
|
||
|
||
if (cementKg <= 0 || slagKg <= 0 || wsKg <= 0 || csKg <= 0 || mm25Kg <= 0 || admixtureKg <= 0) {
|
||
alert("선택 배합의 시멘트/슬래그/세척사/부순모래/25mm/혼화제 수량이 0보다 커야 합니다.");
|
||
return;
|
||
}
|
||
|
||
// User requested fixed target costs in this order:
|
||
// cement, slag, washedSand, crushedSand, mm25, admixture
|
||
const target = {
|
||
cement: 31024,
|
||
slag: 7242,
|
||
washedSand: 9352,
|
||
crushedSand: 4709,
|
||
mm25: 11052,
|
||
admixture: 3984
|
||
};
|
||
|
||
const userPrice = {
|
||
cement: toNum(document.getElementById("pCementPrice").value),
|
||
slag: toNum(document.getElementById("pSlagPrice").value),
|
||
washedSand: toNum(document.getElementById("pWashedSandPrice").value),
|
||
crushedSand: toNum(document.getElementById("pCrushedSandPrice").value),
|
||
mm25: toNum(document.getElementById("pMm25Price").value),
|
||
admixture: toNum(document.getElementById("pAdmixturePrice").value)
|
||
};
|
||
|
||
const baseCost = {
|
||
cement: (cementKg / 1000) * userPrice.cement,
|
||
slag: (slagKg / 1000) * userPrice.slag,
|
||
washedSand: (wsKg / wsDensity) * userPrice.washedSand,
|
||
crushedSand: (csKg / csDensity) * userPrice.crushedSand,
|
||
mm25: (mm25Kg / mm25Density) * userPrice.mm25,
|
||
admixture: admixtureKg * userPrice.admixture
|
||
};
|
||
|
||
const safeAdj = (t, b) => (b > 0 ? t / b : 1);
|
||
const adj = {
|
||
cementAdj: safeAdj(target.cement, baseCost.cement),
|
||
slagAdj: safeAdj(target.slag, baseCost.slag),
|
||
washedSandAdj: safeAdj(target.washedSand, baseCost.washedSand),
|
||
crushedSandAdj: safeAdj(target.crushedSand, baseCost.crushedSand),
|
||
mm25Adj: safeAdj(target.mm25, baseCost.mm25),
|
||
admixtureAdj: safeAdj(target.admixture, baseCost.admixture)
|
||
};
|
||
// Also apply immediately to the currently selected price set so the result reflects right away.
|
||
const activePriceSetId = localStorage.getItem(SELECTED_PRICE_SET_KEY);
|
||
const activeIdx = priceSets.findIndex((p) => p.id === activePriceSetId);
|
||
if (activeIdx >= 0) {
|
||
const current = priceSets[activeIdx];
|
||
priceSets[activeIdx] = {
|
||
...current,
|
||
prices: {
|
||
...(current.prices || {}),
|
||
cementAdj: Number(adj.cementAdj.toFixed(6)),
|
||
slagAdj: Number(adj.slagAdj.toFixed(6)),
|
||
washedSandAdj: Number(adj.washedSandAdj.toFixed(6)),
|
||
crushedSandAdj: Number(adj.crushedSandAdj.toFixed(6)),
|
||
mm25Adj: Number(adj.mm25Adj.toFixed(6)),
|
||
admixtureAdj: Number(adj.admixtureAdj.toFixed(6)),
|
||
washedSandDensity: wsDensity,
|
||
crushedSandDensity: csDensity,
|
||
mm25Density: mm25Density
|
||
},
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
saveJsonArray(PRICE_SETS_KEY, priceSets);
|
||
renderPriceList();
|
||
renderMain();
|
||
}
|
||
|
||
alert(`규격 [${selectedSpec}] 기준 목표값에 맞게 보정계수만 반영했습니다. 입력 단가는 그대로 유지됩니다.`);
|
||
}
|
||
|
||
function validatePriceForm() {
|
||
const year = toNum(document.getElementById("pYear").value);
|
||
const month = toNum(document.getElementById("pMonth").value);
|
||
|
||
if (!year || !month) {
|
||
alert("단가인상 년/월을 입력하세요.");
|
||
return null;
|
||
}
|
||
if (month < 1 || month > 12) {
|
||
alert("월은 1~12 사이여야 합니다.");
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
year,
|
||
month,
|
||
name: `${year}년 ${month}월 단가인상`,
|
||
prices: {
|
||
cement: toNum(document.getElementById("pCementPrice").value),
|
||
slag: toNum(document.getElementById("pSlagPrice").value),
|
||
washedSand: toNum(document.getElementById("pWashedSandPrice").value),
|
||
crushedSand: toNum(document.getElementById("pCrushedSandPrice").value),
|
||
mm20: toNum(document.getElementById("pMm20Price").value),
|
||
mm25: toNum(document.getElementById("pMm25Price").value),
|
||
admixture: toNum(document.getElementById("pAdmixturePrice").value),
|
||
washedSandDensity: toNum(document.getElementById("pWashedSandDensity").value) || 1600,
|
||
crushedSandDensity: toNum(document.getElementById("pCrushedSandDensity").value) || 1600,
|
||
mm20Density: toNum(document.getElementById("pMm20Density").value) || 1500,
|
||
mm25Density: toNum(document.getElementById("pMm25Density").value) || 1500
|
||
}
|
||
};
|
||
}
|
||
|
||
function addPriceSet() {
|
||
const form = validatePriceForm();
|
||
if (!form) return;
|
||
|
||
if (editingPriceId) {
|
||
const editingIndex = priceSets.findIndex((p) => p.id === editingPriceId);
|
||
if (editingIndex < 0) {
|
||
alert("수정 대상 단가를 찾을 수 없습니다.");
|
||
return;
|
||
}
|
||
const duplicateOther = priceSets.find((p) => p.id !== editingPriceId && p.year === form.year && p.month === form.month);
|
||
if (duplicateOther) {
|
||
alert("같은 년/월 단가가 이미 있습니다. 년/월을 변경하거나 기존 항목을 수정하세요.");
|
||
return;
|
||
}
|
||
const existing = priceSets[editingIndex];
|
||
priceSets[editingIndex] = {
|
||
...existing,
|
||
...form,
|
||
prices: {
|
||
...(existing.prices || {}),
|
||
...(form.prices || {})
|
||
},
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
saveJsonArray(PRICE_SETS_KEY, priceSets);
|
||
localStorage.setItem(SELECTED_PRICE_SET_KEY, editingPriceId);
|
||
clearPriceDraft();
|
||
clearPriceForm();
|
||
renderPriceList();
|
||
renderMain();
|
||
closePriceModal();
|
||
alert("단가를 수정했습니다.");
|
||
return;
|
||
}
|
||
|
||
const duplicateIndex = priceSets.findIndex((p) => p.year === form.year && p.month === form.month);
|
||
if (duplicateIndex >= 0) {
|
||
const existing = priceSets[duplicateIndex];
|
||
const updated = {
|
||
...existing,
|
||
...form,
|
||
prices: {
|
||
...(existing.prices || {}),
|
||
...(form.prices || {})
|
||
},
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
priceSets[duplicateIndex] = updated;
|
||
saveJsonArray(PRICE_SETS_KEY, priceSets);
|
||
localStorage.setItem(SELECTED_PRICE_SET_KEY, updated.id);
|
||
clearPriceDraft();
|
||
clearPriceForm();
|
||
renderPriceList();
|
||
renderMain();
|
||
closePriceModal();
|
||
alert("같은 년/월 단가를 업데이트했습니다.");
|
||
return;
|
||
}
|
||
|
||
const item = {
|
||
id: crypto.randomUUID(),
|
||
...form,
|
||
prices: {
|
||
...(form.prices || {}),
|
||
cementAdj: 1,
|
||
slagAdj: 1,
|
||
washedSandAdj: 1,
|
||
crushedSandAdj: 1,
|
||
mm20Adj: 1,
|
||
mm25Adj: 1,
|
||
admixtureAdj: 1
|
||
},
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
|
||
priceSets.unshift(item);
|
||
saveJsonArray(PRICE_SETS_KEY, priceSets);
|
||
localStorage.setItem(SELECTED_PRICE_SET_KEY, item.id);
|
||
clearPriceDraft();
|
||
clearPriceForm();
|
||
renderPriceList();
|
||
renderMain();
|
||
closePriceModal();
|
||
alert("단가가 저장되었습니다.");
|
||
}
|
||
|
||
function deletePriceSet(id) {
|
||
if (!confirm("이 단가를 삭제하시겠습니까?")) return;
|
||
|
||
priceSets = priceSets.filter((x) => x.id !== id);
|
||
saveJsonArray(PRICE_SETS_KEY, priceSets);
|
||
|
||
const selected = localStorage.getItem(SELECTED_PRICE_SET_KEY);
|
||
if (selected === id) {
|
||
if (priceSets.length > 0) localStorage.setItem(SELECTED_PRICE_SET_KEY, priceSets[0].id);
|
||
else localStorage.removeItem(SELECTED_PRICE_SET_KEY);
|
||
}
|
||
|
||
renderPriceList();
|
||
renderMain();
|
||
}
|
||
|
||
function editPriceSet(id) {
|
||
const set = priceSets.find((x) => x.id === id);
|
||
if (!set) return;
|
||
editingPriceId = id;
|
||
priceModalTitle.textContent = "단가 데이터 수정";
|
||
savePriceBtnEl.textContent = "수정 저장";
|
||
|
||
document.getElementById("pYear").value = toNum(set.year);
|
||
document.getElementById("pMonth").value = toNum(set.month);
|
||
document.getElementById("pCementPrice").value = toNum(set.prices?.cement);
|
||
document.getElementById("pSlagPrice").value = toNum(set.prices?.slag);
|
||
document.getElementById("pWashedSandPrice").value = toNum(set.prices?.washedSand);
|
||
document.getElementById("pCrushedSandPrice").value = toNum(set.prices?.crushedSand);
|
||
document.getElementById("pMm20Price").value = toNum(set.prices?.mm20);
|
||
document.getElementById("pMm25Price").value = toNum(set.prices?.mm25);
|
||
document.getElementById("pAdmixturePrice").value = toNum(set.prices?.admixture);
|
||
document.getElementById("pWashedSandDensity").value = toNum(set.prices?.washedSandDensity) || 1600;
|
||
document.getElementById("pCrushedSandDensity").value = toNum(set.prices?.crushedSandDensity) || 1600;
|
||
document.getElementById("pMm20Density").value = toNum(set.prices?.mm20Density) || 1500;
|
||
document.getElementById("pMm25Density").value = toNum(set.prices?.mm25Density) || 1500;
|
||
syncDensitySnapshotFromInputs();
|
||
priceModal.classList.add("open");
|
||
document.getElementById("pYear").focus();
|
||
}
|
||
|
||
function renderPriceList() {
|
||
const area = document.getElementById("priceListArea");
|
||
if (priceSets.length === 0) {
|
||
area.innerHTML = '<div class="empty">저장된 단가가 없습니다.</div>';
|
||
renderPriceCompareControls();
|
||
return;
|
||
}
|
||
|
||
let html = `
|
||
<table class="line-less-table spec-table">
|
||
<thead>
|
||
<tr>
|
||
<th rowspan="2">단가인상</th>
|
||
<th colspan="8">단가</th>
|
||
<th rowspan="2">등록일시</th>
|
||
<th rowspan="2">관리</th>
|
||
</tr>
|
||
<tr>
|
||
<th></th>
|
||
<th>시멘트<br>(C1)kg</th>
|
||
<th>고로슬래그<br>(C2)kg</th>
|
||
<th>세척사<br>(S1)kg</th>
|
||
<th>부순모래<br>(S2)kg</th>
|
||
<th>골재 25mm<br>(G1)kg</th>
|
||
<th>골재 20mm<br>(G2)kg</th>
|
||
<th>혼화제<br>(AD)kg</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
priceSets.forEach((set) => {
|
||
const p = set.prices || {};
|
||
html += `
|
||
<tr>
|
||
<td>${priceSetLabel(set)}</td>
|
||
<td class="kind-cell">단가</td>
|
||
<td>${fmt(p.cement)}</td>
|
||
<td>${fmt(p.slag)}</td>
|
||
<td>${fmt(p.washedSand)}</td>
|
||
<td>${fmt(p.crushedSand)}</td>
|
||
<td>${fmt(p.mm25)}</td>
|
||
<td>${fmt(p.mm20)}</td>
|
||
<td>${fmt(p.admixture)}</td>
|
||
<td>${formatDateTime(set.createdAt)}</td>
|
||
<td><button class="sub" data-edit-price="${set.id}">수정</button> <button class="danger" data-del="${set.id}">삭제</button></td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += "</tbody></table>";
|
||
area.innerHTML = html;
|
||
|
||
area.querySelectorAll("button[data-edit-price]").forEach((btn) => {
|
||
btn.addEventListener("click", () => editPriceSet(btn.dataset.editPrice));
|
||
});
|
||
area.querySelectorAll("button[data-del]").forEach((btn) => {
|
||
btn.addEventListener("click", () => deletePriceSet(btn.dataset.del));
|
||
});
|
||
renderPriceCompareControls();
|
||
}
|
||
|
||
function fmt(n) {
|
||
const v = toNum(n);
|
||
const rounded = Math.round(v * 100) / 100;
|
||
return Number.isInteger(rounded) ? rounded.toLocaleString("ko-KR") : rounded.toLocaleString("ko-KR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function fmtInt(n) {
|
||
return Math.round(toNum(n)).toLocaleString("ko-KR");
|
||
}
|
||
|
||
function getChartTooltip() {
|
||
if (chartTooltipEl && document.body.contains(chartTooltipEl)) return chartTooltipEl;
|
||
chartTooltipEl = document.createElement("div");
|
||
chartTooltipEl.className = "chart-tooltip";
|
||
document.body.appendChild(chartTooltipEl);
|
||
return chartTooltipEl;
|
||
}
|
||
|
||
function hideChartTooltip() {
|
||
const tip = getChartTooltip();
|
||
tip.style.display = "none";
|
||
}
|
||
|
||
function showChartTooltip(clientX, clientY, text) {
|
||
const tip = getChartTooltip();
|
||
tip.textContent = text;
|
||
tip.style.display = "block";
|
||
const offset = 12;
|
||
tip.style.left = `${clientX + offset}px`;
|
||
tip.style.top = `${clientY + offset}px`;
|
||
}
|
||
|
||
function bindCanvasHover(canvas, hitAreas) {
|
||
if (!canvas) return;
|
||
const prev = chartHoverHandlers.get(canvas);
|
||
if (prev) {
|
||
canvas.removeEventListener("mousemove", prev.move);
|
||
canvas.removeEventListener("mouseleave", prev.leave);
|
||
}
|
||
|
||
const move = (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const hit = hitAreas.find((h) => x >= h.x && x <= h.x + h.w && y >= h.y && y <= h.y + h.h);
|
||
if (!hit) {
|
||
hideChartTooltip();
|
||
return;
|
||
}
|
||
showChartTooltip(e.clientX, e.clientY, `${fmtInt(hit.value)}`);
|
||
};
|
||
const leave = () => hideChartTooltip();
|
||
canvas.addEventListener("mousemove", move);
|
||
canvas.addEventListener("mouseleave", leave);
|
||
chartHoverHandlers.set(canvas, { move, leave });
|
||
}
|
||
|
||
function priceSetOrderValue(set) {
|
||
if (!set) return 0;
|
||
const y = toNum(set.year);
|
||
const m = toNum(set.month);
|
||
if (y > 0 && m > 0) return y * 100 + m;
|
||
return new Date(set.createdAt || 0).getTime();
|
||
}
|
||
|
||
function drawPriceCompareChart(canvas, base, target) {
|
||
if (!canvas || !base || !target) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const cssWidth = canvas.clientWidth || 900;
|
||
const cssHeight = canvas.clientHeight || 260;
|
||
canvas.width = Math.floor(cssWidth * dpr);
|
||
canvas.height = Math.floor(cssHeight * dpr);
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||
|
||
const padding = { top: 22, right: 12, bottom: 50, left: 48 };
|
||
const plotW = cssWidth - padding.left - padding.right;
|
||
const plotH = cssHeight - padding.top - padding.bottom;
|
||
const keys = PRICE_COMPARE_FIELDS.map((f) => f.key);
|
||
const labels = PRICE_COMPARE_FIELDS.map((f) => f.label.replace(" 단가", ""));
|
||
const baseVals = keys.map((k) => toNum((base.prices || {})[k]));
|
||
const targetVals = keys.map((k) => toNum((target.prices || {})[k]));
|
||
const maxVal = Math.max(1, ...baseVals, ...targetVals);
|
||
const groupW = plotW / keys.length;
|
||
const barW = Math.max(10, Math.min(22, groupW * 0.28));
|
||
const hitAreas = [];
|
||
|
||
ctx.strokeStyle = "#d1d5db";
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, padding.top);
|
||
ctx.lineTo(padding.left, padding.top + plotH);
|
||
ctx.lineTo(padding.left + plotW, padding.top + plotH);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = "#94a3b8";
|
||
ctx.font = "11px 'Segoe UI', 'Noto Sans KR', sans-serif";
|
||
ctx.textAlign = "right";
|
||
for (let i = 0; i <= 4; i += 1) {
|
||
const v = (maxVal * i) / 4;
|
||
const y = padding.top + plotH - (plotH * i) / 4;
|
||
ctx.fillText(fmtInt(v), padding.left - 6, y + 3);
|
||
ctx.strokeStyle = "#eef2f7";
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, y);
|
||
ctx.lineTo(padding.left + plotW, y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
keys.forEach((_, i) => {
|
||
const centerX = padding.left + groupW * i + groupW / 2;
|
||
const baseH = (baseVals[i] / maxVal) * plotH;
|
||
const targetH = (targetVals[i] / maxVal) * plotH;
|
||
const baseX = centerX - barW - 2;
|
||
const targetX = centerX + 2;
|
||
const baseY = padding.top + plotH - baseH;
|
||
const targetY = padding.top + plotH - targetH;
|
||
|
||
ctx.fillStyle = "#64748b";
|
||
ctx.fillRect(baseX, baseY, barW, baseH);
|
||
ctx.fillStyle = "#0f766e";
|
||
ctx.fillRect(targetX, targetY, barW, targetH);
|
||
hitAreas.push({ x: baseX, y: baseY, w: barW, h: baseH, label: labels[i], value: baseVals[i], series: "기준" });
|
||
hitAreas.push({ x: targetX, y: targetY, w: barW, h: targetH, label: labels[i], value: targetVals[i], series: "비교" });
|
||
|
||
ctx.fillStyle = "#1f2937";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(labels[i], centerX, padding.top + plotH + 16);
|
||
});
|
||
|
||
ctx.fillStyle = "#64748b";
|
||
ctx.fillRect(padding.left, cssHeight - 18, 10, 10);
|
||
ctx.fillStyle = "#1f2937";
|
||
ctx.textAlign = "left";
|
||
ctx.fillText("기준", padding.left + 14, cssHeight - 9);
|
||
ctx.fillStyle = "#0f766e";
|
||
ctx.fillRect(padding.left + 54, cssHeight - 18, 10, 10);
|
||
ctx.fillStyle = "#1f2937";
|
||
ctx.fillText("비교", padding.left + 68, cssHeight - 9);
|
||
bindCanvasHover(canvas, hitAreas);
|
||
}
|
||
|
||
function drawMixCostCompareChart(canvas, labels, datasets) {
|
||
if (!canvas || !labels || labels.length === 0 || !datasets || datasets.length === 0) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const cssWidth = canvas.clientWidth || 980;
|
||
const cssHeight = canvas.clientHeight || 300;
|
||
canvas.width = Math.floor(cssWidth * dpr);
|
||
canvas.height = Math.floor(cssHeight * dpr);
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||
|
||
const colors = ["#0f766e", "#1d4ed8", "#dc2626", "#7c3aed", "#ea580c", "#0e7490", "#4f46e5", "#a16207"];
|
||
const padding = { top: 24, right: 12, bottom: 96, left: 54 };
|
||
const plotW = cssWidth - padding.left - padding.right;
|
||
const plotH = cssHeight - padding.top - padding.bottom;
|
||
const allVals = datasets.flatMap((d) => d.values);
|
||
const maxVal = Math.max(1, ...allVals);
|
||
const groupW = plotW / labels.length;
|
||
const slotW = Math.max(8, Math.min(20, groupW / (datasets.length + 1)));
|
||
const usedW = slotW * datasets.length;
|
||
const startShift = usedW / 2;
|
||
const hitAreas = [];
|
||
|
||
ctx.strokeStyle = "#d1d5db";
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, padding.top);
|
||
ctx.lineTo(padding.left, padding.top + plotH);
|
||
ctx.lineTo(padding.left + plotW, padding.top + plotH);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = "#94a3b8";
|
||
ctx.font = "11px 'Segoe UI', 'Noto Sans KR', sans-serif";
|
||
ctx.textAlign = "right";
|
||
for (let i = 0; i <= 4; i += 1) {
|
||
const v = (maxVal * i) / 4;
|
||
const y = padding.top + plotH - (plotH * i) / 4;
|
||
ctx.fillText(fmtInt(v), padding.left - 6, y + 3);
|
||
ctx.strokeStyle = "#eef2f7";
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, y);
|
||
ctx.lineTo(padding.left + plotW, y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
labels.forEach((label, i) => {
|
||
const centerX = padding.left + groupW * i + groupW / 2;
|
||
datasets.forEach((ds, di) => {
|
||
const v = toNum(ds.values[i]);
|
||
const h = (v / maxVal) * plotH;
|
||
const x = centerX - startShift + di * slotW;
|
||
const y = padding.top + plotH - h;
|
||
ctx.fillStyle = colors[di % colors.length];
|
||
ctx.fillRect(x, y, slotW - 2, h);
|
||
hitAreas.push({ x, y, w: slotW - 2, h, label, value: v, series: ds.name });
|
||
});
|
||
ctx.fillStyle = "#1f2937";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(label, centerX, padding.top + plotH + 16);
|
||
});
|
||
|
||
let lx = padding.left;
|
||
let ly = cssHeight - 14;
|
||
const legendRowStep = 16;
|
||
const maxLegendX = cssWidth - padding.right - 120;
|
||
datasets.forEach((ds, di) => {
|
||
if (lx > maxLegendX) {
|
||
lx = padding.left;
|
||
ly += legendRowStep;
|
||
}
|
||
const color = colors[di % colors.length];
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(lx, ly - 9, 10, 10);
|
||
ctx.fillStyle = "#1f2937";
|
||
ctx.textAlign = "left";
|
||
const name = ds.name.length > 16 ? `${ds.name.slice(0, 16)}...` : ds.name;
|
||
ctx.fillText(name, lx + 14, ly);
|
||
lx += 150;
|
||
});
|
||
bindCanvasHover(canvas, hitAreas);
|
||
}
|
||
|
||
function openMixComparePopup(spec) {
|
||
const rows = mixHistory
|
||
.filter((x) => x.spec === spec && selectedMixCompareIds.has(x.id))
|
||
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||
if (rows.length === 0) {
|
||
alert("비교할 배합을 먼저 체크하세요.");
|
||
return;
|
||
}
|
||
const priceSetMap = getPriceSetMap();
|
||
const itemLabels = MATERIALS.map((m) => m.label).concat(["생산단가"]);
|
||
|
||
const prepared = rows.map((entry) => {
|
||
const set = priceSetMap.get(entry.priceSetId);
|
||
const prices = set ? (set.prices || {}) : {};
|
||
const costs = set ? calcMaterialCosts(entry.materials || {}, prices) : null;
|
||
const production = costs ? sumProductionCosts(costs) : 0;
|
||
const values = [
|
||
toNum(costs?.cement),
|
||
toNum(costs?.slag),
|
||
toNum(costs?.washedSand),
|
||
toNum(costs?.crushedSand),
|
||
toNum(costs?.mm25),
|
||
toNum(costs?.mm20),
|
||
toNum(costs?.admixture),
|
||
production
|
||
];
|
||
return {
|
||
name: `${priceSetLabel(set)} (${formatDateOnly(entry.createdAt).replace(/\s/g, "")})`,
|
||
values
|
||
};
|
||
});
|
||
|
||
let html = '<table class="line-less-table"><thead><tr><th>항목</th>';
|
||
prepared.forEach((p) => {
|
||
html += `<th>${p.name}</th>`;
|
||
});
|
||
html += "<th>증.감비용</th>";
|
||
html += "</tr></thead><tbody>";
|
||
itemLabels.forEach((item, idx) => {
|
||
html += `<tr><td>${item}</td>`;
|
||
prepared.forEach((p) => {
|
||
html += `<td>${Math.round(toNum(p.values[idx])).toLocaleString("ko-KR")}</td>`;
|
||
});
|
||
let diffCost = 0;
|
||
if (prepared.length >= 2) {
|
||
const firstVal = toNum(prepared[0].values[idx]);
|
||
const lastVal = toNum(prepared[prepared.length - 1].values[idx]);
|
||
diffCost = Math.round(lastVal - firstVal);
|
||
}
|
||
const diffText = `${diffCost >= 0 ? "+" : ""}${diffCost.toLocaleString("ko-KR")}`;
|
||
html += `<td>${diffText}</td>`;
|
||
html += "</tr>";
|
||
});
|
||
html += "</tbody></table>";
|
||
html += `
|
||
<div class="compare-chart-wrap">
|
||
<div class="compare-chart-title">체크된 배합 금액 비교 그래프</div>
|
||
<canvas id="mixCompareChart"></canvas>
|
||
</div>
|
||
`;
|
||
mixCompareBody.innerHTML = html;
|
||
mixCompareModal.classList.add("open");
|
||
drawMixCostCompareChart(
|
||
document.getElementById("mixCompareChart"),
|
||
itemLabels,
|
||
prepared.map((p) => ({ name: p.name, values: p.values }))
|
||
);
|
||
}
|
||
|
||
function renderPriceCompareControls() {
|
||
compareBaseSelect.innerHTML = "";
|
||
compareTargetSelect.innerHTML = "";
|
||
const area = document.getElementById("priceCompareArea");
|
||
|
||
if (priceSets.length < 2) {
|
||
area.className = "empty";
|
||
area.textContent = "단가 2개 이상 등록 시 비교표가 표시됩니다.";
|
||
return;
|
||
}
|
||
|
||
priceSets.forEach((set) => {
|
||
const label = priceSetLabel(set);
|
||
const o1 = document.createElement("option");
|
||
o1.value = set.id;
|
||
o1.textContent = label;
|
||
compareBaseSelect.appendChild(o1);
|
||
|
||
const o2 = document.createElement("option");
|
||
o2.value = set.id;
|
||
o2.textContent = label;
|
||
compareTargetSelect.appendChild(o2);
|
||
});
|
||
|
||
const byDateAsc = [...priceSets].sort((a, b) => priceSetOrderValue(a) - priceSetOrderValue(b));
|
||
compareBaseSelect.value = byDateAsc[0].id;
|
||
compareTargetSelect.value = byDateAsc[byDateAsc.length - 1].id;
|
||
|
||
renderPriceComparison();
|
||
}
|
||
|
||
function normalizePriceCompareOrder() {
|
||
const base = priceSets.find((x) => x.id === compareBaseSelect.value);
|
||
const target = priceSets.find((x) => x.id === compareTargetSelect.value);
|
||
if (!base || !target || base.id === target.id) return;
|
||
const baseTs = priceSetOrderValue(base);
|
||
const targetTs = priceSetOrderValue(target);
|
||
if (baseTs > targetTs) {
|
||
compareBaseSelect.value = target.id;
|
||
compareTargetSelect.value = base.id;
|
||
}
|
||
}
|
||
|
||
function renderPriceComparison() {
|
||
const area = document.getElementById("priceCompareArea");
|
||
normalizePriceCompareOrder();
|
||
const base = priceSets.find((x) => x.id === compareBaseSelect.value);
|
||
const target = priceSets.find((x) => x.id === compareTargetSelect.value);
|
||
|
||
if (!base || !target) {
|
||
area.className = "empty";
|
||
area.textContent = "비교할 단가를 선택하세요.";
|
||
return;
|
||
}
|
||
|
||
if (base.id === target.id) {
|
||
area.className = "empty";
|
||
area.textContent = "기준 단가와 비교 단가를 다르게 선택하세요.";
|
||
return;
|
||
}
|
||
|
||
let html = '<table class="line-less-table"><thead><tr><th>항목</th><th>기준값</th><th>비교값</th><th>차이(비교-기준)</th><th>변화율</th></tr></thead><tbody>';
|
||
PRICE_COMPARE_FIELDS.forEach((f) => {
|
||
const b = toNum((base.prices || {})[f.key]);
|
||
const t = toNum((target.prices || {})[f.key]);
|
||
const diff = t - b;
|
||
const rate = b === 0 ? "-" : `${((diff / b) * 100).toFixed(2)}%`;
|
||
html += `
|
||
<tr>
|
||
<td>${f.label}</td>
|
||
<td>${fmt(b)}</td>
|
||
<td>${fmt(t)}</td>
|
||
<td>${diff >= 0 ? "+" : ""}${fmt(diff)}</td>
|
||
<td>${rate}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
html += "</tbody></table>";
|
||
html += `
|
||
<div class="compare-chart-wrap">
|
||
<div class="compare-chart-title">항목별 기준/비교 그래프</div>
|
||
<canvas id="priceCompareChart"></canvas>
|
||
</div>
|
||
`;
|
||
area.className = "";
|
||
area.innerHTML = html;
|
||
drawPriceCompareChart(document.getElementById("priceCompareChart"), base, target);
|
||
}
|
||
|
||
function renderMain() {
|
||
hydrate();
|
||
renderPriceSetSelect();
|
||
renderStatus();
|
||
renderSpecList();
|
||
}
|
||
|
||
function init() {
|
||
hydrate();
|
||
renderMain();
|
||
renderPriceList();
|
||
switchPage("main");
|
||
}
|
||
|
||
document.getElementById("goMainBtn").addEventListener("click", () => switchPage("main"));
|
||
document.getElementById("goPriceBtn").addEventListener("click", () => switchPage("price"));
|
||
document.getElementById("refreshBtn").addEventListener("click", renderMain);
|
||
document.getElementById("newMixBtn").addEventListener("click", openModal);
|
||
document.getElementById("addSpecBtn").addEventListener("click", addCustomSpec);
|
||
document.getElementById("mNewSpec").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
addCustomSpec();
|
||
}
|
||
});
|
||
document.getElementById("closeModalBtn").addEventListener("click", closeModal);
|
||
document.getElementById("cancelBtn").addEventListener("click", closeModal);
|
||
document.getElementById("saveMixBtn").addEventListener("click", saveMixEntry);
|
||
document.getElementById("savePriceBtn").addEventListener("click", addPriceSet);
|
||
document.getElementById("clearPriceBtn").addEventListener("click", clearPriceForm);
|
||
document.getElementById("openPriceModalBtn").addEventListener("click", openPriceModal);
|
||
document.getElementById("closePriceModalBtn").addEventListener("click", closePriceModal);
|
||
compareBaseSelect.addEventListener("change", renderPriceComparison);
|
||
compareTargetSelect.addEventListener("change", renderPriceComparison);
|
||
document.getElementById("swapCompareBtn").addEventListener("click", () => {
|
||
const a = compareBaseSelect.value;
|
||
compareBaseSelect.value = compareTargetSelect.value;
|
||
compareTargetSelect.value = a;
|
||
renderPriceComparison();
|
||
});
|
||
|
||
priceSetSelect.addEventListener("change", () => {
|
||
localStorage.setItem(SELECTED_PRICE_SET_KEY, priceSetSelect.value);
|
||
renderMain();
|
||
});
|
||
|
||
[
|
||
"mPriceSetId", "mWaterCementRatio", "mFineAggRatio", "mWater",
|
||
"mCement", "mSlag", "mWashedSand", "mCrushedSand", "mMm20", "mMm25", "mAdmixture", "mNote"
|
||
].forEach((id) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener("input", () => saveDraft(MIX_DRAFT_KEY, getMixDraftPayload()));
|
||
});
|
||
document.getElementById("mSpec").addEventListener("change", () => saveDraft(MIX_DRAFT_KEY, getMixDraftPayload()));
|
||
|
||
[
|
||
"pYear", "pMonth", "pCementPrice", "pSlagPrice", "pWashedSandPrice", "pCrushedSandPrice",
|
||
"pMm20Price", "pMm25Price", "pAdmixturePrice", "pWashedSandDensity", "pCrushedSandDensity", "pMm20Density", "pMm25Density"
|
||
].forEach((id) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener("input", () => saveDraft(PRICE_DRAFT_KEY, getPriceDraftPayload()));
|
||
});
|
||
|
||
bindDensityPriceAutoAdjust();
|
||
|
||
mixModal.addEventListener("click", (e) => {
|
||
if (e.target === mixModal) closeModal();
|
||
});
|
||
priceModal.addEventListener("click", (e) => {
|
||
if (e.target === priceModal) closePriceModal();
|
||
});
|
||
document.getElementById("closeCalcModalBtn").addEventListener("click", () => {
|
||
calcModal.classList.remove("open");
|
||
});
|
||
document.getElementById("closeMixCompareModalBtn").addEventListener("click", () => {
|
||
mixCompareModal.classList.remove("open");
|
||
});
|
||
calcModal.addEventListener("click", (e) => {
|
||
if (e.target === calcModal) calcModal.classList.remove("open");
|
||
});
|
||
mixCompareModal.addEventListener("click", (e) => {
|
||
if (e.target === mixCompareModal) mixCompareModal.classList.remove("open");
|
||
});
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|