diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 7ab0ce9b4..2c940a2f4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -360,3 +360,11 @@ .m02-hopyo-side > .m02-side__row { margin-top: var(--spacing-12); } + +/* 고름 대기 후보 줄 비교(746-2) — 고르기 칸 아래 · 후보 단추 줄 + 표 */ +.m02-hopyo__cmp { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin-top: var(--spacing-4); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 187f3c533..447a30973 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -234,8 +234,43 @@ export interface SangeunCand { 글: string; /** 729-4 — 기준단위 ↔ 엑셀 단위 성격 다름 = 환산 계수 필요(가 1 = 나 몇) */ 계수?: { 가: string; 나: string; 갈래: string } | null; + /** 746-1 — 그 기초로 견준 줄 비교(앞 셋) · null = 견줌 못함(단위 성격 다름) */ + 줄비교?: SangeunCompare | null; } +/** 줄 비교 꼴(746-1) */ +export type CompareLook = "같음" | "품다름" | "엑셀만" | "기초만"; + +/** 고름 대기 후보 줄 비교(746-1) — 줄마다 엑셀 · 기초 1 단위 품 */ +export interface SangeunCompare { + 줄: { + 갈래: "인력" | "장비" | "자재"; + 이름: string; + 규격?: string; + 단위?: string; + 엑셀: number | null; + 기초: number | null; + 꼴: CompareLook; + }[]; + 수: Record; + 못옮김: SangeunLost[]; + 환산?: unknown; +} + +/** M01 요소 후보(746-1) — 못옮김 · 안 이은 기계 줄 · 고르면 `{노임|기계|자재: 키}` 로 이음 */ +export interface ElemCand { + 키: string; + 갈래: "노임" | "기계" | "자재"; + 이름: string; + 규격?: string; + 단위?: string; + 단가?: number | null; +} + +/** 요소 후보인가(기초 후보 `로직` 과 가름) */ +export const isElem = (c: unknown): c is ElemCand => + typeof c === "object" && c !== null && "키" in c && "갈래" in c; + /** 안 이은 줄(729-2) — 만들면 남을 엑셀 줄 · 단가산출 줄 후보 = 고름 대기 기초 후보 */ export interface HopyoLeft { 차례: number; @@ -245,7 +280,7 @@ export interface HopyoLeft { 수량: number | null; 갈래: string; 까닭: string; - 후보: SangeunCand[]; + 후보: (SangeunCand | ElemCand)[]; /** 729-4 — 못옮김 남은 SK 를 쓰는 줄 = 그 블록 · SK · 못옮김(잇기 = [단가산출 만들기]) */ 단산?: number; SK?: string; @@ -261,7 +296,7 @@ export interface SangeunLost { 수량?: number | null; 갈래: "자재" | "기계" | "노임"; 글?: string; - 후보?: unknown[]; + 후보?: ElemCand[]; } /** 못옮김 잇기 한 줄(729-4) — 이음 키(LB · EQ · MT) 또는 뺌 까닭 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts new file mode 100644 index 000000000..80426135e --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Compare.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Compare.ts + * 불러오기 고름 창 — 후보 블록 줄 비교 · M01 요소 후보(PLAN 746-2 · 요구 585 · 서버 746-1 `줄비교` · `후보`) — + * · `compareView` = 고름 대기 후보(앞 셋)마다 줄 비교 표(줄 · 엑셀 품 · 기초 품 · 꼴 배지) + 못옮김 수 · 후보 바꿈 단추 + * · `elemChoices` = 못옮김 · 안 이은 기계 줄의 M01 요소 후보 셋 단추(누르면 부르는 쪽이 이음) + * 보이기만 — 판정 · 순위 · 미리 고름은 서버 그대로 · 고르기는 부르는 쪽. + * ========================================================================== */ + +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createButton, el } from "@ui/ui_template_elements"; +import { t as L, type LocaleKey } from "@ui/ui_template_locale"; +import { createSegment } from "@ui/ui_template_segment"; +import { createTable } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import type { + CompareLook, + ElemCand, + SangeunCand, + SangeunCompare, +} from "./M02_MasterTemplete_Hopyo_Api"; + +const LOOK: Record = { + 같음: { tone: "success", key: "M02_CmpSame" }, + 품다름: { tone: "warning", key: "M02_CmpDiff" }, + 엑셀만: { tone: "info", key: "M02_CmpExcel" }, + 기초만: { tone: "neutral", key: "M02_CmpBase" }, +}; +const LOOKS = Object.keys(LOOK) as CompareLook[]; +const num = (n: number | null): string => (n === null ? "—" : String(Number(n.toPrecision(4)))); + +/** 선택 칸 글 뒤 요약 — 「같음 3 · 품 다름 1」 · 견줌 못함 = "" */ +export const compareBrief = (c: SangeunCompare | null | undefined): string => + c + ? LOOKS.filter((k) => c.수[k]) + .map((k) => `${L(LOOK[k].key)} ${c.수[k]}`) + .join(" · ") + : ""; + +/** 줄 비교 표 하나 */ +function compareTable(c: SangeunCompare): HTMLElement { + const rows = c.줄.map((x) => [ + `${x.갈래} ${x.이름}${x.규격 ? ` ${x.규격}` : ""}`, + `${num(x.엑셀)} ${x.단위 ?? ""}`.trim(), + `${num(x.기초)} ${x.단위 ?? ""}`.trim(), + createBadge(L(LOOK[x.꼴].key), LOOK[x.꼴].tone), + ]); + return createTable( + [ + { label: L("M02_SangeunColName") }, + { label: L("M02_CmpColExcel"), width: "112px", align: "end" }, + { label: L("M02_CmpColBase"), width: "112px", align: "end" }, + { label: L("M02_CmpColLook"), width: "84px" }, + ], + rows, + { density: "data", empty: L("M02_CmpNone") }, + ); +} + +/** + * 고름 대기 후보 줄 비교 칸 — 앞 후보 줄 비교를 후보 단추로 바꿔 봄 · `show(로직)` 으로 밖(고르기 칸)이 바꿈. + * 줄 비교 있는 후보가 하나도 없으면 null. + */ +export function compareView( + cands: SangeunCand[], +): { root: HTMLElement; show: (로직: string) => void } | null { + const has = cands.filter((c) => c.줄비교); + if (!has.length) return null; + const root = el("div", { className: "m02-hopyo__cmp", attrs: { "data-cmp": "" } }); + const body = el("div"); + const seg = createSegment({ + options: has.map((c, i) => ({ value: c.로직, label: `${i + 1}. ${c.이름}`, title: c.이름 })), + value: has[0]!.로직, + size: 24, + ariaLabel: L("M02_CmpTitle").replace("{name}", ""), + onChange: (v) => paint(v), + }); + const paint = (로직: string): void => { + const c = has.find((x) => x.로직 === 로직) ?? has[0]!; + const cmp = c.줄비교!; + body.dataset.cmpOf = c.로직; + const lost = cmp.못옮김.length; + body.replaceChildren( + createText( + [ + L("M02_CmpTitle").replace("{name}", c.이름), + compareBrief(cmp), + lost ? L("M02_CmpLost").replace("{n}", String(lost)) : "", + ] + .filter(Boolean) + .join(" · "), + "muted", + 12, + ), + compareTable(cmp), + ); + }; + root.append(seg.root, body); + paint(has[0]!.로직); + return { + root, + show: (로직) => { + if (!has.some((c) => c.로직 === 로직)) return; + seg.set(로직); + paint(로직); + }, + }; +} + +/** 요소 후보 단추 줄(셋까지) — 누르면 `pick(후보)` */ +export function elemChoices(cands: ElemCand[], pick: (c: ElemCand) => void): HTMLElement | null { + if (!cands.length) return null; + const box = el("div", { className: "ui-toolbar", attrs: { "data-elem": "" } }); + box.title = L("M02_ElemTitle"); + for (const c of cands.slice(0, 3)) { + const price = c.단가 ? ` · ${c.단가.toLocaleString()}` : ""; + const b = createButton({ + label: `${c.이름}${c.규격 ? ` ${c.규격}` : ""} [${c.단위 ?? ""}]${price}`, + size: 24, + variant: "ghost", + onClick: () => pick(c), + }); + b.dataset.elemKey = c.키; + b.title = `${c.키} · ${c.갈래}`; + box.append(b); + } + return box; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts index a64e37f28..b5a8ed811 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts @@ -31,8 +31,11 @@ import { type HopyoPart, type HopyoScope, type LinkBase, + type SangeunCand, + isElem, } from "./M02_MasterTemplete_Hopyo_Api"; import { bodyOf, draftKey, loadDraft, saveDraft, toTarget } from "./M02_MasterTemplete_Hopyo_Check"; +import { elemChoices } from "./M02_MasterTemplete_Hopyo_Compare"; import { kindsOf, openHopyoChoose } from "./M02_MasterTemplete_Hopyo_Choose"; import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { openLost } from "./M02_MasterTemplete_Hopyo_Lost"; @@ -187,10 +190,11 @@ export function openLeft( const actions = (no: number, row: HopyoLeft, line: HopyoLine | undefined): HTMLElement => { const box = el("div", { className: "ui-toolbar" }); + const bases = row.후보.filter((c): c is SangeunCand => !isElem(c)); const block = (line as { 참조?: { 종류?: string; 번?: number } } | undefined)?.참조; - if (row.후보.length && block?.종류 === "단산" && block.번 !== undefined) { + if (bases.length && block?.종류 === "단산" && block.번 !== undefined) { const pick = createSelectField({ - options: row.후보.map((c) => ({ + options: bases.map((c) => ({ value: c.로직, text: `${c.이름} [${c.단위}] · ${c.판정}${c.글 && c.글 !== c.판정 ? ` · ${c.글}` : ""}`, })), @@ -217,6 +221,8 @@ export function openLeft( lost.dataset.leftLost = String(row.단산); box.append(lost); } + const elems = elemChoices(row.후보.filter(isElem), (c) => line && link(no, row.차례, { [c.갈래]: c.키 }, c.이름)); // prettier-ignore + if (elems) box.append(elems); if (row.갈래 === "%" && line) { const base = createSelectField({ options: [{ value: "", text: L("M02_LeftBase") }].concat( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Lost.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Lost.ts index a02849984..620e3b6de 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Lost.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Lost.ts @@ -4,6 +4,7 @@ * 바로 못 옮긴 엑셀 줄(노임 · 기계 · 자재)을 줄마다 M01 요소로 이음 → SK 더한줄(엑셀 수량) · [뺌] = 놓음(까닭 남김). * · 고름 = 캐시 `m02-hopyo-lost:{층}:{주인}:{키}:{번}` `{id: 잇기}` — 서버는 부를 때마다 그 블록 잇기 전부로 다시 셈 * (앞 잇기 안 남음) → [잇기 만들기] 는 캐시 모두를 보냄 · 바탕 = 그 SK 문서 바탕(로직 · 입력값 · 환산 그대로) + * · 줄마다 M01 요소 후보 셋 단추(746-2 · 서버 `후보`) — 누르면 그 줄에 이음 고름(찾기와 같은 길) * · 남은 못옮김 = 서버 답 그대로 표 · 0 이면 그 SK 를 쓰는 일위대가 줄이 이어짐(남으면 만들기 409) * 계산은 서버 — 화면은 고름 · 보이기만 · 저장은 [잇기 만들기] 누를 때만. * ========================================================================== */ @@ -22,6 +23,7 @@ import { type SangeunLink, type SangeunLost, } from "./M02_MasterTemplete_Hopyo_Api"; +import { elemChoices } from "./M02_MasterTemplete_Hopyo_Compare"; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const NONE = "우리에 없음"; @@ -150,6 +152,7 @@ export function openLost(opt: LostOptions): void { x.규격 ?? "", `${x.수량 ?? ""} ${x.단위 ?? ""}`.trim(), x.글 ?? "", + elemChoices(x.후보 ?? [], (c) => put(x.id, { id: x.id, [kind]: c.키 })) ?? "", linkText(all[x.id]), el("div", { className: "ui-toolbar", children: [find, drop, ...(all[x.id] ? [clear] : [])] }), // prettier-ignore ]; @@ -162,6 +165,7 @@ export function openLost(opt: LostOptions): void { { label: L("M02_LeftSpec") }, { label: L("M02_SangeunColQty"), width: "90px" }, { label: L("M02_LeftWhy") }, + { label: L("M02_ElemTitle") }, { label: L("M02_LeftPicked") }, { label: L("M02_LeftPick"), width: "220px" }, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts index 76d8672ff..5bdf80fcf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts @@ -5,6 +5,7 @@ * · 만든 줄 표 = 블록 · 이름 · 키 · 갈래(기초 · SK 차이 있음) · 이미 만든 블록은 「그대로」 수만 * · 고름 대기 표 = 블록마다 후보(판정 · 글) 고르기 칸 → [고른 기초로 만들기](바탕 = 고른 기초 · 엑셀 품 견줘 SK) · * 단위 성격 다른 후보(729-4 `계수`) = 계수 칸(가 1 = 나 몇) 함께 → `환산 {계수}` + * 후보마다 줄 비교 표(746-2 · `Hopyo_Compare` · 서버 `줄비교`) — 고르기 칸 아래 · 후보 단추로 바꿔 봄 * · 못옮김(729-4) = 만든 줄마다 수 · [잇기] = 못옮김 잇기 창(`Hopyo_Lost`) · 만든 줄은 이 칸이 모아 둠(누를 때마다 더함) · * 이미 만든 SK 의 남은 못옮김(729-5 `남은못옮김`)도 같은 표 * · 같은 내용(733-1) = 갈래 칸 꼬리표 「같은 것 씀」(이 층 같은 SK 를 씀) · 「위 층에 같은 것 — 연결 권함」(회사 · 마스터 · 알림만) · @@ -31,6 +32,7 @@ import { type SangeunBuilt, type SangeunPick, } from "./M02_MasterTemplete_Hopyo_Api"; +import { compareBrief, compareView } from "./M02_MasterTemplete_Hopyo_Compare"; import { openLost } from "./M02_MasterTemplete_Hopyo_Lost"; type Made = SangeunBuilt["만듦"][number]; @@ -154,12 +156,15 @@ export function sangeunStep(scope: HopyoScope, key: string): { root: HTMLElement { label: L("M02_SangeunColName") }, { label: L("M02_SangeunColUnit"), width: "64px" }, { label: L("M02_LeftWhy") }, - { label: L("M02_LeftBase"), width: "40%" }, + { label: L("M02_LeftBase"), width: "62%" }, ], got.고름대기.map((x) => { const pick = createSelectField({ options: [{ value: "", text: L("M02_WizSkNoPick") }].concat( - x.후보.map((c) => ({ value: c.로직, text: candLabel(c) })), + x.후보.map((c) => ({ + value: c.로직, + text: candLabel(c) + (c.줄비교 ? ` · ${compareBrief(c.줄비교)}` : ""), + })), ), compact: true, size: 24, @@ -175,10 +180,14 @@ export function sangeunStep(scope: HopyoScope, key: string): { root: HTMLElement coef.root.hidden = !c; coef.input.title = c ? `${c.가} 1 = ${c.나} ?` : ""; }; - pick.select.addEventListener("change", need); + const cmp = compareView(x.후보); + pick.select.addEventListener("change", () => { + need(); + cmp?.show(pick.select.value); + }); need(); coefs.set(x.번, coef.input); - return [`단산${x.번}`, x.이름, x.단위 ?? "", x.까닭, el("div", { className: "ui-toolbar", children: [pick.root, coef.root] })]; // prettier-ignore + return [`단산${x.번}`, x.이름, x.단위 ?? "", x.까닭, el("div", { children: [el("div", { className: "ui-toolbar", children: [pick.root, coef.root] }), ...(cmp ? [cmp.root] : [])] })]; // prettier-ignore }), { density: "data", empty: L("M02_WizSkNoWait") }, ); diff --git a/resources/tester/test_746_2_hopyo_compare_screen.py b/resources/tester/test_746_2_hopyo_compare_screen.py new file mode 100644 index 000000000..b3ce060ca --- /dev/null +++ b/resources/tester/test_746_2_hopyo_compare_screen.py @@ -0,0 +1,48 @@ +"""746-2 지킴 — 불러오기 고르기 창 후보 블록 줄 비교 · 요소 후보 화면(요구 585). + +서버 없이 소스로 잡음: 고름 대기 칸이 줄 비교 표를 붙임 · 못옮김 창 · 안 이은 줄 창이 요소 후보 단추를 붙임 · +안 이은 줄 창의 기초 후보 칸에 요소 후보(`키`)가 섞여 들어가지 않음 · 서버 응답 칸 이름 · 부품 상자만 · 700 줄. +""" + +import re +from pathlib import Path + +M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete" + + +def _src(name: str) -> str: + return (M02 / f"M02_MasterTemplete_Hopyo_{name}.ts").read_text(encoding="utf-8") + + +def test_고름_대기_칸이_줄_비교_표를_붙임(): + sk = _src("WizardSk") + assert "compareView(x.후보)" in sk and "cmp?.show(pick.select.value)" in sk + assert "compareBrief(c.줄비교)" in sk # 선택 칸 글에 같음 · 품다름 수 + + +def test_못옮김_창_안_이은_줄_창이_요소_후보_단추를_붙임(): + assert "elemChoices(x.후보" in _src("Lost") + left = _src("Left") + assert "elemChoices(row.후보.filter(isElem)" in left + assert ( + "row.후보.filter((c): c is SangeunCand => !isElem(c))" in left + ) # 기초 후보 칸에는 요소 후보 안 듦 + + +def test_서버_응답_칸_이름_그대로(): + api = _src("Api") + for name in ( + "줄비교?: SangeunCompare | null", + "엑셀: number | null", + "기초: number | null", + "못옮김: SangeunLost[]", + ): + assert name in api + assert 'CompareLook = "같음" | "품다름" | "엑셀만" | "기초만"' in api + + +def test_부품_상자만_날_요소_없음_700줄_안(): + src = _src("Compare") + assert not re.search(r'createElement\("(button|input)"|