- 맞춤: 줄 글 지역 = 그 지역 변형(꼬리표 「지역 — 줄 글」) · 지역 없는 변형((전국) 품목 먼저) · 없으면(레미콘) 「전국」 자리표(바탕 이름(전국)-관급/사급 · 규격 · 단위 · 키 · 단가 없음 · 꼬리표 「전국 — 지역은 프로젝트에서」) · 옛 「못 가림 — N개 중 첫째」 없앰
- 후보 = 그 품목 변형 모두(`변형`) · 고르기 창 1위 / 전국 / 지역 변형(접힘) · 대상 꼴 `{전국: true}`
- 미리 셈 · 만들기: 전국 대상은 서버가 줄에서 다시 셈 · 일위대가 자재 칸이 M01 키만 받아 사전조합 `본뜬.전국자재` 에 적고 빠진 줄 까닭 · 장부엔 안 남김 · 만들기 몸 꼴에 `전국` 칸
- 시험: 전국 · 줄 글 지역 · (전국) 품목 · 고르기 지역 → 전국 되돌림 · 만들기 결과 · 화면 도우미
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* M02_MasterTemplete_Hopyo_Choose.ts
|
||
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
|
||
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행)
|
||
* · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 ·
|
||
* 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록)
|
||
* · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
|
||
* · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2)
|
||
* · 자재 줄(361-1) = 1위 / 전국(자리표 · 지역 고른 뒤 되돌림) / 지역 변형(접힘 · 하나 고르면 그 지역) / 그 밖
|
||
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
|
||
* ========================================================================== */
|
||
|
||
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 FOLD_TEXT: Record<string, string> = {
|
||
"지역 변형": "하나 고르면 그 지역 · 「전국」으로 되돌림은 위",
|
||
숫자: "이름 · 출처 안 닮음 · 숫자 지문만",
|
||
};
|
||
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.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<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.전국 ? { 전국: 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<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.자재 ?? (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} — ${FOLD_TEXT[g.제목] ?? FOLD_TEXT.숫자}` }),
|
||
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] }),
|
||
);
|
||
},
|
||
});
|
||
}
|