- 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
216 lines
8.0 KiB
TypeScript
216 lines
8.0 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, Array<[string, string]>> = {
|
|
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;
|
|
}
|