/* ============================================================================= * B09_Draft_Schedule_Panel.ts * B09 왼쪽 패널 「공기 옵션」 카드(PLAN 264-1 · 사용자 164-2 · 초안 — draft 폴더째 지우면 사라짐). * 등록 = `B09_Draft_Schedule_UI` 가 DRAFT.panels(카드) · DRAFT.extend(값 맞춤)에 넣음 — * 본 코드 훅 = `…_UI_Shell` 이 「데이터 점검」 아래 · [저장] 묶음 위에 붙이는 한 줄 * 값 = user.schedule(캐시 → [저장] 한 번에) · 공기산출 · 공기집계 표 칸과 같은 값(어디서 고쳐도 한 벌) * 꼴 = 좌측 패널 공용(B05/B06 「구조물 배치」 칸 — 라벨 위 · 입력 아래 · 두 칸 줄 · 체크) * ========================================================================== */ import "../../B05_Profile/B05_Profile_UI_Style.css"; import { num, type Row } from "../B09_Estimation_Calc_Base"; import { parseNumber } from "../B09_Estimation_UI_Tabs"; import { DEFAULTS } from "./B09_Draft_Schedule_Calc"; type Sync = (mine: Row, src: Row) => void; const HOLIDAY_LABELS: Array<[string, string]> = [ ["substitute", "대체공휴일"], ["election", "선거일"], ["labor", "근로자의 날(노동절)"], ["constitution", "제헌절"], ]; const CHOICES: Record> = { rest: [ ["sun", "일요일만"], ["satsun", "토 · 일"], ], machine_basis: [ ["max", "단위당 시간이 가장 긴 장비"], ["sum", "장비 시간 합(차례로 일하는 장비)"], ], c_round: [ ["none", "그대로"], ["round1", "소수 1자리 반올림"], ], last_off: [ ["ratio", "쓴 비율만큼 올림"], ["all", "그 달 비작업 전부"], ], }; /** 지금 화면의 카드 맞춤 — 셈마다 DRAFT.extend 가 부름(카드를 다시 만들면 새 카드로) */ let syncNow: Sync | null = null; export function syncSchedulePanel(payload: Row, user: Row): void { syncNow?.(user.schedule || {}, payload.inputs?.schedule || {}); } function field(label: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); wrapper.className = "b05-route__field"; const caption = document.createElement("span"); caption.textContent = label; wrapper.append(caption, input); return wrapper; } function pair(...items: HTMLElement[]): HTMLDivElement { const row = document.createElement("div"); row.className = "b05-route__field-row"; row.append(...items); return row; } export function createSchedulePanel(user: () => Row, setUser: (next: Row) => void): HTMLElement { const syncs: Sync[] = []; const change = (fn: (s: Row) => void): void => { const next: Row = structuredClone(user().schedule || {}); fn(next); setUser({ ...user(), schedule: next }); }; /** 수 칸 — 빈칸 · 기본값 = 지움(기본) · 범위 밖 = 되돌림 */ const number = (key: string, label: string, min: number, max: number): HTMLLabelElement => { const input = document.createElement("input"); input.type = "number"; input.min = String(min); input.max = String(max); input.step = "any"; input.dataset.b09Schedule = key; let last: Row = {}; syncs.push((mine) => { last = mine; input.value = String(num(mine[key]) ?? DEFAULTS[key]); }); input.addEventListener("change", () => { const v = parseNumber(input.value); if (v === undefined || (v !== null && (v < min || v > max))) { input.value = String(num(last[key]) ?? DEFAULTS[key]); return; } change((s) => { if (v === null || v === DEFAULTS[key]) delete s[key]; else s[key] = v; }); }); return field(label, input); }; /** 고르기 칸 — 첫째 = 기본(고르면 지움) · options 없으면 원천에서(기후 자료) */ const choose = ( key: string, label: string, options?: Array<[string, string]>, ): HTMLLabelElement => { const select = document.createElement("select"); select.dataset.b09Schedule = key; let values: string[] = []; syncs.push((mine, src) => { const list = options ?? Object.entries((src.climates || {}) as Row).map(([id, c]): [string, string] => [ id, c.name || id, ]); if (list.map(([v]) => v).join() !== values.join()) { values = list.map(([v]) => v); select.replaceChildren(...list.map(([v, text]) => new Option(text, v))); } select.value = values.includes(mine[key]) ? mine[key] : (values[0] ?? ""); }); select.addEventListener("change", () => change((s) => { if (select.value === values[0]) delete s[key]; else s[key] = select.value; }), ); return field(label, select); }; const start = document.createElement("input"); start.type = "date"; start.dataset.b09Schedule = "start"; syncs.push((mine, src) => { const at: Row = { ...(src.start || {}), ...(mine.start || {}) }; const pad = (n: unknown): string => String(Number(n) || 1).padStart(2, "0"); start.value = at.year ? `${at.year}-${pad(at.month)}-${pad(at.day)}` : ""; }); start.addEventListener("change", () => { const hit = /^(\d{4})-(\d{2})-(\d{2})$/.exec(start.value); change((s) => { if (!hit) return void delete s.start; const day = Number(hit[3]); s.start = { year: Number(hit[1]), month: Number(hit[2]), ...(day > 1 ? { day } : {}) }; }); }); const checks = HOLIDAY_LABELS.map(([kind, label]) => { const wrapper = document.createElement("label"); wrapper.className = "b05-route__check"; const input = document.createElement("input"); input.type = "checkbox"; input.dataset.b09Holiday = kind; syncs.push((mine) => { input.checked = !(mine.holiday_off || []).includes(kind); }); input.addEventListener("change", () => change((s) => { const off = HOLIDAY_LABELS.map(([k]) => k).filter((k) => k === kind ? !input.checked : (s.holiday_off || []).includes(k), ); if (off.length) s.holiday_off = off; else delete s.holiday_off; }), ); wrapper.append(input, document.createTextNode(label)); return wrapper; }); const holidayBox = document.createElement("div"); holidayBox.className = "b05-route__field"; const holidayCaption = document.createElement("span"); holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)"; holidayBox.append(holidayCaption, ...checks); const body = document.createElement("div"); body.className = "b05-route__panel-body"; body.append( field("착공일", start), choose("climate_set", "기후 자료(달마다 덮개는 공기집계 표)"), choose("rest", "쉬는 날", CHOICES.rest), holidayBox, number("hours", "일 작업시간(시간)", 0.5, 24), pair( number("units", "장비 기본 대수", 0.001, 1e4), number("crew", "인력 기본 인원", 0.001, 1e4), ), pair( number("prep_docs", "서류 · 착수 준비(일)", 0, 1000), number("prep_setup", "공종별 준비(일)", 0, 1000), ), pair(number("tidy", "정리(일)", 0, 1000), number("etc", "기타 · 현장여건(일)", 0, 1000)), pair( number("rate_haul", "반영률 운반공(%)", 0, 100), number("rate_base", "반영률 그 밖(%)", 0, 100), ), choose("machine_basis", "주 장비 기준", CHOICES.machine_basis), choose("c_round", "중복일수 C 끝수", CHOICES.c_round), choose("last_off", "마지막 달 비작업일", CHOICES.last_off), ); const section = document.createElement("section"); section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; section.dataset.b09SchedulePanel = ""; section.hidden = true; // 원천(서버 공기 자료)이 오면 보임 const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; heading.textContent = "공기 옵션"; section.append(heading, body); syncNow = (mine, src) => { section.hidden = !src.climates; for (const sync of syncs) sync(mine, src); }; syncNow(user().schedule || {}, {}); return section; }