102 lines
12 KiB
JavaScript
102 lines
12 KiB
JavaScript
const key = 'aislo-est-test-v1';
|
|
const sample = () => ({
|
|
sources: [
|
|
{ id: 's1', name: '시험 자재', spec: '예제', unit: '개', kind: '재료비', price: 12500 },
|
|
{ id: 's2', name: '시험 노무', spec: '예제', unit: '인', kind: '노무비', price: 180000 },
|
|
{ id: 's3', name: '시험 장비', spec: '예제', unit: '시간', kind: '경비', price: 45000 },
|
|
],
|
|
units: [{ id: 'u1', name: '시험 공종', unit: '식', lines: [{ sourceId: 's1', quantity: 2 }, { sourceId: 's2', quantity: 0.5 }, { sourceId: 's3', quantity: 1 }] }],
|
|
estimate: [{ id: 'e1', name: '시험 공종', unitId: 'u1', quantity: 3 }],
|
|
costs: [{ id: 'c1', name: '시험 간접비', base: '직접공사비', rate: 5 }, { id: 'c2', name: '시험 추가비', base: '누계', rate: 10 }],
|
|
});
|
|
const copy = value => JSON.parse(JSON.stringify(value));
|
|
function valid(d) {
|
|
const text = v => typeof v === 'string' && v.length <= 500;
|
|
const num = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1e12;
|
|
const ids = arr => new Set(arr.map(x => x.id)).size === arr.length;
|
|
return d && ['sources', 'units', 'estimate', 'costs'].every(k => Array.isArray(d[k]) && d[k].length <= 1000) &&
|
|
d.sources.every(s => text(s.id) && text(s.name) && text(s.spec) && text(s.unit) && EstCalc.kinds.includes(s.kind) && num(s.price)) && ids(d.sources) &&
|
|
d.units.every(u => text(u.id) && text(u.name) && text(u.unit) && Array.isArray(u.lines) && u.lines.length <= 1000 && u.lines.every(l => text(l.sourceId) && num(l.quantity))) && ids(d.units) &&
|
|
d.estimate.every(e => text(e.id) && text(e.name) && text(e.unitId) && num(e.quantity)) && ids(d.estimate) &&
|
|
d.costs.every(c => text(c.id) && text(c.name) && [...EstCalc.kinds, '직접공사비', '누계'].includes(c.base) && num(c.rate)) && ids(d.costs);
|
|
}
|
|
let data;
|
|
try { const saved = JSON.parse(sessionStorage.getItem(key)); data = valid(saved) ? saved : sample(); } catch { data = sample(); }
|
|
let tab = 'sources';
|
|
let selected = data.units[0]?.id || '';
|
|
const $ = id => document.getElementById(id);
|
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
const money = n => new Intl.NumberFormat('ko-KR', {maximumFractionDigits: 2}).format(n);
|
|
const id = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 9)}`;
|
|
const field = (collection, index, name, value, type = 'text', extra = '') => `<input aria-label="${esc(name)}" data-collection="${collection}" data-index="${index}" data-field="${name}" type="${type}" value="${esc(value)}" ${type === 'number' ? 'min="0" max="1000000000000" step="any"' : 'maxlength="500"'} ${extra}>`;
|
|
const options = (values, current) => values.map(([value, label]) => `<option value="${esc(value)}" ${value === current ? 'selected' : ''}>${esc(label)}</option>`).join('');
|
|
const select = (collection, index, name, value, values) => `<select aria-label="${esc(name)}" data-collection="${collection}" data-index="${index}" data-field="${name}">${options(values, value)}</select>`;
|
|
const remove = (collection, index) => `<button class="delete" data-remove="${collection}" data-index="${index}" aria-label="행 삭제">삭제</button>`;
|
|
const add = (collection, label) => `<button data-add="${collection}">+ ${label}</button>`;
|
|
const table = (heads, rows) => `<div class="tablewrap"><table><thead><tr>${heads.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`;
|
|
const card = (title, subtitle, button, body, note = '') => `<div class="card"><div class="cardhead"><div><h2>${title}</h2><p>${subtitle}</p></div>${button}</div>${body}${note ? `<div class="details">${note}</div>` : ''}</div>`;
|
|
function render() {
|
|
const result = EstCalc.calculate(data);
|
|
$('summary').innerHTML = [['재료비',result.direct['재료비']],['노무비',result.direct['노무비']],['경비',result.direct['경비']],['원가 합계',result.total]].map(([label,n]) => `<div class="metric"><span>${label}</span><strong>${money(n)} 원</strong></div>`).join('');
|
|
document.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
|
const sourceChoices = [['', '선택'], ...data.sources.map(s => [s.id, `${s.name} (${s.unit})`])];
|
|
const unitChoices = [['', '선택'], ...data.units.map(u => [u.id, `${u.name} (${u.unit})`])];
|
|
const kindChoices = EstCalc.kinds.map(k => [k,k]);
|
|
if (tab === 'sources') {
|
|
const rows = data.sources.map((s,i) => `<tr><td>${field('sources',i,'name',s.name)}</td><td>${field('sources',i,'spec',s.spec)}</td><td>${field('sources',i,'unit',s.unit)}</td><td>${select('sources',i,'kind',s.kind,kindChoices)}</td><td>${field('sources',i,'price',s.price,'number')}</td><td>${remove('sources',i)}</td></tr>`);
|
|
$('panel').innerHTML = card('기초 단가', '자재·노무·장비 등의 시험 단가를 직접 입력합니다.', add('sources','단가 추가'), table(['품명','규격','단위','비목','단가 (원)',''],rows), '이곳의 단가가 일위대가 구성 행에 연결됩니다. 연결 중인 단가는 삭제할 수 없습니다.');
|
|
} else if (tab === 'units') {
|
|
if (!data.units.some(u => u.id === selected)) selected = data.units[0]?.id || '';
|
|
const u = data.units.find(u => u.id === selected), ui = data.units.indexOf(u);
|
|
const chooser = `<select id="unitChoice" aria-label="일위대가 선택">${options(data.units.map(x=>[x.id,x.name]),selected)}</select>`;
|
|
let body = `<div class="cardhead"><div>${chooser}</div>${u ? remove('units',ui) : ''}</div>`;
|
|
if (u) {
|
|
body += table(['일위대가 이름','단위','합계 (원)'],[`<tr><td>${field('units',ui,'name',u.name)}</td><td>${field('units',ui,'unit',u.unit)}</td><td class="money">${money(result.units[u.id].total)}</td></tr>`]);
|
|
body += `<div class="cardhead"><h2>구성 항목</h2>${add('lines','구성 추가')}</div>`;
|
|
body += table(['기초 단가','소요량','단가 (원)','비목','금액 (원)',''],u.lines.map((l,i) => {const r=result.units[u.id].lines[i]; return `<tr><td>${select('lines',i,'sourceId',l.sourceId,sourceChoices)}</td><td>${field('lines',i,'quantity',l.quantity,'number')}</td><td class="money">${r.source ? money(r.source.price) : '—'}</td><td>${esc(r.source?.kind || '미연결')}</td><td class="money">${money(r.amount)}</td><td>${remove('lines',i)}</td></tr>`;}));
|
|
body += `<div class="details">${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}<br>행 금액은 소수 둘째 자리까지 절사하고 비목별 합계는 원 미만을 절사합니다.</div>`;
|
|
} else body += '<div class="empty">일위대가를 추가하세요.</div>';
|
|
$('panel').innerHTML = card('일위대가', '기초 단가와 소요량을 조합해 단위당 금액을 계산합니다.', add('units','일위대가 추가'), body);
|
|
} else if (tab === 'estimate') {
|
|
const rows = data.estimate.map((e,i) => {const u=result.units[e.unitId], r=result.rows[i]; return `<tr><td>${field('estimate',i,'name',e.name)}</td><td>${select('estimate',i,'unitId',e.unitId,unitChoices)}</td><td>${field('estimate',i,'quantity',e.quantity,'number')}</td><td class="money">${u ? money(u.total) : '—'}</td>${EstCalc.kinds.map(k=>`<td class="money">${money(r.parts[k])}</td>`).join('')}<td class="money">${money(r.total)}</td><td>${remove('estimate',i)}</td></tr>`;});
|
|
$('panel').innerHTML = card('공사 내역서','일위대가와 공사 수량을 연결합니다.',add('estimate','내역 추가'),table(['공종명','일위대가','수량','단가','재료비','노무비','경비','금액',''],rows),'각 비목의 단가 × 수량을 원 미만 절사하여 직접공사비로 집계합니다.');
|
|
} else {
|
|
const bases = [...EstCalc.kinds,'직접공사비','누계'].map(k=>[k,k]);
|
|
const rows = data.costs.map((c,i) => `<tr><td>${field('costs',i,'name',c.name)}</td><td>${select('costs',i,'base',c.base,bases)}</td><td class="money">${money(result.costs[i].base)}</td><td>${field('costs',i,'rate',c.rate,'number')}</td><td class="money">${money(result.costs[i].amount)}</td><td class="money">${money(result.costs[i].running)}</td><td>${remove('costs',i)}</td></tr>`);
|
|
$('panel').innerHTML = card('원가계산서','기준 금액과 요율을 선택해 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','계산 금액','누계',''],[`<tr class="total"><td colspan="4">직접공사비</td><td class="money">${money(result.directTotal)}</td><td class="money">${money(result.directTotal)}</td><td></td></tr>`,...rows]),'각 행은 기준 금액 × 요율 ÷ 100의 원 미만을 절사합니다. 누계는 앞선 항목까지의 합계입니다. 수식 편집 및 법정 요율은 제공하지 않습니다.');
|
|
}
|
|
}
|
|
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==='costs') data.costs.push({id:id(),name:'새 요율',base:'직접공사비',rate:0});
|
|
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))) { alert('일위대가에서 사용 중인 단가입니다. 연결을 먼저 해제하세요.'); return; }
|
|
if (k==='units' && data.estimate.some(e=>e.unitId===item.id)) { alert('내역에서 사용 중인 일위대가입니다. 연결을 먼저 해제하세요.'); return; }
|
|
arr.splice(i,1); persist(); render();
|
|
}
|
|
});
|
|
document.addEventListener('change', event => {
|
|
const el=event.target;
|
|
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.type==='number' && (el.value.trim()==='' || !Number.isFinite(value) || value<0 || value>1e12)) { alert('0 이상 1조 이하의 숫자를 입력하세요.'); 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();
|