`);
$('panel').innerHTML = card('원가계산서','기준 금액·요율 또는 수식을 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','수식','계산 금액','누계',''],[`
직접공사비
${money(result.directTotal)}
${money(result.directTotal)}
`,...rows]),`수식 예: [직접공사비]*0.1, [앞선 항목명]+[재료비]. 수식이 있으면 요율 대신 수식을 사용합니다. ${esc(result.errors.join(' / '))}`);
}
}
function persist() { try { sessionStorage.setItem(key,JSON.stringify(data)); } catch { alert('임시 저장 공간을 사용할 수 없습니다. JSON 파일로 저장해 주세요.'); } }
document.addEventListener('click', event => {
const t = event.target.closest('button'); if (!t) return;
if (t.dataset.tab) { tab=t.dataset.tab; render(); return; }
if (t.dataset.add) {
const k=t.dataset.add;
if (k==='sources') data.sources.push({id:id(),name:'새 단가',spec:'',unit:'식',kind:'재료비',price:0});
if (k==='units') { const u={id:id(),name:'새 일위대가',unit:'식',lines:[]}; data.units.push(u); selected=u.id; }
if (k==='lines') data.units.find(u=>u.id===selected).lines.push({sourceId:data.sources[0]?.id || '',quantity:1});
if (k==='estimate') data.estimate.push({id:id(),name:'새 공종',unitId:data.units[0]?.id || '',quantity:1});
if (k==='quantities') { data.quantities ||= []; data.quantities.push({id:id(),name:'새 수량산출',estimateId:data.estimate[0]?.id || '',length:1,width:1,count:1}); }
if (k==='machines') { data.machines ||= []; data.machines.push({sourceId:'',purchase:0,rentalCoefficient:0,fuelPrice:0,fuelQuantity:0,suppliesRate:0,laborPrice:0,laborFactor:0,laborHours:0}); }
if (k==='escalation') { data.escalation ||= []; data.escalation.push({name:'새 품목',bid:0,contract:0,current:0,quantity:0,planned:0,actual:0,progress:0}); }
if (k==='costs') data.costs.push({id:id(),name:'새 요율',base:'직접공사비',rate:0});
persist(); render();
}
if (t.id==='syncProgress') {
data.progress ||= [];
for (const e of data.estimate) if (!data.progress.some(p => p.estimateId === e.id)) data.progress.push({estimateId:e.id,previous:0,current:0,approved:0});
persist(); render();
}
if (t.id==='applyQuantities') {
const rows = (data.quantities || []).filter(q => q.estimateId && data.estimate.some(e => e.id === q.estimateId));
if (!rows.length) { alert('연결된 수량산출 행이 없습니다.'); return; }
if (!confirm('연결된 공종의 기존 수량을 산출 합계로 바꾸시겠습니까?')) return;
const sums = data.estimate.map(e => rows.filter(q => q.estimateId === e.id).reduce((sum,q) => sum + q.length*q.width*q.count,0));
if (sums.some(n => !Number.isFinite(n) || n > 1e12)) { alert('산출 수량이 허용 범위(1조)를 넘습니다.'); return; }
data.estimate.forEach((e,i) => { if (rows.some(q => q.estimateId === e.id)) e.quantity = sums[i]; });
persist(); render();
}
if (t.dataset.remove) {
const k=t.dataset.remove, i=Number(t.dataset.index), arr=k==='lines' ? data.units.find(u=>u.id===selected).lines : data[k], item=arr[i];
if (k==='sources' && (data.units.some(u=>u.lines.some(l=>l.sourceId===item.id)) || (data.machines || []).some(m=>m.sourceId===item.id))) { alert('사용 중인 단가입니다. 연결을 먼저 해제하세요.'); return; }
if (k==='estimate' && ((data.quantities || []).some(q=>q.estimateId===item.id) || (data.progress || []).some(p=>p.estimateId===item.id))) { alert('수량산출 또는 기성고에서 사용 중인 공종입니다.'); return; }
if (k==='units' && (data.estimate.some(e=>e.unitId===item.id) || data.units.some(u => u.id !== item.id && u.lines.some(l => l.type === '일위대가' && l.unitId === item.id)))) { alert('다른 항목에서 사용 중인 일위대가입니다. 연결을 먼저 해제하세요.'); return; }
arr.splice(i,1); persist(); render();
}
});
document.addEventListener('change', event => {
const el=event.target;
if (el.id==='progressApproved') { data.progressApproved=el.checked; persist(); render(); return; }
if (el.id==='projectName') { data.projectName=el.value; persist(); return; }
if (el.id==='unitChoice') { selected=el.value; render(); return; }
if (!el.dataset.collection) return;
const arr=el.dataset.collection==='lines' ? data.units.find(u=>u.id===selected).lines : data[el.dataset.collection];
const value=el.type==='number' ? Number(el.value) : el.value;
if (el.dataset.collection==='machines' && el.dataset.field==='sourceId' && value && data.machines.some((m,i)=>i!==Number(el.dataset.index) && m.sourceId===value)) { alert('이미 다른 기계에 연결된 기초단가입니다.'); render(); return; }
if (el.type==='number' && (el.value.trim()==='' || !Number.isFinite(value) || value<0 || value>1e12)) { alert('0 이상 1조 이하의 숫자를 입력하세요.'); render(); return; }
if (el.dataset.field==='digits' && (!Number.isInteger(value) || value>6)) { alert('자릿수는 0부터 6까지 입력하세요.'); render(); return; }
if (el.dataset.field==='priceIndex') { if (!Number.isInteger(value) || value<1 || value>6) { alert('선택 번호는 1부터 6까지 입력하세요.'); render(); return; } arr[Number(el.dataset.index)].priceIndex=value-1; persist(); render(); return; }
if (/^price[0-5]$/.test(el.dataset.field)) { const source=arr[Number(el.dataset.index)]; source.prices ||= [0,0,0,0,0,0]; source.prices[Number(el.dataset.field.slice(-1))]=value; persist(); render(); return; }
arr[Number(el.dataset.index)][el.dataset.field]=value; persist(); render();
});
$('save').addEventListener('click', () => { const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'})); a.download='est-test.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); });
$('open').addEventListener('change', async event => { const file=event.target.files[0]; if (!file) return; try { if(file.size>5_000_000) throw Error(); const imported=JSON.parse(await file.text()); if(!valid(imported)) throw Error(); if(!confirm('현재 탭의 작업을 파일 내용으로 바꾸시겠습니까? 저장하지 않은 작업은 사라집니다.')) return; data=copy(imported); selected=data.units[0]?.id || ''; persist(); render(); } catch { alert('올바른 테스트 앱 JSON 파일이 아닙니다.'); } finally { event.target.value=''; } });
$('reset').addEventListener('click', () => { if(!confirm('현재 탭의 작업을 예제로 초기화하시겠습니까? 저장하지 않은 작업은 사라집니다.')) return; data=sample(); selected=data.units[0].id; persist(); render(); });
render();
document.getElementById('recalculate').addEventListener('click', () => { render(); });
document.getElementById('print').addEventListener('click', () => window.print());