diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts index 712cdba99..d4f5be81f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -1,15 +1,13 @@ /* ============================================================================= * M02_MasterTemplete_AreaCellPick.ts - * 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌. - * 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어 - * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). - * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 - * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) - * · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true` → `linkAreaCell(…, true)`) · [취소](모달 그대로) + * 면적산출 칸 고르기 시트(PLAN 40-3 · 43-1) — 그 열 면적산출 문서를 읽기만 시트로 붙이고 칸을 누르면 그 주소를 알림. + * 일위대가 수량산출 모달(sub2 `Labor_QtyModal`)이 모달 안에 바로 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 + * 이어 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). + * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 실제 값(식 칸 = 푼 값) + * · 칸 누름 · Enter = 그 칸 · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true`) · [취소](그대로) * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; +import { el } from "@ui/ui_template_elements"; import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask"; import { linksAt } from "../A00_Common/spreadsheet/spreadsheet_link_set"; import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; @@ -19,118 +17,99 @@ import "./M02_MasterTemplete_AreaPanel.css"; export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set"; const TEXT = { - title: "엑셀에서 고르기 — {value}", - close: "닫기", - hint: "칸을 누르면 그 칸으로 이음 · Enter 도 됨", - now: "지금 연결 {value}", - none: "지금 연결 없음", - unlink: "연결 풀기", loading: "면적산출을 읽는 중…", failed: "면적산출을 못 읽음 — {value}", }; const fill = (text: string, value: string): string => text.replace("{value}", value); -export interface AreaCellPickOptions { +/** 고른 칸 — `{칸: "B6"}` = 그 칸(다른 알약과 함께) · `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */ +export interface AreaCellPick { + 칸: string; + 바꾸기?: boolean; +} + +export interface AreaSheetOptions { /** 그 열 면적산출 문서(저장 안 한 것 포함) — 없으면 `layer` · `column` 저장본을 읽음 */ doc?: Workbook | null; layer: Layer; /** 집계표 열 id */ column: string; - /** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 지금 이은 칸을 보임 */ + /** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 같은 칸 물음에서 자기는 뺌 */ pill: string; - /** 모달 제목에 쓸 줄 이름 */ - label: string; + /** 칸을 고름(물음까지 끝난 뒤) */ + onPick: (pick: AreaCellPick) => void; + /** 시트를 다 읽음(그 문서) · 못 읽으면 null */ + onLoad?: (book: Workbook | null) => void; } -/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸(다른 알약과 함께) · - * `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */ -export type AreaCellPick = { 칸: string | null; 바꾸기?: boolean } | null; +export interface AreaSheetHandle { + destroy: () => void; +} -export function pickAreaCell(opts: AreaCellPickOptions): Promise { - return new Promise((resolve) => { - let handle: SpreadsheetHandle | null = null; - let done = false; - let picked = "A1"; - let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름 - const finish = (value: AreaCellPick): void => { +/** 읽기만 면적산출 시트를 `host` 에 붙임 — 칸을 눌러 뗌 · Enter = 그 칸(처음 고름 A1 · 화살표 옮김은 안 고름) */ +export function mountAreaSheet(host: HTMLElement, opts: AreaSheetOptions): AreaSheetHandle { + let handle: SpreadsheetHandle | null = null; + let done = false; + let picked = "A1"; + let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름 + let book: Workbook | null = null; + host.replaceChildren(el("p", { className: "m02-cellpick__now", text: TEXT.loading })); + const choose = async (): Promise => { + const at = picked; + const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id; + const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : []; + const choice = others.length + ? await askShare( + at, + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice || done) return; + opts.onPick(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at }); + }; + void (async () => { + try { + const [{ createSpreadsheet }, got] = await Promise.all([ + import("../A00_Common/spreadsheet/spreadsheet"), + opts.doc + ? Promise.resolve(opts.doc) + : readTemplate(opts.layer, "area", opts.column, null).then((got) => got.문서 as Workbook), + ]); if (done) return; + book = got; + host.replaceChildren(); + handle = createSpreadsheet(host, structuredClone(got), { + readOnly: true, + tabs: false, + onSelect: (s) => { + picked = s.칸; + moved = true; + }, + }); + const grid = host.querySelector(".ss-grid-host"); + grid?.addEventListener("mousedown", () => (moved = false), true); + grid?.addEventListener("mouseup", (e) => { + if ((e as MouseEvent).button === 0 && moved) void choose(); + }); + host.addEventListener("keydown", (e) => e.key === "Enter" && void choose()); + opts.onLoad?.(got); + } catch (error) { + if (done) return; + host.replaceChildren( + el("p", { + className: "m02-cellpick__now", + text: fill(TEXT.failed, error instanceof Error ? error.message : ""), + }), + ); + opts.onLoad?.(null); + } + })(); + return { + destroy: () => { done = true; handle?.destroy(); handle = null; - resolve(value); - }; - const { close } = openModal({ - title: fill(TEXT.title, opts.label), - closeLabel: TEXT.close, - dialogClass: "m02-modal m02-cellpick", - beforeClose: () => (finish(null), true), - mount: (body) => { - const head = el("p", { className: "m02-cellpick__now", text: TEXT.loading }); - const unlink = createButton({ - label: TEXT.unlink, - variant: "ghost", - size: "sm", - onClick: () => (finish({ 칸: null }), close()), - }); - unlink.hidden = true; - const host = el("div", { className: "m02-cellpick__sheet" }); - body.append( - el("div", { className: "m02-cellpick__head", children: [head, unlink] }), - el("p", { className: "m02-cellpick__hint", text: TEXT.hint }), - host, - ); - let book: Workbook | null = null; - const choose = async (): Promise => { - const at = picked; - const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id; - const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : []; - const choice = others.length - ? await askShare( - at, - others.map((l) => l.이름 ?? l.키), - ) - : "함께"; - if (!choice || done) return; - finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at }); - close(); - }; - void (async () => { - try { - const [{ createSpreadsheet }, got] = await Promise.all([ - import("../A00_Common/spreadsheet/spreadsheet"), - opts.doc - ? Promise.resolve(opts.doc) - : readTemplate(opts.layer, "area", opts.column, null).then( - (got) => got.문서 as Workbook, - ), - ]); - if (done) return; - book = got; - const at = got.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; - head.textContent = at ? fill(TEXT.now, at) : TEXT.none; - unlink.hidden = !at; - handle = createSpreadsheet(host, structuredClone(got), { - readOnly: true, - tabs: false, - onSelect: (s) => { - picked = s.칸; - moved = true; - }, - }); - // 칸을 눌러 뗌 = 그 칸(처음 고름 A1 · 화살표 옮김은 안 닫음) - const grid = host.querySelector(".ss-grid-host"); - grid?.addEventListener("mousedown", () => (moved = false), true); - grid?.addEventListener("mouseup", (e) => { - if ((e as MouseEvent).button === 0 && moved) void choose(); - }); - host.addEventListener("keydown", (e) => e.key === "Enter" && void choose()); - } catch (error) { - if (done) return; - head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : ""); - } - })(); - }, - }); - }); + }, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 220c81a2a..9d9844672 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -351,3 +351,21 @@ border-color: var(--color-primary); background: var(--color-mist-violet); } + +/* 수량산출 모달(43-1) — 위 지금 수량 · 손 입력 · [연결 풀기] / 아래 면적산출 시트(읽기만) */ +.m02-labqty { + width: min(960px, 94vw); +} + +.m02-labqty__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-12); + margin-bottom: var(--spacing-4); +} + +.m02-labqty__now { + font-weight: 600; + font-variant-numeric: tabular-nums; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index b72958ff9..02b57263e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -19,7 +19,7 @@ import { type ItemLogic, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; -import { linkAreaCell, pickAreaCell } from "./M02_MasterTemplete_AreaCellPick"; +import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, @@ -28,6 +28,7 @@ import { normalizeLabor, type LaborCalc, type LaborDoc, + type LaborQty, type LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { @@ -35,9 +36,10 @@ import { diagramTitle, renderComboDiagram, } from "./M02_MasterTemplete_Labor_Diagram"; -import { openLaborInput, openQtyModal, type GoArea } from "./M02_MasterTemplete_Labor_Input"; +import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; +import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; @@ -174,37 +176,22 @@ export function mountLaborDetail( } else recalc(); }; - /** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */ - const linkedText = (pill: string | null | undefined, unit: string): string | null => { - const at = pill ? links.get(pill) : undefined; - if (!at) return null; - return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`; - }; - - /** [엑셀에서 고르기] — 그 열 면적산출 칸을 골라 그 알약을 이음(화면 문서 · 저장은 [저장]) · 이었으면 참 */ - const pickCell = async (pill: string | null | undefined, label: string): Promise => { - if (!pill || !area) { - showToast(tc(area ? "Labor_PickWait" : "Labor_NoArea"), "error"); + /** 칸 잇기 · 풀기(`cell` null) — 화면 문서만(저장은 [저장]) · 됐으면 참 */ + const linkCell = (pill: string, cell: string | null, replace = false): boolean => { + if (!area) { + showToast(tc("Labor_NoArea"), "error"); return false; } - const got = await pickAreaCell({ - doc: area.doc, - layer: opt.layer, - column: opt.column, - pill, - label, - }); - if (!got || !alive) return false; const info = pills.get(pill); const next = linkAreaCell( area.doc, pill, - got.칸, + cell, { ...(info?.이름 ? { 이름: info.이름 } : {}), ...(info?.단위 ? { 단위: info.단위 } : {}), }, - got.바꾸기 === true, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 고르기 모달 안) + replace, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 시트 안) ); if (!next) { showToast(tc("Labor_BadCell"), "error"); @@ -217,6 +204,37 @@ export function mountLaborDetail( return true; }; + /** 수량산출 모달(43-1) — 로직 줄 · 자재 줄 같음 · 손 · 칸 잇기 · 풀기로 `target.수량` 을 바꿈 */ + const openQtyFor = ( + title: string, + target: { 수량: LaborQty }, + line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, + unit: string, + ): void => { + const pill = line?.알약 ?? null; + openQtyModal({ + title, + now: qtyShort(line, unit, cells()), + linked: !!pill && links.has(pill), + hand: target.수량?.손 ?? null, + unit, + readOnly: !edit, + sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill }, + goArea: opt.goArea, + done: (r) => { + if (r.kind === "keep") return; + if (r.kind === "hand") target.수량 = { 손: r.손 }; + else if (r.kind === "follow") target.수량 = null; + else { + const cell = r.kind === "cell" ? r.pick.칸 : null; + if (!pill || !linkCell(pill, cell, r.kind === "cell" && r.pick.바꾸기 === true)) return; + target.수량 = null; // 칸을 따름 + } + changed(true); + }, + }); + }; + /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); @@ -247,85 +265,38 @@ export function mountLaborDetail( function openRow(i: number): void { const row: LaborRow = cur.담은로직[i]; const logic = logics.get(row.로직) ?? null; - const line = lineOf(row.로직); openLaborInput({ title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), logic, values: row.입력값, memo: row.메모, - seat: { - qty: row.수량, - linked: linkedText(line?.알약, line?.분모 ?? ""), - unit: line?.분모 ?? "", - pick: () => pickRow(i), - }, readOnly: !edit, - goArea: opt.goArea, done: (next) => { const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; - const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값); + const same = JSON.stringify(values) === JSON.stringify(row.입력값); row.입력값 = values; row.메모 = next.메모; - row.수량 = next.수량; - changed(qtyOnly); + changed(same); // 입력값이 그대로면(메모만) 서버 안 부름 }, }); } - /** 그 줄 알약을 칸에 이음 — 이으면 손 수량은 지움(칸을 따름) */ - async function pickRow(i: number): Promise { - const row = cur.담은로직[i]; - const logic = logics.get(row.로직); - if (!(await pickCell(lineOf(row.로직)?.알약, logic?.이름 ?? row.로직))) return false; - row.수량 = null; - changed(true); - return true; - } - - /** 수량산출 칸 누름 — 손 입력 · 엑셀에서 고르기 */ + /** 수량산출 칸 누름 — 로직 줄 */ function openQty(i: number): void { const row = cur.담은로직[i]; const line = lineOf(row.로직); - openQtyModal( + openQtyFor( `${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(), - { - qty: row.수량, - linked: linkedText(line?.알약, line?.분모 ?? ""), - unit: line?.분모 ?? "", - pick: () => pickRow(i), - }, - !edit, - opt.goArea, - (qty) => { - row.수량 = qty; - changed(true); - }, + row, + line, + line?.분모 ?? "", ); } + /** 자재 줄 누름 — 수량산출 모달 */ function openMat(i: number): void { const mat = cur.자재[i]; - const line = matOf(mat.코드); - openQtyModal( - `${mat.코드} ${mat.이름 ?? ""}`.trim(), - { - qty: mat.수량, - linked: linkedText(line?.알약, mat.단위 ?? ""), - unit: mat.단위 ?? "", - pick: async () => { - if (!(await pickCell(line?.알약, mat.이름 ?? mat.코드))) return false; - mat.수량 = null; - changed(true); - return true; - }, - }, - !edit, - opt.goArea, - (qty) => { - mat.수량 = qty; - changed(true); - }, - ); + openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? ""); } const addLogic = createButton({ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index 2b0b7c445..019b3541a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -1,14 +1,13 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Input.ts - * 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량 - * (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`). + * 일위대가 입력 모달(39-3) — 단가산출 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모. + * 수량은 이 모달에 없음(43-2) — 수량산출 칸 누름 = `Labor_QtyModal`(면적산출 시트를 모달 안에). * [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; -import type { LaborQty } from "./M02_MasterTemplete_Labor_Api"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { createValueControl } from "./M02_Labor_Values"; import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model"; @@ -20,102 +19,6 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** 면적산출 상세로 감 — 옮겨 갔으면 참(물음에서 취소 = 거짓) */ export type GoArea = () => Promise; -/** 옮겨 갔을 때만 모달을 닫음 — 취소면 모달 · 넣던 값 그대로 */ -const leaveTo = - (go: GoArea, close: () => void): (() => void) => - () => - void go().then((moved) => moved && close()); - -export interface QtySeat { - /** 지금 수량 */ - qty: LaborQty; - /** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */ - linked: string | null; - /** 손 입력 단위 표기(분모 · 자재 단위) */ - unit: string; - /** [엑셀에서 고르기](40-1) — 그 열 면적산출 칸을 골라 이음 · 이었으면 참(모달 닫음) · 없으면 단추 없음 */ - pick?: (() => Promise) | null; -} - -interface QtyPart { - root: HTMLElement; - /** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */ - read: () => LaborQty | undefined; -} - -/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ -function qtyPart( - seat: QtySeat, - readOnly: boolean, - goArea: (() => void) | null, - close: () => void, -): QtyPart { - const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`; - const radio = (value: "cell" | "hand"): HTMLInputElement => { - const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement; - input.disabled = readOnly; - return input; - }; - const cell = radio("cell"); - const hand = radio("hand"); - const number = el("input", { - className: "ui-input m02-inputval__control", - attrs: { type: "number", step: "any", "data-labor-qty": "hand" }, - }) as HTMLInputElement; - number.readOnly = readOnly; - if (seat.qty) number.value = String(seat.qty.손); - (seat.qty ? hand : cell).checked = true; - number.disabled = !hand.checked; - for (const r of [cell, hand]) - r.addEventListener("change", () => { - number.disabled = !hand.checked; - if (hand.checked) number.focus(); - }); - const go = goArea - ? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea }) - : null; - const pickFn = seat.pick; - const pick = - pickFn && !readOnly - ? createButton({ - label: tc("In_QtyPick"), - variant: "ghost", - size: "sm", - onClick: () => void pickFn().then((linked) => linked && close()), - }) - : null; - const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement => - el("label", { - className: "m02-labin__qty-line", - children: [input, el("span", { text: label }), ...rest], - }); - const root = el("section", { - className: "m02-labin__qty", - children: [ - el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }), - line( - cell, - tc("In_QtyCell"), - el("small", { - className: "m02-labin__muted", - text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"), - }), - ...(pick ? [pick] : []), - ...(go ? [go] : []), - ), - line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })), - ], - }); - return { - root, - read: () => { - if (!hand.checked) return null; - const v = Number(number.value); - return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined; - }, - }; -} - function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement { return el("div", { className: "m02-labin__foot", @@ -131,20 +34,16 @@ function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLEleme export interface LaborInputResult { 입력값: LaborValues; 메모: string | null; - 수량: LaborQty; } export interface LaborInputOptions { /** 「GF000219 메쌓기」 */ title: string; - /** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */ + /** 로직 입력 목록(없으면 = 못 읽음 · 메모만) */ logic: ItemLogic | null; values: LaborValues; memo: string | null; - seat: QtySeat; readOnly: boolean; - /** 「면적산출에서 잇기」 — 면적산출 상세로 감(옮겨 가면 참 · 「저장 안 한 고침」 물음에서 취소면 거짓 · 없으면 단추 없음) */ - goArea: GoArea | null; done: (next: LaborInputResult) => void; } @@ -192,12 +91,9 @@ export function openLaborInput(opt: LaborInputOptions): void { }) as HTMLInputElement; memo.value = opt.memo ?? ""; memo.readOnly = opt.readOnly; - const qty = qtyPart(opt.seat, opt.readOnly, opt.goArea && leaveTo(opt.goArea, close), close); const ok = (): void => { if (bad.size) return showToast(tc("In_Bad"), "error"); - const 수량 = qty.read(); - if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); - opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 }); + opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null }); close(); }; body.append( @@ -222,7 +118,6 @@ export function openLaborInput(opt: LaborInputOptions): void { memo, ], }), - qty.root, ], }), footer(opt.readOnly, ok, close), @@ -230,28 +125,3 @@ export function openLaborInput(opt: LaborInputOptions): void { }, }); } - -/** 자재 줄 수량 모달 */ -export function openQtyModal( - title: string, - seat: QtySeat, - readOnly: boolean, - goArea: GoArea | null, - done: (qty: LaborQty) => void, -): void { - openModal({ - title, - closeLabel: tc("In_Close"), - dialogClass: "m02-modal m02-labin", - mount: (body, close) => { - const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, close), close); - const ok = (): void => { - const 수량 = qty.read(); - if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); - done(수량); - close(); - }; - body.append(qty.root, footer(readOnly, ok, close)); - }, - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts new file mode 100644 index 000000000..3e466130f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts @@ -0,0 +1,170 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_QtyModal.ts + * 일위대가 수량산출 모달(43-1) — 수량산출 칸 누름(로직 줄 · 자재 줄 같음) = 모달 하나. + * · 위 = 지금 수량(손 · 칸 · —) · 손 입력 칸 · [연결 풀기] + * · 아래 = 그 열 면적산출 시트를 모달 안에 바로(읽기만 · 실제 값 · 이은 칸 색 · `mountAreaSheet`) · + * 칸 누름 = 그 칸을 고름(함께 잇기 · 바꾸기 물음 그대로) + * · [확인] = 손 값이 있으면 손 · 아니면 고른 칸으로 잇기 · 풀기 · [닫기] = 그대로 + * 잇기 · 풀기는 화면 문서만(저장은 일위대가 [저장] 때 면적 문서와 함께 · `Labor_Detail`). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { mountAreaSheet, type AreaCellPick } from "./M02_MasterTemplete_AreaCellPick"; +import type { GoArea } from "./M02_MasterTemplete_Labor_Input"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_Labor_Values.css"; +import "./M02_MasterTemplete_Labor.css"; + +/** 모달 답 — 손 · 칸 따름(손 지움) · 고른 칸으로 잇기 · 연결 풀기 · 그대로 */ +export type QtyResult = + | { kind: "hand"; 손: number } + | { kind: "follow" } + | { kind: "cell"; pick: AreaCellPick } + | { kind: "unlink" } + | { kind: "keep" }; + +export interface QtyModalOptions { + /** 「GF000219 돌쌓기 메쌓기(인력)」 */ + title: string; + /** 지금 수량 글(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) */ + now: string; + /** 지금 칸에 이어져 있나 — [연결 풀기] */ + linked: boolean; + /** 지금 손 값(없으면 null) */ + hand: number | null; + /** 손 입력 단위(분모 · 자재 단위) */ + unit: string; + readOnly: boolean; + /** 그 열 면적산출 — `pill` 이 없으면(계산 전) 시트 대신 안내 */ + sheet: { doc: Workbook | null; layer: Layer; column: string; pill: string | null }; + /** [면적산출 상세로] — 없으면 단추 없음 */ + goArea: GoArea | null; + done: (result: QtyResult) => void; +} + +export function openQtyModal(opt: QtyModalOptions): void { + let pending: QtyResult = { kind: "keep" }; + let sheet: { destroy: () => void } | null = null; + openModal({ + title: `${tc("Labor_QtyCol")} — ${opt.title}`, + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin m02-labqty", + beforeClose: () => (sheet?.destroy(), true), + mount: (body, closeNow) => { + const finish = (): void => { + sheet?.destroy(); + closeNow(); + }; + const now = el("span", { + className: "m02-labqty__now", + text: opt.now, + attrs: { "data-labor-qty-now": "" }, + }); + const state = el("small", { + className: "m02-labin__muted", + attrs: { "data-labor-qty-state": "" }, + }); + const hand = el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: "number", + step: "any", + "data-labor-qty": "hand", + "aria-label": tc("In_QtyHand"), + }, + }) as HTMLInputElement; + hand.readOnly = opt.readOnly; + if (opt.hand !== null) hand.value = String(opt.hand); + const unlink = + opt.linked && !opt.readOnly + ? createButton({ + label: tc("Labor_Unlink"), + variant: "ghost", + onClick: () => { + pending = { kind: "unlink" }; + hand.value = ""; + state.textContent = tc("Labor_UnlinkPending"); + }, + }) + : null; + const go = opt.goArea + ? createButton({ + label: tc("In_QtyGoArea"), + variant: "ghost", + onClick: () => + void opt.goArea?.().then((moved) => { + if (moved) finish(); + }), + }) + : null; + const host = el("div", { + className: "m02-cellpick__sheet", + attrs: { "data-labor-qty-sheet": "" }, + }); + if (opt.sheet.pill) { + sheet = mountAreaSheet(host, { + doc: opt.sheet.doc, + layer: opt.sheet.layer, + column: opt.sheet.column, + pill: opt.sheet.pill, + onPick: (pick) => { + if (opt.readOnly) return; + pending = { kind: "cell", pick }; + hand.value = ""; // 칸을 고르면 손 값은 지움(칸을 따름) + state.textContent = tc("Labor_CellPending").replace("{value}", pick.칸); + }, + }); + } else + host.replaceChildren( + el("p", { className: "m02-labin__muted", text: tc("Labor_PickWait") }), + ); + hand.addEventListener("input", () => { + if (hand.value.trim() !== "") state.textContent = ""; + }); + const ok = (): void => { + const raw = hand.value.trim(); + if (raw !== "") { + const v = Number(raw); + if (!Number.isFinite(v)) return showToast(tc("In_QtyNeed"), "error"); + opt.done({ kind: "hand", 손: v }); + } else if (pending.kind !== "keep") opt.done(pending); + else if (opt.hand !== null) opt.done({ kind: "follow" }); // 손 값을 지움 = 칸을 따름 + finish(); + }; + body.append( + el("div", { + className: "m02-labqty__head", + children: [ + el("span", { className: "m02-inputval__name", text: tc("Labor_QtyNow") }), + now, + el("label", { + className: "m02-labin__qty-line", + children: [ + el("span", { text: tc("In_QtyHand") }), + hand, + el("small", { text: opt.unit }), + ], + }), + ...(unlink ? [unlink] : []), + ...(go ? [go] : []), + ], + }), + state, + el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }), + host, + el("div", { + className: "m02-labin__foot", + children: opt.readOnly + ? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: finish })] + : [ + createButton({ label: tc("In_Close"), variant: "ghost", onClick: finish }), + createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }), + ], + }), + ); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index cc1b30de5..deae683f6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -60,10 +60,17 @@ const TEXT = { Labor_Item: ["항목", "Item"], Labor_Hand: ["손", "Manual"], Labor_QtyPick: ["누르면 손 입력 · 엑셀에서 고르기", "Manual or pick a cell"], - In_QtyPick: ["엑셀에서 고르기", "Pick in sheet"], Labor_PickWait: ["계산이 끝난 뒤 고름", "Wait for the calculation"], Labor_NoArea: ["이 열 면적산출을 못 읽음", "Area sheet not loaded"], Labor_BadCell: ["칸 주소가 틀림", "Invalid cell"], + Labor_QtyNow: ["지금 수량", "Now"], + Labor_Unlink: ["연결 풀기", "Unlink"], + Labor_UnlinkPending: ["연결 풀기 — [확인]을 누르면 풂", "Unlink on OK"], + Labor_CellPending: ["고른 칸 {value} — [확인]을 누르면 이음", "Cell {value} — link on OK"], + Labor_SheetHint: [ + "면적산출 시트 — 칸을 누르면 그 칸을 고름 · 손 입력이 있으면 손이 앞섬", + "Area sheet — click a cell to pick it · manual value wins", + ], Labor_Base: ["기준", "Base"], Labor_Applied: ["반영", "Applied"], Labor_BasePrice: ["기준 단가", "Base price"], @@ -81,12 +88,9 @@ const TEXT = { In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], In_Memo: ["메모", "Memo"], - In_Qty: ["수량", "Quantity"], In_QtyCell: ["면적산출 칸", "Area sheet cell"], In_QtyEmpty: ["빈 칸", "empty cell"], In_QtyHand: ["손 입력", "Manual"], - In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"], - In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"], In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"], In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"], In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"], diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index e53925a02..3448993d1 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -94,12 +94,15 @@ def test_카드_알약_취향은_계정_받은_뒤_올림(): def test_엑셀_칸_고르기_모달_부품(): - """40-3 ① — 읽기만 시트 · 칸 누름 = 주소 · 풀기 = {칸: null} · 그만둠 = null · 저장 안 함.""" + """40-3 ① · 43-1 — 읽기만 시트(수량산출 모달 안에 바로 · `mountAreaSheet`) · 칸 누름 = 주소 · + 풀기 · 손 · 확인 · 닫기는 일위대가 수량산출 모달(`Labor_QtyModal`) · 저장 안 함.""" pick = _text("M02_MasterTemplete_AreaCellPick.ts") assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick - assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick - assert "finish({ 칸: null })" in pick - assert 'finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick + assert "export function mountAreaSheet(" in pick and "readOnly: true," in pick + assert 'opts.onPick(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick + qty = _text("M02_MasterTemplete_Labor_QtyModal.ts") + assert "sheet = mountAreaSheet(host, {" in qty and 'pending = { kind: "unlink" };' in qty + assert "엑셀에서 고르기" not in qty # 두 단계 모달 없앰(43-1) assert "linksAt(book, sheetId, at, opts.pill)" in pick and "askShare(" in pick assert "if ((e as MouseEvent).button === 0 && moved) void choose();" in pick assert "handle?.destroy();" in pick diff --git a/resources/tester/ui_checks/43-1_수량산출_모달.js b/resources/tester/ui_checks/43-1_수량산출_모달.js new file mode 100644 index 000000000..ffab377bb --- /dev/null +++ b/resources/tester/ui_checks/43-1_수량산출_모달.js @@ -0,0 +1,134 @@ +/* 43-1 · 43-2 일위대가 수량산출 모달 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌. + * + * 시작 화면: M02 마스터 템플릿(시스템 관리자) · 좌측 일위대가 줄 「SU-000003」 이 보이는 상태 · + * 그 열 면적산출(SA-000003) B6 = 12.5(검증 전 넣고 끝나면 마스터 사본으로 되씀) · 저장은 안 함(끝에 다시 열기 → 버림). + * 기대 수치: + * qtyModal.sheetCells > 0 · qtyModal.twoStepButton = false(「엑셀에서 고르기」 없음) · qtyModal.hasHand = true + * hand.applied = round(기준 × 5) · hand.qty = "손 5 ㎡" + * cell.qty = "B6 = 12.5" · cell.applied = round(기준 × 12.5)(배 1) + * follow.qty = "B6 = 12.5"(손 지움 = 칸 따름 · 연결은 남음) · unlink.qty = "—" + * matRow.opens = true(자재 줄이 있으면 같은 모달) · inputModal.hasQty = false(43-2 · 입력값 · 메모만) + * discarded = true(다시 열기 → 확인 → 처음 수량 글과 같음) + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const btn = (root, text) => $$("button", root).find((b) => b.innerText.trim() === text); + const num = (t) => Number(String(t).replace(/[^0-9.-]/g, "")); + const LOGIC = "GF000219"; + const row = () => $(`[data-labor-logic="${LOGIC}"]`); + const qtyText = () => + row().querySelector(".m02-labrow__qty .m02-labrow__applied").innerText.trim(); + const money = () => + $$(".m02-labrow__money", row()).map((c) => [ + num(c.querySelector(".m02-labrow__base").innerText), + c.querySelector(".m02-labrow__applied").innerText.trim(), + ]); + const out = {}; + const side = $$(".m02-side__item").find((b) => b.innerText.startsWith("SU-000003")); + if (!side) return JSON.stringify({ error: "SU-000003 줄 없음" }); + side.click(); + await W(5000); + const base = money()[3][0]; // 합계 기준 + out.initial = qtyText(); + const openQty = async () => { + row().querySelector("[data-labor-qty-open]").click(); + await W(2500); + return $(".m02-labqty"); + }; + // ① 모달 모양 + 손 5 + let m = await openQty(); + out.qtyModal = { + open: !!m, + sheetCells: $$("[data-labor-qty-sheet] .aislo-cell", m).length, + twoStepButton: !!btn(m, "엑셀에서 고르기"), + hasHand: !!$("[data-labor-qty]", m), + now: $("[data-labor-qty-now]", m)?.innerText, + }; + const hand = $("[data-labor-qty]", m); + hand.value = "5"; + hand.dispatchEvent(new Event("input")); + btn(m, "확인").click(); + await W(600); + out.hand = { qty: qtyText(), applied: num(money()[3][1]), expect: Math.round(base * 5) }; + out.hand.ok = Math.abs(out.hand.applied - out.hand.expect) <= 5; // 기준은 화면 반올림 값 + // ② 칸 B6 고름 → 확인 + m = await openQty(); + const c = $('[data-labor-qty-sheet] [data-addr="B6"]', m); + const r = c.getBoundingClientRect(); + const o = { + bubbles: true, + cancelable: true, + clientX: r.x + 10, + clientY: r.y + 8, + button: 0, + pointerId: 1, + pointerType: "mouse", + }; + c.dispatchEvent(new PointerEvent("pointerdown", o)); + c.dispatchEvent(new MouseEvent("mousedown", o)); + await W(80); + c.dispatchEvent(new PointerEvent("pointerup", o)); + c.dispatchEvent(new MouseEvent("mouseup", o)); + c.dispatchEvent(new MouseEvent("click", o)); + await W(600); + const share = btn(document, "함께 잇기"); // 그 칸에 다른 알약이 있으면 물음 + if (share) { + share.click(); + await W(300); + } + out.cell = { + state: $("[data-labor-qty-state]", m)?.innerText, + handCleared: $("[data-labor-qty]", m).value === "", + }; + btn(m, "확인").click(); + await W(800); + Object.assign(out.cell, { + qty: qtyText(), + applied: num(money()[3][1]), + expect: Math.round(base * 12.5), + }); + // ③ 손 넣었다 지우고 확인 = 칸 따름 · ④ 연결 풀기 + m = await openQty(); + $("[data-labor-qty]", m).value = ""; + btn(m, "확인").click(); + await W(500); + out.follow = { qty: qtyText() }; + m = await openQty(); + const unlink = btn(m, "연결 풀기"); + out.unlink = { button: !!unlink }; + if (unlink) { + unlink.click(); + btn(m, "확인").click(); + await W(500); + out.unlink.qty = qtyText(); + } else btn(m, "닫기").click(); + // ⑤ 자재 줄 · ⑥ 입력 모달(43-2) + const mat = $("[data-labor-mat]"); + if (mat) { + mat.click(); + await W(2500); + out.matRow = { opens: !!$(".m02-labqty") }; + btn($(".m02-labqty"), "닫기")?.click(); + await W(300); + } else out.matRow = { opens: "자재 없음" }; + row().querySelector(".m02-labrow__name").click(); + await W(400); + const im = $(".m02-labin:not(.m02-labqty)"); + out.inputModal = { + open: !!im, + hasQty: !!$("[data-labor-qty]", im) || !!btn(im, "엑셀에서 고르기"), + hasMemo: !!$("[data-labor-memo]", im), + }; + btn(im, "취소")?.click(); + await W(300); + // ⑦ 버림 + btn($(".m02-main__head"), "다시 열기").click(); + await W(600); + btn($(".ui-confirm"), "확인")?.click(); + await W(4500); + out.afterDiscard = qtyText(); + out.discarded = out.afterDiscard === out.initial; + return JSON.stringify(out); +})();