- 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
381 lines
16 KiB
TypeScript
381 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* 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();
|
||
},
|
||
});
|
||
}
|