From dda5f6233f5fb3f6731c4debb2f7d0cd62a1b268 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:49:43 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(M02):=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=C2=B7=20=EC=83=81=EC=84=B8=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=20=C2=B7=20=EC=97=B0=EA=B2=B0=20=ED=99=94=EB=A9=B4(51?= =?UTF-8?q?-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서고 목록표에 구분 · 상세구분 칸(키 뒤) - 단가승격 상세 정보 칸 = 구분 · 상세구분 · 연결(원 로직 키 · 출처) 읽기만 - 사전조합 상세 정보 칸 = 구분 · 상세구분 고르기(로직 구분 목록 logic-lib/subs · 구분 바꾸면 상세구분 = 그 첫 줄) - 새 사전조합 처음 값 = 로직 구분 목록 첫 줄 - 로직 고르기 창 구분 · 상세구분 거름에 승격 · 사전조합 줄도 걸림 - 화면 시나리오 51-12 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_Labor_Api.ts | 3 + .../M02_MasterTemplete_Labor_Detail.ts | 80 +++++++++- .../M02_MasterTemplete_Labor_Pick.ts | 12 +- .../M02_MasterTemplete_Labor_Text.ts | 2 + .../M02_MasterTemplete_Library_List.ts | 53 +++++-- resources/tester/ui_checks/51-12_서고_구분.js | 142 ++++++++++++++++++ 6 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 resources/tester/ui_checks/51-12_서고_구분.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index ebf4db18c..d4c4bf6c6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -233,6 +233,9 @@ export interface LibraryRow { 이름: string; 제목?: string | null; 단위?: string | null; + /** 51-12 — 승격 = 원 로직 것 · 사전조합 = 고른 것 */ + 구분?: string | null; + 상세구분?: string | null; } /** 서고 목록 — 그 층(주인) 단가승격 `promote` · 사전조합 `precombo` 문서 */ export const fetchLibrary = async ( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index d0bbc892b..e38ba6874 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -9,15 +9,23 @@ * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · + * 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) · * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) * ========================================================================== */ -import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import type { SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; import { @@ -33,6 +41,7 @@ import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, calcLabor, + fetchLibLogicSubs, fetchLogicInputs, normalizeLabor, type LaborCalc, @@ -457,6 +466,69 @@ export function mountLaborDetail( opt.onChanged(dirty()); }); + /** 서고 구분 · 상세구분(51-12) — 승격 = 원 로직 것 읽기만 + 연결(원 로직 키 · 출처) · + * 사전조합 = 로직 구분 목록(logic-lib/subs)에서 고름(구분 바꾸면 상세구분 = 그 첫 줄) */ + const meta = cur as { 구분?: string | null; 상세구분?: string | null; 연결?: unknown }; + const readField = (label: string, field: string, value: string): HTMLElement => { + const f = createInputField({ type: "text", label }); + f.root.dataset.laborField = field; + f.input.value = value; + f.input.readOnly = true; + return f.root; + }; + const libMeta = (): HTMLElement[] => { + if (promote) { + const link = (meta.연결 ?? {}) as { 로직?: string; 키?: string; 출처?: string }; + return [ + readField(tc("Sub"), "구분", meta.구분 ?? ""), + readField(tc("Detail"), "상세구분", meta.상세구분 ?? ""), + readField( + tc("Lib_Link"), + "연결", + [link.로직 ?? link.키, link.출처].filter(Boolean).join(" · "), + ), + ]; + } + const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit }); + const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit }); + sub.root.dataset.laborField = "구분"; + detail.root.dataset.laborField = "상세구분"; + let subs: SubBrief[] = []; + // 목록에 없는 지금 값도 선택지로 둠 + const opts = (names: string[], now: string | null | undefined) => + [...new Set([...(now ? [now] : []), ...names])].map((v) => ({ value: v, text: v })); + const fill = (): void => { + sub.setOptions( + opts( + subs.map((s) => s.name), + meta.구분, + ), + meta.구분 ?? "", + ); + const details = subs.find((s) => s.name === meta.구분)?.details ?? []; + detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? ""); + }; + sub.select.addEventListener("change", () => { + meta.구분 = sub.select.value; + meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null; + fill(); + opt.onChanged(dirty()); + }); + detail.select.addEventListener("change", () => { + meta.상세구분 = detail.select.value; + opt.onChanged(dirty()); + }); + fill(); + void fetchLibLogicSubs().then( + (got) => { + subs = got; + fill(); + }, + () => undefined, + ); + return [sub.root, detail.root]; + }; + const editor = (body: HTMLElement): HTMLElement => side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body; host.replaceChildren( @@ -473,7 +545,11 @@ export function mountLaborDetail( children: [ el("div", { className: "m01lab__info", - children: [...(lib ? [nameField.root] : []), unit.root, origin.root], + children: [ + ...(lib ? [nameField.root, ...libMeta()] : []), + unit.root, + origin.root, + ], }), ], }), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 445b48d45..af891ca02 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -82,9 +82,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra (!detail.select.value || x.상세구분 === detail.select.value) && (!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)), ); - const libFound = sub.select.value - ? [] - : libRows.filter((x) => !q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)); + // 서고 줄도 구분 · 상세구분으로 거름(51-12) + const libFound = libRows.filter( + (x) => + (!sub.select.value || x.row.구분 === sub.select.value) && + (!detail.select.value || x.row.상세구분 === detail.select.value) && + (!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)), + ); const total = all.length + libRows.length; const shown = found.length + libFound.length; finder.hint.textContent = loading @@ -110,7 +114,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra key: x.row.이름, tag: tc(x.kind), name: x.row.제목 || x.row.이름, - sub: x.row.이름, + sub: `${x.row.이름} · ${x.row.구분 ?? ""} ${x.row.상세구분 ?? ""}`.trim(), unit: x.row.단위 ?? "", onPick: () => { close(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index c7be730db..9ac8e5424 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -124,6 +124,8 @@ const TEXT = { Lib_Unit: ["단위", "Unit"], Lib_Base: ["기준 금액", "Base amount"], Lib_Date: ["고친 날", "Modified"], + // 서고 구분 · 연결(51-12) + Lib_Link: ["연결", "Linked logic"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 3a183094e..4e729405d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Library_List.ts * 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸. - * 표 = 고름 · 키 · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세. + * 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세. * 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로). * 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import. * ========================================================================== */ @@ -30,12 +30,19 @@ import { type LibraryBody, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchLibLogicSubs } from "./M02_MasterTemplete_Labor_Api"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo"; /** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */ -type Row = TemplateInfo & { 제목?: string | null; 단위?: string | null; 기준금액?: number | null }; +type Row = TemplateInfo & { + 제목?: string | null; + 단위?: string | null; + 기준금액?: number | null; + 구분?: string | null; + 상세구분?: string | null; +}; export interface LibraryListOptions { layer: Layer; @@ -99,9 +106,15 @@ export function mountLibraryList( attrs: { "data-lib-key": row.이름 }, children: [ pick, - ...[row.이름, row.제목 ?? "", row.단위 ?? "", money(row.기준금액), row.수정일].map( - (v) => el("td", { text: v, attrs: { title: v } }), - ), + ...[ + row.이름, + row.구분 ?? "", + row.상세구분 ?? "", + row.제목 ?? "", + row.단위 ?? "", + money(row.기준금액), + row.수정일, + ].map((v) => el("td", { text: v, attrs: { title: v } })), ], }); tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row))); @@ -109,7 +122,7 @@ export function mountLibraryList( }) : [ el("tr", { - children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "6" } })], + children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "8" } })], }), ]), ); @@ -164,7 +177,8 @@ export function mountLibraryList( const create = async (body: LibraryBody, label: string): Promise => made((await newLibrary(opt.layer, opt.kind, body)).이름, label); - /** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것 · 사전조합 = 이름만 · 빈 묶음 */ + /** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위 · 구분 · 연결은 로직 것(서버가 채움) · + * 사전조합 = 이름 + 구분 · 상세구분 처음 값 = 로직 구분 목록 첫 줄(51-12 · 상세에서 바꿈) · 빈 묶음 */ btnNew.addEventListener("click", () => { if (opt.kind === "promote") return openLogicPicker((x) => { @@ -177,9 +191,17 @@ export function mountLibraryList( showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"), ); }); - nameDialog(L("M02_NewTitle"), "", (name) => - create({ 문서: { 이름: name, 단위: null, 담은로직: [] } }, name), - ); + nameDialog(L("M02_NewTitle"), "", async (name) => { + const first = (await fetchLibLogicSubs())[0]; + const 문서 = { + 이름: name, + 구분: first?.name ?? null, + 상세구분: first?.details[0] ?? null, + 단위: null, + 담은로직: [], + }; + await create({ 문서 }, name); + }); }); /** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */ @@ -221,7 +243,16 @@ export function mountLibraryList( page = 1; draw(); }); - const heads = ["", "Lib_Key", "Lib_Name", "Lib_Unit", "Lib_Base", "Lib_Date"] as const; + const heads = [ + "", + "Lib_Key", + "Sub", + "Detail", + "Lib_Name", + "Lib_Unit", + "Lib_Base", + "Lib_Date", + ] as const; host.replaceChildren( buildHeadFilters([searchField.root, pager]), el("div", { diff --git a/resources/tester/ui_checks/51-12_서고_구분.js b/resources/tester/ui_checks/51-12_서고_구분.js new file mode 100644 index 000000000..87d16fb80 --- /dev/null +++ b/resources/tester/ui_checks/51-12_서고_구분.js @@ -0,0 +1,142 @@ +/* 51-12 — 서고(단가승격 · 사전조합) 구분 · 상세구분 · 연결 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — GET · calc 밖 요청은 가짜 400(새로 만들기 POST 본문만 잡음) · 마스터 SP-000001 · SC-000001 읽기만 + * 기대 수치: + * - heads = ["", "키", "구분", "상세구분", "이름", ...] · listSP = SP-000001 줄 구분 · 상세구분 빈 것 아님 + * - promote = { 구분, 상세구분, 연결 } 셋 다 글 있음 · readOnly = true + * - precombo = { selects: 2, 구분 = 문서 값 · subOptions ≥ 1 } · switched.상세구분 = 바꾼 구분의 첫 상세구분 · back = 처음 값 + * - created = { 구분: 목록 첫 구분, 상세구분: 그 첫 상세구분 } + * - picker = 구분(SP-000001 것) 고르면 SP-000001 줄 보임 · 다른 구분이면 안 보임 + */ +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 100) { + const v = fn(); + if (v) return v; + await wait(100); + } + return null; + }; + const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const real = window.fetch; + let posted = null; + window.fetch = async (input, init = {}) => { + const url = String(input?.url ?? input); + const method = (init.method ?? "GET").toUpperCase(); + if (method === "POST" && url.includes("/library/precombo")) { + posted = JSON.parse(init.body); + return json({ detail: "시험 — 쓰기 막음" }, 400); + } + if (method !== "GET" && !url.includes("/calc")) + return json({ detail: "시험 — 쓰기 막음" }, 400); + return real(input, init); + }; + const sec = [...document.querySelectorAll(".m02-side__lists > section")].find( + (s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() === "일위대가 조합", + ); + const items = [...sec.querySelectorAll("[data-precombo]")]; + const list = (kind) => document.querySelector(`[data-lib-list="${kind}"]`); + const edit = () => document.querySelector(".m02-main__host [data-labor]"); + const field = (name) => edit()?.querySelector(`[data-labor-field="${name}"]`); + const btn = (root, text) => + [...(root?.querySelectorAll("button") ?? [])].find((b) => b.textContent === text); + const subs = await ( + await real("/api/m02/logic-lib/subs", { credentials: "include" }) + ) + .json() + .then((d) => d.subs); + try { + // ① 승격 목록 · 상세 + items[0].click(); + const spRow = await until(() => list("promote")?.querySelector('[data-lib-key="SP-000001"]')); + const heads = [...list("promote").querySelectorAll("thead th")].map((th) => th.textContent); + const cells = [...spRow.querySelectorAll("td")].map((td) => td.textContent); + const listSP = { 구분: cells[2], 상세구분: cells[3] }; + spRow.querySelector("td:nth-child(5)").click(); + await until(() => field("연결")); + const val = (name) => field(name)?.querySelector("input")?.value ?? null; + const promote = { + 구분: val("구분"), + 상세구분: val("상세구분"), + 연결: val("연결"), + readOnly: ["구분", "상세구분", "연결"].every( + (n) => field(n)?.querySelector("input")?.readOnly, + ), + }; + // ② 사전조합 상세 — 고르기 · 바꿔 보고 되돌림 + items[1].click(); + ( + await until(() => + list("precombo")?.querySelector('[data-lib-key="SC-000001"] td:nth-child(5)'), + ) + ).click(); + const subSel = await until(() => field("구분")?.querySelector("select")); + await until(() => subSel.options.length > 1, 4000); + const detSel = field("상세구분").querySelector("select"); + const start = { 구분: subSel.value, 상세구분: detSel.value }; + const precombo = { + selects: edit().querySelectorAll(".m01lab__info select").length, + ...start, + subOptions: subSel.options.length, + }; + const other = subs.find((s) => s.name !== start.구분 && s.details.length); + subSel.value = other.name; + subSel.dispatchEvent(new Event("change")); + const switched = { + 구분: field("구분").querySelector("select").value, + 상세구분: field("상세구분").querySelector("select").value, + want: other.details[0], + }; + const s2 = field("구분").querySelector("select"); + s2.value = start.구분; + s2.dispatchEvent(new Event("change")); + const d2 = field("상세구분").querySelector("select"); + d2.value = start.상세구분; + d2.dispatchEvent(new Event("change")); + const back = { + 구분: field("구분").querySelector("select").value, + 상세구분: field("상세구분").querySelector("select").value, + }; + // ③ 고르기 창 — 구분 거름에 승격 줄 + btn(edit(), "로직 추가").click(); + const pick = await until(() => document.querySelector(".m01-logic__pick")); + await until(() => pick.querySelector('[data-pick="SP-000001"]'), 20000); + const [fSub, fDet] = pick.querySelectorAll(".ui-find__filters select"); + const pickerWith = async (s, d) => { + fSub.value = s; + fSub.dispatchEvent(new Event("change")); + if (d != null) { + fDet.value = d; + fDet.dispatchEvent(new Event("change")); + } + await wait(100); + return !!pick.querySelector('[data-pick="SP-000001"]'); + }; + const picker = { + same: await pickerWith(promote.구분, promote.상세구분), + other: await pickerWith(subs.find((s) => s.name !== promote.구분)?.name ?? "", null), + }; + pick.closest(".m01-logic__backdrop")?.remove(); + // ④ 새 사전조합 처음 값(POST 본문만 잡음) + items[1].click(); + await until(() => list("precombo")); + btn(document.querySelector(".m02-main__slot"), "생성").click(); + const box = await until(() => document.querySelector(".m02-modal")); + box.querySelector("input").value = "시험 51-12"; + btn(box, "생성").click(); + await until(() => posted); + btn(document.querySelector(".m02-modal"), "닫기")?.click(); + document.querySelector(".m02-modal")?.closest(".ui-modal__backdrop, .ui-modal")?.remove(); + const created = { + 구분: posted?.문서?.구분, + 상세구분: posted?.문서?.상세구분, + want: [subs[0].name, subs[0].details[0] ?? null], + }; + return JSON.stringify({ heads, listSP, promote, precombo, switched, back, picker, created }); + } finally { + window.fetch = real; + } +})();