설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* 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 || {});
|
||
});
|
||
}
|