Files
issue-sample/remicon_cost_app.html

2516 lines
96 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>