Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts
T
eomsangdonandClaude Opus 5.5 01d3b0d2f2 feat(M02): 338-3c 확인 창 · 고르기 창 · 별칭 장부 화면 — 체크한 줄만 [확인하고 사전조합 만들기] · 옛 즉시 확정 · 바로 만들기 없앰
- 새 Hopyo_Check(확인 창 · 본 칸 · 모달) — 상태 색 · 체크 제안 · 우리 로직 여럿 · 배 · 몫 · 옵션 · 품 비교 · 뺌 · 원문 보기 · 캐시 m02-hopyo-check · 셈 중 3 초 다시 읽음 · 최신 응답만
- 새 Hopyo_Choose(고르기 창 · 후보 판정 · 여럿 고름 · 몫 · 찾기 글 미리 넣음) · 새 Hopyo_Ledger(장부 화면 · 고친 것 캐시 · [저장] 때만 PUT)
- 고침 View(호표 하나 = 확인 창) · Sheet(펼침 뒤 한 번 · 하위 호표 먼저) · Pick(확인된 것 바로 참조 · 아니면 확인 창) · Api(3-3 꼴 · 만들기 몸 · 장부 길) · Side([별칭 장부]) · css · Labor_Pick(query)
- 없앰 saveHopyoMemory · [확정] · [같음 모두 확정] · [근거 맞음 모두 확정] · 바로 만들기
- 시험 test_m02_hopyo_screen.py 새로 · helper_m02_hopyo_check.cjs 새 · pick · seam 도우미 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:37:24 +09:00

232 lines
9.7 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.
/* =============================================================================
* M02_MasterTemplete_Hopyo_Choose.ts
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행)
* · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음
* · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
* · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말)
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type {
BuildTarget,
HopyoBasisHit,
HopyoLine,
HopyoLineBasis,
HopyoQty,
HopyoTarget,
} from "./M02_MasterTemplete_Hopyo_Api";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { pickMaterial } from "./M02_Material_Pick";
import "./M02_MasterTemplete_Hopyo.css";
const MATERIAL = new Set(["자재", "별도계상"]);
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
const num = (v: number): string => String(Math.round(v * 10000) / 10000);
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
export const targetText = (t: HopyoTarget | null | undefined): string => {
if (!t) return "";
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
const spec = t.규격 ? ` ${t.규격}` : "";
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
};
/** 입력값 글 — 「이름=값」 · 칸 출처(블록 · 규격 · 별칭) · 못 가르는 칸 표시 */
export const inputsText = (
values: Record<string, unknown>,
unread: string[],
from: Record<string, string> = {},
): string =>
[
Object.entries(values)
.map(([k, v]) => `${k}=${String(v)}${from[k] && from[k] !== "자동값" ? `(${from[k]})` : ""}`)
.join(", "),
unread.length ? `⚠ 못 가름: ${unread.join(", ")}` : "",
]
.filter(Boolean)
.join(" · ");
/** 만들기 결과 낱말 — 있던 것을 덮음 · 새로 만듦 */
export const builtWord = (got: { 다시씀: boolean }): string =>
got.다시씀 ? "있던 것을 확인한 내용으로 덮음" : "만듦";
/** 근거 맞음 — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean =>
!!b &&
b.까닭.startsWith("조항 같음") &&
b.단위 &&
(b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부"));
/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */
export const basisTag = (b: HopyoBasisHit | undefined): string =>
b
? [
b.조항.length ? `조항 ${b.조항.join(", ")}` : "",
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
b.단위 ? "단위 같음" : "",
]
.filter(Boolean)
.join(" · ")
: "";
/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */
export const sourceText = (b: HopyoLineBasis | undefined): string =>
b
? [
b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "",
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
`단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`,
]
.filter(Boolean)
.join(" · ")
: "";
/** 품 글 — 「콘크리트공 0.1091 · 굴착기 0.29hr · 레미콘 1.02」 */
export const qtyText = (q: HopyoQty | undefined): string =>
q
? [
...Object.entries(q.인력 ?? {}).map(([k, v]) => `${k} ${num(v)}`),
...(q.장비 ?? []).map((x) => `${[x.갈래].flat().join("+")} ${num(x.시간)}hr`),
...(q.자재 ?? []).map((x) => `${x.이름} ${num(x.수량)}`),
].join(" · ")
: "";
/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */
export function searchHint(line: Pick<HopyoLine, "이름" | "근거">): string {
const clause = line.근거?.출처?.find((x) => x[1])?.[1];
if (clause) return clause;
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
}
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
picked.map(({ c, 몫 }) => ({
...(c.로직 ? { 로직: c.로직 } : { 자재: c.자재 }),
입력값: {},
배: 몫 ?? 1,
몫,
}));
export interface ChooseOptions {
line: HopyoLine;
/** [엑셀에서 보기] — 단가산출 그 행 */
openSheet?: (행: [number, number]) => void;
done: (targets: BuildTarget[]) => void;
}
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */
export function openHopyoChoose(opt: ChooseOptions): void {
const { line } = opt;
const material = MATERIAL.has(line.갈래);
openModal({
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
closeLabel: "닫기",
dialogClass: "m02-modal m02-hopyo__choose",
mount: (body, close) => {
const finish = (targets: BuildTarget[]): void => {
close();
opt.done(targets);
};
const picked = new Map<HopyoTarget, HTMLInputElement>();
const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true });
const paint = (): void => {
together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`;
together.disabled = picked.size < 2;
};
together.addEventListener("click", () =>
finish(
pickedTargets(
[...picked].map(([c, box]) => ({ c, 몫: box.value === "" ? null : Number(box.value) })),
),
),
);
const list = (line.맞춤.후보 ?? []).map((c) => {
const share = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" },
}) as HTMLInputElement;
const box = el("input", { attrs: { type: "checkbox", title: "함께 고름(조합)" } });
box.addEventListener("change", () => {
if ((box as HTMLInputElement).checked) picked.set(c, share);
else picked.delete(c);
paint();
});
const tag = basisTag(c.근거);
return el("div", {
className: "m02-hopyo__cand",
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
children: [
box,
createButton({
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
variant: "ghost",
size: "sm",
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
}),
...(c.판정
? [
el("span", {
className: `m02-hopyo__state is-${c.판정.replace(" ", "")}`,
text: c.판정,
}),
]
: []),
...(c.글 ? [note(c.글)] : []),
...(tag && c.근거
? [
el("span", {
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
text: tag,
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
}),
]
: []),
share,
],
});
});
const excel = qtyText(line.맞춤.품?.엑셀);
const basis = line.근거;
const head = el("div", {
className: "m02-hopyo__bar m02-hopyo__source",
children: [
...(excel ? [note(`엑셀 품 — ${excel}`)] : []),
...(basis ? [note(`엑셀 근거 — ${sourceText(basis)}`)] : []),
...(basis && opt.openSheet
? [
createButton({
label: "엑셀에서 보기",
variant: "ghost",
size: "sm",
onClick: () => opt.openSheet?.(basis.행),
}),
]
: []),
],
});
const more = createButton({
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
size: "sm",
onClick: () => {
close();
if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
else
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
query: searchHint(line),
});
},
});
body.append(
...(head.childElementCount ? [head] : []),
note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"),
el("div", { className: "m02-hopyo__cands", children: list }),
el("div", { className: "m02-hopyo__bar", children: [together, more] }),
);
},
});
}