feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)

공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음.
- 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용),
  프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외
- 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정
- 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결
- 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등)
정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 18:01:02 +09:00
1 parent b2bebe47eb
commit fc520ec03d
47 files changed
+2848 -149

No files matched your search

+30 -18
View File
@@ -1,34 +1,41 @@
import {createExcelPreview} from './excel-preview';
import {quantityPlanSvg,planPng} from './plan-preview';
import {openProjectInputs,projectRecords} from './project-inputs';
import {openProjectInputs,projectRecords,type QuantitySnapshot} from './project-inputs';
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 type {QuantityInputs} from './model';
import {templateHash} from './template-data';
import {exportRecords,finishQuantityExport,type ExportScope} from './export-state';
import './workspace.css';
type Snapshot={project:{name:string;bridge:string};selected:string|null;piers:{input:PierInput;model:PierModel|null;dirty:boolean;rebarTon?:number|null}[]};
type Snapshot=QuantitySnapshot;
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:'표지및목차',zoom:0};
let mode='materials';const fullState={sheet:'__all__',zoom:1.1},miniState={sheet:'표지및목차',zoom:0};
let exportBusy=false;
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','수량산출']]){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(', '));}
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(scope:ExportScope='all'){
if(exportBusy)throw new Error('Excel을 생성 중입니다. 완료 후 다시 출력하세요.');
exportBusy=true;
try{
const snap=snapshot(),records=exportRecords(snap,scope),problems:string[]=[];
for(const r of records){const reasons=r.pier.dirty?['형상 재생성 필요']:!r.result?['형상 없음']:[...r.result.missing,...r.result.issues,...(!r.stored.reviewed?['원본 기준 확인 필요']:[]),...(r.stored.lastSignature!==r.result.signature?['재계산 필요']:[])];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 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,solplateMode:r.result!.solplateMode,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);
const blob=await response.blob();
const changed=finishQuantityExport(snap,scope,snapshot(),save);
const target=scope==='all'?'전체교각':records[0].pier.input.name;
const url=URL.createObjectURL(blob),link=e('a');link.href=url;link.download=`${snap.project.bridge||'교량'}_${target}_수량산출서.xlsx`.replace(/[\\/:*?"<>|]/g,'_');link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
if(changed.length)throw new Error(`Excel은 생성 시작 시점 기준으로 저장했습니다. 생성 중 변경된 ${changed.join(', ')} 입력은 보존했습니다. 재계산 후 다시 출력하세요.`);
}finally{exportBusy=false;}
}
function refresh(){
document.querySelectorAll<HTMLDialogElement>('[data-quantity-preview-dialog]').forEach(d=>{d.close();d.remove();});
@@ -37,16 +44,21 @@ export function createTakeoffWorkspace(host:HTMLElement,snapshot:()=>Snapshot,se
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;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 records=projectRecords(snap);
const projectRows=records.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 record=records.find(r=>r.pier===pier)!;
const stored=record.stored,result=record.result!,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=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);
exporter.textContent='선택 교각 Excel';exporter.disabled=exportBusy;exporter.title='선택한 교각만 출력합니다.';exporter.dataset.takeoffExport='selected';
const allExporter=e('button','전체 교각 Excel');allExporter.type='button';allExporter.disabled=exportBusy;allExporter.dataset.takeoffExport='all';
const notice=(message:string)=>{const current=area.querySelector<HTMLElement>('[role="status"]')??status;current.textContent=message;current.title=message;};
const runExport=async(scope:ExportScope)=>{exporter.disabled=allExporter.disabled=true;notice((scope==='all'?'전체 교각':'선택 교각')+' Excel 생성 중…');try{await exportProject(scope);notice('Excel 출력 완료');}catch(error){notice((error as Error).message);}finally{host.querySelectorAll<HTMLButtonElement>('[data-takeoff-export]').forEach(b=>{b.disabled=exportBusy;});}};
exporter.onclick=()=>runExport('selected');allExporter.onclick=()=>runExport('all');
openPreview.onclick=()=>{mode=mode==='preview'?'takeoff':'preview';refresh();};const allInputs=e('button','전체 교각 입력');allInputs.type='button';allInputs.onclick=()=>openProjectInputs(snapshot,save,refresh,()=>exportProject('all'));actions.append(allInputs,calc,exporter,allExporter,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,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;