Files
Aislo/B09_Estimation/B09_Estimation_UI_Haul_Modal.ts
T
eomsangdonandClaude Opus 5.5 d909d6aabe feat(B09): 263-1 운반 단가 모달 = 고른 항목 입력만 + 거리 자동
- 사이클시간 입력 · 8-2-8 표 고르기 칸 없앰 · 사이클 = 8-2-8 기본값(계산 절차에만)
- 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 · 거리 칸 없는 항목 = 사이클 줄 없음
- [변경] = 단가연결 한 목록(onPick) · [다른 종류] 없앰 · 조합 = [적용] 때 사본 들여옴 · 호표 = 그대로 연결
- 옛 cycle = 서버 읽기 그대로 · [적용]하면 지움

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

312 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_Estimation_UI_Haul_Modal.ts
* 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
* 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) ·
* 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음) ·
* 오른 = 계산 절차 — 거리 줄 · 사이클 식 줄(TS 짝 `B08_Quantity_Haul_Cycle` · 8-2-8 기본값 · 거리 칸 있을 때만) +
* 로직 산출 줄(서버 `POST …/estimation/logic-preview` · 값 바꾸면 다시 · 최신 응답만).
* 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「거리에서」) · 손으로 고치면 덮음(`own`) ·
* 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값).
* 연결 없는 줄도 같은 모달(빈칸 → [고르기]) · [적용] = 부르는 쪽 `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 type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
importLib,
openPriceLink,
type Picked,
type PriceLinkOpts,
} from "./B09_Estimation_UI_Tabs_Bill_Link";
export interface HaulModalOpts extends PriceLinkOpts {
/** 지금 연결(없으면 null) */
ref: Row | null;
/** 그 행 거리 — L1 포장 계 · L2 비포장(km) */
dist: Dist;
/** 줄 이름(제목) */
name: string;
}
/** 옛 사이클 손 칸(253-3) — 새 모달은 받지 않음 */
const OLD_OWN = 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);
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 = Cms × n / (60 × Es) = ${v.Cms} × ${r.n} / (60 × ${v.Es}) = ${r.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);
const KIND_TEXT: Record<string, string> = {
logic: "단가산출",
precombo: "일위대가 사전조합",
combo: "구조물 조합",
dg: "일위대가 호표",
sg: "단가산출 호표",
};
/** 「운반 단가」 모달 */
export function openHaulModal(o: HaulModalOpts): void {
const logicRef = o.ref?.tab === "logic" ? o.ref : null;
const old = (logicRef?.cycle as Row) || {};
const st = {
/** 고른 것 — 단가산출 아닌 것(조합 · 호표) */
other: (o.ref && !logicRef
? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } }
: null) as Picked | null,
code: String(logicRef?.code ?? ""),
name: String(logicRef?.name ?? ""),
given: { ...((logicRef?.given as Row) || {}) } as Row,
own: new Set<string>(((logicRef?.own as string[]) || []).filter((n) => !OLD_OWN.has(n))),
rate: logicRef?.rate ?? null,
picks: logicRef?.picks ?? null,
inputs: [] as ItemInput[],
unit: "",
};
const oldCycle = Object.keys(old).length > 0;
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] });
// 사이클 = 8-2-8 기본값 + 거리(옛 손 값 안 씀 · [적용]하면 지움)
const ref = (): Row => ({ own: [...st.own], 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 fills = st.code !== "" && names().some((n) => n in CYCLE_FILL);
const input = cycleInputs(ref(), o.dist);
const head2 = fills
? [
`거리 — 포장 ${fmt(o.dist?.L1)} km + 비포장 ${fmt(o.dist?.L2)} km`,
...cycleLines(input),
"",
]
: [];
window.clearTimeout(timer);
const my = ++seq;
if (!st.code) {
const what = st.other
? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결`
: "없음";
steps.textContent = `연결 — ${what}`;
return;
}
steps.textContent = [...head2, "단가산출 — 셈 중…"].join("\n");
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 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 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 muted = (text: string): HTMLElement => el("p", { className: "m02-labin__muted", text });
/** 고른 것 바뀜 — 왼쪽 칸을 새로 */
const refresh = (): void => {
shows.splice(0);
const lines = st.code
? st.inputs.length
? logicRows()
: [muted("입력 없음")]
: [muted(st.other ? "입력 없음 — 그대로 연결" : "연결 없음 — [고르기]")];
left.replaceChildren(
el("h4", { text: "옵션 값", attrs: { style: "margin:12px 0 4px" } }),
...lines,
...(oldCycle && st.code
? [muted("옛 사이클 손 값 — 셈은 8-2-8 기본값 · [적용]하면 지움")]
: []),
);
const p = st.other;
title.textContent = st.code
? `단가산출 ${st.code} ${st.name}`
: p
? `${KIND_TEXT[p.kind] ?? p.kind} ${String("from" in p ? (p.from.name ?? "") : "name" in p ? p.name : "")}`
: "연결 — 없음";
change.querySelector(".ui-btn__label")!.textContent = st.code || p ? "변경" : "고르기";
update();
};
const useLogic = (code: string, name: string): void => {
fetchLogicInputs([code]).then(
(got) => {
st.other = null;
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 usePicked = (p: Picked): void => {
if (p.kind === "logic") return useLogic(p.code, p.name);
st.other = p;
st.code = "";
st.inputs = [];
refresh();
};
const change = createButton({
label: "변경",
variant: "ghost",
size: "sm",
onClick: () => void openPriceLink({ ...o, onPick: usePicked }),
});
linkBox.append(title, change);
const ok = createButton({
label: "적용",
onClick: () => {
const p = st.other;
if (!st.code && !p) return showToast("연결할 것을 먼저 고르세요", "error");
if (p && (p.kind === "combo" || p.kind === "precombo")) void importLib(o, p);
else if (p && "ref" in p) o.apply(p.ref, p.from);
else {
const next: Row = {
tab: "logic",
code: st.code,
name: st.name,
given: filled(),
rate: st.rate,
picks: st.picks,
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();
},
});
}