/* ============================================================================= * B09_Draft_Schedule_UI.ts * B09 위 탭 「공기」(PLAN 252-3 · 사용자 161번 · 초안) — 아래 탭 공기집계 · 공기산출. * 등록 = 이 파일을 `…_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 · 같은 값 · 비고는 고른 옵션을 따름) * 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 / * 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동) * ========================================================================== */ import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; import { num, type Row } from "../B09_Estimation_Calc_Base"; import { DRAFT, parseNumber, qty, TAB_GROUPS, TABS, text, type TabContext, type TabId, type TabTable, } from "../B09_Estimation_UI_Tabs"; 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; /** 사용자 공기 값 고침 → 캐시 · 바로 다시 셈 */ function change(ctx: TabContext, fn: (s: Row) => void): void { const next: Row = structuredClone(ctx.user.schedule || {}); fn(next); ctx.setUser({ ...ctx.user, schedule: next }); } /** 칸 값 — 빈칸 = 지움(기본값) · 수 아님 = 무시 */ function setNum(box: Row, key: string, value: string, min = 0, max = 1000): void { const v = parseNumber(value); if (v === undefined) return; if (v === null || v < min || v > max) delete box[key]; else box[key] = v; } const pct = (n: number): string => String(Math.round(n * 10) / 10); /* ── 공기산출 ─────────────────────────────────────────────────────────── */ const CALC_COLUMNS: DataColumn[] = [ text("name", "구분 · 명칭", 220), text("spec", "규격", 120), qty("qty", "시공량", 2), text("unit", "단위", 56), text("basis", "기준", 64), text("who", "주 자원", 150), qty("per", "단위당 시간 · 인", 4), qty("put", "투입 대 · 인", 1), qty("q", "시간당 작업량 Q", 2), qty("daily", "일작업량", 2), qty("rate", "반영률 %", 1), qty("days", "작업일수", 1), text("note", "비고", 220), ]; const FIXED: Record = { prep_docs: "days", prep_setup: "days", hours: "put", etc: "days", tidy: "days", }; function itemNote(r: Row): string { if (r.rate === 0) return "다른 공종과 겹침(반영 0 %)"; if (r.basis === "연결 없음") return "설계내역서 [단가연결] 하면 자동 · 또는 일작업량 입력"; if (r.basis === "장비") return `Q = 1 ÷ ${r.per} 시간`; if (r.basis === "인력") return `${r.put}인 ÷ 단위당 ${r.per}인`; return "일작업량 = 입력"; } export function buildScheduleCalc(ctx: TabContext): TabTable | string { const sch: Row | undefined = ctx.sheets.schedule; if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; const w: Row = sch.work; const rows: DataRow[] = [ { key: "h:prep", name: "1. 준비기간", lv: 1, head: true }, { key: "prep_docs", name: "각종 서류 및 착수 준비", lv: 2, days: w.prep_docs }, { key: "prep_setup", name: "공종별 준비기간", lv: 2, days: w.prep_setup, note: "자재운반 등" }, { key: "h:work", name: "2. 공종별 작업일수", lv: 1, head: true }, { key: "hours", name: "일 작업시간", lv: 2, put: w.hours, unit: "시간", note: "장비 일작업량 = 이 시간 × Q × 대수", }, ...(w.rows as Row[]).map((r) => r.kind === "head" ? { key: r.key, name: r.name, lv: r.lv + 1, head: true } : { ...r, lv: r.lv + 1, qty: r.qty, per: r.per ?? "", put: r.put ?? "", q: r.q ?? "", daily: r.daily ?? "", days: r.days ?? "", note: itemNote(r), item: true, }, ), { key: "etc", name: "기타(현장여건 감안)", lv: 2, days: w.etc }, { key: "subtotal", name: "작업일수 소계", lv: 2, days: w.subtotal, note: "ROUND(Σ 작업일수 + 기타, 0)", head: true, }, { key: "tidy", name: "3. 정리기간", lv: 1, days: w.tidy, note: "1개월 범위 안", head: true }, ]; const missing = w.missing ? ` · 연결 없음 ${w.missing}줄` : ""; return { columns: CALC_COLUMNS, rows, total: { name: "총작업일수", days: w.total, note: "준비 + 소계 + 정리 → 공기집계로" }, frozen: 1, level: { key: "name", of: (i) => Number(rows[i].lv) || 1 }, rowClass: (i) => (rows[i].head ? "is-title" : null), // 사용자 덮개 칸(기본값과 다름) = 설계내역서 고친 칸 꼴 cellClass: (i, key) => { const k = String(rows[i].key); const mine: Row = ctx.user.schedule || {}; if (FIXED[k]) return FIXED[k] === key && num(mine[k]) !== null ? "is-edited" : null; const own: Row = mine.rows?.[k] || {}; const field = key === "put" ? (rows[i].basis === "장비" ? "units" : "crew") : key; return rows[i].item && ["rate", "daily", "put"].includes(key) && num(own[field]) !== null ? "is-edited" : null; }, hint: `작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비(일 작업시간 × Q × 대수) · 인력(인원 ÷ 단위당 인) · 입력${missing}`, editable: (i, key) => { const r = rows[i]; if (FIXED[String(r.key)]) return FIXED[String(r.key)] === key; if (!r.item) return false; if (key === "put") return r.basis === "장비" || r.basis === "인력"; return key === "daily" || key === "rate"; }, onEdit: (i, key, value) => { const r = rows[i]; const k = String(r.key); change(ctx, (s) => { if (FIXED[k]) return setNum(s, k, value, k === "hours" ? 0.5 : 0, k === "hours" ? 24 : 1000); s.rows ??= {}; const box: Row = (s.rows[k] ??= {}); if (key === "rate") setNum(box, "rate", value, 0, 100); else if (key === "daily") setNum(box, "daily", value, 1e-9, 1e9); else if (key === "put") setNum(box, r.basis === "장비" ? "units" : "crew", value, 1e-9, 1e4); if (!Object.keys(box).length) delete s.rows[k]; }); }, }; } /* ── 공기집계 ─────────────────────────────────────────────────────────── */ const LINES: Array<[string, string, string, string]> = [ // [키(달 값 칸), 구분, 세부, 비고] ["days", "달력일수", "", ""], ["rain", "기후여건 A", "강수일", "일강수량 5mm 이상(달마다 · 해 무관)"], ["heat", "기후여건 A", "혹서일", "33℃ 이상"], ["a", "기후여건 A", "소계", "ROUNDUP(강수 + 혹서, 1)"], ["b", "법정공휴일 B", "", "일요일 + 공휴일 · 대체 · 선거일(풍선 = 이름 · 고치면 그 달 덮개)"], ["c", "중복일수 C", "", "A × B ÷ 달력일수"], ["off", "비작업일수", "", "ROUNDDOWN(A + B − C, 0)"], ["avail", "작업가능일수", "", "달력일수 − 비작업일수"], ["prep", "공사기간", "준비기간", "작업가능일을 준비 → 작업 → 정리 차례로 채움"], ["work", "공사기간", "작업일수", ""], ["tidy", "공사기간", "정리기간", ""], ["used", "공사기간", "작업일수 계", ""], ["off_used", "공사기간", "비작업일수", "다 쓴 달 = 전부 · 마지막 달 = 쓴 비율 올림"], ["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 "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; const cal: Row = sch.calendar; const months: Row[] = cal.months; const src: Row = ctx.payload.inputs?.schedule || {}; const mine: Row = ctx.user.schedule || {}; 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), ...months.map((m, i) => ({ ...qty(`m${i}`, `${m.month}월`, 2), group: [`${m.year}년`], width: 58, })), qty("sum", "계", 2), text("note", "비고", 340), ]; const rows: DataRow[] = [ { key: "start", label: "착공", sub: start, note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`, }, ...LINES.map(([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; return row; }), ]; const monthOf = (key: string): number => (key.startsWith("m") ? Number(key.slice(1)) : -1); const ym = (m: Row): string => `${m.year}-${String(m.month).padStart(2, "0")}`; return { columns, rows, frozen: 2, mergeSame: ["label"], rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null), hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 「연.월」 또는 「연.월.일」(왼쪽 「공기 옵션」과 같은 값) · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", tip: (i, key) => { const m = months[monthOf(key)]; if (!m || rows[i].key !== "b") return null; const names = (m.names as string[]).join(" · "); const own = num(mine.holiday?.[ym(m)]); return [ names || "공휴일 없음(일요일만)", own !== null ? `자동 ${m.b_auto} → 덮개 ${own}` : "", ] .filter(Boolean) .join(" · "); }, cellClass: (i, key) => { const m = months[monthOf(key)]; if (!m) return null; const k = String(rows[i].key); const own = k === "b" ? num(mine.holiday?.[ym(m)]) : k === "rain" || k === "heat" ? num(mine.climate?.[k]?.[m.month - 1]) : null; return own !== null ? "is-edited" : null; // 사용자 덮개(기본값과 다름) }, editable: (i, key) => { const k = String(rows[i].key); if (k === "start") return key === "sub"; return (k === "rain" || k === "heat" || k === "b") && monthOf(key) >= 0; }, onEdit: (i, key, value) => { const k = String(rows[i].key); change(ctx, (s) => { if (k === "start") { 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) { 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)]; if (k === "b") { s.holiday ??= {}; setNum(s.holiday, ym(m), value, 0, 31); if (!Object.keys(s.holiday).length) delete s.holiday; return; } s.climate ??= {}; const list: Array = s.climate[k] ?? Array(12).fill(null); const v = parseNumber(value); if (v === undefined) return; list[m.month - 1] = v === null || v < 0 || v > 31 ? null : v; if (list.some((x) => x !== null)) s.climate[k] = list; else delete s.climate[k]; if (!Object.keys(s.climate).length) delete s.climate; }); }, }; } /* ── 등록 ─────────────────────────────────────────────────────────────── */ if (!TABS.some(([id]) => id === SUM)) { TABS.push([SUM, "공기집계"], [CALC, "공기산출"]); TAB_GROUPS.push(["공기", [SUM, CALC]]); DRAFT.builders[SUM] = buildScheduleSum; DRAFT.builders[CALC] = buildScheduleCalc; DRAFT.extend.push((payload, user, sheets) => { 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); }