Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts
T
eomsangdonandClaude Opus 5.5 8c562812da feat(M02 실무자료): 358-1 후보 묶음 — 이름후보(옛 324 다섯) · 뜻겹침 · 출처 같음 · 책 표시 · 고르기 창 묶음 · 검색 칸 = 품명 낱말 · 일괄 적용(N)
- 서버 줄 맞춤에 이름후보(옛 데스크탑 판과 같음 · 600줄 대조 0 다름) · 후보마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 — 차례 · 무게 · 상태 그대로
- 고르기 창 = 1위 / 출처 같음 / 이름 닮은 로직 / 숫자만 닮음(접힘 · 뜻 안 겹치는 후보 회색 끝) · 같은 이름 책 표시
- 버튼 명사형 — 일괄 적용(N) · 엑셀 보기 · 다른 로직 검색 · 재료비 있는 줄은 다른 자재 검색도
- 시험: match 이름후보 다섯 · 되메우기 · 다웰바 · golden 다섯 안 ∪ 이름후보 · screen 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 20:55:48 +09:00

321 lines
14 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 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행)
* · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 ·
* 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록)
* · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
* · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2)
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type {
BuildTarget,
HopyoBasisHit,
HopyoLine,
HopyoLineBasis,
HopyoMatch,
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(" · ")
: "";
/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */
export function searchHint(line: Pick<HopyoLine, "이름">): string {
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
}
/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */
export interface CandGroup {
제목: string;
접힘: boolean;
후보: HopyoTarget[];
}
/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */
export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false;
/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) /
* 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */
export function candGroups(
m: Pick<HopyoMatch, "후보" | "이름후보">,
material = false,
): CandGroup[] {
const all = m.후보 ?? [];
const seen = new Set<string>();
const take = (list: HopyoTarget[]): HopyoTarget[] =>
list.filter((c) => {
const id = c.로직 ?? c.자재 ?? c.이름 ?? "";
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const top = take(all.slice(0, 1));
if (material)
return [
{ 제목: "1위", 접힘: false, 후보: top },
{ 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) },
].filter((g) => g.후보.length);
const named = m.이름후보 ?? [];
const source = take([...all.slice(1), ...named].filter((c) => c.출처같음));
const name = take(named);
const rest = take(all.slice(1));
rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b)));
return [
{ 제목: "1위", 접힘: false, 후보: top },
{ 제목: "출처 같음", 접힘: false, 후보: source },
{ 제목: "이름 닮은 로직", 접힘: false, 후보: name },
{ 제목: "숫자만 닮음", 접힘: true, 후보: rest },
].filter((g) => g.후보.length);
}
/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */
export function sameNames(groups: CandGroup[]): Set<string> {
const count = new Map<string, number>();
for (const c of groups.flatMap((g) => g.후보))
count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1);
return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name));
}
/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */
export const candText = (c: HopyoTarget, twins: Set<string>): string =>
`${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`;
/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */
export const canPickMaterial = (line: Pick<HopyoLine, "갈래" | "단가">): boolean =>
MATERIAL.has(line.갈래) || !!line.단가?.재료비;
/** 고른 후보 → 만들기 대상(배 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 groups = candGroups(line.맞춤, material);
const twins = sameNames(groups);
const row = (c: HopyoTarget): HTMLElement => {
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${weakCand(c) ? " is-weak" : ""}`,
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
children: [
box,
createButton({
label: candText(c, twins),
variant: "ghost",
size: "sm",
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
}),
...(c.판정
? [
el("span", {
className: `m02-hopyo__state is-${c.판정.replace(" ", "")}`,
text: c.판정,
}),
]
: []),
...(c.글 ? [note(c.글)] : []),
...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []),
...(tag && c.근거
? [
el("span", {
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
text: tag,
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
}),
]
: []),
share,
],
});
};
const sections = groups.map((g) => {
const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) });
const title = `${g.제목} (${g.후보.length})`;
if (!g.접힘)
return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] });
const fold = el("details", {
className: "m02-hopyo__group",
attrs: { "data-group": g.제목 },
children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list],
});
return fold;
});
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 findMaterial = createButton({
label: "다른 자재 검색…",
size: "sm",
onClick: () => {
close();
pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
},
});
const findLogic = createButton({
label: "다른 로직 검색…",
size: "sm",
onClick: () => {
close();
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
query: searchHint(line),
});
},
});
const more = [
...(material ? [] : [findLogic]),
...(canPickMaterial(line) ? [findMaterial] : []),
];
body.append(
...(head.childElementCount ? [head] : []),
note(
groups.length
? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]"
: "가까운 것 없음 — 아래 검색",
),
...sections,
el("div", { className: "m02-hopyo__bar", children: [together, ...more] }),
);
},
});
}