feat: expand EST test workspace

This commit is contained in:
2026-11-04 21:58:59 +09:00
parent 173b719826
commit 9d0ebb71da
6 changed files with 272 additions and 166 deletions
+15
View File
@@ -40,3 +40,18 @@ def test_est_test_unlinked_rows_contribute_zero():
}
result = calculate(data)
assert result["units"]["u"]["total"] == result["total"] == 0
def test_progress_uses_approved_quantity_only_when_selected():
script = (
'const vm=require("vm"),fs=require("fs");const c={};'
'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.calc=EstCalc",c);'
"console.log(JSON.stringify([c.calc.progress(100,2,3,1,false),"
"c.calc.progress(100,2,3,1,true)]));"
)
ordinary, approved = json.loads(
subprocess.check_output(["node", "-e", script, str(CALC)], text=True, encoding="utf-8")
)
assert ordinary["currentAmount"] == 300
assert ordinary["cumulativeAmount"] == 500
assert approved["cumulativeAmount"] == 300
+2 -2
View File
@@ -2,6 +2,6 @@
`index.html`을 브라우저에서 열면 됩니다. 별도 서버, 로그인, 설치 또는 Aislo DB가 필요하지 않습니다.
기초 단가 → 일위대가 → 내역서 → 요율 원가계산 흐름을 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다.
기초 단가 → 일위대가 → 내역서 → 요율 원가계산 흐름, 간단한 수량산출 연결과 기성 수량 입력을 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다.
EST Plus 2 REA 분석의 일부 계산 흐름을 참고한 도구이며 실제 EST와 동일한 결과를 보장하지 않습니다. 수식 단가산출, ESTX 가져오기, 기성·물가변동과 법정 요율은 구현하지 않았습니다.
EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 및 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다.
+39 -2
View File
@@ -1,5 +1,9 @@
const key = 'aislo-est-test-v1';
const sample = () => ({
projectName: '시험 내역서',
quantities: [],
progress: [],
progressApproved: false,
sources: [
{ id: 's1', name: '시험 자재', spec: '예제', unit: '개', kind: '재료비', price: 12500 },
{ id: 's2', name: '시험 노무', spec: '예제', unit: '인', kind: '노무비', price: 180000 },
@@ -14,7 +18,7 @@ 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) &&
return d && (d.projectName === undefined || text(d.projectName)) && (d.progressApproved === undefined || typeof d.progressApproved === 'boolean') && (d.progress === undefined || (Array.isArray(d.progress) && d.progress.length <= 1000 && d.progress.every(p => text(p.estimateId) && num(p.previous) && num(p.current) && num(p.approved)))) && (d.quantities === undefined || (Array.isArray(d.quantities) && d.quantities.length <= 1000 && d.quantities.every(q => text(q.id) && text(q.estimateId) && text(q.name) && num(q.length) && num(q.width) && num(q.count)))) && ['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) &&
@@ -22,7 +26,7 @@ function valid(d) {
}
let data;
try { const saved = JSON.parse(sessionStorage.getItem(key)); data = valid(saved) ? saved : sample(); } catch { data = sample(); }
let tab = 'sources';
let tab = 'estimate';
let selected = data.units[0]?.id || '';
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
@@ -37,6 +41,7 @@ const table = (heads, rows) => `<div class="tablewrap"><table><thead><tr>${heads
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);
$('projectName').value = data.projectName || '';
$('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})`])];
@@ -60,6 +65,17 @@ function render() {
} 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 if (tab === 'quantities') {
const choices = [['', '선택'], ...data.estimate.map(e => [e.id, e.name])];
const rows = (data.quantities || []).map((q,i) => `<tr><td>${field('quantities',i,'name',q.name)}</td><td>${select('quantities',i,'estimateId',q.estimateId,choices)}</td><td>${field('quantities',i,'length',q.length,'number')}</td><td>${field('quantities',i,'width',q.width,'number')}</td><td>${field('quantities',i,'count',q.count,'number')}</td><td class="money">${money(q.length*q.width*q.count)}</td><td>${remove('quantities',i)}</td></tr>`);
$('panel').innerHTML = card('수량산출서','수량을 산출하고 선택한 내역에 반영합니다.',`${add('quantities','산출 행 추가')} <button id="applyQuantities">내역에 수량 반영</button>`,table(['산출 항목','연결 공종','길이','폭','횟수','산출 수량',''],rows),'산출 수량 = 길이 × 폭 × 횟수. 같은 공종에 연결된 산출 수량을 합산해 내역 수량을 덮어씁니다.');
} else if (tab === 'progress') {
const rows = (data.progress || []).map((p,i) => {
const e = data.estimate.find(x => x.id === p.estimateId), price = e ? result.units[e.unitId]?.total || 0 : 0;
const r = EstCalc.progress(price, p.previous, p.current, p.approved, data.progressApproved);
return `<tr><td>${esc(e?.name || '연결 없음')}</td><td class="money">${money(e?.quantity || 0)}</td><td class="money">${money(price)}</td><td>${field('progress',i,'previous',p.previous,'number')}</td><td>${field('progress',i,'current',p.current,'number')}</td><td>${field('progress',i,'approved',p.approved,'number')}</td><td class="money">${money(r.currentAmount)}</td><td class="money">${money(r.cumulative)}</td><td class="money">${money(r.cumulativeAmount)}</td><td>${remove('progress',i)}</td></tr>`;
});
$('panel').innerHTML = card('기성고', '전회·금회·사정 수량을 입력해 금회와 누계를 확인합니다.', '<button id="syncProgress">내역 불러오기</button>', `<div class="details"><label><input id="progressApproved" type="checkbox" style="width:auto;min-width:0" ${data.progressApproved ? 'checked' : ''}> 누계에 금회사정 수량 사용</label></div>${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>`);
@@ -76,18 +92,36 @@ document.addEventListener('click', event => {
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==='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))) { 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)) { 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];
@@ -99,3 +133,6 @@ $('save').addEventListener('click', () => { const a=document.createElement('a');
$('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());
+6 -1
View File
@@ -33,6 +33,11 @@ const EstCalc = (() => {
});
return { units, rows, direct, directTotal, costs, total: running };
}
return { kinds, calculate };
function progress(unitPrice, previous, current, approved, useApproved) {
const accepted = useApproved ? approved : current;
const cumulative = previous + accepted;
return { currentAmount: Math.trunc(unitPrice * current), approvedAmount: Math.trunc(unitPrice * approved), cumulative, cumulativeAmount: Math.trunc(unitPrice * cumulative) };
}
return { kinds, calculate, progress };
})();
if (typeof module !== 'undefined') module.exports = EstCalc;
+14 -15
View File
@@ -1,20 +1,19 @@
<!doctype html>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EST 계산 흐름 테스트</title>
<link rel="stylesheet" href="style.css">
<script defer src="calc.js"></script>
<script defer src="app.js"></script>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>EST 업무 테스트</title><link rel="stylesheet" href="style.css">
<script defer src="calc.js"></script><script defer src="app.js"></script>
</head>
<body>
<header class="top"><div><span class="eyebrow">독립 테스트 도구</span><h1>EST 계산 흐름 테스트</h1><p>기초 단가부터 원가계산까지 입력하고 계산 과정을 확인합니다.</p></div><div class="actions"><button id="reset" class="secondary">예제 초기화</button><label class="button secondary">파일 열기<input id="open" type="file" accept=".json,application/json" hidden></label><button id="save">JSON 저장</button></div></header>
<main>
<div class="notice"><strong>테스트 전용</strong> 예제 단가와 요율은 임의의 숫자입니다. 원본 EST와의 동등성 및 실무 적용 여부는 검증되지 않았습니다.</div>
<div class="summary" id="summary"></div>
<nav class="tabs" aria-label="계산 단계"><button data-tab="sources" class="active">01 기초 단가</button><button data-tab="units">02 일위대가</button><button data-tab="estimate">03 내역서</button><button data-tab="costs">04 원가계산서</button></nav>
<section id="panel" aria-live="polite"></section>
<p class="footnote">입력 내용은 현재 브라우저 탭에 임시 보관됩니다. 보존하려면 JSON 저장을 누르세요. 세부 계산은 각 단계에서 확인할 수 있습니다.</p>
</main>
<header>
<div class="titlebar"><strong>EST 업무 테스트</strong><span>독립 실행 · 시험자료</span></div>
<div class="menubar"><span>파일</span><span>편집</span><span>공종</span><span>재계산</span><span>원가계산서</span><span>수량</span><span>도구</span><span>작업모드</span></div>
<div class="toolbar"><button id="reset">▤ 새 내역서</button><label class="button">▣ 불러오기<input id="open" type="file" accept=".json,application/json" hidden></label><button id="save">▣ 저장하기</button><i></i><button id="recalculate">⟳ 전체 재계산</button><button id="print">▧ 인쇄</button><i></i><span>공사명</span><input id="projectName" aria-label="공사명" value="시험 내역서" maxlength="200"></div>
</header>
<div class="workspace">
<aside><div class="sidehead">작업화면 이동</div><nav class="tabs" aria-label="작업 화면"><button data-tab="estimate">▦ <span>내역서</span></button><button data-tab="units">▤ <span>일위대가</span></button><button data-tab="sources">▥ <span>기초단가</span></button><button data-tab="quantities">▤ <span>수량산출</span></button><button data-tab="costs">▧ <span>원가계산서</span></button><button data-tab="progress">▦ <span>기성고</span></button></nav><div class="sideinfo">작업 자료<br><b>독립 테스트</b><p>내용을 수정한 뒤 저장하기를 눌러 파일로 보관하세요.</p></div></aside>
<main><div class="notice">구현 중 · 상단 메뉴는 배치 참고용이며 왼쪽 작업화면과 도구 버튼만 동작합니다. 예제 수치는 임의 값이며 원본 EST와의 동등성은 검증되지 않았습니다.</div><div class="summary" id="summary"></div><section id="panel" aria-live="polite"></section></main>
</div>
<footer><span>● 준비</span><span>저장: JSON 파일 · 탭을 닫기 전에 저장하세요</span></footer>
</body></html>
+196 -146
View File
@@ -3,138 +3,166 @@
}
body {
margin: 0;
background: #f4f6f8;
color: #172a3a;
background: #e9edf1;
color: #202a34;
font:
15px system-ui,
"Malgun Gothic",
13px "Malgun Gothic",
sans-serif;
}
.top {
background: #122c40;
color: white;
padding: 32px max(24px, calc((100% - 1320px) / 2));
header {
background: #f3f5f7;
}
.titlebar {
height: 38px;
background: #294b68;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
gap: 20px;
padding: 0 15px;
}
.eyebrow {
font-size: 12px;
letter-spacing: 0.12em;
color: #8ed8cd;
font-weight: 700;
.titlebar strong {
font-size: 15px;
}
h1 {
font-size: 27px;
margin: 8px 0;
.titlebar span {
color: #c5d9e6;
font-size: 11px;
}
.top p {
margin: 0;
color: #bfd0da;
}
.actions {
.menubar {
height: 32px;
display: flex;
gap: 8px;
align-items: center;
gap: 27px;
padding: 0 17px;
background: #fafbfc;
border-bottom: 1px solid #c3ccd5;
}
.toolbar {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
border-bottom: 1px solid #b9c5cf;
flex-wrap: wrap;
}
button,
.button {
border: 0;
border-radius: 7px;
background: #087f73;
color: white;
padding: 10px 15px;
font: inherit;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
.toolbar i {
height: 25px;
border-left: 1px solid #bdc7d1;
margin: 0 8px;
}
button:hover,
.button:hover {
filter: brightness(0.93);
.toolbar input {
width: 210px;
}
.secondary {
background: #e9f0f1;
color: #234253;
.workspace {
display: flex;
min-height: calc(100vh - 142px);
}
main {
max-width: 1320px;
margin: auto;
padding: 24px;
aside {
width: 150px;
flex: none;
background: #e3e9ef;
border-right: 1px solid #aebcc8;
}
.notice {
background: #fff6e1;
border: 1px solid #efdfb5;
border-radius: 8px;
padding: 13px 16px;
color: #705315;
}
.notice strong {
margin-right: 10px;
}
.summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin: 20px 0;
}
.metric,
.card {
background: white;
border: 1px solid #dfe7ea;
border-radius: 9px;
}
.metric {
padding: 16px;
}
.metric span {
display: block;
color: #6d818c;
font-size: 13px;
}
.metric strong {
display: block;
font-size: 21px;
margin-top: 6px;
.sidehead {
padding: 11px 12px;
background: #d0dbe5;
font-weight: bold;
border-bottom: 1px solid #bdc8d1;
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid #cbd8dc;
overflow: auto;
flex-direction: column;
padding: 8px;
gap: 3px;
}
.tabs button {
text-align: left;
background: transparent;
color: #6a7f8b;
border-radius: 0;
padding: 14px 18px;
border-bottom: 3px solid transparent;
border: 1px solid transparent;
font-weight: normal;
color: #253847;
padding: 10px;
}
.tabs button.active {
color: #087f73;
border-bottom-color: #087f73;
background: #fff;
border-color: #9eb6c8;
color: #164f7b;
font-weight: bold;
}
.tabs button span {
margin-left: 8px;
}
.sideinfo {
border-top: 1px solid #c4ced6;
padding: 15px 12px;
line-height: 1.8;
color: #6a7b89;
}
.sideinfo b {
color: #33546b;
}
.sideinfo p {
font-size: 11px;
}
main {
padding: 12px;
min-width: 0;
flex: 1;
}
.notice {
background: #fff9e6;
border: 1px solid #e4d6a8;
padding: 8px 10px;
color: #735c24;
font-size: 12px;
}
.summary {
display: flex;
gap: 0;
background: #f7f9fb;
border: 1px solid #bdc9d1;
margin-top: 10px;
}
.metric {
padding: 8px 15px;
border-right: 1px solid #d6dfe5;
min-width: 150px;
}
.metric span {
color: #667c8b;
font-size: 11px;
}
.metric strong {
display: block;
font-size: 16px;
margin-top: 3px;
color: #244660;
}
.card {
margin-top: 18px;
overflow: hidden;
background: white;
border: 1px solid #b7c5cf;
margin-top: 10px;
min-height: 400px;
}
.cardhead {
padding: 20px;
background: #e5edf4;
border-bottom: 1px solid #bccbd6;
padding: 9px 12px;
display: flex;
align-items: center;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.cardhead h2 {
margin: 0;
font-size: 19px;
font-size: 14px;
color: #234962;
}
.cardhead p {
margin: 6px 0 0;
color: #71838c;
font-size: 13px;
margin: 4px 0 0;
color: #72818a;
font-size: 11px;
}
.tablewrap {
overflow: auto;
@@ -146,89 +174,111 @@ table {
}
th,
td {
padding: 10px 12px;
border-top: 1px solid #e8eef0;
border: 1px solid #d6e0e6;
padding: 4px 5px;
text-align: left;
vertical-align: middle;
}
th {
background: #f8fafb;
color: #6a7d87;
background: #eaf0f5;
color: #365269;
font-size: 12px;
white-space: nowrap;
text-align: center;
height: 31px;
}
td.money {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-weight: 600;
}
tr:nth-child(even) {
background: #f8fafb;
}
tr:hover {
background: #edf6ff;
}
input,
select {
min-width: 70px;
width: 100%;
min-width: 75px;
padding: 8px;
border: 1px solid #cfdbdf;
border-radius: 5px;
border: 1px solid #d1dce2;
background: white;
color: #203746;
padding: 5px;
color: #273b4b;
font: inherit;
}
input:focus,
select:focus {
outline: 2px solid #74a8ca;
outline-offset: -1px;
}
input[type="number"] {
text-align: right;
}
button,
.button {
background: #fff;
border: 1px solid #b6c5d0;
color: #29465a;
padding: 6px 10px;
font: inherit;
cursor: pointer;
white-space: nowrap;
}
button:hover,
.button:hover {
background: #ddecf7;
border-color: #8faec1;
}
.delete {
background: #f3f6f7;
color: #8a5151;
padding: 7px 10px;
color: #87585b;
padding: 4px 7px;
}
.details {
padding: 16px 20px;
color: #59717d;
font-size: 13px;
border-top: 1px solid #e8eef0;
line-height: 1.7;
padding: 12px;
color: #667e8d;
font-size: 12px;
line-height: 1.8;
border-top: 1px solid #d6e0e6;
}
.total {
background: #f0f8f6;
}
.total td {
font-weight: 700;
background: #eaf3f8;
font-weight: bold;
}
.empty {
padding: 24px;
color: #738892;
padding: 25px;
color: #758995;
}
.footnote {
font-size: 12px;
color: #72848d;
margin-top: 20px;
}
.tag {
display: inline-block;
padding: 4px 8px;
background: #e5f4f0;
color: #087f73;
border-radius: 4px;
font-size: 12px;
font-weight: 700;
}
.warning {
color: #a45228;
footer {
height: 28px;
border-top: 1px solid #becbd4;
background: #e4eaf0;
color: #586f80;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
font-size: 11px;
}
@media (max-width: 720px) {
.top {
display: block;
}
.actions {
margin-top: 20px;
aside {
width: 110px;
}
.summary {
grid-template-columns: 1fr 1fr;
flex-wrap: wrap;
}
.metric {
min-width: 50%;
}
.menubar {
gap: 14px;
overflow: auto;
}
main {
padding: 16px;
padding: 6px;
}
.metric strong {
font-size: 17px;
.titlebar span,
.sideinfo {
display: none;
}
}