diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index 1171ce553..ac0fbabe6 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -22,7 +22,7 @@ import "./M02_Labor_Values.css"; const fill = (text: string, values: Record): string => text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); -const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" }; +const CONTROL_TEXT = { outOfRange: "범위 {value} 밖" }; const autoValue = (input: ItemInput): string | number | null => { const auto = input.자동값; @@ -60,17 +60,20 @@ export function createValueControl( }, }) ) as HTMLInputElement | HTMLSelectElement; + // 고르기 칸 — 「자동값 xx」 빈 선택지는 두지 않음(40-1 ③ · 자동값 = 늘 첫 선택지) · 값이 없으면 자동값을 + // 고른 채 흐리게 보이고 저장은 사용자가 바꿀 때만 · 자동값도 없으면 「—」 빈 선택지 하나 if (control instanceof HTMLSelectElement) { control.append( - el("option", { - attrs: { value: "" }, - text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`, - }), + ...(auto === null ? [el("option", { attrs: { value: "" }, text: "—" })] : []), ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), ); } const empty = value === null || value === ""; - control.value = empty ? "" : String(value); + control.value = empty + ? control instanceof HTMLSelectElement && auto !== null + ? String(auto) + : "" + : String(value); control.classList.toggle("is-auto", empty); const error = el("span", { className: "m02-inputval__error" }); const out: ValueControl = { control, error, wasBad: false }; @@ -81,7 +84,7 @@ export function createValueControl( error.textContent = bad ? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) : ""; - control.classList.toggle("is-auto", raw === ""); + control.classList.toggle("is-auto", raw === ""); // 고르기 칸은 고르면 늘 저장 값 onChange(storedOf(input, raw), bad); }); return out; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 964c7ddcd..6bccbd95a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -181,6 +181,23 @@ text-align: right; } + /* 수량산출 칸 — 윗줄에(차례 · 단위와 한 줄) · 칸 이름 없이 */ + .m02-labrow:not(.m02-labline) tr:has(.m02-labrow__qty) .m02-labrow__no, + .m02-labrow tr:has(.m02-labrow__qty) .m02-labrow__unit:not([data-label]) { + grid-column: span 1; + } + + .m02-labrow .m02-labrow__qty { + order: 0; + grid-column: span 2; + text-align: left; + } + + .m02-labrow .m02-labrow__qty::before { + content: none; + } + + .m02-labrow__sum .m02-labrow__qty, .m02-labrow__sum .m02-labrow__no, .m02-labrow__sum .m02-labrow__unit { display: none; @@ -264,3 +281,27 @@ padding: var(--spacing-4) 3px; } } + +/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */ +.m02-labrow__qty { + white-space: nowrap; + vertical-align: top; +} + +.m02-labrow__qtybtn { + display: block; + width: 100%; + padding: 2px var(--spacing-4); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm, 4px); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-labrow__qtybtn:hover { + border-color: var(--color-primary); + background: var(--color-mist-violet); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 35704c6f4..21fc12072 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -11,7 +11,15 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; -import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; +import { + readTemplate, + saveTemplate, + type ItemLogic, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; +import { linkAreaCell, pickAreaCell } from "./M02_MasterTemplete_AreaCellPick"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, @@ -50,6 +58,9 @@ export interface LaborDetailOptions { export interface LaborDetailHandle { getDoc: () => LaborDoc; + /** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) — + * 서버가 면적 저장 때 그 열 일위대가 계산값을 다시 적어 일위대가 판이 바뀜 → 부르는 쪽이 판을 다시 읽음 */ + saveArea: () => Promise; destroy: () => void; } @@ -72,6 +83,12 @@ export function mountLaborDetail( const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); const given = (): Record => Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]]))); + /** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */ + let area: { doc: Workbook; 판: string } | null = null; + let areaDirty = false; + /** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */ + let pills = new Map(); + const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved; const logicHost = el("div"); const matHost = el("div"); @@ -112,7 +129,7 @@ export function mountLaborDetail( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells() }, // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) - edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, + edit ? { open: openRow, qty: openQty, move, remove: removeRow } : { open: openRow }, ), ); matHost.replaceChildren( @@ -133,9 +150,13 @@ export function mountLaborDetail( fail = null; draw(); try { - const got = await calcLabor(opt.layer, opt.column, cur); + // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) + const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined); if (!alive || mine !== seq) return; calc = got; + pills = new Map( + (got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]), + ); } catch (error) { if (!alive || mine !== seq) return; fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); @@ -146,7 +167,7 @@ export function mountLaborDetail( /** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */ const changed = (qtyOnly = false): void => { - opt.onChanged(JSON.stringify(cur) !== saved); + opt.onChanged(dirty()); if (qtyOnly && calc) { calc = settle(calc, cur, given()); draw(); @@ -160,6 +181,36 @@ export function mountLaborDetail( 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"); + 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.칸, { + ...(info?.이름 ? { 이름: info.이름 } : {}), + ...(info?.단위 ? { 단위: info.단위 } : {}), + }); + if (!next) { + showToast(tc("Labor_BadCell"), "error"); + return false; + } + next.계산값 = recalcWorkbook(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈 + area.doc = next; + links = areaLinks(next); + areaDirty = true; + return true; + }; + /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); @@ -200,6 +251,7 @@ export function mountLaborDetail( qty: row.수량, linked: linkedText(line?.알약, line?.분모 ?? ""), unit: line?.분모 ?? "", + pick: () => pickRow(i), }, readOnly: !edit, goArea: opt.goArea, @@ -214,6 +266,37 @@ export function mountLaborDetail( }); } + /** 그 줄 알약을 칸에 이음 — 이으면 손 수량은 지움(칸을 따름) */ + 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( + `${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(), + { + qty: row.수량, + linked: linkedText(line?.알약, line?.분모 ?? ""), + unit: line?.분모 ?? "", + pick: () => pickRow(i), + }, + !edit, + opt.goArea, + (qty) => { + row.수량 = qty; + changed(true); + }, + ); + } + function openMat(i: number): void { const mat = cur.자재[i]; const line = matOf(mat.코드); @@ -223,6 +306,12 @@ export function mountLaborDetail( 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, @@ -236,7 +325,6 @@ export function mountLaborDetail( const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", - size: "sm", onClick: () => openLogicPicker((x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) @@ -248,7 +336,6 @@ export function mountLaborDetail( const addMat = createButton({ label: tc("Labor_AddMat"), variant: "filled", - size: "sm", onClick: () => pickMaterial((m) => { if (cur.자재.some((x) => x.코드 === m.코드)) @@ -268,7 +355,7 @@ export function mountLaborDetail( unit.input.setAttribute("data-labor-field", "단위"); unit.input.addEventListener("input", () => { cur.단위 = unit.input.value.trim() || null; - opt.onChanged(JSON.stringify(cur) !== saved); + opt.onChanged(dirty()); }); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); @@ -306,7 +393,10 @@ export function mountLaborDetail( void Promise.all([ loadLogics(), readTemplate(opt.layer, "area", opt.column, null).then( - (got) => (links = areaLinks(got.문서)), + (got) => { + area = { doc: got.문서 as Workbook, 판: got.판 ?? "" }; + links = areaLinks(got.문서); + }, () => undefined, ), ]).then(() => { @@ -315,6 +405,13 @@ export function mountLaborDetail( return { getDoc: () => cur, + saveArea: async () => { + if (!area || !areaDirty) return false; + const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc); + area.판 = info.판; + areaDirty = false; + return true; + }, destroy: () => { alive = false; window.clearTimeout(timer); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index ce7aa9db1..2b0b7c445 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -33,6 +33,8 @@ export interface QtySeat { linked: string | null; /** 손 입력 단위 표기(분모 · 자재 단위) */ unit: string; + /** [엑셀에서 고르기](40-1) — 그 열 면적산출 칸을 골라 이음 · 이었으면 참(모달 닫음) · 없으면 단추 없음 */ + pick?: (() => Promise) | null; } interface QtyPart { @@ -42,7 +44,12 @@ interface QtyPart { } /** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ -function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart { +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; @@ -67,6 +74,16 @@ function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): 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", @@ -83,6 +100,7 @@ function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): 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 })), @@ -174,7 +192,7 @@ 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)); + 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(); @@ -226,7 +244,7 @@ export function openQtyModal( closeLabel: tc("In_Close"), dialogClass: "m02-modal m02-labin", mount: (body, close) => { - const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, 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"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index efa456a74..976a9976a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -73,6 +73,36 @@ export function qtyShort( return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`; } +/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) · + * `open` 이 있으면 단추(줄 누름과 따로) */ +function qtyCell( + line: LaborCalcLine | undefined, + cells: ReadonlyMap | undefined, + open: (() => void) | null, +): HTMLElement { + const parts = [ + el("small", { className: "m02-labrow__base", text: baseQtyText(line) || "—" }), + el("span", { className: "m02-labrow__applied", text: qtyShort(line, line?.분모 ?? "", cells) }), + ]; + const body = open + ? el("button", { + className: "m02-labrow__qtybtn", + attrs: { type: "button", title: tc("Labor_QtyPick"), "data-labor-qty-open": "" }, + children: parts, + }) + : el("span", { children: parts }); + if (open) + body.addEventListener("click", (e) => { + e.stopPropagation(); // 줄 누름(입력 모달)과 따로 + open(); + }); + return el("td", { + className: "m02-labrow__qty", + attrs: { "data-label": tc("Labor_QtyCol") }, + children: [body], + }); +} + /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ function moneyCell( base: number | null | undefined, @@ -120,6 +150,8 @@ const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLE /** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */ export interface RowHandlers { open: (i: number) => void; + /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ + qty?: (i: number) => void; move?: (i: number, d: number) => void; remove?: (i: number) => void; } @@ -146,14 +178,12 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const line = lineOf(row.로직); const unit = logic?.결과단위 ?? ""; const values = valueLine(logic?.입력 ?? [], row.입력값); - const qty = qtyText(line, line?.분모 ?? "", data.cells); - const note = [values.text, qty].filter(Boolean).join(" · "); + const note = values.text; const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); const small = (label: string, run: () => void): HTMLElement => createButton({ label, variant: "ghost", - size: "sm", onClick: (e) => { e.stopPropagation(); // 줄 누름(입력 모달)과 따로 run(); @@ -189,6 +219,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement : []), ], }), + qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null), el("td", { className: "m02-labrow__unit", text: unit }), ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))), ...(move && remove @@ -217,6 +248,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), + el("td", { className: "m02-labrow__qty", text: "" }), el("td", { className: "m02-labrow__unit", text: "" }), ...COSTS.map((c) => moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])), @@ -229,6 +261,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const heads = [ tc("Col_Order"), tc("Col_Name"), + tc("Labor_QtyCol"), tc("Col_Unit"), ...COSTS.map((c) => tc(HEAD[c])), ...(tools ? [tc("Col_Move")] : []), @@ -300,7 +333,6 @@ export function matTable(data: MatTableData, on?: Omit): HT createButton({ label: tc("Remove"), variant: "ghost", - size: "sm", onClick: (e) => { e.stopPropagation(); remove(i); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 5e8742a8c..d1df35571 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -58,6 +58,11 @@ const TEXT = { Labor_Qty: ["수량", "Quantity"], Labor_QtyCol: ["수량산출", "Quantity"], 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_Base: ["기준", "Base"], Labor_Applied: ["반영", "Applied"], Labor_BasePrice: ["기준 단가", "Base price"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 7b711db23..7e6d10079 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -370,7 +370,7 @@ export function createMain( if (at.kind === "labor") { // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); - editor = mountLaborDetail(host, doc, { + const labor = mountLaborDetail(host, doc, { layer: at.layer, column: at.name, readOnly, @@ -381,6 +381,16 @@ export function createMain( return true; }, }); + // [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값) + editor = { + getDoc: async () => { + // 면적 저장이 일위대가 계산값을 다시 적어 판이 바뀜 — 판을 다시 읽어 409 를 막음 + if (await labor.saveArea()) + version = (await readTemplate(at.layer, "labor", at.name, at.projectId)).판 ?? ""; + return labor.getDoc(); + }, + destroy: labor.destroy, + }; return; } if (at.kind === "area") {