# WO-7 (설정 편집 폼 확장 — 신규 필드 운전원 노출) — 완전코드 작업지시서 > **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**. > **선행 완료 전제(검증됨)**: §0 + WO-1~6 전부 머지 완료. 백엔드(`ColumnConfig`/`StreamConfig` 신규 필드, `ff_*` DDL, > ConfigStore Save/Load, Controller `MapConfig`)는 **이미 신규 필드를 저장·반환**한다. **본 WO는 프론트 폼(ff.js)만** 손댄다. > **불변식**: 쓰기 0건(설정 저장은 advisory config일 뿐). C# 코드 변경 없음. ## 배경 / 목적 현재 설정 모달(`ffEditColumn`)은 §0 이전의 기본 필드(Feed/압력/Scan/필터/스트림 K·θ·τ…)만 폼에 노출한다. WO-2~6에서 추가된 **온도/PCT·θ자동튜닝·느린바이어스·전환류 트리거/설정·스트림 환류/복귀SP** 필드는 **API로는 저장/조회되지만 폼에 입력칸이 없어** 운전원이 화면에서 못 바꾼다(저장 시 `undefined`→백엔드 기본값 유지). 특히 운전원 질문 "**균형 심각붕괴 트리거를 수정할 수 있나?**" → 현재 폼엔 없음. **본 WO로 노출**한다. > **검증 사실**: `GET /api/ff/config`는 `tempTags, sensitiveTrayTag, dtdp, pRef, steamOpTag, thetaAutoTune, biasMaWindowSec, > recoveryEnabled, recoveryAutoArm, imbalanceTriggerFrac, imbalanceTriggerSec, recoverySettleSec, returnRampSec, > feedRecoverySp, deltaPTag, deltaPFloodLimit`(컬럼) + `isReflux, recoverySp`(스트림)을 이미 반환한다(Controller MapConfig). > 따라서 **기존 컬럼 편집 시**엔 `{...c}`로 값이 이미 들어오고, **새 컬럼**만 default 보강이 필요하다. ## 변경 파일 (총 2개) 1. `src/Web/wwwroot/js/ff.js` — `def`(새컬럼 기본값) + `colHtml`(입력칸) + `ffStreamRow`(스트림 2칸) + `ffSaveForm`(저장) 2. `src/Web/wwwroot/css/ff.css` — 트리거 강조 스타일(선택) --- ## STEP 1 — 새 컬럼 기본값(`def`)에 신규 필드 추가 **파일**: `src/Web/wwwroot/js/ff.js` **위치**: `ffEditColumn` 함수의 `const def = isNew ? {...} : {...}` **찾기**: ```javascript const def = isNew ? { name:'', enabled:false, feedTag:'', pressureTag:'', scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5, pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P', streams:[ {key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A'}, {key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A'}, {key:'D',flowTag:'',role:'LevelDriven',levelTag:'lica-6113',targetCoeff:0.02,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'}, {key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'} ] } : { ...c, pressureTag: c.pressureTag||'' }; ``` **바꾸기**: ```javascript const def = isNew ? { name:'', enabled:false, feedTag:'', pressureTag:'', scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5, pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P', // WO-2 온도/PCT · WO-3 θ자동튜닝 · WO-4 바이어스 tempTags:[], sensitiveTrayTag:'', dtdp:0, pRef:null, steamOpTag:'', thetaAutoTune:false, biasMaWindowSec:21600, // WO-6 전환류 복귀 recoveryEnabled:false, recoveryAutoArm:false, imbalanceTriggerFrac:0.10, imbalanceTriggerSec:600, recoverySettleSec:1800, returnRampSec:600, feedRecoverySp:0, deltaPTag:'', deltaPFloodLimit:1e9, streams:[ {key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:0}, {key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A',isReflux:true,recoverySp:null}, {key:'D',flowTag:'',role:'LevelDriven',levelTag:'lica-6113',targetCoeff:0.02,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B',isReflux:false,recoverySp:0}, {key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B',isReflux:false,recoverySp:0} ] } : { ...c, pressureTag: c.pressureTag||'', tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'' }; ``` > 기존 컬럼은 `{...c}`로 숫자/불리언 신규 필드가 이미 들어온다. 위 추가 라인은 **null일 수 있는 문자열/배열 필드만** 빈값 정규화(입력칸에 `undefined`/`null` 표시 방지). `tempTags`는 배열이므로 폼에선 콤마 문자열로 변환해 보여준다(STEP 2). --- ## STEP 2 — 입력칸(`colHtml`)에 신규 섹션 2개 추가 **파일**: `src/Web/wwwroot/js/ff.js` **찾기** (colHtml의 두번째 `.ff-modal-col` 닫는 부분 + 백틱 종료): ```javascript `; ``` **바꾸기** (기존 2칸 뒤에 온도/θ·전환류 2칸을 추가): ```javascript
온도 프로파일 / θ 자동튜닝 (WO-2·3·4)
전환류 평형복귀 (WO-6) ★
`; ``` --- ## STEP 3 — 스트림 행(`ffStreamRow`)에 환류/복귀SP 2칸 추가 ### 3.1 스트림 테이블 헤더에 2칸 추가 **파일**: `src/Web/wwwroot/js/ff.js` **찾기**: ```javascript KeyFlow 태그역할레벨태그Kθ_upθ_dnτ SP_minSP_maxRate_upRate_dn환류신뢰 ``` **바꾸기**: ```javascript KeyFlow 태그역할레벨태그Kθ_upθ_dnτ SP_minSP_maxRate_upRate_dn환류전환류R복귀SP신뢰 ``` ### 3.2 `ffStreamRow`의 ``에 입력칸 2개 추가 **찾기**: ```javascript ``` **바꾸기**: ```javascript ``` ### 3.3 스트림 추가 버튼 기본값에도 신규 필드 > `ff-stream-add` 클릭 시 새 행 객체에 신규 필드 없으면 체크박스/값이 깨질 수 있다. **찾기**: ```javascript tb.insertAdjacentHTML('beforeend', ffStreamRow({ key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0, tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9, refluxFromProduct:false,grade:'A' }, i)); ``` **바꾸기**: ```javascript tb.insertAdjacentHTML('beforeend', ffStreamRow({ key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0, tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9, refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:null }, i)); ``` --- ## STEP 4 — 저장(`ffSaveForm`)에서 신규 필드 읽기 **파일**: `src/Web/wwwroot/js/ff.js` ### 4.1 컬럼 레벨 필드 추가 **찾기**: ```javascript staleSec: +g('ff-f-staleSec').value, productKey: g('ff-f-productKey').value, advisoryOnly: true, streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => { ``` **바꾸기**: ```javascript staleSec: +g('ff-f-staleSec').value, productKey: g('ff-f-productKey').value, advisoryOnly: true, // WO-2/3/4 tempTags: g('ff-f-tempTags').value.split(',').map(s=>s.trim()).filter(Boolean), sensitiveTrayTag: g('ff-f-sensitiveTrayTag').value || null, dtdp: +g('ff-f-dtdp').value, pRef: g('ff-f-pRef').value === '' ? null : +g('ff-f-pRef').value, steamOpTag: g('ff-f-steamOpTag').value || null, thetaAutoTune: g('ff-f-thetaAutoTune').checked, biasMaWindowSec: +g('ff-f-biasMaWindowSec').value, // WO-6 recoveryEnabled: g('ff-f-recoveryEnabled').checked, recoveryAutoArm: g('ff-f-recoveryAutoArm').checked, imbalanceTriggerFrac: +g('ff-f-imbalanceTriggerFrac').value, imbalanceTriggerSec: +g('ff-f-imbalanceTriggerSec').value, recoverySettleSec: +g('ff-f-recoverySettleSec').value, returnRampSec: +g('ff-f-returnRampSec').value, feedRecoverySp: +g('ff-f-feedRecoverySp').value, deltaPTag: g('ff-f-deltaPTag').value || null, deltaPFloodLimit: +g('ff-f-deltaPFloodLimit').value, streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => { ``` ### 4.2 스트림 레벨 필드 추가 **찾기**: ```javascript rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'), refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade') }; ``` **바꾸기**: ```javascript rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'), refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade'), isReflux: v(null,'isReflux'), recoverySp: (() => { const x = v(null,'recoverySp'); return x === '' ? null : +x; })() }; ``` --- ## STEP 5 — `ff.css` (선택, 트리거 강조) **파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가: ```css /* WO-7 설정폼 신규 섹션 */ .ff-modal-subhd{font-weight:600;margin:4px 0 6px;color:var(--t1);border-bottom:1px solid var(--bd);padding-bottom:3px} .ff-modal-subhd small{font-weight:400;color:var(--t2)} .ff-recovery-col{background:rgba(90,0,0,.08);border-radius:6px;padding:6px} .ff-trig{border-color:#ff8a80 !important} ``` --- ## STEP 6 — 검증 ```bash # 1) JS 문법 node -c src/Web/wwwroot/js/ff.js && echo "JS OK" # 2) C# 미변경 확인(이 WO는 프론트 전용) — 빌드는 영향 없음(원하면) dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Error" ``` **기대**: `JS OK`. (C# 변경 없음 → 빌드 영향 없음.) ### 런타임 확인(브라우저) 1. `Ctrl+F5`로 캐시 무효화 후 Tab "유량 권장(FF)" → "설정 ▾" → 기존 컬럼 "편집" 또는 "+ 컬럼". 2. 모달에 **온도/θ 칸**과 **전환류 평형복귀 칸**(붉은 박스)이 보인다. 3. **트리거 수정 확인**(운전원 질문 대응): "불균형 트리거 비율"=0.15, "트리거 지속(초)"=300 으로 바꿔 저장 → 다시 "편집" 열어 값이 유지되는지 확인(= API 저장·재로드 라운드트립). → **운전원이 트리거를 직접 수정 가능**. 4. 스트림 표에 "전환류R"(체크) / "복귀SP" 칸이 보이고 저장·재로드 유지. --- ## 감독자 Sign-off | 항목 | 상태 | 서명 | |:--|:--:|:--:| | 새 컬럼 def에 신규 필드 기본값(undefined 표시 없음) | ✅ | windpacer 2026-05-31 | | 온도/θ 섹션 입력칸 노출 | ✅ | windpacer 2026-05-31 | | 전환류 트리거(비율·지속) 입력칸 노출 + 저장·재로드 유지 | ✅ | windpacer 2026-05-31 | | 스트림 전환류R·복귀SP 칸 노출 | ✅ | windpacer 2026-05-31 | | tempTags 콤마↔배열 변환, pRef/recoverySp 빈값→null | ✅ | windpacer 2026-05-31 | | node -c 통과 | ✅ | windpacer 2026-05-31 | ## 주의(약한 LLM 함정) 1. **C# 손대지 말 것** — 백엔드는 이미 신규 필드 저장/반환. 본 WO는 ff.js(+css)만. 2. **tempTags는 배열↔콤마문자열** — 표시는 `join(',')`, 저장은 `split(',')...filter(Boolean)`. 3. **빈값→null 매핑** — `pRef`/`recoverySp`는 빈 문자열이면 null(백엔드가 NaN/NULL 시드 처리). 0과 빈값을 혼동 말 것. 4. **체크박스는 `.checked`** — `v(null,'isReflux')`는 기존 `v` 헬퍼가 checkbox면 `el.checked`(불리언) 반환하므로 그대로 사용. 5. **스트림 칸 추가는 헤더와 행 둘 다** — `` 2개와 `` 2개 개수 일치(안 맞으면 표 깨짐). 6. **스트림 add 버튼 기본객체에도** isReflux/recoverySp 추가(STEP 3.3) — 빠뜨리면 새 행 체크박스 깨짐.