import {createExcelPreview} from './excel-preview'; import {quantityPlanSvg,planPng} from './plan-preview'; import {createInputPanel} from './input-panel'; import {createResultsPanel} from './results-panel'; import {createReviewPanel} from './review-panel'; import {node as e,icon,rawSelection,type DashboardState,type Selection} from './dashboard-types'; import type {PierInput} from '../src/core/types'; import type {PierModel} from '../src/core/geometry'; import {calculate,withTemplateDefaults,type QuantityInputs} from './model'; import {templateHash} from './template-data'; 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}; const state:DashboardState={query:'',view:'member',selected:'W6',closed:new Set(),openInputs:new Set(['basic','ground','material','conditions']),inputScroll:0}; const calculated=new Map>();let changed=new Set(),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); function refresh(){ document.querySelectorAll('[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); 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();} 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); 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 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 results=createResultsPanel({result,stale,reviewed:stored.reviewed,state,changed:Date.now()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); results.paint(); } return {refresh}; }