- 운반 단가 창 몸 두 칸 비율 — 왼쪽(옵션 값) 약 0.8 배 - 단가산출근거 [박스보기] 머리 줄 = 운반 창 ③ 칸과 같은 꼴(이름 · 값 오른쪽 굵게 · 식 작게) · 출처 = 칸 맨 위 - 줄 나눔 · 그리기 한 벌 — Haul_Steps headSteps 내보내 같이 씀 · 공용 묶음 카드 = 선택 몸 위 칸(top) - 호표 줄 · 총계 · 표 보기 · 다른 탭 카드 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Haul_Modal.ts
|
|
* 「운반 단가」 모달(PLAN 253-3 · 263-1 · 요구 162-1 · 164-1) — 운반공 줄 하나(거리이정표 행 하나)의 단가연결.
|
|
* 위 = 고른 항목 이름 · [변경](단가연결 한 목록 `openPriceLink` — 단가산출 · 사전조합 · 구조물 조합 · 호표) ·
|
|
* 왼 = 고른 항목이 요구하는 입력만(단가산출 = 로직 입력 `fetchLogicInputs` · 호표 = 입력 없음 ·
|
|
* 사전조합 · 구조물 조합 = 담은 로직 이름별 입력 `fetchLibInputs` · 263-2) ·
|
|
* 오른 = 계산 칸 차례(277 · `B09_Estimation_UI_Haul_Steps`) — ① 거리(거리 칸 받을 때) ·
|
|
* ② 사이클 8-2-8(Cm · 사이클시간 · t2 받을 때만) · ③ 단가산출 블록(서버 `POST …/estimation/logic-preview`
|
|
* `blocks` · 하위 산근 이어 붙임 · 조합 = 담은 로직 줄마다 · 값 바꾸면 다시 · 최신 응답만 · 셈 중 = 옛 칸 흐리게).
|
|
* 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 채움(「자동계산」 켜짐) · 손으로 고치면 덮음(`own` · 꺼짐) ·
|
|
* 단추 누름 · 빈칸으로 지우면 다시 거리에서. 옛 사이클 손 값(`cycle`) = 칸 없음 · [적용]하면 지움(기본값).
|
|
* 조합 [적용] = 거리 · 손 값 사본(서버 · 같은 값 = 같은 사본) · 연결 {tab dg, key, lib, given, own}.
|
|
* [적용] = 부르는 쪽 `apply`(캐시 → [저장] 때 서버 정본) · 크기 = 큰 창보다 작게(276).
|
|
* 여는 곳 = B08 거리이정표 표 우클릭 · B09 설계내역서 운반공 줄 단가연결 — 연결 있는 줄만(276 ·
|
|
* 연결 없음 = 단가연결 목록 → 옵션 값 창 옛 길).
|
|
* ========================================================================== */
|
|
|
|
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_FILL,
|
|
cycleInputs,
|
|
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 {
|
|
fetchLibInputs,
|
|
importLib,
|
|
openPriceLink,
|
|
parseLib,
|
|
type LibLine,
|
|
type LibPick,
|
|
type Picked,
|
|
type PriceLinkOpts,
|
|
} from "./B09_Estimation_UI_Tabs_Bill_Link";
|
|
import {
|
|
cycleBox,
|
|
distBox,
|
|
logicBox,
|
|
noteBox,
|
|
type StepBlock,
|
|
} from "./B09_Estimation_UI_Haul_Steps";
|
|
|
|
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 AUTO = "자동계산";
|
|
/** ② 사이클 칸 = 이 입력을 받는 로직만(운반거리만 받는 로직은 자기 식으로 셈) */
|
|
const CYCLE_ONLY = new Set(["Cm", "사이클시간", "t2"]);
|
|
|
|
interface Preview {
|
|
lines: string[];
|
|
unit: string;
|
|
price: number | null;
|
|
why: string | null;
|
|
blocks: StepBlock[];
|
|
}
|
|
|
|
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 libRef = o.ref?.tab === "dg" && o.ref.lib ? parseLib(String(o.ref.lib)) : null;
|
|
const st = {
|
|
/** 고른 것 — 단가산출 아닌 것(조합 · 호표) */
|
|
other: (libRef
|
|
? { ...libRef, name: String(o.ref?.name ?? ""), unit: "" }
|
|
: o.ref && !logicRef
|
|
? { kind: o.ref.tab, ref: o.ref, from: { name: o.ref.name ?? o.ref.key } }
|
|
: null) as Picked | null,
|
|
/** 조합 담은 로직 줄(263-2) · 손 값 {줄 id: {입력: 값}} · 덮은 칸 `줄 id|입력` */
|
|
lines: [] as LibLine[],
|
|
libGiven: structuredClone((libRef && (o.ref?.given as Row)) || {}) as Record<string, Row>,
|
|
libOwn: new Set<string>((libRef && (o.ref?.own as string[])) || []),
|
|
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) => {
|
|
// 크기(276) — 공용 큰 창(96vw × 94vh)보다 작게 · 가운데 · 몸 안 두 칸이 따로 스크롤
|
|
body.parentElement?.setAttribute(
|
|
"style",
|
|
"--modal-max:min(680px,86vh);width:min(1100px,94vw);height:var(--modal-max)",
|
|
);
|
|
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("div", { className: "b09-haulsteps" });
|
|
/** 마지막 단가산출 칸들 — 셈 중엔 흐리게 둠(고른 것 바뀌면 비움) */
|
|
let last: HTMLElement[] = [];
|
|
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 isLib = (p: Picked | null): p is LibPick & { name: string; unit: string } =>
|
|
p?.kind === "combo" || p?.kind === "precombo";
|
|
/** 거리 칸 값(거리 없으면 undefined · 서버 짝 `Bill_Library.haul_values`) */
|
|
const fromDist = (name: string): unknown =>
|
|
o.dist ? fillGiven({ given: {}, own: [], cycle: {} }, [name], o.dist)[name] : undefined;
|
|
|
|
/** 조합 줄 거리 칸 — 따르면 거리 값(아니면 undefined) */
|
|
const libAuto = (lid: string, name: string, id = `${lid}|${name}`): unknown =>
|
|
name in CYCLE_FILL && !st.libOwn.has(id) ? fromDist(name) : undefined;
|
|
/** 조합 줄 미리보기 값 = 원값 + 손 값 + 거리 값(서버 짝 `haul_values`) */
|
|
const libFilled = (line: LibLine): Row => {
|
|
const out: Row = { ...line.값, ...(st.libGiven[line.id] ?? {}) };
|
|
for (const { 이름 } of line.입력) {
|
|
const value = libAuto(line.id, 이름);
|
|
if (value !== undefined) out[이름] = value;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const redraw = (): void => {
|
|
const lib = isLib(st.other);
|
|
const want = !o.dist
|
|
? []
|
|
: lib
|
|
? st.lines.flatMap((l) => l.입력.map((i) => i.이름))
|
|
: st.code
|
|
? names()
|
|
: [];
|
|
const input = cycleInputs(ref(), o.dist);
|
|
const head2 = [
|
|
...(want.some((n) => n in CYCLE_FILL) ? [distBox(o.dist, input)] : []),
|
|
...(want.some((n) => CYCLE_ONLY.has(n)) ? [cycleBox(input)] : []),
|
|
];
|
|
const show = (...boxes: HTMLElement[]): void => steps.replaceChildren(...head2, ...boxes);
|
|
window.clearTimeout(timer);
|
|
const my = ++seq;
|
|
const jobs = lib
|
|
? st.lines.map((l) => ({ code: l.로직, given: libFilled(l), rate: null, picks: null }))
|
|
: st.code
|
|
? [{ code: st.code, given: filled(), rate: st.rate, picks: st.picks }]
|
|
: [];
|
|
if (!jobs.length) {
|
|
const what = lib
|
|
? `${KIND_TEXT[st.other!.kind]} — 담은 로직 없음`
|
|
: st.other
|
|
? `${KIND_TEXT[st.other.kind] ?? ""} — 입력 없음 · 그대로 연결`
|
|
: "없음";
|
|
return show(noteBox(`연결 — ${what}`));
|
|
}
|
|
const tail = lib ? [noteBox("조합 단가 = [적용] · [저장] 뒤 서버 셈")] : [];
|
|
last.forEach((b) => b.classList.add("is-stale"));
|
|
show(...(last.length ? last : [noteBox("단가산출 — 셈 중…")]), ...tail);
|
|
timer = window.setTimeout(() => {
|
|
Promise.all(jobs.map((j) => preview(j.code, j.given, j.rate, j.picks))).then(
|
|
(got) => {
|
|
if (my !== seq) return; // 최신 응답만
|
|
if (!lib) st.unit = got[0].unit;
|
|
last = got.flatMap((g) =>
|
|
g.blocks.map((b, i) => logicBox(b, i ? "하위 산근" : "단가산출")),
|
|
);
|
|
show(...last, ...tail);
|
|
},
|
|
(error: unknown) => {
|
|
if (my !== seq) return;
|
|
last = [];
|
|
show(noteBox(`못 셈 — ${String(error)}`, true), ...tail);
|
|
},
|
|
);
|
|
}, 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> = [];
|
|
|
|
/** 입력 칸 하나 — auto = 거리에서 채울 값(아니면 undefined) · set(null = 비움) ·
|
|
* unown = 「자동계산」 누름(덮음 풀기 · 거리 칸 + 거리 있을 때만 단추) */
|
|
const valueRow = (
|
|
input: ItemInput,
|
|
auto: () => unknown,
|
|
hand: () => unknown,
|
|
set: (value: unknown) => void,
|
|
unown?: () => void,
|
|
): HTMLElement => {
|
|
const now = auto();
|
|
const field = createValueControl(
|
|
input,
|
|
((now === undefined ? hand() : now) as string | number | null) ?? null,
|
|
(next, bad) => {
|
|
if (bad) return;
|
|
const empty = next === null || next === "";
|
|
const num = typeof next === "string" && Number.isFinite(Number(next));
|
|
set(empty ? null : num ? Number(next) : next);
|
|
update();
|
|
},
|
|
);
|
|
field.control.setAttribute("data-labor-input", input.이름);
|
|
const button =
|
|
unown && o.dist
|
|
? createButton({
|
|
label: AUTO,
|
|
variant: "pill",
|
|
size: "sm",
|
|
onClick: () => {
|
|
unown();
|
|
update();
|
|
},
|
|
})
|
|
: null;
|
|
shows.push(() => {
|
|
const value = auto();
|
|
if (button) {
|
|
button.classList.toggle("is-active", value !== undefined);
|
|
button.setAttribute("aria-pressed", String(value !== undefined));
|
|
}
|
|
if (value !== undefined && document.activeElement !== field.control)
|
|
field.control.value = String(value ?? "");
|
|
});
|
|
const cells = button ? [field.control, button, field.error] : [field.control, field.error];
|
|
return row(label(input.이름, input.단위 || ""), cells);
|
|
};
|
|
const logicRows = (): HTMLElement[] =>
|
|
st.inputs.map((input) => {
|
|
const name = input.이름;
|
|
const fill = name in CYCLE_FILL;
|
|
return valueRow(
|
|
input,
|
|
() => (fill && !st.own.has(name) ? filled()[name] : undefined),
|
|
() => st.given[name],
|
|
(value) => {
|
|
if (value === null) delete st.given[name];
|
|
else st.given[name] = value;
|
|
if (fill && value !== null) st.own.add(name);
|
|
else st.own.delete(name);
|
|
},
|
|
fill
|
|
? () => {
|
|
st.own.delete(name);
|
|
delete st.given[name];
|
|
}
|
|
: undefined,
|
|
);
|
|
});
|
|
/** 조합 = 담은 로직 이름별 입력(263-2) */
|
|
const libRows = (): HTMLElement[] =>
|
|
st.lines.flatMap((line) => [
|
|
el("h5", { text: `${line.이름} (${line.로직})`, attrs: { style: "margin:8px 0 2px" } }),
|
|
...line.입력.map((input) => {
|
|
const name = input.이름;
|
|
const id = `${line.id}|${name}`;
|
|
const fill = name in CYCLE_FILL;
|
|
return valueRow(
|
|
input,
|
|
() => libAuto(line.id, name),
|
|
() => st.libGiven[line.id]?.[name] ?? line.값[name],
|
|
(value) => {
|
|
const mine = (st.libGiven[line.id] ??= {});
|
|
if (value === null) delete mine[name];
|
|
else mine[name] = value;
|
|
if (fill && value !== null) st.libOwn.add(id);
|
|
else st.libOwn.delete(id);
|
|
},
|
|
fill
|
|
? () => {
|
|
st.libOwn.delete(id);
|
|
delete st.libGiven[line.id]?.[name];
|
|
}
|
|
: undefined,
|
|
);
|
|
}),
|
|
]);
|
|
|
|
/** 값 바뀜 — 칸은 그대로 두고 표시 · 계산 절차만 */
|
|
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);
|
|
last = [];
|
|
const lines = st.code
|
|
? st.inputs.length
|
|
? logicRows()
|
|
: [muted("입력 없음")]
|
|
: isLib(st.other)
|
|
? st.lines.length
|
|
? libRows()
|
|
: [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 useLib = (p: LibPick): void => {
|
|
fetchLibInputs(p).then(
|
|
(lines) => {
|
|
st.lines = lines;
|
|
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 = [];
|
|
st.lines = [];
|
|
st.libGiven = {};
|
|
st.libOwn.clear();
|
|
if (isLib(p)) useLib(p);
|
|
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 (isLib(p)) {
|
|
const haul = { given: st.libGiven, own: [...st.libOwn], dist: o.dist };
|
|
const { kind, layer, owner, key, lib } = p;
|
|
void importLib(o, { kind, layer, owner, key, lib, haul });
|
|
} 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,
|
|
// 278-1 왼쪽(옵션 값) 폭 = 예전 약 0.8 배(칸 · 단추는 줄 안에서 접힘)
|
|
el("div", {
|
|
attrs: {
|
|
style:
|
|
"display:grid;grid-template-columns:minmax(260px,0.7fr) minmax(340px,1.4fr);gap:16px;flex:1;min-height:0",
|
|
},
|
|
children: [left, right],
|
|
}),
|
|
);
|
|
if (st.code) useLogic(st.code, st.name);
|
|
else {
|
|
if (libRef) useLib(libRef);
|
|
refresh();
|
|
}
|
|
},
|
|
});
|
|
}
|