From 0db53a9032437ae69ead184e039d38ed7a6d9d61 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:40:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20264-1=20=EC=99=BC=EC=AA=BD=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=E3=80=8C=EA=B3=B5=EA=B8=B0=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=E3=80=8D=20=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - draft 새 파일 B09_Draft_Schedule_Panel — 착공일 · 기후 자료 · 쉬는 날 · 공휴일 넣기 · 일 작업시간 · 투입 기본 · 준비 · 정리 · 기타 · 반영률 · 주 장비 기준 · 중복 C 끝수 · 마지막 달 - 값 = user.schedule(캐시 → [저장]) · 표 칸과 같은 값 · 셈마다 카드 맞춤 - 훅 = DRAFT.panels(기본 빈 목록) · Shell 「데이터 점검」 아래 한 줄 - 공기집계 비고 = 고른 옵션을 따름 · 착공 칸 「연.월.일」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B09_Estimation/B09_Estimation_UI_Shell.ts | 2 +- B09_Estimation/B09_Estimation_UI_Tabs.ts | 6 +- .../draft/B09_Draft_Schedule_Panel.ts | 215 ++++++++++++++++++ B09_Estimation/draft/B09_Draft_Schedule_UI.ts | 55 ++++- 4 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Panel.ts diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index d1f6797ea..474e7bcf1 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -370,7 +370,7 @@ export function renderB09Estimation(root: HTMLElement): void { actions.append(save, reset); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; - left.append(section, actions); + left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); attachCollapsible(left); const layout = createWorkflowLayout({ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index dbaa1c443..97d937b6e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -122,11 +122,13 @@ export interface TabTable { export type TabBuild = (ctx: TabContext) => TabTable | string; /** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — - * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) */ + * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) · + * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */ export const DRAFT: { builders: Record; extend: Array<(payload: Row, user: Row, sheets: Row) => void>; -} = { builders: {}, extend: [] }; + panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; +} = { builders: {}, extend: [], panels: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts new file mode 100644 index 000000000..379a59b63 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Panel.ts @@ -0,0 +1,215 @@ +/* ============================================================================= + * 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; +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts index ad39247d6..45f700bdf 100644 --- a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts @@ -4,6 +4,7 @@ * 등록 = 이 파일을 `…_UI_Shell` 이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 TABS · TAB_GROUPS · * DRAFT(탭 짜기 · 셈 곁)에 넣음 — draft 폴더째 지우면 탭 · 셈이 함께 사라짐 * 셈 = `B09_Draft_Schedule_Calc`(TS 짝 · 조작 즉시) · 원천 = 서버 `inputs.schedule` · 값 = `user.schedule` + * 왼쪽 「공기 옵션」 카드 = `B09_Draft_Schedule_Panel`(264-1 · 같은 값 · 비고는 고른 옵션을 따름) * 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 / * 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동) * ========================================================================== */ @@ -21,7 +22,8 @@ import { type TabId, type TabTable, } from "../B09_Estimation_UI_Tabs"; -import { computeSchedule } from "./B09_Draft_Schedule_Calc"; +import { climateBase, computeSchedule } from "./B09_Draft_Schedule_Calc"; +import { createSchedulePanel, syncSchedulePanel } from "./B09_Draft_Schedule_Panel"; const SUM = "schedule_sum" as TabId; const CALC = "schedule_calc" as TabId; @@ -185,6 +187,35 @@ const LINES: Array<[string, string, string, string]> = [ ["span", "공사기간", "공사일수", "작업일수 계 + 비작업일수"], ]; +/** 264 고른 옵션을 따르는 비고 — 기본과 같으면 LINES 글 그대로(rain 은 늘 기후 자료 이름) */ +function optionNotes(src: Row, mine: Row, midMonth: boolean): Record { + const base = climateBase(src, mine); + const out: Record = {}; + if (base.label) + out.rain = `${base.rain_note ?? "일강수량 5mm 이상"}(달마다 · 해 무관) · 기본값 = ${base.label}`; + if (base.heat_note) out.heat = base.heat_note; + if (midMonth) { + out.days = "첫 달 = 착공일부터"; + out.a = "ROUNDUP(강수 + 혹서, 1) · 첫 달 = 날수 비례"; + } + const off: string[] = mine.holiday_off || []; + if (mine.rest === "satsun" || off.length) { + const rest = mine.rest === "satsun" ? "토 · 일" : "일요일"; + const cut = HOLIDAY_NAMES.filter(([k]) => off.includes(k)).map(([, n]) => n); + out.b = `${rest} + 공휴일${cut.length ? `(뺌 = ${cut.join(" · ")})` : ""} · 풍선 = 이름 · 고치면 그 달 덮개`; + } + if (mine.c_round === "round1") out.c = "A × B ÷ 달력일수 · 소수 1자리 반올림"; + if (mine.last_off === "all") out.off_used = "다 쓴 달 · 마지막 달 = 그 달 비작업 전부"; + return out; +} + +const HOLIDAY_NAMES: Array<[string, string]> = [ + ["substitute", "대체"], + ["election", "선거일"], + ["labor", "노동절"], + ["constitution", "제헌절"], +]; + export function buildScheduleSum(ctx: TabContext): TabTable | string { const sch: Row | undefined = ctx.sheets.schedule; if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; @@ -192,7 +223,9 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { const months: Row[] = cal.months; const src: Row = ctx.payload.inputs?.schedule || {}; const mine: Row = ctx.user.schedule || {}; - const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}`; + const day = cal.start.day > 1 ? `.${String(cal.start.day).padStart(2, "0")}` : ""; + const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}${day}`; + const notes = optionNotes(src, mine, Boolean(day)); const columns: DataColumn[] = [ text("label", "구분", 96), text("sub", "세부", 96), @@ -212,15 +245,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`, }, ...LINES.map(([key, label, sub, note]) => { - const row: DataRow = { key, label, sub, note }; + const row: DataRow = { key, label, sub, note: notes[key] ?? note }; let sum = 0; months.forEach((m, i) => { row[`m${i}`] = m[key]; sum += Number(m[key]) || 0; }); row.sum = sum; - if (key === "rain" && src.climate?.label) - row.note = `${note} · 기본값 = ${src.climate.label}`; return row; }), ]; @@ -232,7 +263,7 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { frozen: 2, mergeSame: ["label"], rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null), - hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 그 달 1일 · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", + hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공기 옵션」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", tip: (i, key) => { const m = months[monthOf(key)]; if (!m || rows[i].key !== "b") return null; @@ -266,10 +297,13 @@ export function buildScheduleSum(ctx: TabContext): TabTable | string { const k = String(rows[i].key); change(ctx, (s) => { if (k === "start") { - const hit = /(\d{4})\D+(\d{1,2})/.exec(value); + const hit = /(\d{4})\D+(\d{1,2})(?:\D+(\d{1,2}))?/.exec(value); if (!value.trim()) delete s.start; - else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) - s.start = { year: Number(hit[1]), month: Number(hit[2]) }; + else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) { + const [year, month, day] = [Number(hit[1]), Number(hit[2]), Number(hit[3] ?? 1)]; + const last = new Date(Date.UTC(year, month, 0)).getUTCDate(); + s.start = { year, month, ...(day > 1 && day <= last ? { day } : {}) }; + } return; } const m = months[monthOf(key)]; @@ -303,4 +337,7 @@ if (!TABS.some(([id]) => id === SUM)) { const src = payload.inputs?.schedule; if (src) sheets.schedule = computeSchedule(src, sheets.bill?.rows || [], user.schedule || {}); }); + // 264-1 왼쪽 「공기 옵션」 카드 — 셈마다 지금 값으로 맞춤 + DRAFT.panels.push(createSchedulePanel); + DRAFT.extend.push(syncSchedulePanel); }