Files
Aislo/B09_Estimation/draft/B09_Draft_Schedule_UI.ts
T
eomsangdonandClaude Opus 5.5 0db53a9032 feat(B09): 264-1 왼쪽 패널 「공기 옵션」 카드
- draft 새 파일 B09_Draft_Schedule_Panel — 착공일 · 기후 자료 · 쉬는 날 · 공휴일 넣기 · 일 작업시간 · 투입 기본 · 준비 · 정리 · 기타 · 반영률 · 주 장비 기준 · 중복 C 끝수 · 마지막 달
- 값 = user.schedule(캐시 → [저장]) · 표 칸과 같은 값 · 셈마다 카드 맞춤
- 훅 = DRAFT.panels(기본 빈 목록) · Shell 「데이터 점검」 아래 한 줄
- 공기집계 비고 = 고른 옵션을 따름 · 착공 칸 「연.월.일」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 16:40:29 +09:00

344 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, string> = {
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<string, string> {
const base = climateBase(src, mine);
const out: Record<string, string> = {};
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<number | null> = 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);
}