Files
Aislo/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts
T
eomsangdonandClaude Opus 5.5 cb2db4ff49 feat(b09): 운반 단가 모달 · 로직 미리보기 길 (253-3)
- POST /api/projects/{id}/estimation/logic-preview — 로직 한 산근을 프로젝트 단가로 풀어 계산 절차 글 · 단가(저장 안 함)
- 「운반 단가」 모달 — 단가산출 이름 · [변경] · [다른 종류] · 옵션 값(로직 입력 + 사이클 입력 · 표 고르기 CC000054~058) · 계산 절차(사이클 식 + 미리보기)
- 여는 곳 = B08 거리이정표 표 우클릭 [단가연결] · B09 설계내역서 운반공 줄 단가연결 · 운반 항목에 거리(dist)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 14:10:38 +09:00

381 lines
16 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_Estimation_UI_Haul_Modal.ts
* 「운반 단가」 모달(PLAN 253-3 · 요구 162-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
* 위 = 단가산출 이름 · [변경](`openLogicPicker`) · [다른 종류](옛 단가연결 창) ·
* 왼 = 옵션 값 — 로직 입력(`fetchLogicInputs`) + 사이클 입력(L1 · L2 · V1~V4 · t1 · t3~t6 · t1 셈 값) ·
* 오른 = 계산 절차 — 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle`) + 로직 산출 줄
* (서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
* 로직 입력 Cm · 사이클시간 · t2 · 운반거리 = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
* 빈칸으로 지우면 다시 거리에서.
* 연결 없는 줄도 같은 모달(단가산출 빈칸 → [고르기]) · [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본).
* 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결.
* ========================================================================== */
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
CYCLE_DEFAULTS,
CYCLE_FILL,
cycleInputs,
cycleTime,
fillGiven,
type Dist,
} from "../B08_Quantity/B08_Quantity_Haul_Cycle";
import { loadTable, type TableRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Note";
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
import type { Row } from "./B09_Estimation_Calc_Base";
type Apply = (ref: Row | null, from?: Row) => void;
export interface HaulModalOpts {
/** 지금 연결(없으면 null) */
ref: Row | null;
/** 그 행 거리 — L1 포장 계 · L2 비포장(km) */
dist: Dist;
/** 줄 이름(제목) */
name: string;
apply: Apply;
/** 옛 단가연결 창(일위대가 · 구조물 조합 등 다른 종류) */
other?: () => void;
}
/** 사이클 입력 칸 — [이름, 글, 단위] · 무리 = 화면 묶음 */
const CYCLE_FIELDS: Array<[string, string, string][]> = [
[
["L1", "포장 운반거리 L1", "km"],
["L2", "비포장 운반거리 L2", "km"],
],
[
["V1", "포장 적재 V1", "km/hr"],
["V2", "포장 공차 V2", "km/hr"],
["V3", "비포장 적재 V3", "km/hr"],
["V4", "비포장 공차 V4", "km/hr"],
],
[
["t1", "적재 t1", "분"],
["T", "적재량 T", "ton"],
["rt", "단위중량 rt", "t/㎥"],
["f", "체적환산 f", ""],
["qs", "버킷용량 qs", "㎥"],
["k", "버킷계수 k", ""],
["Es", "작업효율 Es", ""],
["Cms", "적재기계 Cms", "초"],
],
[
["t3", "적하 t3", "분"],
["t4", "대기 t4", "분"],
["t5", "덮개 t5", "분"],
["t6", "세륜 t6", "분"],
],
];
/** 표 고르기(건설품셈 8-2-8 · master_data 읽기만) — [표 키, 채울 칸(값칸 → 입력), 조건 칸들] */
const PICKS: Array<[string, Record<string, string>, string]> = [
["CC000054", { 적재: "V1", 공차: "V2" }, "포장 도로상태"],
["CC000054", { 적재: "V3", 공차: "V4" }, "비포장 도로상태"],
["CC000055", { 시간: "t3" }, "적하(토질 · 작업조건)"],
["CC000056", { 시간: "t4" }, "대기(적재장소)"],
["CC000057", { 시간: "t5" }, "덮개"],
["CC000058", { 시간: "t6" }, "세륜"],
];
const OWNABLE = new Set(["L1", "L2", "t1"]);
const FROM_DIST = "거리에서";
const fmt = (v: number | null | undefined): string =>
v == null || !Number.isFinite(v) ? "—" : String(v);
/** 계산 절차 — 사이클 식 줄(거창 단가산출 꼴) */
export function cycleLines(input: Record<string, unknown>): string[] {
const v: Record<string, number> = { ...CYCLE_DEFAULTS };
for (const k of Object.keys(v)) if (typeof input[k] === "number") v[k] = input[k] as number;
const r = cycleTime(input);
const t1 =
typeof input.t1 === "number"
? `t1 = ${fmt(r.t1)} 분(손 입력)`
: `t1 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.t1} 분`;
return [
"사이클시간 — 건설품셈 8-2-8 덤프트럭",
`q = T / rt × f = ${v.T} / ${v.rt} × ${v.f} = ${r.q} ㎥`,
`n = q / (qs × k) = ${r.q} / (${v.qs} × ${v.k}) = ${r.n} 회`,
t1,
`t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 = (${v.L1}/${v.V1} + ${v.L1}/${v.V2} + ${v.L2}/${v.V3} + ${v.L2}/${v.V4}) × 60 = ${r.t2} 분`,
`Cm = t1 + t2 + t3 + t4 + t5 + t6 = ${r.t1} + ${r.t2} + ${v.t3} + ${v.t4} + ${v.t5} + ${v.t6} = ${r.cm} 분`,
`운반거리 = L1 + L2 = ${r.L} km`,
];
}
interface Preview {
lines: string[];
unit: string;
price: number | null;
why: string | null;
}
async function preview(code: string, given: Row, rate: unknown, picks: unknown): Promise<Preview> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) throw new Error("프로젝트 없음");
const res = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/logic-preview`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, given, rate: rate ?? null, picks: picks ?? null }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as Preview;
}
const label = (text: string, unit: string): string => (unit ? `${text} (${unit})` : text);
/** 「운반 단가」 모달 */
export function openHaulModal(o: HaulModalOpts): void {
const logicRef = o.ref?.tab === "logic" ? o.ref : null;
const st = {
code: String(logicRef?.code ?? ""),
name: String(logicRef?.name ?? ""),
given: { ...((logicRef?.given as Row) || {}) } as Row,
cycle: { ...((logicRef?.cycle as Row) || {}) } as Row,
own: new Set<string>((logicRef?.own as string[]) || []),
rate: logicRef?.rate ?? null,
picks: logicRef?.picks ?? null,
inputs: [] as ItemInput[],
unit: "",
};
let seq = 0;
let timer = 0;
openModal({
title: `운반 단가 — ${o.name}`,
closeLabel: "닫기",
wide: true,
dialogClass: "b09-haul-modal",
mount: (body, close, head) => {
const title = el("strong");
const linkBox = el("div", {
attrs: { style: "display:flex;gap:8px;align-items:center;margin-bottom:8px" },
});
const left = el("div", { attrs: { style: "overflow:auto;min-width:0" } });
const steps = el("pre", {
attrs: { style: "margin:0;white-space:pre-wrap;font-size:12px;line-height:1.6" },
});
const right = el("div", { attrs: { style: "overflow:auto;min-width:0" }, children: [steps] });
const ref = (): Row => ({ own: [...st.own], cycle: st.cycle, given: st.given });
const names = (): string[] => st.inputs.map((i) => i.이름);
const filled = (): Row => fillGiven(ref(), names(), o.dist) as Row;
const redraw = (): void => {
const input = cycleInputs(ref(), o.dist);
const head2 = cycleLines(input);
steps.textContent = [...head2, "", st.code ? "단가산출 — 셈 중…" : "단가산출 — 없음"].join(
"\n",
);
window.clearTimeout(timer);
if (!st.code) return;
const my = ++seq;
timer = window.setTimeout(() => {
preview(st.code, filled(), st.rate, st.picks).then(
(got) => {
if (my !== seq) return; // 최신 응답만
st.unit = got.unit;
const price = got.price == null ? `빈칸 — ${got.why ?? ""}` : `${got.price} 원`;
steps.textContent = [...head2, "", ...got.lines, "", `단가 = ${price}`].join("\n");
},
(error: unknown) => {
if (my === seq)
steps.textContent = [...head2, "", `못 셈 — ${String(error)}`].join("\n");
},
);
}, 250);
};
const numberField = (name: string, text: string, unit: string): HTMLElement => {
const input = el("input", {
className: "ui-input m02-inputval__control",
attrs: { type: "number", step: "any", "data-haul-cycle": name },
}) as HTMLInputElement;
const tag = el("span", { className: "m02-labin__muted" });
const show = (): void => {
const now = cycleInputs(ref(), o.dist);
const auto =
name === "t1" ? cycleTime(now).t1 : ((now[name] as number) ?? CYCLE_DEFAULTS[name]);
const mine = OWNABLE.has(name) ? st.own.has(name) : name in st.cycle;
if (document.activeElement !== input)
input.value = mine ? String(st.cycle[name] ?? "") : "";
input.placeholder = String(auto ?? "");
tag.textContent = (name === "L1" || name === "L2") && !mine && o.dist ? FROM_DIST : "";
};
input.addEventListener("change", () => {
const v = input.value.trim() === "" ? null : Number(input.value);
if (v === null || !Number.isFinite(v)) {
delete st.cycle[name];
st.own.delete(name);
} else {
st.cycle[name] = v;
if (OWNABLE.has(name)) st.own.add(name);
}
update();
});
shows.push(show);
return row(label(text, unit), [input, tag]);
};
const row = (text: string, cells: HTMLElement[]): HTMLElement =>
el("div", {
className: "m02-inputval__row",
children: [
el("span", { className: "m02-inputval__name", text }),
el("div", { className: "m02-inputval__cell", children: cells }),
],
});
const shows: Array<() => void> = [];
const tablePick = (key: string, map: Record<string, string>, text: string): HTMLElement => {
const select = el("select", {
className: "ui-select ui-select--sm m02-inputval__control",
attrs: { "data-haul-pick": Object.values(map).join(",") },
}) as HTMLSelectElement;
select.append(el("option", { text: "표에서 고르기", attrs: { value: "" } }));
void loadTable(key).then((t: TableRow | null) => {
const conds = Object.keys(t?.조건 || {});
(t?.줄 || []).forEach((line, i) => {
const what = conds.map((c) => String(line[c] ?? "")).join(" · ");
const vals = Object.keys(map)
.map((col) => `${map[col]} ${String(line[col] ?? "")}`)
.join(" · ");
select.append(el("option", { text: `${what} — ${vals}`, attrs: { value: String(i) } }));
});
select.onchange = () => {
const line = t?.줄?.[Number(select.value)];
if (!line) return;
for (const [col, name] of Object.entries(map))
if (typeof line[col] === "number") st.cycle[name] = line[col] as number;
select.value = "";
update();
};
});
return row(text, [select]);
};
const logicRows = (): HTMLElement[] =>
st.inputs.map((input) => {
const fill = input.이름 in CYCLE_FILL && !st.own.has(input.이름);
const value = fill ? filled()[input.이름] : st.given[input.이름];
const field = createValueControl(
input,
(value as string | number | null) ?? null,
(next, bad) => {
if (bad) return;
if (next === null || next === "") {
delete st.given[input.이름];
st.own.delete(input.이름);
} else {
st.given[input.이름] =
typeof next === "string" && Number.isFinite(Number(next)) ? Number(next) : next;
if (input.이름 in CYCLE_FILL) st.own.add(input.이름);
}
update();
},
);
field.control.setAttribute("data-labor-input", input.이름);
const tag = el("span", { className: "m02-labin__muted" });
shows.push(() => {
const auto = input.이름 in CYCLE_FILL && !st.own.has(input.이름);
tag.textContent = auto ? FROM_DIST : "";
if (auto && document.activeElement !== field.control)
field.control.value = String(filled()[input.이름] ?? "");
});
return row(label(input.이름, input.단위 || ""), [field.control, tag, field.error]);
});
/** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */
const update = (): void => {
shows.forEach((show) => show());
redraw();
};
/** 로직 바뀜 — 왼쪽 칸을 새로 */
const refresh = (): void => {
shows.splice(0);
const sec = (text: string): HTMLElement =>
el("h4", { text, attrs: { style: "margin:12px 0 4px" } });
left.replaceChildren(
sec("단가산출 옵션 값"),
...(st.code
? logicRows()
: [el("p", { className: "m02-labin__muted", text: "단가산출 없음 — [고르기]" })]),
sec("사이클시간 입력"),
...CYCLE_FIELDS.flatMap((group) => group.map(([n, t, u]) => numberField(n, t, u))),
sec("표에서 고르기(건설품셈 8-2-8)"),
...PICKS.map(([key, map, text]) => tablePick(key, map, text)),
);
title.textContent = st.code ? `단가산출 ${st.code} ${st.name}` : "단가산출 — 없음";
change.querySelector(".ui-btn__label")!.textContent = st.code ? "변경" : "고르기";
update();
};
const useLogic = (code: string, name: string): void => {
fetchLogicInputs([code]).then(
(got) => {
st.code = code;
st.name = name;
st.inputs = got.로직[0]?.입력 ?? [];
const keep = new Set(st.inputs.map((i) => i.이름));
for (const k of Object.keys(st.given)) if (!keep.has(k)) delete st.given[k];
refresh();
},
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
);
};
const change = createButton({
label: "변경",
variant: "ghost",
size: "sm",
onClick: () => openLogicPicker((logic) => useLogic(logic.키, logic.이름)),
});
linkBox.append(title, change);
if (o.other)
linkBox.append(
createButton({
label: "다른 종류",
variant: "ghost",
size: "sm",
onClick: () => {
close();
o.other?.();
},
}),
);
const ok = createButton({
label: "적용",
onClick: () => {
if (!st.code) return showToast("단가산출을 먼저 고르세요", "error");
const next: Row = {
tab: "logic",
code: st.code,
name: st.name,
given: filled(),
rate: st.rate,
picks: st.picks,
cycle: st.cycle,
own: [...st.own],
};
o.apply(next, { name: st.name, unit: st.unit.replace(/^원\//, "") });
showToast("적용함 — [저장]하면 서버가 거리로 다시 셈", "success");
close();
},
});
head.append(ok);
body.append(
linkBox,
el("div", {
attrs: {
style:
"display:grid;grid-template-columns:minmax(320px,1fr) minmax(360px,1.4fr);gap:16px;min-height:60vh",
},
children: [left, right],
}),
);
if (st.code) useLogic(st.code, st.name);
else refresh();
},
});
}