/* ============================================================================= * M02_MasterTemplete_Hopyo_Choose.ts * 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행) * · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 · * 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록) * · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 * · [다른 항목 검색](736-1) = 찾아 넣기 창(`Find_Pick`) 하나 · 품명 첫 뜻 낱말 · 기초 = 자재 아닌 줄 · 재료 = 자재 · 재료비 있는 줄(358-2) * · 자재 줄(361-1) = 1위 / 전국(자리표 · 지역 고른 뒤 되돌림) / 지역 변형(접힘 · 하나 고르면 그 지역) / 그 밖 * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; 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 { getActiveLayer, getActiveOwner } from "./M02_MasterTemplete_Api_Fetch"; import { type FindKind, openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { formatNumber } from "../common_util/common_util_money"; import "./M02_MasterTemplete_Hopyo.css"; const MATERIAL = new Set(["자재", "별도계상"]); const FOLD_TEXT: Record = { "지역 변형": "하나 고르면 그 지역 · 「전국」으로 되돌림은 위", 숫자: "이름 · 출처 안 닮음 · 숫자 지문만", }; const PRE_TIP = "미리 고름 — 엑셀 인용 조항 = 1위 기초 조항 · 누르기는 사용자"; const note = (text: string): HTMLElement => el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } }); const num = formatNumber; /** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제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, unread: string[], from: Record = {}, ): string => [ Object.entries(values) .map( ([k, v]) => `${k}=${formatNumber(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): 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, material = false, ): CandGroup[] { const all = m.후보 ?? []; const seen = new Set(); 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.filter((c) => c.전국)) }, { 제목: "지역 변형", 접힘: true, 후보: take(all.filter((c) => c.변형)) }, { 제목: "그 밖 후보", 접힘: 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 { const count = new Map(); 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 => `${targetText(c)} · ${c.로직 ?? c.자재 ?? "키 없음"}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`; /** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */ export const canPickMaterial = (line: Pick): boolean => MATERIAL.has(line.갈래) || !!line.단가?.재료비; /** 736-2 [다른 항목 검색…] 탭 — 줄 갈래대로(서버 이음 칸 = 노임 LB · 기계 EQ · 자재 · 로직) */ export const kindsOf = (line: Pick): FindKind[] => line.갈래 === "노임" ? ["labor"] : line.갈래 === "기계" ? ["machine"] : [ ...(MATERIAL.has(line.갈래) ? [] : (["logic"] as const)), ...(canPickMaterial(line) ? (["material"] as const) : []), ]; /** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] => picked.map(({ c, 몫 }) => ({ ...(c.로직 ? { 로직: c.로직 } : c.전국 ? { 전국: true } : { 자재: 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(); 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); // 739-1 내 제안 — 맨 위 · 한 번 눌러 짝 통째로(입력값 · 배 · 몫 그대로) const mine = line.맞춤.제안?.짝.length ? line.맞춤.제안 : null; const mineRow = mine ? el("div", { attrs: { "data-group": "내 제안" }, children: [ note("내 제안 — 승인 전(M01 별칭 장부 승인 뒤 자동)"), el("div", { className: "m02-hopyo__cand", attrs: { "data-cand": "내 제안" }, children: [ createButton({ label: mine.대상.map((c) => candText(c, new Set())).join(" + "), variant: "ghost", size: "sm", onClick: () => finish( mine.짝.map((p) => ({ ...(p.로직 ? { 로직: p.로직 } : { 자재: p.자재 }), 입력값: p.입력값 ?? {}, 배: p.배, 몫: p.몫 ?? null, })), ), }), el("span", { className: "m02-hopyo__state is-별칭", text: "내 제안" }), ], }), ], }) : null; 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.자재 ?? (c.전국 ? "전국" : ""), ...(c.미리 ? { "data-pre": "" } : {}), }, children: [ ...(c.미리 ? [createBadge(PRE_TIP, "accent", { kind: "dot" })] : []), 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.글 ? [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} — ${FOLD_TEXT[g.제목] ?? FOLD_TEXT.숫자}` }), list, ], }); return fold; }); const excel = qtyText(line.맞춤.품?.엑셀); const basis = line.근거; const head = el("div", { className: "ui-toolbar 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.행), }), ] : []), ], }); // 736-1 · 736-2 찾아 넣기 창 하나 — 줄 갈래대로 탭(노임 = 인력 · 기계 = 기계 · 자재 = 재료 · 그 밖 = 기초 로직 · 재료비 있는 줄은 재료도) const kinds = kindsOf(line); const find = createButton({ label: "다른 항목 검색…", size: "sm", onClick: () => { close(); openFindPicker({ layer: getActiveLayer(), owner: getActiveOwner(), kinds, query: searchHint(line.갈래 === "기계" ? { 이름: line.규격 || line.이름 } : line), // 기계 = 규격 낱말(굴착기) logic: (x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), material: (e) => opt.done([{ 자재: e.코드, 배: 1 }]), element: (e) => opt.done([{ 노임: e.코드, 배: 1 }]), machine: (e) => opt.done([{ 기계: e.코드, 배: 1 }]), }); }, }); const more = kinds.length ? [find] : []; body.append( ...(head.childElementCount ? [head] : []), note( groups.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]" : "가까운 것 없음 — 아래 검색", ), ...(mineRow ? [mineRow] : []), ...sections, el("div", { className: "ui-toolbar m02-hopyo__bar", children: [together, ...more] }), ); }, }); }