diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts new file mode 100644 index 000000000..191848fd2 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -0,0 +1,325 @@ +/* ============================================================================= + * M02_MasterTemplete_Find_Pick.ts + * M02 찾아 넣기 창(PLAN 721-2 · 요구서_내역16 1장 6 · 7 · 2장 1 · 2) — 탭 [M01 전체 | M02 단가산출 | 일위대가] · + * 찾기 칸 하나 · 표 하나(품명 · 규격 · 단위 · 금액 · 구분 · 출처) · 옛 거름 값(종류 · 공종 · 구분 · 상세구분 · 층) = 「구분」 · 「출처」 열 + * · M01 전체 = 단가산출 로직(기준 목록 `/logic-lib/standard` · 줄 누름 = 계산서 보기 → [담기]) + + * 인력(값 있는 노임 직종) · 재료(자재품목) = `/logic-lib/pick` 서버 찾기(친 낱말 · 상한 200) · 기계 뺌 · 실무 호표 뺌 + * · M02 단가산출 · 일위대가 = 위 층(마스터 › 회사 › 개인) 서고 목록(`/layers/{층}/templates`) · 고름 = 참조 {층 · 주인 · 키} + * · 인력 · 재료 고름 = `element` · 일위대가 고정 줄 꼴 = `fixedOf`(705-1 이음 노임 LB · 자재 MT · 수량 1 은 부르는 쪽) · + * `element` 없으면 그 줄 회색 막힘(까닭 = 일위대가 표 개편 뒤 · 723) + * · 부르는 곳 = M02 단가산출 [기본 로직 선택](`logicOnly`) · [항목 추가](`Sangeun_Pick`) · 일위대가 [로직 추가] + * (`Labor_Pick` 이 `lib.sangeun` 이면 이 창) — B09 · 불러오기는 옛 창(`Labor_Pick`) + * ========================================================================== */ + +import { createDataTable } from "@ui/ui_template_data_table"; +import { createInputField, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; +import { openModal } from "@ui/ui_template_modal"; +import { createTabs } from "@ui/ui_template_tabs"; +import { createText } from "@ui/ui_template_text"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import type { LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchLibLogics, fetchStandard, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; +import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { ownOwner } from "./M02_MasterTemplete_Layer"; +import { listLayerDocs, SANGEUN_KIND, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 +import "./M02_MasterTemplete_Labor.css"; + +type Scope = Omit; +type Tab = "m01" | "sk" | "pre"; + +/** M01 인력 · 재료 한 줄(`/logic-lib/pick` · 코드 = LB- · MT- 키) */ +export interface M01Element { + 종류: "인력" | "재료"; + 코드: string; + 이름: string; + 규격: string; + 단위: string; + 값: number | null; +} + +export interface FindPickOptions { + layer: Layer; + owner: string | null; + title?: string; + /** M01 단가산출 로직 고름 */ + logic: (x: LogicSummary) => void; + /** M02 단가산출 · 일위대가 고름(참조) */ + ref?: (ref: LaborRef, name: string) => void; + /** M01 인력 · 재료 고름 — 없으면 그 줄 회색 막힘 */ + element?: (e: M01Element) => void; + /** 로직 줄만(탭 · 인력 · 재료 없음 — [기본 로직 선택]) */ + logicOnly?: boolean; +} + +/** 일위대가 고정 줄(705-1 이음 꼴) — 인력 = 노임 LB · 재료 = 자재 MT · 단가 = 지금 값(셈은 단가 책 값) */ +export function fixedOf(e: M01Element): LaborFixed { + const labor = e.종류 === "인력"; + return { + 이름: e.이름, + 규격: e.규격, + 단위: e.단위, + 단가: { 노무비: labor ? (e.값 ?? 0) : 0, 재료비: labor ? 0 : (e.값 ?? 0), 경비: 0 }, + 이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null }, + }; +} + +/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ +export async function upper(layer: Layer, owner: string | null): Promise { + if (layer === "project") return []; + const own = + layer === "system" + ? null + : (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null))); + const out: Scope[] = [{ 층: "system", 주인: null }]; + if (own) out.push({ 층: "company", 주인: own.split("/")[0] }); + if (own && layer === "personal") out.push({ 층: "personal", 주인: own }); + return out; +} + +const LAYER_TEXT: Record = { + system: "Lib_System", + company: "Lib_Company", + personal: "Lib_Personal", +}; +/** 노임 「원/일」 → 줄 단위 「인」 · 「원/시」 → 「시」 · 재료 단위 그대로 */ +const unitOf = (u: string): string => (u === "원/일" ? "인" : u.replace(/^원\//, "")); +const join = (...xs: Array): string => xs.filter(Boolean).join(" · "); + +interface PickItem { + ref: string; + 구분?: string | null; + 이름: string; + 규격?: string | null; + 단위?: string | null; + 값: unknown; +} +/** 표 한 줄 + 누름 */ +interface Line { + 품명: string; + 규격: string; + 단위: string; + 금액: number | null; + 구분: string; + 출처: string; + dim?: boolean; + go: () => void; +} + +export function openFindPicker(opt: FindPickOptions): void { + let tab: Tab = "m01"; + let close = (): void => {}; + let logics: LogicSummary[] = []; + let std: StandardRow[] | null = null; + let people: PickItem[] = []; + let goods: PickItem[] = []; + const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null }; + let shown: Line[] = []; + let seq = 0; + let timer = 0; + + const search = createInputField({ type: "search", placeholder: L("M02_FindSearch") }); + const hint = createText(""); + const table = createDataTable({ + caption: L("M02_FindTitle"), + columns: [ + { key: "품명", label: tc("Std_Name"), width: 200 }, + { key: "규격", label: tc("Std_Spec"), width: 110 }, + { key: "단위", label: tc("Std_Unit"), width: 44 }, + { key: "금액", label: tc("Std_Money"), kind: "number", width: 84 }, + { key: "구분", label: L("M02_FindClass"), width: 180 }, + { key: "출처", label: tc("Std_Source"), width: 120 }, + ], + rows: [], + rowClass: (i) => (shown[i]?.dim ? "is-dim" : null), + onRowClick: (i) => shown[i]?.go(), + }); + const take = (f: () => void) => (): void => { + close(); + f(); + }; + + const logicLines = (q: string): Line[] => + (std ?? logics.map(plain)).flatMap((x) => { + const hit = matchLogic(q, { ...x, 이름: `${x.이름} ${x.규격}` }); // 공용 · 208-1 + if (!hit.ok) return []; + const logic = logics.find((y) => y.키 === x.키); + return [ + { + 품명: join(x.이름, aliasNote(hit.alias)), + 규격: x.규격, + 단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`, + 금액: x.금액, + 구분: join(L("M02_FindLogic"), x.공종, x.구분, x.상세구분), + 출처: x.출처, + go: () => + logic && + openStandardSheet( + x, + take(() => opt.logic(logic)), + ), + }, + ]; + }); + const elementLines = (kind: M01Element["종류"], items: PickItem[]): Line[] => + items.map((m) => { + const e: M01Element = { + 종류: kind, + 코드: m.ref, + 이름: m.이름, + 규격: m.규격 ?? "", + 단위: unitOf(m.단위 ?? ""), + 값: Number.isFinite(Number(m.값)) && m.값 !== null ? Number(m.값) : null, + }; + const put = opt.element; + return { + 품명: m.이름, + 규격: e.규격, + 단위: e.단위, + 금액: e.값, + 구분: join(kind, m.구분, put ? "" : L("M02_FindElementLater")), + 출처: `M01 ${m.ref}`, + dim: !put, + go: put ? take(() => put(e)) : () => showToast(L("M02_FindElementLater"), "warning"), + }; + }); + const docLines = (rows: SangeunRow[], kind: string): Line[] => + rows.map((x) => ({ + 품명: x.제목 || x.이름, + 규격: "", + 단위: x.단위 ?? "", + 금액: x.기준금액, + 구분: join(kind, x.구분, x.상세구분), + 출처: LAYER_TEXT[x.ref.층] ? tc(LAYER_TEXT[x.ref.층]) : x.ref.층, + go: take(() => opt.ref?.(x.ref, x.제목 || x.이름)), + })); + + function draw(): void { + const q = search.input.value.trim(); + if (tab === "m01") { + shown = logicLines(q); + if (!opt.logicOnly) + shown.push(...elementLines("인력", people), ...elementLines("재료", goods)); + } else { + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const kind = tab === "sk" ? L("M02_PickSangeun") : L("M02_PrecomboTitle"); + shown = docLines(lists[tab] ?? [], kind).filter((x) => { + const text = join(x.품명, x.구분).toLowerCase(); + return words.every((w) => text.includes(w)); + }); + } + const loading = tab === "m01" ? !logics.length : !lists[tab]; + hint.textContent = loading ? tc("Loading") : shown.length ? `${shown.length}` : tc("Pick_None"); + table.setRows(shown.map(({ go: _go, dim: _dim, ...row }) => row)); + } + + /** 인력 · 재료 = 서버 찾기(친 낱말) — 재료는 낱말 있을 때만 · 늦게 온 답은 버림 */ + async function findElements(): Promise { + if (opt.logicOnly) return; + const mine = ++seq; + const q = search.input.value.trim(); + const get = (kind: "job" | "price"): Promise => + call<{ items: PickItem[] }>( + `/logic-lib/pick?kind=${kind}&q=${encodeURIComponent(q)}&limit=200`, + ).then((d) => d.items); + try { + const [p, g] = await Promise.all([get("job"), q ? get("price") : Promise.resolve([])]); + if (mine !== seq) return; + [people, goods] = [p, g]; + } catch (error) { + if (mine !== seq) return; + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + if (tab === "m01") draw(); + } + + search.input.addEventListener("input", () => { + draw(); + window.clearTimeout(timer); + timer = window.setTimeout(() => void findElements(), 250); + }); + + const tabs = opt.logicOnly + ? null + : createTabs({ + tabs: [ + { key: "m01", label: L("M02_FindM01") }, + { key: "sk", label: L("M02_PickSangeun") }, + { key: "pre", label: L("M02_PrecomboTitle") }, + ], + value: tab, + kind: "line", + onChange: (key) => { + tab = key as Tab; + draw(); + }, + }); + + ({ close } = openModal({ + title: opt.title ?? L("M02_FindTitle"), + closeLabel: tc("Pick_Close"), + dialogClass: "m01-logic__pick m01-combo", + width: "960px", // 표 여섯 칸 + backdropClass: "m01-logic__backdrop", + mount: (body) => + body.append( + el("div", { + className: "m02-std-pick", + attrs: { "data-find-pick": "" }, + children: [...(tabs ? [tabs.root] : []), search.root, hint, table.root], + }), + ), + })); + search.input.focus(); + draw(); + + const fail = (error: unknown): void => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + void Promise.all([fetchLibLogics(), fetchStandard().catch(() => null)]) + .then(([l, s]) => { + logics = l; + std = s?.줄.length ? s.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로 + draw(); + }) + .catch(fail); + void findElements(); + if (tabs) + void upper(opt.layer, opt.owner) + .then((scopes) => + Promise.all( + (["sk", "pre"] as const).map((t) => + listLayerDocs(scopes, t === "sk" ? SANGEUN_KIND : "precombo").then((rows) => { + lists[t] = rows; + tabs.setBadge(t, rows.length); + }), + ), + ), + ) + .catch((error) => { + lists.sk ??= []; + lists.pre ??= []; + fail(error); + }) + .finally(draw); +} + +/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ +const plain = (x: LogicSummary): StandardRow => ({ + 키: x.키, + 이름: x.이름, + 규격: "", + 단위: x.결과단위.replace(/^원\/?/, ""), + 금액: null, + 출처: x.출처 ?? "", + 공종: "", + 별칭: x.별칭 ?? "", + 구분: x.구분, + 상세구분: x.상세구분, + 막힘: x.blocked, + 바꿈: {}, +}); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 36381ec7c..3e30c7c38 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -9,8 +9,9 @@ * 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 · * 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 · * 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`) - * 717-4 `lib.sangeun` = 탭 [기준 목록 | 단가산출] · 단가산출 = 위 층 SK(마스터 › 회사 › 개인 · `Sangeun_Pick`)를 - * 같은 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = `lib.pick` 참조(SK 키) + * 721-2 `lib.sangeun`(M02 일위대가 [로직 추가] · `withHopyo`) = 찾아 넣기 창(`Find_Pick` · 탭 M01 전체 · 단가산출 · + * 일위대가 · 실무 호표 줄 없음) · M01 인력 · 재료 = `lib.fixed`(일위대가 고정 줄 · 없으면 회색 막힘) · + * B09 · 불러오기는 이 창 그대로 * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -19,7 +20,6 @@ import { createFindList } from "@ui/ui_template_find_list"; import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { t as L } from "@ui/ui_template_locale"; import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { fetchLibLogics as fetchLogics, @@ -30,8 +30,7 @@ import { type StandardRow, } from "./M02_MasterTemplete_Labor_Api"; import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet"; -import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; -import { ownOwner } from "./M02_MasterTemplete_Layer"; +import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo"; import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; @@ -39,8 +38,7 @@ import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; -import { createTabs } from "@ui/ui_template_tabs"; -import { listSangeun, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick"; +import { fixedOf, openFindPicker, upper } from "./M02_MasterTemplete_Find_Pick"; export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; @@ -75,22 +73,12 @@ export interface LibraryPick { more?: () => Promise; /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ kindFilter?: Array<{ value: string; text: string }>; - /** 717-4 「단가산출」 탭(M02 SK · 위 층 모두) — 고르면 `pick` 참조 */ + /** 721-2 찾아 넣기 창(`Find_Pick`)으로 — M02 일위대가 [로직 추가] */ sangeun?: boolean; + /** 721-2 M01 인력 · 재료 → 일위대가 고정 줄(705-1 이음 꼴 · 담은로직 `{로직: "", 고정, 수량}`) */ + fixed?: (fx: LaborFixed, name: string) => void; } -/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ -async function upper(layer: Layer, owner: string | null): Promise { - if (layer === "project") return []; - const own = - layer === "system" - ? null - : (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null))); - const out: Scope[] = [{ 층: "system", 주인: null }]; - if (own) out.push({ 층: "company", 주인: own.split("/")[0] }); - if (own && layer === "personal") out.push({ 층: "personal", 주인: own }); - return out; -} const LAYER_TEXT = { system: "Lib_System", company: "Lib_Company", @@ -103,6 +91,17 @@ export function openLogicPicker( lib?: LibraryPick, pre?: { query?: string }, ): void { + if (lib?.sangeun && lib.layer !== "project") { + const fixed = lib.fixed; + return openFindPicker({ + layer: lib.layer, + owner: lib.owner, + title: lib.title, + logic: done, + ref: lib.pick, + element: fixed && ((e) => fixed(fixedOf(e), e.이름)), + }); + } const subs: SubBrief[] = []; let classes: ReturnType = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만 let all: LogicSummary[] = []; @@ -114,8 +113,6 @@ export function openLogicPicker( detail.select.disabled = true; let close = (): void => {}; let rowPick: Array<() => void> = []; // 표 줄 누름 — 탭마다 다시 셈 - let tab: "std" | "sk" = "std"; - let skRows: SangeunRow[] | null = null; // 717-4 — 오기 전 = null /* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */ const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); @@ -171,7 +168,6 @@ export function openLogicPicker( const find = (): void => { const q = finder.search.value.trim().toLowerCase(); - if (tab === "sk") return findSangeun(q); const rows = std ?? all.map(plain); const found = rows.filter( (x) => @@ -250,32 +246,6 @@ export function openLogicPicker( })), ]); }; - /** 717-4 단가산출 탭 — 같은 표 꼴 · 찾기 = 이름 · 제목 · 구분 */ - const findSangeun = (q: string): void => { - const rows = (skRows ?? []).filter( - (x) => !q || [x.이름, x.제목, x.구분, x.상세구분].join(" ").toLowerCase().includes(q), - ); - finder.hint.textContent = !skRows - ? tc("Loading") - : rows.length - ? `${rows.length} / ${skRows.length}` - : tc("Pick_None"); - rowPick = rows.map((x) => () => { - close(); - lib?.pick(x.ref, x.제목 || x.이름); - }); - table.root.hidden = false; - table.setRows( - rows.map((x) => ({ - 품명: x.제목 || x.이름, - 규격: [x.구분, x.상세구분].filter(Boolean).join(" "), - 단위: x.단위 ?? "", - 금액: x.기준금액, - 출처: `${L("M02_PickSangeun")} · ${tc(LAYER_TEXT[x.ref.층 as keyof typeof LAYER_TEXT])}`, - })), - ); - finder.setRows([]); - }; const loadLib = async (): Promise => { if (!lib || !scope) return; const mine = ++seq; @@ -340,45 +310,12 @@ export function openLogicPicker( layerBar.style.display = "none"; const hint = createText(tc("Std_Hint")); - /** 탭 바꿈 — 단가산출 탭은 찾기 칸만(거름 · 층 단추 · 계산서 안내 숨김) */ - const tabs = lib?.sangeun - ? createTabs({ - tabs: [ - { key: "std", label: L("M02_PickStd") }, - { key: "sk", label: L("M02_PickSangeun") }, - ], - value: tab, - kind: "line", - onChange: (key) => { - tab = key === "sk" ? "sk" : "std"; - const std = tab === "std"; - for (const f of [kindSel?.root, work.root, sub.root, detail.root]) - if (f) f.style.display = std ? "" : "none"; - layerBar.style.display = std && scopes.length ? "" : "none"; - hint.hidden = !std; - find(); - }, - }) - : null; if (lib) void upper(lib.layer, lib.owner).then((got) => { scopes = got; - if (tabs) - listSangeun(got).then( - (rows) => { - skRows = rows; - tabs.setBadge("sk", rows.length); - find(); - }, - (error) => { - skRows = []; - find(); - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); - }, - ); scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층 if (!got.length) return; - layerBar.style.display = tab === "std" ? "" : "none"; + layerBar.style.display = ""; drawLayers(); void loadLib(); void fetchStructureDef().then( @@ -401,7 +338,7 @@ export function openLogicPicker( body.append( el("div", { className: "m02-std-pick", - children: [...(tabs ? [tabs.root] : []), finder.root, hint, table.root], + children: [finder.root, hint, table.root], }), ), })); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 3bbbde1dc..038660563 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -27,7 +27,7 @@ import { import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { calcSangeun, skCoverTable } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { @@ -101,12 +101,17 @@ export function mountSangeunDetail( variant: "filled", size: 28, onClick: () => - openLogicPicker((x) => { - doc.바탕 = { 로직: x.키 }; - delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 - if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위; - touch(false); - void loadBase(); + openFindPicker({ + ...scope, + logicOnly: true, + title: L("M02_SangeunPickBase"), + logic: (x) => { + doc.바탕 = { 로직: x.키 }; + delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 + if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위; + touch(false); + void loadBase(); + }, }), }); pick.hidden = true; // 보임은 drawHead 가 정함 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts index 39d004145..8bcf49dd2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -1,19 +1,16 @@ /* ============================================================================= * M02_MasterTemplete_Sangeun_Pick.ts * M02 단가산출(SK) 고르기(PLAN 717-4) — 위 층 SK 목록 한 벌 · 단가산출 더한줄 고르기. - * · `listSangeun` = 층 목록 길(`/layers/{층}/templates` · 종류 `sangeun`)을 마스터 › 회사 › 개인 차례로 이음 · - * 금액 = 서버가 붙인 1 단위 `기준금액` · 일위대가 [로직 추가] 「단가산출」 탭 · B09 재료 창고 출처가 씀 + * · `listLayerDocs` = 층 목록 길(`/layers/{층}/templates` · 종류 하나)을 마스터 › 회사 › 개인 차례로 이음 · + * 금액 = 서버가 붙인 1 단위 `기준금액` · `listSangeun` = 종류 `sangeun` · 찾아 넣기 창(`Find_Pick`) 탭 · B09 재료 창고 출처가 씀 * · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침) - * · `openSangeunLinePicker` = 단가산출 더한줄 고르기 — 같은 [로직 추가] 창(M01 로직 · 일위대가 · 단가산출 · - * M01 재료) → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴) + * · `openSangeunLinePicker` = 단가산출 [항목 추가] — 찾아 넣기 창(721-2 · M01 로직 · 인력 · 재료 · 단가산출 · 일위대가) + * → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴 · 인력 · 재료 = 요소 키 LB · MT · 수량 1) * ========================================================================== */ -import { t as L } from "@ui/ui_template_locale"; import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { pickMaterial } from "./M02_Material_Pick"; export const SANGEUN_KIND = "sangeun"; /** 층 차례 — 위에서 아래 */ @@ -41,7 +38,14 @@ interface ListRow { } /** 위 층 SK — 층 차례(마스터 › 회사 › 개인) · 층 안은 서버 차례 */ -export async function listSangeun(scopes: Omit[]): Promise { +export const listSangeun = (scopes: Omit[]): Promise => + listLayerDocs(scopes, SANGEUN_KIND); + +/** 위 층 서고 문서 한 종류(sangeun · precombo) — 층 차례 · 층 안은 서버 차례 */ +export async function listLayerDocs( + scopes: Omit[], + kind: string, +): Promise { const rank = (x: Omit): number => LAYER_ORDER.indexOf(x.층 as (typeof LAYER_ORDER)[number]); const sorted = [...scopes].sort((a, b) => rank(a) - rank(b)); @@ -50,7 +54,7 @@ export async function listSangeun(scopes: Omit[]): Promise(`/layers/${at.층}/templates${q}`); return got.양식 - .filter((r) => r.종류 === SANGEUN_KIND) + .filter((r) => r.종류 === kind) .map((r) => ({ ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }, 이름: r.이름, @@ -69,36 +73,17 @@ export async function listSangeun(scopes: Omit[]): Promise; 수량: number } | { 참조: LaborRef; 수량: number } - | { 종류: "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; + | { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; -/** 단가산출 더한줄 고르기 — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ +/** 단가산출 [항목 추가] — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ export function openSangeunLinePicker( scope: { layer: Layer; owner: string | null }, done: (line: SangeunLine) => void, ): void { - const element = L("M02_PickElement"); - openLogicPicker((x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), { + openFindPicker({ ...scope, - sangeun: true, - pick: (ref) => done({ 참조: ref, 수량: 1 }), - kindFilter: [ - { value: "logic", text: tc("Kind_Logic") }, - { value: "precombo", text: L("M02_PrecomboTitle") }, - { value: "element", text: element }, - ], - extra: [ - { - kind: "element", - key: "element:material", - tag: element, - name: L("M02_PickMaterial"), - sub: "", - unit: "", - onPick: () => - pickMaterial((m) => - done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 }), - ), - }, - ], + logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), + ref: (ref) => done({ 참조: ref, 수량: 1 }), + element: (e) => done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }), }); } diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index c2c14859a..dad482a19 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -27,20 +27,21 @@ def test_sk_list_layer_order_and_ref_shape(): order = re.search(r"LAYER_ORDER = \[([^\]]+)\]", pick).group(1) assert [x.strip().strip('"') for x in order.split(",")] == ["system", "company", "personal"] assert "[...scopes].sort((a, b) => rank(a) - rank(b))" in pick - assert "r.종류 === SANGEUN_KIND" in pick and 'SANGEUN_KIND = "sangeun"' in pick + assert "r.종류 === kind" in pick and 'SANGEUN_KIND = "sangeun"' in pick + assert "listLayerDocs(scopes, SANGEUN_KIND)" in pick assert 'ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }' in pick - labor = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") - assert "lib?.pick(x.ref, x.제목 || x.이름);" in labor # SK 탭 줄 누름 = 참조 - assert 'if (tab === "sk") return findSangeun(q);' in labor + # 721-2 — SK · 일위대가 탭은 찾아 넣기 창(Find_Pick) · 줄 누름 = 참조 + find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") + assert "go: take(() => opt.ref?.(x.ref, x.제목 || x.이름))" in find assert "sangeun: true," in _text( M02 / "M02_MasterTemplete_Hopyo_Pick.ts" - ) # 일위대가 [로직 추가] + ) # 일위대가 [로직 추가] → 찾아 넣기 창 store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts") assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts - # 더한줄 고르기 — 715 이음 꼴 셋 - assert 'done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick - assert "done({ 참조: ref, 수량: 1 })" in pick - assert 'done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 })' in pick + # 더한줄 고르기 — 715 이음 꼴 셋(인력 · 재료 = 요소 키) + assert 'logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick + assert "ref: (ref) => done({ 참조: ref, 수량: 1 })" in pick + assert "done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 })" in pick def _sk(folder, name): diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py new file mode 100644 index 000000000..43aec8847 --- /dev/null +++ b/resources/tester/test_721_find_pick.py @@ -0,0 +1,106 @@ +"""721 M02 찾아 넣기 창 — 단추 이름 · 자리 · 부르는 곳 · 인력 · 재료 줄 꼴(요구서_내역16 1장 5~7 · 2장 1 · 2 · 8). + +- 721-1 단가산출 상세 = [기본 로직 선택]이 둘째 줄 [항목 추가] 왼쪽 +- 721-2 M02 세 곳(기본 로직 선택 · 항목 추가 · 일위대가 [로직 추가])만 새 창 · B09 · 불러오기는 옛 창 +- M01 전체 = 로직 · 인력(job) · 재료(price) · 기계 · 실무 호표 없음 +- 고른 인력 · 재료 = 단가산출 더한줄 `{종류, 요소}` · 일위대가 고정 줄(705-1 이음 꼴) — 둘 다 서버가 받아 노임 · 자재 값으로 셈 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Sangeun_Calc as calc + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +LB, MT = "LB000033", "MT000018" # 석공 · 합판(내수) — M01 에 있음 + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_721_1_단추_이름과_자리(): + detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") + assert "children: [views.root, pick, addButton, baseHint]" in detail + assert " pick,\n );" not in detail # 머리 줄에서 뺌 + locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts") + assert 'M02_SangeunPickBase: ["기본 로직 선택"' in locale + assert 'M02_SangeunAddLine: ["항목 추가"' in locale + + +def test_721_2_부르는_곳_셋만_새_창(): + find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") + labor_pick = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") + assert 'if (lib?.sangeun && lib.layer !== "project")' in labor_pick + assert "return openFindPicker({" in labor_pick + assert "findSangeun" not in labor_pick # 옛 창의 단가산출 탭 걷음 + assert "logicOnly: true," in _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") + assert "openFindPicker({" in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") + assert "sangeun: true," in _text(M02 / "M02_MasterTemplete_Hopyo_Pick.ts") # 일위대가 + # 옛 창 그대로 — B09 · 불러오기는 sangeun 없이 부름 + for f in ( + ROOT / "B09_Estimation" / "B09_Estimation_UI_Link.ts", + ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts", + M02 / "M02_MasterTemplete_Hopyo_Choose.ts", + M02 / "M02_MasterTemplete_Hopyo_Ledger.ts", + ): + text = _text(f) + assert "openLogicPicker(" in text and "sangeun:" not in text, f.name + # 탭 셋 · 찾기 칸 하나 · 거름 고르기 없음 · M01 = 로직 · 인력 · 재료(기계 · 호표 없음) + for key in ('key: "m01"', 'key: "sk"', 'key: "pre"'): + assert key in find + assert "createSelectField" not in find and "Hopyo" not in find + assert '(kind: "job" | "price")' in find and "/elements" not in find + + +def test_721_2_고정_줄_꼴(): + find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") + assert "이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null }" in find + assert "go: put ? take(() => put(e))" in find + assert "dim: !put," in find # 받는 쪽 없으면 회색 막힘 + + +def _fx(name: str, unit: str, code: str) -> dict: + """`fixedOf` 꼴 — 인력 = 노임 이음 · 재료 = 자재 이음 · 단가 = 고를 때 값(셈은 단가 책)""" + labor_ = code.startswith("LB") + return { + "이름": name, + "규격": "", + "단위": unit, + "단가": {"노무비": 1 if labor_ else 0, "재료비": 0 if labor_ else 1, "경비": 0}, + "이음": {"노임": code if labor_ else None, "자재": None if labor_ else code, "기계": None}, + } + + +def test_721_2_인력_재료_서버가_받아_셈(): + whole = labor.m01.loaded()[1] + wage = whole.get(LB)["값"] + sk = calc.calc_doc( + { + "키": "SK-000001", + "이름": "시험", + "단위": "㎡", + "바탕": None, + "더한줄": [ + {"종류": "인력", "요소": LB, "이름": "석공", "단위": "인", "수량": 1}, + {"종류": "재료", "요소": MT, "이름": "합판", "단위": "㎡", "수량": 1}, + ], + } + )["1단위"] + doc = labor.clean( + { + "담은로직": [ + {"로직": "", "고정": _fx("석공", "인", LB), "수량": {"손": 1}}, + {"로직": "", "고정": _fx("합판", "㎡", MT), "수량": {"손": 1}}, + ] + } + ) + got = labor.calc(doc, {}) + assert sk["노무비"] == pytest.approx(wage) and sk["재료비"] > 0 + assert got["반영합"]["계"] == pytest.approx(sk["계"]) + assert [ln["이은"] for ln in got["줄"]] == [["노무비"], ["재료비"]] diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index dc3f2fcb1..4d404a9ba 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -181,6 +181,11 @@ font-weight: var(--font-weight-bold); } +/* 흐린 줄(721-2 · rowClass) — 지금 못 고르는 줄 */ +.ui-data-table__table tbody tr.is-dim td { + color: var(--color-text-muted); +} + .ui-data-table__table tbody tr.is-clickable { cursor: pointer; } diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 7ffe3a975..eb64b3e3a 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -9,7 +9,7 @@ * 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 } * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) - * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 + * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 · is-dim 흐림(721-2) * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) * 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도) * 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index e0949225d..3f3a8d0f2 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -240,8 +240,11 @@ export const ui_locales_m2 = { M02_SangeunColAmount: ["금액", "Amount"], M02_SangeunSubtotal: ["소계", "subtotal"], M02_SangeunTotal: ["계", "Total"], - M02_PickStd: ["기준 목록", "Standard list"], M02_PickSangeun: ["단가산출", "Unit-cost calcs"], - M02_PickElement: ["M01 요소", "M01 elements"], - M02_PickMaterial: ["재료 고르기", "Pick a material"], + M02_FindTitle: ["찾아 넣기", "Find and add"], + M02_FindSearch: ["이름 · 규격 · 키 · 구분으로 찾기", "Search by name, spec, key or class"], + M02_FindM01: ["M01 전체", "All M01"], + M02_FindClass: ["구분", "Class"], + M02_FindLogic: ["단가산출 로직", "Unit-cost logic"], + M02_FindElementLater: ["일위대가 표 개편 뒤", "After the unit-cost table rework"], } as const;