29 lines
7.8 KiB
TypeScript
29 lines
7.8 KiB
TypeScript
import {node,icon,fmt,rawSelection,sumSelection,type TakeoffResult,type QuantityRow,type DashboardState,type Selection} from './dashboard-types';
|
|
function member(row:QuantityRow):string{if(row.row<=31)return '지반 · 토공';if(row.row===50)return '두부보';if(row.row===51)return '기둥';if([54,55,56,57,65].includes(row.row))return '기초';if([53,59].includes(row.row))return '버림콘크리트';if([52,62,63,64].includes(row.row))return '받침 · 교좌면';if(row.row===58)return '본체 · 두부보/기둥/블럭';if(row.row>=68&&row.row<=73||row.row===76)return '말뚝 · 시공';if(row.row===67)return '철근';return '공통 · 가설/타설/양생';}
|
|
export function rowStatus(rows:QuantityRow[],stale:boolean,issues:boolean,reviewed:boolean):{text:string;kind:string}{if(rows.some(r=>r.value===null||r.error))return{text:'검토필요',kind:'review'};if(stale)return{text:'재계산 필요',kind:'stale'};if(issues||!reviewed)return{text:'검토필요',kind:'review'};return{text:'완료',kind:'done'};}
|
|
export function createResultsPanel(o:{result:TakeoffResult;stale:boolean;reviewed:boolean;state:DashboardState;changed:Set<string>;onSelect:(s:Selection)=>void;onReview:()=>void}){
|
|
const {result,state}=o,panel=node('section',undefined,'tq-panel tq-results-panel');panel.append(node('h3','수량 결과'));
|
|
const totals=node('div',undefined,'takeoff-totals tq-summary-cards');
|
|
for(const [label,type,cells,unit]of [['콘크리트','concrete',['W60'],'m³'],['철근','rebar',['W67'],'ton'],['거푸집','formwork',Array.from({length:7},(_,i)=>'W'+(50+i)),'m²'],['굴착량','earth',Array.from({length:24},(_,i)=>'W'+(6+i)),'m³']] as [string,string,string[],string][]){const rows=result.results.filter(r=>cells.includes(r.cell)),s=sumSelection(rows,label),card=node('button',undefined,'tq-summary-card');card.type='button';const title=node('span',undefined,'tq-summary-title');title.append(icon(type),node('span',label+(cells.length===1?' ('+cells[0]+')':'')));const amount=node('strong',fmt(s.value,s.value!==null&&Math.abs(s.value)>=100?2:3));amount.append(node('small',' '+unit));card.append(title,amount);card.title='기존 산출 행: '+cells.join(', ');card.onclick=()=>o.onSelect(s);totals.append(card);}panel.append(totals);
|
|
const review=node('details',undefined,'tq-review-notices');review.append(node('summary',`입력·산식 검토 · 누락 ${result.missing.length}개 / 적용 검토 ${result.issues.length}개`));for(const text of [...result.issues,...result.warnings])review.append(node('p',text));if(!o.reviewed)review.append(node('p','좌측 하단에서 원본 산식과 모델 적용 기준을 확인하세요.'));panel.append(review);
|
|
const controls=node('div',undefined,'tq-result-controls'),tabs=node('div',undefined,'tq-view-tabs');tabs.setAttribute('role','group');tabs.setAttribute('aria-label','수량 결과 보기');for(const [key,label]of [['member','부재별 수량'],['work','공종별 집계'],['material','자재별 집계']]){const b=node('button',label);b.type='button';b.dataset.quantityView=key;b.setAttribute('aria-pressed',String(state.view===key));b.onclick=()=>{state.view=key as DashboardState['view'];tabs.querySelectorAll('button').forEach(n=>n.setAttribute('aria-pressed',String(n.dataset.quantityView===key)));paint();};tabs.append(b);}
|
|
const search=node('input');search.type='search';search.placeholder='항목 검색 (예: 구조물, 육상토사)';search.setAttribute('aria-label','수량 항목 검색');search.value=state.query;search.oninput=()=>{state.query=search.value;paint();};controls.append(tabs,search);panel.append(controls);
|
|
const scroll=node('div',undefined,'tq-table-scroll'),table=node('table',undefined,'takeoff-table tq-result-table');const head=node('thead'),hr=node('tr');for(const title of ['항목','세부구분','산식 / 참조','단위','수량','상태'])hr.append(node('th',title));head.append(hr);const body=node('tbody');table.append(head,body);scroll.append(table);panel.append(scroll);
|
|
const footer=node('p','집계는 동일 공종·규격·단위 안에서만 수행합니다. 콘크리트와 타설량은 중복 합산하지 않습니다.','tq-table-note');panel.append(footer);
|
|
let visibleSelections:Selection[]=[];
|
|
const activate=(s:Selection)=>{state.selected=s.id;body.querySelectorAll<HTMLTableRowElement>('tr[data-selection]').forEach(tr=>{tr.classList.toggle('is-selected',tr.dataset.selection===s.id);tr.setAttribute('aria-selected',String(tr.dataset.selection===s.id));});o.onSelect(s);};
|
|
const rowElement=(s:Selection)=>{const tr=node('tr');tr.dataset.selection=s.id;tr.tabIndex=0;tr.setAttribute('aria-selected',String(s.id===state.selected));tr.classList.toggle('is-selected',s.id===state.selected);if(s.rows.some(r=>o.changed.has(r.cell)))tr.classList.add('tq-quantity-changed');
|
|
const first=s.rows[0],parts=s.title.split(' / '),a=node('td'),label=node('button',parts[0]);label.type='button';label.dataset.quantityCell=s.rows.length===1?first.cell:'';label.title=s.title;a.append(label);const formula=node('td');formula.append(node('span',s.rows.length>1?'동일 공종·규격 합계':String(first.description??'원본 산식')),node('code',s.rows.map(r=>r.cell).join(' + ')));
|
|
const status=rowStatus(s.rows,o.stale,!!result.issues.length,o.reviewed),stateCell=node('td');stateCell.append(node('span',status.text,'tq-status-tag '+status.kind));tr.append(a,node('td',parts.slice(1).join(' / ')||'—'),formula,node('td',s.unit),node('td',fmt(s.value)),stateCell);tr.onclick=()=>activate(s);tr.onkeydown=event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();activate(s);}};return tr;
|
|
};
|
|
function paint(){body.replaceChildren();visibleSelections=[];const query=state.query.trim().toLowerCase(),rows=result.results.filter(r=>!query||(r.name+' '+r.description+' '+r.cell+' '+r.formula).toLowerCase().includes(query)),groups=new Map<string,Selection[]>();
|
|
if(state.view==='member'){for(const row of rows){const key=member(row);if(!groups.has(key))groups.set(key,[]);groups.get(key)!.push(rawSelection(row,result.book));}}
|
|
else if(state.view==='work'){const work=new Map<string,QuantityRow[]>();for(const row of rows){const parts=row.name.split(' / '),key=[parts[0],parts[1]??'',row.unit??''].join(' / ');if(!work.has(key))work.set(key,[]);work.get(key)!.push(row);}for(const [name,rs]of work){const section=rs[0].name.split(' / ')[0];if(!groups.has(section))groups.set(section,[]);groups.get(section)!.push(rs.length===1?rawSelection(rs[0],result.book):sumSelection(rs,name));}}
|
|
else{const material=new Map<string,QuantityRow[]>();for(const row of rows.filter(r=>[57,58,59,62,63,64,67,70].includes(r.row))){const parts=row.name.split(' / '),key=parts.slice(0,2).join(' / ');if(!material.has(key))material.set(key,[]);material.get(key)!.push(row);}for(const [key,rs]of material)groups.set(key,[rs.length===1?rawSelection(rs[0],result.book):sumSelection(rs,key)]);}
|
|
for(const [name,selections]of groups){const gkey=state.view+':'+name,tr=node('tr',undefined,'tq-group-row'),td=node('td');td.colSpan=6;const b=node('button',`${state.closed.has(gkey)?'▸':'▾'} ${name}`);b.type='button';b.setAttribute('aria-expanded',String(!state.closed.has(gkey)));b.append(node('small',String(selections.length)+'항목'));b.onclick=()=>{if(state.closed.has(gkey))state.closed.delete(gkey);else state.closed.add(gkey);paint();};td.append(b);tr.append(td);body.append(tr);visibleSelections.push(...selections);if(!state.closed.has(gkey))for(const s of selections)body.append(rowElement(s));}
|
|
if(!groups.size){const row=node('tr'),cell=node('td','검색 결과가 없습니다.');cell.colSpan=6;row.append(cell);body.append(row);}
|
|
const selected=visibleSelections.find(s=>s.id===state.selected)??visibleSelections[0];if(selected)activate(selected);
|
|
}
|
|
return {panel,paint,review:()=>{review.open=true;review.focus();review.scrollIntoView({block:'nearest'});},focusTable:()=>scroll.focus()};
|
|
}
|