Add project takeoff verification and bearing dimensions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
d22c5008f0
commit
7182b6cbb4
15 files changed
+337
-28
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# 교각 수량산출
|
||||
|
||||
**현재 출력 방식:** `전체 교각 입력`에서 교각별 미입력값을 함께 입력하고, Excel 출력은 전체 교각별 산출서와 합산 집계표를 생성합니다. `수량산출_ex)` 예시·미사용 시트는 결과에서 제외합니다. [전체 교각 개선 보고서](docs/PROJECT_TAKEOFF_REPORT.md)를 참고하세요. 아래의 초기 단일 교각 검증 기록과 현재 출력 시트 수는 다를 수 있습니다.
|
||||
|
||||
`5. 재료표` 탭의 **교각 수량산출**에서 현재 생성된 교각을 선택합니다. 기존 철근재료표 메뉴는 그대로 유지합니다.
|
||||
|
||||
1. 자동 입력값을 확인합니다. 길이/EL은 m, 본수는 개, 횡경사는 %입니다.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import {it,expect} from 'vitest';
|
||||
import {bearingDimensions} from './bearing-dimensions';
|
||||
import type {PierModel} from '../src/core/geometry';
|
||||
it('받침 본체는 3D 형상의 솔플레이트 여유와 양측 100mm 공제를 동일하게 적용한다',()=>{
|
||||
const m={blocks:[{plan:{A:700,B:640,margin:100}}]} as PierModel;
|
||||
expect(bearingDimensions(m).length).toBeCloseTo(.6);expect(bearingDimensions(m).width).toBeCloseTo(.54);
|
||||
});
|
||||
it('서로 다른 받침 치수나 형상 미생성은 단일 받침 치수로 추정하지 않는다',()=>{
|
||||
expect(bearingDimensions({blocks:[{},{}]} as PierModel)).toEqual({length:null,width:null});
|
||||
expect(bearingDimensions({blocks:[{plan:{A:700,B:640,margin:100}},{plan:{A:800,B:640,margin:100}}]} as PierModel)).toEqual({length:null,width:.54});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type {PierModel} from '../src/core/geometry';
|
||||
/** Same bearing-body dimensions as src/view/scene.ts; A/B alone are solplate inputs. */
|
||||
export function bearingDimensions(model:PierModel){
|
||||
const plans=model.blocks.map(b=>b.plan);
|
||||
const uniform=(values:(number|undefined)[])=>values.length&&values.every(v=>v!=null&&Number.isFinite(v)&&v>0&&Math.abs(v-values[0]!)<1e-7)?values[0]!:null;
|
||||
return {length:uniform(plans.map(p=>p?(p.A+p.margin)/1000-.2:undefined)),width:uniform(plans.map(p=>p?(p.B+p.margin)/1000-.2:undefined))};
|
||||
}
|
||||
@@ -24,8 +24,8 @@
|
||||
|말뚝 두께|Tp|m|3.2 관련변수!N37|자동|입력 말뚝두께 mm→m|
|
||||
|말뚝 길이|Lp|m|3.2 관련변수!N38|자동|말뚝 상면-하면|
|
||||
|말뚝 수|Np|개|3.2 관련변수!N39|자동|model.piles.length|
|
||||
|받침 길이|Lb|m|3.2 관련변수!N40|직접 입력/검토|받침 plan.A mm→m|
|
||||
|받침 폭|Bb|m|3.2 관련변수!N41|직접 입력/검토|받침 plan.B mm→m|
|
||||
|받침 길이|Lb|m|3.2 관련변수!N40|직접 입력/검토|3D 받침 본체: (plan.A+margin)/1000−0.2 m|
|
||||
|받침 폭|Bb|m|3.2 관련변수!N41|직접 입력/검토|3D 받침 본체: (plan.B+margin)/1000−0.2 m|
|
||||
|받침 수|Nb|개|3.2 관련변수!N42|자동|model.blocks.length|
|
||||
|받침 블럭 길이|Lbc|m|3.2 관련변수!N43|자동|모델 분리블럭 크기|
|
||||
|받침 블럭 폭|Bbc|m|3.2 관련변수!N44|자동|모델 분리블럭 크기|
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 전체 교각 입력·산출서·집계 개선
|
||||
|
||||
## 사용법
|
||||
|
||||
`5. 재료표 → 교각 수량산출 → 전체 교각 입력`에서 모든 교각을 행으로 확인한다. 기본적으로 미입력 변수만 모아 보여주며 체크를 해제하면 자동값·원본 기본값·직접값을 모두 확인할 수 있다. Excel 표를 붙여넣을 수 있으며 값은 각 교각의 기존 `quantityTakeoff` 필드에 저장된다. 입력 후 교각별 기준 검토를 체크하고 전체 재계산 및 전체 교각 Excel 출력을 실행한다. 대시보드의 Excel 출력도 전체 프로젝트를 대상으로 한다.
|
||||
|
||||
미입력·형상 미생성·적용 범위 문제가 있으면 해당 교각과 사유를 표시하고 불완전한 수량을 완료 Excel로 만들지 않는다. 기존 다기둥·꺾인 교각 등 원본 산식 적용 제한은 유지한다. 원본 집계표가 같은 규격을 전제로 하므로 교각별 재료 규격이 서로 다르면 규격 분리 검토를 안내한다.
|
||||
|
||||
## 산출서 구성
|
||||
|
||||
- 표지 및 목차
|
||||
- 전체 수량집계: 원본 53개 공종 행에 실제 교각별 수량을 참조하고 SUM으로 합산
|
||||
- 원본 3.1 산출기준 3개 시트
|
||||
- 각 실제 교각별 3.2 변수 시트와 3.3 수량산출서
|
||||
|
||||
`수량산출_ex)`와 고정 P2~P6 미사용 시트를 결과에서 제거한다. 교각 수에 맞춰 산출서·변수 시트를 생성하고 집계 열을 확장한다. 각 상세 산식은 해당 교각의 변수 시트만 참조하도록 바꾼다. 원본 수량 계산식·스타일·그림은 기존 OOXML 보존 출력을 활용한다. 미리보기에서도 예시/미사용 시트를 숨기고 수량집계를 전체 교각 결과로 표시한다. 원본 Excel은 변경하지 않는다.
|
||||
|
||||
## 받침 길이·폭
|
||||
|
||||
기존 매핑은 `plan.A/B`를 그대로 사용했지만 이는 솔플레이트 입력이다. 실제 3D 받침 본체 생성 코드와 같은 `(A + margin)/1000 - 0.2`, `(B + margin)/1000 - 0.2` m로 연결했다. 실제 받침 형상이 없거나 한 교각 내 치수가 달라 단일값을 정할 수 없으면 자동 치수를 추정하지 않는다. 교좌면 1.2m·몰탈 1m를 받침 치수로 대체하지 않는다.
|
||||
|
||||
## 주요 변경 파일
|
||||
|
||||
- `project-inputs.ts`: 전체 교각 입력 표, 붙여넣기, 저장·재계산·출력 진입
|
||||
- `bearing-dimensions.ts`, `model.ts`: 실제 받침 본체 제원 연결
|
||||
- `workspace.ts`, `workspace.css`: 전체 교각 작업 흐름과 모달
|
||||
- `excel-preview.ts`, `review-panel.ts`: 예시 제거, 실제 전체 집계 미리보기
|
||||
- `server.ts`, `tools/project_workbook.py`: 가변 교각 수의 원본 양식 합성 출력
|
||||
- `bearing-dimensions.test.ts`, `tools/verify_project_package.py`: 형상 치수 및 전체 합산 검증
|
||||
|
||||
## 검증 결과
|
||||
|
||||
- 실제 운곡교 6개 교각: 전체 미입력 입력·저장·재계산·출력, 예시 제거, 브라우저 오류 0건.
|
||||
- 실제 출력은 17시트, 상세 6개. 53개 집계행과 318개 상세 참조·계산캐시 차이 0건.
|
||||
- 17교각 **합성 확장 시험**: 39시트, 53개 집계행과 901개 상세 참조·합계 차이 0건. 실제 산외교 수량 검증을 뜻하지 않는다.
|
||||
- TypeScript 검사, 수량산출 테스트 12개, Vite 빌드 통과. 기존 번들 크기 경고 유지.
|
||||
- 실제 Microsoft Excel 앱의 재계산까지 검증한 것은 아니다. ZIP·XML·관계·원식 참조·캐시 검증을 수행했다.
|
||||
- 검증 입력의 토층·미생성 받침 치수는 원본 예시값을 사용한 시험 조건이다. 실무 확정값으로 사용하지 않는다.
|
||||
|
||||
검증 근거: `project-package-verification.json`. 원본에 없는 수량 규칙은 이번 UI·출력 개선을 이유로 추가하지 않았다.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"piers": 6,
|
||||
"sheets": 17,
|
||||
"summary_rows_checked": 53,
|
||||
"source_formula_links": 318,
|
||||
"example_removed": true,
|
||||
"cache_differences": 0
|
||||
}
|
||||
+15
-8
@@ -16,12 +16,12 @@ const referenceImages:Record<string,string[]>={
|
||||
};
|
||||
const letters=(v:number):string=>v?letters(Math.floor((v-1)/26))+String.fromCharCode(65+(v-1)%26):'';
|
||||
const node=<K extends keyof HTMLElementTagNameMap>(tag:K,text?:string)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;return n;};
|
||||
export function createExcelPreview(options:{result:ReturnType<typeof calculate>;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};plan?:{selected:PierModel;models:readonly PierModel[]};compact?:boolean;state:{sheet:string;zoom:number};onCell:(cell:string)=>void}):HTMLElement{
|
||||
export function createExcelPreview(options:{result:ReturnType<typeof calculate>;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};projectRows?:{name:string;result:ReturnType<typeof calculate>}[];plan?:{selected:PierModel;models:readonly PierModel[]};compact?:boolean;state:{sheet:string;zoom:number};onCell:(cell:string)=>void}):HTMLElement{
|
||||
const {result,stored,pier,state}=options,host=node('section');host.className='takeoff-excel-preview'+(options.compact?' is-compact':'');const toolbar=node('div');toolbar.className='takeoff-excel-toolbar';
|
||||
const selector=node('select');selector.setAttribute('aria-label',options.compact?'미니 산출서 시트 선택':'Excel 샘플 시트 선택');
|
||||
const names=Object.keys(layouts);for(const name of ['__all__',...names]){const o=node('option',name==='__all__'?'표지부터 전체 보기 (13시트)':name===DETAIL?'3.3 수량산출_'+pier:name);o.value=name;o.selected=state.sheet===name;selector.append(o);}toolbar.append(selector);
|
||||
const names=Object.keys(layouts).filter(name=>name!=='3.3 수량산출_ex)'&&!/^3\.3 수량산출_P[2-6]$/.test(name));for(const name of ['__all__',...names]){const o=node('option',name==='__all__'?'표지부터 선택 교각 전체 보기':name===DETAIL?'3.3 수량산출_'+pier:name);o.value=name;o.selected=state.sheet===name;selector.append(o);}toolbar.append(selector);
|
||||
const previous=node('button',options.compact?'‹':'이전 시트'),next=node('button',options.compact?'›':'다음 시트');previous.setAttribute('aria-label','이전 시트');next.setAttribute('aria-label','다음 시트');previous.type=next.type='button';const counter=node('output');counter.className='takeoff-excel-page-count';toolbar.append(previous,counter,next);
|
||||
const zoom=node('select');zoom.setAttribute('aria-label','수량산출서 확대');for(const z of options.compact?[.3,.4,.5,.6,.8,1]:[.8,1,1.1,1.25,1.5]){const o=node('option',Math.round(z*100)+'%');o.value=String(z);o.selected=z===state.zoom;zoom.append(o);}toolbar.append(zoom,node('span','원본 Excel 양식 · 수량 셀을 누르면 산식 확인'));
|
||||
const zoom=node('select');zoom.setAttribute('aria-label','수량산출서 확대');for(const z of [0,...(options.compact?[.3,.4,.5,.6,.8,1]:[.8,1,1.1,1.25,1.5])]){const o=node('option',z===0?'한 장 맞춤':Math.round(z*100)+'%');o.value=String(z);o.selected=z===state.zoom;zoom.append(o);}toolbar.append(zoom,node('span','원본 Excel 양식 · 수량 셀을 누르면 산식 확인'));
|
||||
const viewport=node('div');viewport.className='takeoff-excel-viewport';const paper=node('div');paper.className='takeoff-excel-paper';viewport.append(paper);host.append(toolbar,viewport);
|
||||
const overrides:Overrides={...result.overrides};
|
||||
for(let i=2;i<=6;i++){const s='3.3 수량산출_P'+i;for(const [cell,c]of Object.entries(template[s]))if(typeof c.value==='number'||typeof c.value==='string'&&c.value.startsWith('='))overrides[cellKey(s,cell)]=0;const col=['P','R','T','V','X'][i-2];for(let r=18;r<=46;r++)overrides[cellKey(INPUTS,col+r)]=0;}
|
||||
@@ -32,19 +32,26 @@ export function createExcelPreview(options:{result:ReturnType<typeof calculate>;
|
||||
if(/^3\.3 수량산출_P[2-6]$/.test(sheet)){if(cell==='F2')return '(미사용)';const raw=template[sheet]?.[cell]?.value;if(typeof raw==='number'||typeof raw==='string'&&raw.startsWith('=')&&raw.length>1)return '';}
|
||||
if(sheet===DETAIL){if(cell==='F2')return '('+pier+')';const spec=specifications.find(v=>v.cell===cell);if(spec)return stored.values[spec.key]||'규격 확인 필요';const row=result.results.find(r=>r.cell===cell);if(row)return row.value;}
|
||||
if(sheet===INPUTS){if(cell==='N30'&&result.copingMode==='straight')return '직선';if(cell==='N17')return pier;if(/^[PRTVX](1[89]|[234]\d)$/.test(cell))return '';if(/^[PRTVX]17$/.test(cell))return '미사용';if(/^N\d+$/.test(cell)&&Object.hasOwn(result.overrides,cellKey(INPUTS,cell)))return result.overrides[cellKey(INPUTS,cell)];}
|
||||
if(sheet==='1. 수량집계'){if(cell==='N4')return pier;if(/^(Q|T|W|Z|AC)4$/.test(cell))return '미사용';}
|
||||
if(sheet==='1. 수량집계'&&options.projectRows){const match=cell.match(/^([A-Z]+)(\d+)$/)!;let col=0;for(const letter of match[1])col=col*26+letter.charCodeAt(0)-64;const row=Number(match[2]),source=template[sheet]['N'+row],ref=String(source?.value??'').match(/!\$?([A-Z]+)\$?(\d+)/),values=ref?options.projectRows.map(p=>p.result.results.find(r=>r.cell===ref[1]+ref[2])?.value??null):[];
|
||||
if(col>=14){const p=options.projectRows[Math.floor((col-14)/3)];if(!p)return '';if(row===4||row===48)return p.name;if(ref)return values[Math.floor((col-14)/3)];}
|
||||
if(col===11&&ref)return values.some(v=>v===null)?null:values.reduce((a,b)=>a!+b!,0);
|
||||
}
|
||||
const c=template[sheet]?.[cell];if(!c)return '';if(typeof c.value==='string'&&c.value.startsWith('=')&&c.value.length>1){try{return book.cell(sheet,cell);}catch{return null;}}return c.value;
|
||||
};
|
||||
const formatted=(value:unknown,format:string)=>{if(value===null)return '미산정';if(value===undefined)return '';if(typeof value!=='number')return String(value);if(format==='General')return String(value);
|
||||
if(value===0&&format.includes('"-"'))return '−';const m=format.split(';')[0].match(/\.([0#]+)/),digits=m?.[1].length??0;
|
||||
return value.toLocaleString('ko-KR',{minimumFractionDigits:digits,maximumFractionDigits:digits,useGrouping:format.includes(',')});};
|
||||
const draw=()=>{state.sheet=selector.value;state.zoom=Number(zoom.value);paper.replaceChildren();paper.style.zoom=String(state.zoom);counter.textContent=state.sheet==='__all__'?'전체 / 13':(names.indexOf(state.sheet)+1)+' / '+names.length;
|
||||
const fit=()=>{if(state.zoom!==0||!host.isConnected)return;paper.style.zoom='1';const css=getComputedStyle(viewport),w=viewport.clientWidth-parseFloat(css.paddingLeft)-parseFloat(css.paddingRight)-4,h=viewport.clientHeight-parseFloat(css.paddingTop)-parseFloat(css.paddingBottom)-4;paper.style.zoom=String(Math.max(.05,Math.min(w/paper.offsetWidth,h/paper.offsetHeight,1.5)));viewport.scrollTo(0,0);};
|
||||
const observer=new ResizeObserver(()=>{if(host.isConnected)fit();else observer.disconnect();});observer.observe(viewport);
|
||||
const draw=()=>{state.sheet=selector.value;state.zoom=Number(zoom.value);paper.replaceChildren();paper.style.zoom=String(state.zoom||1);viewport.classList.toggle('is-page-fit',state.zoom===0);counter.textContent=state.sheet==='__all__'?'전체 / '+names.length:(names.indexOf(state.sheet)+1)+' / '+names.length;
|
||||
const renderSheet=(sheet:string)=>{
|
||||
const page=node('article');page.className='takeoff-excel-page';page.dataset.previewSheet=sheet;
|
||||
const title=sheet===DETAIL?'3.3 수량산출 · '+pier:sheet==='3.3 수량산출_ex)'?sheet+' · 원본 참고 예시':/^3\.3 수량산출_P[2-6]$/.test(sheet)?sheet+' · 미사용':sheet;
|
||||
page.append(node('h4',title));paper.append(page);
|
||||
const layout=layouts[sheet],table=node('table');table.className='takeoff-excel-sheet';table.dataset.sheet=sheet;table.style.width=layout.widths.reduce((a,b)=>a+b,0)+'px';
|
||||
if(referenceImages[sheet]){for(const [i,src]of referenceImages[sheet].entries()){const image=node('img');image.src=src;image.alt=sheet+' · 원본 PDF '+(i+1)+'페이지';image.className='takeoff-reference-page';image.style.width=table.style.width;page.append(image);}return;}
|
||||
let layout=layouts[sheet];
|
||||
if(sheet==='1. 수량집계'&&options.projectRows){const n=options.projectRows.length,widths=[...layout.widths.slice(0,13),...Array.from({length:n},()=>layout.widths.slice(13,16)).flat()],cells={...layout.cells},merges=layout.merges.filter(m=>m[3]<14);for(let i=0;i<n;i++){for(const m of layout.merges.filter(m=>m[1]>=14&&m[3]<=16))merges.push([m[0],m[1]+3*i,m[2],m[3]+3*i]);for(let r=1;r<=layout.rows;r++)for(let c=0;c<3;c++)cells[letters(14+3*i+c)+r]=layout.cells[letters(14+c)+r];}layout={...layout,widths,cols:widths.length,cells,merges};}
|
||||
const table=node('table');table.className='takeoff-excel-sheet';table.dataset.sheet=sheet;table.style.width=layout.widths.reduce((a,b)=>a+b,0)+'px';
|
||||
if(referenceImages[sheet]){for(const [i,src]of referenceImages[sheet].entries()){const image=node('img');image.src=src;image.onload=fit;image.alt=sheet+' · 원본 PDF '+(i+1)+'페이지';image.className='takeoff-reference-page';image.style.width=table.style.width;page.append(image);}return;}
|
||||
const cols=node('colgroup');for(const w of layout.widths){const c=node('col');c.style.width=w+'px';cols.append(c);}table.append(cols);
|
||||
const merged=new Map<string,number[]>(),covered=new Set<string>();for(const [r,c,re,ce]of layout.merges){merged.set(r+','+c,[r,c,re,Math.min(ce,layout.cols)]);for(let y=r;y<=re;y++)for(let x=c;x<=ce;x++)if(y!==r||x!==c)covered.add(y+','+x);}
|
||||
for(let r=layout.firstRow;r<=layout.rows;r++){const tr=node('tr');tr.style.height=layout.heights[r-1]+'px';for(let c=1;c<=layout.cols;c++){
|
||||
@@ -65,7 +72,7 @@ export function createExcelPreview(options:{result:ReturnType<typeof calculate>;
|
||||
};
|
||||
for(const sheet of state.sheet==='__all__'?names:[state.sheet])renderSheet(sheet);
|
||||
previous.disabled=state.sheet!=='__all__'&&names.indexOf(state.sheet)===0;next.disabled=names.indexOf(state.sheet)===names.length-1;
|
||||
|
||||
requestAnimationFrame(fit);
|
||||
};
|
||||
previous.onclick=()=>{selector.value=names[Math.max(0,names.indexOf(state.sheet)-1)];draw();viewport.scrollTo(0,0);};
|
||||
next.onclick=()=>{selector.value=names[Math.min(names.length-1,names.indexOf(state.sheet)+1)];draw();viewport.scrollTo(0,0);};
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import {bearingDimensions} from './bearing-dimensions';
|
||||
import {straightCopingFormulas} from './coping-formulas';
|
||||
import type {PierInput} from '../src/core/types';
|
||||
import {DIM,type PierModel} from '../src/core/geometry';
|
||||
@@ -16,9 +17,9 @@ export function withTemplateDefaults(stored:QuantityInputs):QuantityInputs{
|
||||
}
|
||||
export function automaticInputs(input:PierInput,m:PierModel):Mapped[]{
|
||||
const uniform=(v:(number|undefined)[])=>v.length&&v.every(x=>x!==undefined&&Number.isFinite(x)&&Math.abs(x!-v[0]!)<1e-7)?v[0]!:null;
|
||||
const footing=m.footings[0],nopiles=m.piles.length===0;
|
||||
const footing=m.footings[0],nopiles=m.piles.length===0,bearing=bearingDimensions(m);
|
||||
const slope=m.copingL>0&&m.segments.length?((m.segments[m.segments.length-1].top-m.segments[0].top)/m.copingL)*100:null;
|
||||
const auto:Record<number,[number|null,string]>={18:[m.copingRefTop,'model.copingRefTop (원본 Zb 의미 검토)'],19:[input.groundEl,'input.groundEl'],20:[input.groundWaterOn===true&&Number.isFinite(input.groundWaterEl)?input.groundWaterEl!:null,'input.groundWaterEl / 지하수 미지정은 직접 확인'],24:[m.copingL,'model.copingL'],25:[m.copingB,'model.copingB'],26:[m.h2,'model.h2 · 하부 테이퍼 높이'],27:[m.h1,'model.h1 · 상부 높이'],28:[slope,'모델 양단 상면 높이차/Lc ×100 · 단차 포함 등가경사'],29:[0,'현재 코핑 모델은 수직 끝단 · 별도 돌출부 없음'],30:[null,'직선 모델: 곡률 없음 · 원본 곡률항의 직선 극한 사용'],31:[uniform(m.columns.map(c=>c.diameter)),'model.columns[].diameter'],32:[footing?.bottom??null,'model.footings[0].bottom'],33:[footing?.length??null,'model.footings[0].length'],34:[footing?.width??null,'model.footings[0].width'],35:[footing?footing.top-footing.bottom:null,'기초 상면-하면'],36:[nopiles?0:uniform(m.piles.map(p=>p.diameter)),'model.piles[].diameter'],37:[nopiles?0:input.pileThicknessMm!=null?input.pileThicknessMm/1000:null,'입력 말뚝두께 mm→m'],38:[nopiles?0:uniform(m.piles.map(p=>p.top-p.bottom)),'말뚝 상면-하면'],39:[m.piles.length,'model.piles.length'],40:[uniform(m.blocks.map(b=>b.plan?.A!==undefined?b.plan.A/1000:undefined)),'받침 plan.A mm→m'],41:[uniform(m.blocks.map(b=>b.plan?.B!==undefined?b.plan.B/1000:undefined)),'받침 plan.B mm→m'],42:[m.blocks.length,'model.blocks.length'],43:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],44:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],45:[m.blocks.length?m.blocks.reduce((s,b)=>s+b.top-b.bottom,0)/m.blocks.length:null,'동일 평면 블럭 높이 합/Nb (원식의 등가 높이)'],46:[uniform(m.blocks.map(b=>b.plan?.T!==undefined?b.plan.T/1000:undefined))??(input.solplate?.T!=null?input.solplate.T/1000:null),'실제 솔플레이트 plan.T 또는 입력 공통 T mm→m']};
|
||||
const auto:Record<number,[number|null,string]>={18:[m.copingRefTop,'model.copingRefTop (원본 Zb 의미 검토)'],19:[input.groundEl,'input.groundEl'],20:[input.groundWaterOn===true&&Number.isFinite(input.groundWaterEl)?input.groundWaterEl!:null,'input.groundWaterEl / 지하수 미지정은 직접 확인'],24:[m.copingL,'model.copingL'],25:[m.copingB,'model.copingB'],26:[m.h2,'model.h2 · 하부 테이퍼 높이'],27:[m.h1,'model.h1 · 상부 높이'],28:[slope,'모델 양단 상면 높이차/Lc ×100 · 단차 포함 등가경사'],29:[0,'현재 코핑 모델은 수직 끝단 · 별도 돌출부 없음'],30:[null,'직선 모델: 곡률 없음 · 원본 곡률항의 직선 극한 사용'],31:[uniform(m.columns.map(c=>c.diameter)),'model.columns[].diameter'],32:[footing?.bottom??null,'model.footings[0].bottom'],33:[footing?.length??null,'model.footings[0].length'],34:[footing?.width??null,'model.footings[0].width'],35:[footing?footing.top-footing.bottom:null,'기초 상면-하면'],36:[nopiles?0:uniform(m.piles.map(p=>p.diameter)),'model.piles[].diameter'],37:[nopiles?0:input.pileThicknessMm!=null?input.pileThicknessMm/1000:null,'입력 말뚝두께 mm→m'],38:[nopiles?0:uniform(m.piles.map(p=>p.top-p.bottom)),'말뚝 상면-하면'],39:[m.piles.length,'model.piles.length'],40:[bearing.length,'3D 받침 본체: (plan.A+margin)/1000−0.2 m'],41:[bearing.width,'3D 받침 본체: (plan.B+margin)/1000−0.2 m'],42:[m.blocks.length,'model.blocks.length'],43:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],44:[m.integratedBlocks.length?null:DIM.blockSize,'모델 분리블럭 크기'],45:[m.blocks.length?m.blocks.reduce((s,b)=>s+b.top-b.bottom,0)/m.blocks.length:null,'동일 평면 블럭 높이 합/Nb (원식의 등가 높이)'],46:[uniform(m.blocks.map(b=>b.plan?.T!==undefined?b.plan.T/1000:undefined))??(input.solplate?.T!=null?input.solplate.T/1000:null),'실제 솔플레이트 plan.T 또는 입력 공통 T mm→m']};
|
||||
return variables.map(v=>{const a=auto[v.row];return {...v,value:a?.[0]??null,source:a?.[1]??`${INPUTS}!N${v.row} · 모델에 없는 조건`,automatic:a?.[0]!=null||v.row===30,notApplicable:v.row===30};});
|
||||
}
|
||||
export function geometrySignature(input:PierInput,m:PierModel):string{const {quantityTakeoff,...raw}=input;return JSON.stringify({raw,auto:automaticInputs(input,m),version:templateHash});}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {PierInput} from '../src/core/types';
|
||||
import type {PierModel} from '../src/core/geometry';
|
||||
import {calculate,withTemplateDefaults,type QuantityInputs} from './model';
|
||||
import {inputFields} from './input-panel';
|
||||
import {node} from './dashboard-types';
|
||||
export type QuantitySnapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]};
|
||||
export function projectRecords(s:QuantitySnapshot){return s.piers.map(p=>{const stored=withTemplateDefaults(p.input.quantityTakeoff??{values:{},reviewed:false});return {pier:p,stored,result:p.model?calculate(p.input,p.model,stored,p.rebarTon):null};});}
|
||||
export function openProjectInputs(read:()=>QuantitySnapshot,save:(name:string,data:QuantityInputs)=>void,onClose:()=>void,onExport:()=>Promise<void>){
|
||||
const dialog=node('dialog');dialog.className='tq-project-dialog';dialog.dataset.projectInputs='true';
|
||||
const head=node('header'),title=node('h2','전체 교각 입력 · 수량산출'),close=node('button','닫기 ×');close.type='button';close.onclick=()=>dialog.close();head.append(title,close);dialog.append(head);
|
||||
const status=node('p'),tools=node('div');tools.className='tq-project-tools';
|
||||
const filter=node('label'),missingOnly=node('input');missingOnly.type='checkbox';missingOnly.checked=true;filter.append(missingOnly,document.createTextNode('미입력 항목만'));tools.append(filter);
|
||||
const recalc=node('button','전체 재계산'),exporter=node('button','전체 교각 Excel 출력');recalc.type=exporter.type='button';tools.append(recalc,exporter);dialog.append(tools,status);
|
||||
dialog.append(node('p','자동값은 모델에서 가져옵니다. 직접 입력값은 교각별 저장됩니다. 표에 Excel 셀 범위를 붙여넣을 수 있습니다. 받침 형상이 없거나 서로 다른 치수이면 직접 확인이 필요합니다.'));
|
||||
const scroll=node('div');scroll.className='tq-project-table';dialog.append(scroll);
|
||||
const draw=()=>{
|
||||
const records=projectRecords(read());const rows=records.map(r=>({...r,fields:r.result?inputFields(r.result,r.stored,r.pier):[]}));
|
||||
const columns=new Map<string,(typeof rows)[number]['fields'][number]>();
|
||||
for(const r of rows)for(const f of r.fields)if(f.required&&(!missingOnly.checked||f.missing))columns.set(f.key,f);
|
||||
// Show real bearing geometry even while reviewing only missing conditions.
|
||||
for(const r of rows)for(const f of r.fields)if(['r40','r41'].includes(f.key))columns.set(f.key,f);
|
||||
const keys=[...columns.keys()],table=node('table'),thead=node('thead'),tr=node('tr');tr.append(node('th','교각 / 상태'));
|
||||
for(const f of columns.values()){const th=node('th',f.name+(f.unit?' · '+f.unit:''));th.title=f.source;tr.append(th);}tr.append(node('th','원본 기준 검토'));thead.append(tr);table.append(thead);
|
||||
const body=node('tbody');table.append(body);let missing=0;
|
||||
for(const [ri,r]of rows.entries()){
|
||||
const row=node('tr');row.dataset.projectPier=r.pier.input.name;
|
||||
const label=node('th',r.pier.input.name+' · '+r.pier.input.type);const issues=r.pier.dirty?['형상 재생성 필요']:!r.result?['형상 없음']:r.result.issues;
|
||||
label.append(node('small',issues.length?issues.join(' / '):r.result?.missing.length?'누락 '+r.result.missing.length+'개':r.stored.reviewed?'계산 가능':'기준 검토 필요'));row.append(label);missing+=r.result?.missing.length??1;
|
||||
for(const key of keys){const f=r.fields.find(f=>f.key===key),td=node('td');if(!f){td.textContent='—';row.append(td);continue;}
|
||||
if(f.kind==='auto'){td.append(node('output',f.value),node('small','자동'));td.title=f.source;}
|
||||
else{const control=node('input');control.type=f.numeric?'number':'text';if(f.numeric)control.step='any';control.value=f.value;control.dataset.batchKey=key;control.setAttribute('aria-label',r.pier.input.name+' '+f.name);control.classList.toggle('is-missing',f.missing);control.onchange=()=>{const live=projectRecords(read()).find(x=>x.pier.input.name===r.pier.input.name)!;save(r.pier.input.name,{...live.stored,values:{...live.stored.values,[key]:control.value===''?null:f.numeric?Number(control.value):control.value},lastSignature:undefined});control.classList.toggle('is-missing',control.value==='');status.textContent='입력값 저장됨 · 전체 재계산으로 누락 및 적용 범위를 확인하세요.';};
|
||||
control.onpaste=event=>{const raw=event.clipboardData?.getData('text/plain');if(!raw||!/[\t\n]/.test(raw))return;event.preventDefault();for(const [dy,line]of raw.trimEnd().split(/\r?\n/).entries())for(const [dx,value]of line.split('\t').entries()){const target=body.rows[ri+dy]?.querySelector<HTMLInputElement>(`[data-batch-key="${keys[keys.indexOf(key)+dx]}"]`);if(target){target.value=value.trim();target.dispatchEvent(new Event('change'));}}};td.append(control,node('small',f.kind==='template'?'원본 Excel':'직접 입력'));
|
||||
}row.append(td);
|
||||
}
|
||||
const td=node('td'),review=node('input');review.type='checkbox';review.checked=r.stored.reviewed;review.setAttribute('aria-label',r.pier.input.name+' 원본 기준 확인');review.onchange=()=>{const live=projectRecords(read()).find(x=>x.pier.input.name===r.pier.input.name)!;save(r.pier.input.name,{...live.stored,reviewed:review.checked,lastSignature:undefined});};td.append(review);row.append(td);body.append(row);
|
||||
}
|
||||
scroll.replaceChildren(table);status.textContent=`전체 ${rows.length}개 교각 · 미입력 ${missing}개 · 교각별 산출서 합산 방식`;
|
||||
};
|
||||
missingOnly.onchange=draw;
|
||||
recalc.onclick=()=>{for(const r of projectRecords(read()))if(r.result)save(r.pier.input.name,{...r.stored,lastSignature:r.result.signature});draw();};
|
||||
exporter.onclick=async()=>{exporter.disabled=true;try{await onExport();status.textContent='전체 교각 Excel 출력 완료';}catch(error){status.textContent=(error as Error).message;status.classList.add('tq-project-error');}finally{exporter.disabled=false;}};
|
||||
dialog.addEventListener('close',()=>{dialog.remove();onClose();},{once:true});document.body.append(dialog);draw();dialog.showModal();
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import {cellKey} from './formula';
|
||||
import {node,fmt,type TakeoffResult,type Selection} from './dashboard-types';
|
||||
import {rowStatus} from './results-panel';
|
||||
import type {QuantityInputs} from './model';
|
||||
export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};plan:{selected:PierModel;models:readonly PierModel[]};stale:boolean;miniState:{sheet:string;zoom:number};onCell:(cell:string)=>void}){
|
||||
export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;pier:string;project:{name:string;bridge:string};projectRows:{name:string;result:TakeoffResult}[];plan:{selected:PierModel;models:readonly PierModel[]};stale:boolean;miniState:{sheet:string;zoom:number};onCell:(cell:string)=>void}){
|
||||
const panel=node('aside',undefined,'tq-right-panel'),preview=node('section',undefined,'tq-panel tq-mini-panel');const heading=node('div',undefined,'tq-panel-heading');heading.append(node('h3','산출서 미리보기'));const expand=node('button','⛶');expand.type='button';expand.setAttribute('aria-label','산출서 미리보기 전체화면');heading.append(expand);preview.append(heading);
|
||||
const mini=createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,state:o.miniState,compact:true,onCell:o.onCell});preview.append(mini);panel.append(preview);
|
||||
const mini=createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,projectRows:o.projectRows,state:o.miniState,compact:true,onCell:o.onCell});preview.append(mini);panel.append(preview);
|
||||
const trace=node('section',undefined,'tq-panel tq-formula-panel takeoff-trace');trace.setAttribute('aria-live','polite');panel.append(trace);
|
||||
expand.onclick=()=>{const dialog=node('dialog',undefined,'tq-preview-dialog');dialog.dataset.quantityPreviewDialog='true';dialog.setAttribute('aria-label','수량산출서 전체화면');const close=node('button','닫기 ×');close.type='button';close.className='tq-dialog-close';close.onclick=()=>dialog.close();dialog.append(close,createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,state:{sheet:'__all__',zoom:1},onCell:o.onCell}));dialog.addEventListener('close',()=>{dialog.remove();expand.focus({preventScroll:true});},{once:true});document.body.append(dialog);dialog.showModal();};
|
||||
let dialogTrace:HTMLElement|undefined,hasSelection=false;
|
||||
const open=()=>{const dialog=node('dialog',undefined,'tq-preview-dialog tq-report-dialog');dialog.dataset.quantityPreviewDialog='true';dialog.setAttribute('aria-label','수량산출서 확대 및 산식 상세');const close=node('button','닫기 ×');close.type='button';close.className='tq-dialog-close';close.onclick=()=>dialog.close();const grid=node('div',undefined,'tq-report-grid'),documentView=createExcelPreview({result:o.result,stored:o.stored,pier:o.pier,project:o.project,plan:o.plan,projectRows:o.projectRows,state:{sheet:'표지및목차',zoom:0},onCell:o.onCell});dialogTrace=trace.cloneNode(true) as HTMLElement;dialogTrace.classList.add('tq-dialog-trace');grid.append(documentView,dialogTrace);dialog.append(close,grid);dialog.addEventListener('close',()=>{dialogTrace=undefined;dialog.remove();expand.focus({preventScroll:true});},{once:true});document.body.append(dialog);dialog.showModal();};
|
||||
expand.onclick=open;
|
||||
const select=(s:Selection)=>{
|
||||
trace.replaceChildren(node('h3','산식 상세'),node('p',s.title,'tq-selected-title'));
|
||||
const block=(label:string,content:HTMLElement)=>{const row=node('div',undefined,'tq-formula-block');row.append(node('b',label),content);trace.append(row);};
|
||||
@@ -23,7 +25,8 @@ export function createReviewPanel(o:{result:TakeoffResult;stored:QuantityInputs;
|
||||
const source=node('details',undefined,'tq-formula-source');source.append(node('summary','원본식 · 중간 계산값 · 참조 셀'));source.append(node('pre',s.formula));
|
||||
for(const r of s.rows)if(r.formula!==r.sourceFormula)source.append(node('p',r.cell+' · 직선 모델: 원본 곡률항의 R→∞ 극한 적용'),node('pre','원본: '+r.sourceFormula));
|
||||
for(const key of deps){const ix=key.lastIndexOf('!'),sheet=key.slice(0,ix),cell=key.slice(ix+1);if(s.rows.some(r=>sheet===DETAIL&&r.cell===cell))continue;let value:string;try{value=String(o.result.book.cell(sheet,cell));}catch{value='미입력';}source.append(node('p',key+' = '+value));}trace.append(source);
|
||||
mini.dispatchEvent(new CustomEvent('takeoff:focus-cells',{detail:s.rows.map(r=>r.cell)}));
|
||||
if(dialogTrace)dialogTrace.replaceChildren(...Array.from(trace.childNodes,n=>n.cloneNode(true)));
|
||||
if(hasSelection)mini.dispatchEvent(new CustomEvent('takeoff:focus-cells',{detail:s.rows.map(r=>r.cell)}));hasSelection=true;
|
||||
};
|
||||
return {panel,select,focusPreview:()=>{preview.tabIndex=-1;preview.focus();}};
|
||||
return {panel,select,open,focusPreview:()=>{preview.tabIndex=-1;preview.focus();}};
|
||||
}
|
||||
+5
-2
@@ -10,9 +10,12 @@ export function quantityPlugin(root:string):Plugin{
|
||||
const host=req.headers.host??'',remote=req.socket.remoteAddress??'';
|
||||
if(req.method!=='POST'||req.headers['x-pierzainer-action']!=='quantity'||! /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host)||!['127.0.0.1','::1','::ffff:127.0.0.1'].includes(remote)||req.headers.origin&&req.headers.origin!==`http://${host}`&&req.headers.origin!==`https://${host}`){reply(403,{error:'이 PC의 Pierzainer에서 출력하세요.'});return;}
|
||||
try{let raw='';for await(const part of req){raw+=part;if(raw.length>2000000)throw new Error('출력 요청이 너무 큽니다.');}
|
||||
const payload=exportEdits(JSON.parse(raw) as ExportRequest),folder=path.join(root,'수량산출/outputs',randomUUID());await mkdir(folder,{recursive:true});
|
||||
const request=JSON.parse(raw) as ExportRequest&{piers?:ExportRequest[]};
|
||||
const records=request.piers??[request];if(!records.length||records.length>100)throw new Error('출력 교각 수를 확인하세요.');
|
||||
for(const key of ['specFooting','specBody','specBlinding','specBearing','specMortar'])if(new Set(records.map(r=>String(r.specs[key]??''))).size>1)throw new Error('교각별 재료 규격이 서로 다릅니다. 원본 집계표의 동일 공종·규격에 합산할 수 없어 규격별 집계 검토가 필요합니다.');
|
||||
const payload={projects:records.map(r=>({...exportEdits(r),pier:r.pier})),bridge:records[0].project.bridge},folder=path.join(root,'수량산출/outputs',randomUUID());await mkdir(folder,{recursive:true});
|
||||
const input=path.join(folder,'edits.json'),output=path.join(folder,'quantity.xlsx');await writeFile(input,JSON.stringify(payload),'utf8');
|
||||
await new Promise<void>((resolve,reject)=>{const child=spawn(path.join(root,'pierzainer_ground/.venv/Scripts/python.exe'),['-B','-X','utf8',path.join(root,'수량산출/tools/preserve_template.py'),input,output],{cwd:root,windowsHide:true});let logs='';child.stdout.on('data',b=>logs+=b);child.stderr.on('data',b=>logs+=b);const timer=setTimeout(()=>{child.kill();reject(new Error('Excel 출력 제한시간 초과'));},60000);child.once('error',e=>{clearTimeout(timer);reject(e);});child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(new Error(logs));else resolve();});});
|
||||
await new Promise<void>((resolve,reject)=>{const child=spawn(path.join(root,'pierzainer_ground/.venv/Scripts/python.exe'),['-B','-X','utf8',path.join(root,'수량산출/tools/project_workbook.py'),input,output],{cwd:root,windowsHide:true});let logs='';child.stdout.on('data',b=>logs+=b);child.stderr.on('data',b=>logs+=b);const timer=setTimeout(()=>{child.kill();reject(new Error('Excel 출력 제한시간 초과'));},60000);child.once('error',e=>{clearTimeout(timer);reject(e);});child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(new Error(logs));else resolve();});});
|
||||
res.writeHead(200,{'Content-Type':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','Content-Disposition':'attachment; filename="quantity.xlsx"','Cache-Control':'no-store'});res.end(await readFile(output));
|
||||
}catch(e){reply(422,{error:(e as Error).message});}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Compose source-format per-pier reports and a formula-linked project summary.
|
||||
|
||||
Uses original OOXML parts. No spreadsheet-library roundtrip or source overwrite.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from copy import deepcopy
|
||||
import json,re,zipfile,sys
|
||||
from xml.etree import ElementTree as E
|
||||
from preserve_template import write,ROOT
|
||||
S='http://schemas.openxmlformats.org/spreadsheetml/2006/main'
|
||||
R='http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
||||
P='http://schemas.openxmlformats.org/package/2006/relationships'
|
||||
ns={'s':S}
|
||||
E.register_namespace('',S);E.register_namespace('r',R)
|
||||
def col(n):
|
||||
out=''
|
||||
while n:n,i=divmod(n-1,26);out=chr(65+i)+out
|
||||
return out
|
||||
def number(ref):
|
||||
n=0
|
||||
for c in re.match('[A-Z]+',ref)[0]:n=n*26+ord(c)-64
|
||||
return n
|
||||
def xml(n):return E.tostring(n,encoding='utf-8',xml_declaration=True)
|
||||
def text(c,value):
|
||||
for n in list(c):c.remove(n)
|
||||
c.set('t','inlineStr');E.SubElement(E.SubElement(c,'{'+S+'}is'),'{'+S+'}t').text=value
|
||||
def formula(c,value,cache):
|
||||
for n in list(c):c.remove(n)
|
||||
c.attrib.pop('t',None);E.SubElement(c,'{'+S+'}f').text=value;E.SubElement(c,'{'+S+'}v').text=str(cache)
|
||||
def build(payload_path,output_path):
|
||||
output=Path(output_path).resolve()
|
||||
if not output.is_relative_to((ROOT/'outputs').resolve()) or output.exists():raise ValueError('새 결과 경로를 사용하세요.')
|
||||
payload=json.loads(Path(payload_path).read_text(encoding='utf-8-sig'));projects=payload['projects']
|
||||
if not projects or len(projects)>100:raise ValueError('교각 수는 1~100개여야 합니다.')
|
||||
archives=[];names=[];used=set()
|
||||
for i,p in enumerate(projects):
|
||||
name=p['pier'];safe=re.sub(r'[\\/*?:\[\]]','_',name)[:18]
|
||||
if safe in used:raise ValueError('교각명 중복: '+name)
|
||||
used.add(safe);names.append(('3.2 변수_'+safe,'3.3 수량산출_'+safe))
|
||||
patch=output.parent/f'pier-{i+1}-edits.json';part=output.parent/f'pier-{i+1}.xlsx'
|
||||
patch.write_text(json.dumps(p,ensure_ascii=False),encoding='utf-8');write(patch,part);archives.append(zipfile.ZipFile(part))
|
||||
base=archives[0];parts={i.filename:base.read(i.filename) for i in base.infolist() if not i.filename.startswith('xl/worksheets/') and i.filename!='xl/calcChain.xml'}
|
||||
source=zipfile.ZipFile(ROOT/'교각수량산출.xlsx');summary=E.fromstring(source.read('xl/worksheets/sheet2.xml'))
|
||||
# Preserve the source summary's work rows and formatting. Repeat the P1 3-column
|
||||
# block for each actual pier and sum only those calculated pier cells.
|
||||
summary_checks=0
|
||||
detail_values=[]
|
||||
for z in archives:
|
||||
cells=E.fromstring(z.read('xl/worksheets/sheet8.xml')).findall('.//s:c',ns)
|
||||
detail_values.append({c.get('r'):float(c.find('s:v',ns).text) for c in cells if c.find('s:v',ns) is not None and c.get('t') not in ('s','e')})
|
||||
for row in summary.find('s:sheetData',ns):
|
||||
cells={c.get('r'):c for c in row};rn=int(row.get('r'));samples=[deepcopy(c) for c in row if 14<=number(c.get('r'))<=16]
|
||||
for c in list(row):
|
||||
if number(c.get('r'))>=14:row.remove(c)
|
||||
values=[]
|
||||
for i,p in enumerate(projects):
|
||||
for sample in samples:
|
||||
c=deepcopy(sample);c.set('r',col(number(sample.get('r'))+i*3)+str(rn))
|
||||
old=sample.find('s:f',ns)
|
||||
if old is not None:
|
||||
target=re.search(r'!\$?([A-Z]+)\$?(\d+)',old.text or '')
|
||||
if target:
|
||||
cell=target[1]+target[2];cache=detail_values[i].get(cell)
|
||||
if cache is None:raise ValueError(f'{p["pier"]} {cell} 미산정')
|
||||
formula(c,"'"+names[i][1].replace("'","''")+"'!"+cell,cache)
|
||||
if number(sample.get('r'))==14:values.append(float(cache))
|
||||
elif rn in (4,48) and number(sample.get('r'))==14:text(c,p['pier'])
|
||||
row.append(c)
|
||||
total=cells.get('K'+str(rn))
|
||||
if total is not None and values:
|
||||
formula(total,'SUM('+','.join(col(14+3*i)+str(rn) for i in range(len(projects)))+')',sum(values));summary_checks+=1
|
||||
merges=summary.find('s:mergeCells',ns)
|
||||
if merges is not None:
|
||||
originals=[m.get('ref') for m in merges];merges.clear()
|
||||
for ref in originals:
|
||||
a,b=ref.split(':') if ':' in ref else (ref,ref)
|
||||
if number(b)<14:E.SubElement(merges,'{'+S+'}mergeCell',{'ref':ref})
|
||||
elif number(a)>=14 and number(b)<=16:
|
||||
for i in range(len(projects)):
|
||||
shift=lambda s:col(number(s)+i*3)+re.search(r'\d+',s)[0]
|
||||
E.SubElement(merges,'{'+S+'}mergeCell',{'ref':shift(a)+':'+shift(b)})
|
||||
merges.set('count',str(len(merges)))
|
||||
end=col(13+3*len(projects));dimension=summary.find('s:dimension',ns)
|
||||
if dimension is not None:dimension.set('ref','A1:'+end+'85')
|
||||
cols=summary.find('s:cols',ns)
|
||||
if cols is not None and len(projects)>6:E.SubElement(cols,'{'+S+'}col',{'min':'32','max':str(13+3*len(projects)),'width':'2.5','customWidth':'1'})
|
||||
sheets=[('표지및목차',base.read('xl/worksheets/sheet1.xml'),1),('1. 수량집계',xml(summary),2)]
|
||||
for i,name in [(3,'3.1 수량산출기준_토공'),(4,'3.1 수량산출기준_구조체 현황'),(5,'3.1 수량산출기준_구조체')]:sheets.append((name,base.read(f'xl/worksheets/sheet{i}.xml'),i))
|
||||
for i,z in enumerate(archives):
|
||||
for oldindex,newname in [(6,names[i][0]),(8,names[i][1])]:
|
||||
data=z.read(f'xl/worksheets/sheet{oldindex}.xml').decode('utf8')
|
||||
# Work on formula text only: identifiers/comments and labels stay unchanged.
|
||||
def refs(m):return m[0].replace('3.2 관련변수',names[i][0]).replace('3.3 수량산출_P1',names[i][1])
|
||||
data=re.sub(r'<f\b[^>]*>.*?</f>',refs,data,flags=re.S)
|
||||
sheets.append((newname,data.encode('utf8'),oldindex))
|
||||
workbook=E.fromstring(base.read('xl/workbook.xml'));sheetlist=workbook.find('s:sheets',ns);sheetlist.clear()
|
||||
defined=workbook.find('s:definedNames',ns)
|
||||
if defined is None:defined=E.SubElement(workbook,'{'+S+'}definedNames')
|
||||
olddefs=list(defined);defined.clear()
|
||||
rels=E.fromstring(base.read('xl/_rels/workbook.xml.rels'))
|
||||
for r in list(rels):
|
||||
if r.get('Type','').endswith(('/worksheet','/calcChain')):rels.remove(r)
|
||||
types=E.fromstring(base.read('[Content_Types].xml'));ct='http://schemas.openxmlformats.org/package/2006/content-types'
|
||||
for t in list(types):
|
||||
if t.get('PartName','').startswith('/xl/worksheets/'):types.remove(t)
|
||||
for index,(name,data,original) in enumerate(sheets,1):
|
||||
path=f'xl/worksheets/sheet{index}.xml';parts[path]=data
|
||||
E.SubElement(sheetlist,'{'+S+'}sheet',{'name':name,'sheetId':str(index),'{'+R+'}id':'projectSheet'+str(index)})
|
||||
E.SubElement(rels,'{'+P+'}Relationship',{'Id':'projectSheet'+str(index),'Type':R+'/worksheet','Target':'worksheets/sheet'+str(index)+'.xml'})
|
||||
E.SubElement(types,'{'+ct+'}Override',{'PartName':'/'+path,'ContentType':'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml'})
|
||||
oldrel=f'xl/worksheets/_rels/sheet{original}.xml.rels'
|
||||
if oldrel in base.namelist():parts[f'xl/worksheets/_rels/sheet{index}.xml.rels']=base.read(oldrel)
|
||||
for d in olddefs:
|
||||
if d.get('localSheetId')==str(original-1):
|
||||
item=deepcopy(d);item.set('localSheetId',str(index-1));item.text="'"+name.replace("'","''")+"'!"+(d.text or '').split('!',1)[-1]
|
||||
if original==2:item.text=re.sub(r'\$AE\$',f'${end}$',item.text)
|
||||
defined.append(item)
|
||||
for v in workbook.findall('s:bookViews/s:workbookView',ns):v.set('activeTab','0');v.set('firstSheet','0')
|
||||
parts['xl/workbook.xml']=xml(workbook);parts['xl/_rels/workbook.xml.rels']=xml(rels);parts['[Content_Types].xml']=xml(types)
|
||||
# Cover lists actual reports, not the original P1~P6 or example.
|
||||
cover=E.fromstring(parts['xl/worksheets/sheet1.xml'])
|
||||
for c in cover.findall('.//s:c',ns):
|
||||
if c.get('r')=='A14':text(c,payload.get('bridge','')+' · 전체 '+str(len(projects))+'개 교각')
|
||||
parts['xl/worksheets/sheet1.xml']=xml(cover)
|
||||
with zipfile.ZipFile(output,'w',zipfile.ZIP_DEFLATED) as z:
|
||||
for path,data in parts.items():z.writestr(path,data)
|
||||
report={'piers':[p['pier'] for p in projects],'sheets':[s[0] for s in sheets],'summary_rows':summary_checks,'example_removed':True,'summary_policy':'source work rows; SUM of per-pier formula-linked values','original_preserved':True}
|
||||
output.with_suffix('.validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf8')
|
||||
for z in archives:z.close()
|
||||
return report
|
||||
if __name__=='__main__':
|
||||
try:print(json.dumps(build(sys.argv[1],sys.argv[2]),ensure_ascii=False))
|
||||
except Exception as e:print(json.dumps({'error':str(e)},ensure_ascii=False));sys.exit(1)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Read-only verification of emitted per-pier caches and all summary SUM rows."""
|
||||
import zipfile,re,json,sys
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as E
|
||||
S={'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||
def check(path,expected):
|
||||
z=zipfile.ZipFile(path);assert z.testzip() is None
|
||||
wb=E.fromstring(z.read('xl/workbook.xml'));sheets=wb.find('s:sheets',S);names=[s.get('name') for s in sheets]
|
||||
assert not any('_ex)' in n or '미사용' in n for n in names)
|
||||
detail=[n for n in names if n.startswith('3.3 수량산출_')];assert len(detail)==expected,(detail,expected)
|
||||
rels={r.get('Id'):r.get('Target') for r in E.fromstring(z.read('xl/_rels/workbook.xml.rels'))}
|
||||
allcells={}
|
||||
for sheet in sheets:
|
||||
path='xl/'+rels[sheet.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id')]
|
||||
root=E.fromstring(z.read(path));allcells[sheet.get('name')]={c.get('r'):c for c in root.findall('.//s:c',S)}
|
||||
for f in root.findall('.//s:f',S):assert '_ex)' not in (f.text or '')
|
||||
summary=allcells['1. 수량집계'];checks=0
|
||||
for ref,c in summary.items():
|
||||
f=c.find('s:f',S)
|
||||
if not ref.startswith('K') or f is None or not (f.text or '').startswith('SUM('):continue
|
||||
values=[]
|
||||
for cellref in f.text.removeprefix('SUM(').removesuffix(')').split(','):
|
||||
cell=summary[cellref];rf=cell.find('s:f',S).text
|
||||
m=re.match(r"'(.+)'!([A-Z]+\d+)",rf);assert m and m[1] in detail,rf
|
||||
source=allcells[m[1]][m[2]]
|
||||
a=float(source.find('s:v',S).text);b=float(cell.find('s:v',S).text);assert abs(a-b)<1e-8
|
||||
values.append(a)
|
||||
assert len(values)==expected
|
||||
assert abs(sum(values)-float(c.find('s:v',S).text))<1e-7
|
||||
checks+=1
|
||||
assert checks==53,checks
|
||||
return {'piers':expected,'sheets':len(sheets),'summary_rows_checked':checks,'source_formula_links':checks*expected,'example_removed':True,'cache_differences':0}
|
||||
if __name__=='__main__':print(json.dumps(check(sys.argv[1],int(sys.argv[2])),ensure_ascii=False))
|
||||
@@ -51,3 +51,7 @@
|
||||
@media(max-width:700px){.tq-dashboard-grid{display:flex;flex-direction:column;height:auto;min-height:0}.tq-input-panel{height:430px;flex-shrink:0}.tq-results-panel{height:650px;flex-shrink:0}.tq-right-panel{display:grid;grid-template-columns:1fr;grid-template-rows:360px 400px;flex-shrink:0}.tq-flow{gap:2px}.tq-flow button{gap:3px}.tq-flow svg{display:none}.tq-flow em{font-size:8px}.tq-toolbar{gap:6px}.takeoff-actions{width:100%;justify-content:flex-end}.tq-header-progress{display:none}}
|
||||
@media(prefers-reduced-motion:reduce){.tq-quantity-changed{animation:none}}
|
||||
@media print{.takeoff-menu,.takeoff-actions,.tq-flow,.tq-input-panel,.tq-result-controls{display:none!important}.tq-dashboard-grid{display:block}.tq-results-panel,.tq-right-panel,.tq-panel{overflow:visible;height:auto}.tq-table-scroll{overflow:visible}.tq-summary-cards{break-inside:avoid}}
|
||||
.tq-project-dialog{width:min(96vw,1800px);max-height:92dvh;box-sizing:border-box;border:1px solid #cfdeec;border-radius:14px;padding:20px;background:#f8fbff;color:#17324c;box-shadow:0 20px 80px #1234;overflow:auto}
|
||||
.tq-project-dialog::backdrop{background:#142a4780}.tq-project-dialog>header,.tq-project-tools{display:flex;align-items:center;gap:12px;justify-content:space-between}.tq-project-dialog h2{font-size:20px;margin:0}.tq-project-dialog p{font-size:13px;white-space:pre-line}.tq-project-tools{justify-content:flex-end;margin:16px 0}.tq-project-tools label{margin-right:auto;display:flex;align-items:center;gap:8px}.tq-project-dialog button{padding:10px 14px;border:1px solid #b8d4ef;border-radius:7px;background:white;color:#175da0}.tq-project-table{max-height:64dvh;overflow:auto;border:1px solid #d4e1ed;background:white}.tq-project-table table{border-collapse:collapse;min-width:100%;font-size:12px}.tq-project-table th,.tq-project-table td{border:1px solid #dde6ef;padding:9px;min-width:150px}.tq-project-table th{background:#edf5fd;text-align:left}.tq-project-table thead th{position:sticky;top:0;z-index:2}.tq-project-table tbody th{position:sticky;left:0;z-index:1;max-width:220px}.tq-project-table small{display:block;margin-top:5px;color:#72839a;max-width:240px;font-weight:400}.tq-project-table input:not([type=checkbox]){width:130px;box-sizing:border-box;padding:9px;border:1px solid #b9d3ed;border-radius:5px;background:white;color:#17324c}.tq-project-table input.is-missing{border-color:#f49d53;background:#fff8ed}.tq-project-table output{display:block;background:#edf6ff;padding:9px;border-radius:5px}.tq-project-error{color:#a94413}
|
||||
.tq-report-dialog{display:flex;flex-direction:column;overflow:hidden}.tq-report-dialog:not([open]){display:none}.tq-report-grid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px;flex:1;min-height:0}.tq-report-grid>.takeoff-excel-preview{display:flex;flex-direction:column;min-width:0;min-height:0}.tq-report-grid .takeoff-excel-toolbar{flex-shrink:0}.tq-report-grid .takeoff-excel-viewport{flex:1;min-height:0;max-height:none;padding:14px}.tq-report-grid .takeoff-excel-paper{margin:auto}.tq-dialog-trace{overflow:auto;min-height:0;padding:16px;background:white;border:1px solid #d5e2ed;border-radius:8px}.takeoff-excel-viewport.is-page-fit{display:flex;align-items:center;justify-content:center}.takeoff-excel-viewport.is-page-fit>.takeoff-excel-paper{flex:none;margin:auto}.tq-report-dialog .tq-dialog-close{flex-shrink:0}
|
||||
@media(max-width:1000px){.tq-report-grid{grid-template-columns:minmax(0,1fr) 260px}.tq-report-grid .takeoff-excel-toolbar>span{display:none}}
|
||||
+23
-9
@@ -1,5 +1,6 @@
|
||||
import {createExcelPreview} from './excel-preview';
|
||||
import {quantityPlanSvg,planPng} from './plan-preview';
|
||||
import {openProjectInputs,projectRecords} from './project-inputs';
|
||||
import {createInputPanel} from './input-panel';
|
||||
import {createResultsPanel} from './results-panel';
|
||||
import {createReviewPanel} from './review-panel';
|
||||
@@ -12,40 +13,53 @@ import './workspace.css';
|
||||
type Snapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]};
|
||||
const blank=():QuantityInputs=>({values:{},reviewed:false});
|
||||
export function createTakeoffWorkspace(host:HTMLElement,snapshot:()=>Snapshot,select:(name:string)=>void,save:(name:string,data:QuantityInputs)=>void){
|
||||
let mode='takeoff';const fullState={sheet:'__all__',zoom:1.1},miniState={sheet:'3.3 수량산출_P1',zoom:.4};
|
||||
let mode='takeoff';const fullState={sheet:'__all__',zoom:1.1},miniState={sheet:'표지및목차',zoom:0};
|
||||
const state:DashboardState={query:'',view:'member',selected:'W6',closed:new Set(),openInputs:new Set(['basic','ground','material','conditions']),inputScroll:0};
|
||||
const calculated=new Map<string,Map<string,number|null>>();let changed=new Set<string>(),changedUntil=0,lastPier='';
|
||||
const legacy=[...host.children] as HTMLElement[],nav=e('nav');nav.className='takeoff-menu';nav.setAttribute('aria-label','재료표 및 수량산출');
|
||||
const area=e('section');area.className='takeoff-workspace';area.hidden=true;
|
||||
for(const [id,label]of [['materials','재료표'],['takeoff','교각 수량산출'],['preview','수량산출서 미리보기']]){const b=e('button',label);b.type='button';b.dataset.takeoffMode=id;b.onclick=()=>{mode=id;refresh();};nav.append(b);}host.prepend(nav);host.append(area);
|
||||
for(const [id,label]of [['materials','재료표'],['takeoff','수량산출']]){const b=e('button',label);b.type='button';b.dataset.takeoffMode=id;b.onclick=()=>{mode=id;refresh();};nav.append(b);}host.prepend(nav);host.append(area);
|
||||
async function exportProject(){
|
||||
const snap=snapshot(),records=projectRecords(snap),problems:string[]=[];
|
||||
for(const r of records){const reasons=r.pier.dirty?['형상 재생성 필요']:!r.result?['형상 없음']:[...r.result.missing,...r.result.issues,...(!r.stored.reviewed?['원본 기준 확인 필요']:[])];if(reasons.length)problems.push(r.pier.input.name+': '+reasons.join(', '));}
|
||||
if(!records.length||problems.length)throw new Error(problems.length?problems.join('\n'):'출력할 교각이 없습니다.');
|
||||
const models=records.flatMap(r=>r.pier.model?[r.pier.model]:[]),image=await planPng(quantityPlanSvg(models[0],models));
|
||||
const piers=records.map((r,i)=>({project:snap.project,pier:r.pier.input.name,overrides:r.result!.overrides,specs:r.stored.values,reviewed:r.stored.reviewed,copingMode:r.result!.copingMode,templateHash,...(i===0?{planPng:image}:{})}));
|
||||
const response=await fetch('/__pierzainer/quantity/export',{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'quantity'},body:JSON.stringify({piers})});
|
||||
if(!response.ok){const error=await response.json();throw new Error(error.error??'전체 교각 Excel 출력 실패');}
|
||||
for(const r of records)save(r.pier.input.name,{...r.stored,lastSignature:r.result!.signature});
|
||||
const url=URL.createObjectURL(await response.blob()),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_전체교각_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}
|
||||
function refresh(){
|
||||
document.querySelectorAll<HTMLDialogElement>('[data-quantity-preview-dialog]').forEach(d=>{d.close();d.remove();});
|
||||
legacy.forEach(n=>n.hidden=mode!=='materials');area.hidden=mode==='materials';for(const b of nav.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.takeoffMode===mode));if(mode==='materials')return;
|
||||
const snap=snapshot(),pier=snap.piers.find(p=>p.input.name===snap.selected)??snap.piers[0];area.replaceChildren();area.classList.toggle('is-full-preview',mode==='preview');
|
||||
const toolbar=e('header',undefined,'takeoff-toolbar tq-toolbar'),selector=e('select');selector.setAttribute('aria-label','수량산출 대상 교각');for(const p of snap.piers){const opt=e('option',p.input.name+' · '+p.input.type);opt.value=p.input.name;opt.selected=p.input.name===pier?.input.name;selector.append(opt);}selector.onchange=()=>select(selector.value);toolbar.append(e('h2','교각 수량산출'),selector);area.append(toolbar);
|
||||
const toolbar=e('header',undefined,'takeoff-toolbar tq-toolbar'),selector=e('select');selector.setAttribute('aria-label','수량산출 대상 교각');for(const p of snap.piers){const opt=e('option',p.input.name+' · '+p.input.type);opt.value=p.input.name;opt.selected=p.input.name===pier?.input.name;selector.append(opt);}selector.onchange=()=>select(selector.value);toolbar.append(selector);area.append(toolbar);
|
||||
if(!pier?.model){area.append(e('p','형상 생성에서 교각을 먼저 생성하세요.'));return;}
|
||||
if(lastPier!==pier.input.name){lastPier=pier.input.name;state.selected='W6';state.inputScroll=0;changed=new Set();}
|
||||
if(lastPier!==pier.input.name){lastPier=pier.input.name;miniState.sheet='표지및목차';state.selected='W6';state.inputScroll=0;changed=new Set();}
|
||||
const projectRows=projectRecords(snap).flatMap(r=>r.result?[{name:r.pier.input.name,result:r.result}]:[]);
|
||||
const plan={selected:pier.model,models:snap.piers.flatMap(p=>p.model?[p.model]:[])};
|
||||
const stored=withTemplateDefaults(pier.input.quantityTakeoff??blank()),result=calculate(pier.input,pier.model,stored,pier.rebarTon),stale=stored.lastSignature!==result.signature||pier.dirty;
|
||||
const status=e('p',pier.dirty?'형상 재생성 필요':result.issues.length?'적용 범위 검토 필요':result.missing.length?`누락 ${result.missing.length}개`:stale?'재계산 필요':result.complete?'계산 완료':'원본 기준 확인 필요','takeoff-status');status.dataset.state=!stale&&result.complete?'done':'pending';status.setAttribute('role','status');toolbar.append(status);
|
||||
const persist=(next:QuantityInputs)=>{save(pier.input.name,next);refresh();};
|
||||
const actions=e('div',undefined,'takeoff-actions'),calc=e('button','↻ 재계산'),exporter=e('button','Excel 출력'),openPreview=e('button',mode==='preview'?'대시보드로 돌아가기':'미리보기 열기');calc.type=exporter.type=openPreview.type='button';calc.setAttribute('aria-label','수량산출 재계산');
|
||||
calc.onclick=()=>{const before=calculated.get(pier.input.name);changed=new Set(result.results.filter(r=>before?.has(r.cell)&&before.get(r.cell)!==r.value).map(r=>r.cell));changedUntil=Date.now()+2500;calculated.set(pier.input.name,new Map(result.results.map(r=>[r.cell,r.value])));persist({...stored,lastSignature:result.signature});};
|
||||
exporter.disabled=stale||!result.complete;exporter.title=result.missing.length?'필수 누락 항목을 입력하고 재계산하세요.':result.issues.length?'원본 적용 범위를 먼저 검토하세요.':stale?'재계산 후 출력할 수 있습니다.':'원본 양식 Excel 출력';
|
||||
exporter.onclick=async()=>{exporter.disabled=true;status.textContent='Excel 생성 중…';try{const response=await fetch('/__pierzainer/quantity/export',{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'quantity'},body:JSON.stringify({project:snap.project,pier:pier.input.name,overrides:result.overrides,specs:stored.values,reviewed:stored.reviewed,copingMode:result.copingMode,templateHash,planPng:await planPng(quantityPlanSvg(plan.selected,plan.models))})});if(!response.ok){const error=await response.json();throw new Error(error.error??'Excel 출력 실패');}const url=URL.createObjectURL(await response.blob()),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_${pier.input.name}_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);status.textContent='Excel 출력 완료';}catch(error){status.textContent=(error as Error).message;status.title=status.textContent;}finally{exporter.disabled=false;}};
|
||||
openPreview.onclick=()=>{mode=mode==='preview'?'takeoff':'preview';refresh();};actions.append(calc,exporter,openPreview);toolbar.append(actions);
|
||||
exporter.disabled=false;exporter.title='모든 교각의 산출서와 합산 수량집계를 출력합니다.';
|
||||
exporter.onclick=async()=>{exporter.disabled=true;status.textContent='전체 교각 Excel 생성 중…';try{await exportProject();status.textContent='전체 교각 Excel 출력 완료';}catch(error){status.textContent=(error as Error).message;status.title=status.textContent;}finally{exporter.disabled=false;}};
|
||||
openPreview.onclick=()=>{mode=mode==='preview'?'takeoff':'preview';refresh();};const allInputs=e('button','전체 교각 입력');allInputs.type='button';allInputs.onclick=()=>openProjectInputs(snapshot,save,refresh,exportProject);actions.append(allInputs,calc,exporter,openPreview);toolbar.append(actions);
|
||||
if(mode==='preview'){
|
||||
const full=e('section',undefined,'tq-full-preview');const detail=e('section',undefined,'takeoff-trace tq-full-trace');
|
||||
const paper=createExcelPreview({result,stored,pier:pier.input.name,project:snap.project,plan,state:fullState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(!row)return;detail.replaceChildren(e('h3',row.name),e('pre',String(row.formula)),e('pre',result.book.substitution('3.3 수량산출_P1',String(row.formula))),e('p',String(row.value??'미산정')+' '+row.unit));detail.scrollIntoView({block:'nearest'});}});full.append(paper,detail);area.append(full);return;
|
||||
const paper=createExcelPreview({result,stored,pier:pier.input.name,project:snap.project,plan,projectRows,state:fullState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(!row)return;detail.replaceChildren(e('h3',row.name),e('pre',String(row.formula)),e('pre',result.book.substitution('3.3 수량산출_P1',String(row.formula))),e('p',String(row.value??'미산정')+' '+row.unit));detail.scrollIntoView({block:'nearest'});}});full.append(paper,detail);area.append(full);return;
|
||||
}
|
||||
const inputs=createInputPanel({result,stored,pier,project:snap.project,state,onChange:persist});
|
||||
const review=createReviewPanel({result,stored,pier:pier.input.name,project:snap.project,plan,stale,miniState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(row){state.selected=cell;results.paint();review.select(rawSelection(row,result.book));}}});
|
||||
const review=createReviewPanel({result,stored,pier:pier.input.name,project:snap.project,plan,projectRows,stale,miniState,onCell:cell=>{const row=result.results.find(r=>r.cell===cell);if(row){state.selected=cell;results.paint();review.select(rawSelection(row,result.book));}}});
|
||||
const results=createResultsPanel({result,stale,reviewed:stored.reviewed,state,changed:Date.now()<changedUntil?changed:new Set(),onSelect:s=>review.select(s),onReview:()=>{}});
|
||||
const progress=e('span',`필수입력 ${inputs.done}/${inputs.total}`,'tq-header-progress');toolbar.insertBefore(progress,status);
|
||||
const flow=e('div',undefined,'tq-flow');flow.setAttribute('aria-label','수량산출 작업 단계');
|
||||
for(const [i,title,description,type]of [[1,'입력','교각 정보 및 계산 조건 입력','input'],[2,'검토','입력값 및 누락 항목 확인','review'],[3,'수량 결과','항목별 수량 산출 및 확인','result'],[4,'산출서 미리보기','최종 검토 및 출력','preview']] as [number,string,string,string][]){const b=e('button');b.type='button';b.dataset.workflowStep=String(i);b.append(icon(type));const text=e('span');text.append(e('b',title),e('small',description));if(i===2&&result.missing.length)text.append(e('em',`누락 ${result.missing.length}개`));b.append(text);b.onclick=()=>{if(i===1)inputs.focusMissing();else if(i===2)results.review();else if(i===3)results.focusTable();else review.focusPreview();};flow.append(b);}
|
||||
const grid=e('div',undefined,'tq-dashboard-grid');grid.append(inputs.panel,results.panel,review.panel);area.append(flow,grid);
|
||||
const footer=e('div',undefined,'tq-input-reset');const reset=e('button','직접 입력 초기화');reset.type='button';reset.onclick=()=>persist(blank());footer.append(reset);inputs.panel.append(footer);
|
||||
openPreview.onclick=()=>review.open();
|
||||
results.paint();
|
||||
}
|
||||
return {refresh};
|
||||
|
||||
Reference in new issue
Block a user