Files
Aislo/B09_Estimation/draft/B09_Draft_Schedule_UI.ts
T
eomsangdonandClaude Opus 5.5 f87831533c feat(b09): 공기 초안 — 메인 탭 공기 · 공기집계 · 공기산출 (draft 폴더 · 252)
설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:51:32 +09:00

307 lines
13 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`
* 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 /
* 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동)
* ========================================================================== */
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 { computeSchedule } from "./B09_Draft_Schedule_Calc";
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", "공사기간", "공사일수", "작업일수 계 + 비작업일수"],
];
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 start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}`;
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 };
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;
}),
];
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: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 그 달 1일 · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)",
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})/.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]) };
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 || {});
});
}