diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index d79b2c472..a3b1aa85f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -10,8 +10,7 @@ * · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) + * 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 - * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) · - * 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) + * · 서고 모드(50-3 · `mode`) = 일위대가표만 → `Labor_Table`(723-2 · 수량 = 휘발 임시 입력) * · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음) * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`) * ========================================================================== */ @@ -67,7 +66,7 @@ import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model"; import { keepKnown } from "./M02_Labor_Values_Model"; import { addMaterialButton } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; -import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta"; +import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; @@ -105,10 +104,10 @@ export function mountLaborDetail( doc: unknown, opt: LaborDetailOptions, ): LaborDetailHandle { + if (opt.mode === "precombo") return mountLaborTable(host, doc, opt); // 723-2 — 서고 = 일위대가표만 const cur = normalizeLabor(doc, opt.column); const saved = JSON.stringify(cur); const edit = !opt.readOnly; - const lib = opt.mode ?? null; const logics = new Map(); let calc: LaborCalc | null = null; /** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */ @@ -168,7 +167,7 @@ export function mountLaborDetail( const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); const drawDg = (): void => { - if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음 + if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음 const data = buildLaborDiagram({ rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함 logics: [...logics.values()], @@ -205,8 +204,7 @@ export function mountLaborDetail( move: reorder, remove: removeRow, alias: setAlias, - // 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음) - ...(lib ? {} : { promote: setPromote }), + promote: setPromote, // 134-1 — 구조물 일위대가 로직 줄 ...sk, } : { open: pick, option: openRow, ref: openRef, ...sk }, @@ -214,7 +212,7 @@ export function mountLaborDetail( ), ); const draw = (): void => { - gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed, !lib)); + gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed)); drawLogic(); matHost.replaceChildren( matTable( @@ -326,7 +324,6 @@ export function mountLaborDetail( unit, readOnly: !edit, sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill }, - ...(lib ? { handOnly: true } : {}), done: (r) => { if (r.kind === "keep") return; if (r.kind === "hand") target.수량 = { 손: r.손 }; @@ -515,8 +512,7 @@ export function mountLaborDetail( const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); - const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) - cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 }); + cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: null }); changed(); }, }; @@ -528,9 +524,7 @@ export function mountLaborDetail( (x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); - // 사전조합 = 면적산출 없음 → 손 1(51-11) - const 수량 = lib ? { 손: 1 } : null; - cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량 }); + cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); void loadLogics().then(() => changed()); }, // 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422) @@ -587,16 +581,6 @@ export function mountLaborDetail( ], }); - // 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름) - const nameField = createInputField({ type: "text", label: tc("Lib_Name") }); - nameField.root.dataset.laborField = "이름"; - nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? ""); - nameField.input.readOnly = !edit; - nameField.input.addEventListener("input", () => { - (cur as { 이름?: string }).이름 = nameField.input.value.trim(); - opt.onChanged(dirty()); - }); - const editor = (body: HTMLElement): HTMLElement => side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body; host.replaceChildren( @@ -613,13 +597,7 @@ export function mountLaborDetail( children: [ el("div", { className: "m01lab__info", - children: [ - ...(lib - ? [nameField.root, ...libMeta(cur, edit, () => opt.onChanged(dirty()))] - : []), - unit.root, - origin.root, - ], + children: [unit.root, origin.root], }), ], }), @@ -630,7 +608,7 @@ export function mountLaborDetail( section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), section(tc("Labor_Mats"), opt.card ? null : addMat, matHost), ].filter(() => edit || !opt.card), // 읽기만 카드 = 격자만 - ...(opt.card || lib ? [] : [dgBox]), + ...(opt.card ? [] : [dgBox]), ], }), ), @@ -639,30 +617,22 @@ export function mountLaborDetail( // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 void Promise.all([ loadLogics(), - lib // 서고 모드엔 면적산출 없음 - ? undefined - : Promise.all([ - opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null), - import("../A00_Common/spreadsheet/spreadsheet_recalc"), - ]).then( - ([got, { recalcWorkbook }]) => { - area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook }; - links = areaLinks(got.문서); - }, - () => undefined, - ), + Promise.all([ + opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null), + import("../A00_Common/spreadsheet/spreadsheet_recalc"), + ]).then( + ([got, { recalcWorkbook }]) => { + area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook }; + links = areaLinks(got.문서); + }, + () => undefined, + ), ]).then(() => { if (alive) recalc(); }); return { - // 서고 모드 = 문서 종류 그대로 · 열 없음 - getDoc: () => { - if (!lib) return cur; - const out: Record = { ...cur, 종류: "사전조합" }; - delete out.열; - return out as unknown as LaborDoc; - }, + getDoc: () => cur, saveArea: async () => { if (!area || !areaDirty) return false; const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts new file mode 100644 index 000000000..55e61d9eb --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts @@ -0,0 +1,207 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_RowModal.ts + * 일위대가표 줄 누름 모달(PLAN 723-2 · 요구서_내역16 2장 4) — 일위대가표에서 빠진 줄 동작을 모달 안으로. + * · 로직 줄 · 단가산출(SK) 참조 줄 = 머리(이름 · 「1 ㎡ 당」) · [옵션 · 메모](`Labor_Input` · 켜쌓기율) · + * 로직 자재(바꾸기 · 기본 · 세 칸 · `Labor_LogicMats`) · [단가산출 보기](717-3 `Labor_SangeunModal`) + * · 사용자 자재 줄 = 자재 모달(세 칸 · `Labor_MatTable` 한 줄) + * · 고침 = 그 줄 화면 캐시(저장은 [저장]) · 금액은 표가 다시 셈 → `watch` 로 받아 다시 그림 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborCalc, LaborMat, LaborRow, MatMarkKey } from "./M02_MasterTemplete_Labor_Api"; +import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; +import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks"; +import { matTable } from "./M02_MasterTemplete_Labor_MatTable"; +import { openRowSangeun } from "./M02_MasterTemplete_Labor_SangeunModal"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { SkScope } from "./M02_MasterTemplete_Sangeun_Cover"; +import { releaseTied } from "./M02_Labor_LogicMats_Model"; +import { keepKnown, valueLine } from "./M02_Labor_Values_Model"; + +/** 표가 다시 센 답을 받음 — 돌려준 함수 = 그만 받음 */ +export type CalcWatch = (fn: (calc: LaborCalc | null) => void) => () => void; + +export interface RowModalOptions { + row: LaborRow; + logic: ItemLogic | null; + title: string; + /** 일위대가 머리(이름 · 「1 ㎡ 당」) */ + head: string; + /** 줄 분모(SK 단위 기본) */ + unit: string; + scope: SkScope; + readOnly: boolean; + /** 셈 주입(프로젝트 복사본) — [단가산출 보기] 없음 */ + inject: boolean; + /** 사용자 자재 코드 — 로직 자재 [바꾸기]로 같은 품목 막음 */ + userMats: string[]; + watch: CalcWatch; + /** 고침 뒤 — `again` = 서버 다시 셈(입력 · 고름 · 별도계상) · 아니면 화면만 */ + done: (again: boolean) => void; + /** [단가산출로 저장] 뒤 그 줄을 새 SK 참조 줄로 */ + replace?: (row: LaborRow) => void; +} + +export function openRowModal(o: RowModalOptions): void { + const row = o.row; + const edit = !o.readOnly; + const info = el("div", { attrs: { "data-row-head": "" } }); + const mats = el("div", { attrs: { "data-row-mats": "" } }); + let calc: LaborCalc | null = null; + const line = () => calc?.줄.find((l) => !row.참조 && l.로직 === row.로직); + + const draw = (): void => { + const values = valueLine(o.logic?.입력 ?? [], row.입력값); + info.replaceChildren( + createText(o.head, "muted", 14), + ...(values.text ? [createText(values.text)] : []), + ...(values.empty + ? [createText(tc("Labor_Empty").replace("{n}", String(values.empty)), "error")] + : []), + ...(row.켜쌓기율 != null + ? [createText(tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)), "muted")] + : []), + ...(row.메모 ? [createText(row.메모, "muted")] : []), + ); + const lead = row.참조 + ? [] + : logicMatRows([row], calc, !calc, { + open: () => undefined, + ...(edit + ? { + swap: (_r: number, at: number, key: string) => { + if (o.userMats.includes(key)) + return showToast(tc("Labor_AlreadyMat").replace("{value}", key), "error"); + row.고름 = { ...row.고름, [String(at)]: key }; + o.done(true); + }, + reset: (_r: number, at: number) => { + if (!row.고름) return; + delete row.고름[String(at)]; + if (!Object.keys(row.고름).length) delete row.고름; + o.done(true); + }, + mark: (_r: number, at: number, key: MatMarkKey, v: string) => { + setLogicMark(row, at, key, v); + o.done(key === "별도계상"); + draw(); + }, + } + : {}), + }); + mats.replaceChildren( + ...(lead.length + ? [el("h4", { text: tc("Labor_Mats") }), matTable({ mats: [], calc, lead })] + : []), + ); + }; + + const option = createButton({ + label: tc("Labor_Option"), + variant: "ghost", + onClick: () => + openLaborInput({ + title: o.title, + logic: o.logic, + values: row.입력값, + memo: row.메모, + rate: row.켜쌓기율 ?? null, + readOnly: o.readOnly, + done: (next) => { + const values = o.logic ? keepKnown(o.logic.입력, next.입력값).kept : next.입력값; + const same = + JSON.stringify(values) === JSON.stringify(row.입력값) && + next.켜쌓기율 === (row.켜쌓기율 ?? null); + const before = row.입력값; + row.입력값 = values; + releaseTied(row, before, line()?.재료 ?? []); + row.메모 = next.메모; + if (next.켜쌓기율 === null) delete row.켜쌓기율; + else row.켜쌓기율 = next.켜쌓기율; + o.done(!same); + draw(); + }, + }), + }); + option.dataset.rowOption = ""; + const sk = createButton({ + label: tc("Labor_Sangeun"), + variant: "ghost", + onClick: () => + openRowSangeun({ + scope: o.scope, + row, + title: o.title, + head: o.head, + qty: "", + unit: o.unit, + readOnly: o.readOnly, + apply: (p) => { + if (p) row.패치 = p; + else delete row.패치; + o.done(true); + }, + ...(o.replace ? { replace: o.replace } : {}), + }), + }); + sk.dataset.rowSangeun = ""; + const stop = o.watch((c) => { + calc = c; + draw(); + }); + openModal({ + title: o.title, + closeLabel: tc("In_Close"), + width: "960px", + actions: [...(row.참조 ? [] : [option]), ...(o.inject ? [] : [sk])], + beforeClose: () => (stop(), true), + mount: (body) => body.append(info, mats), + }); + draw(); +} + +export interface MatModalOptions { + mat: LaborMat; + watch: CalcWatch; + readOnly: boolean; + /** 세 칸 고침 뒤 — `money` = 별도계상(금액에 닿음) */ + done: (money: boolean) => void; +} + +/** 사용자 자재 줄 — 세 칸(148-4 · 222-1) · 가격 다섯 보기 */ +export function openMatModal(o: MatModalOptions): void { + const host = el("div", { attrs: { "data-row-mat": o.mat.코드 } }); + let calc: LaborCalc | null = null; + const draw = (): void => + host.replaceChildren( + matTable( + { mats: [o.mat], calc }, + o.readOnly + ? { open: () => undefined } + : { + open: () => undefined, + mark: (_i, key, v) => { + setMark(o.mat, key, v); + o.done(key === "별도계상"); + draw(); + }, + }, + ), + ); + const stop = o.watch((c) => { + calc = c; + draw(); + }); + openModal({ + title: `${o.mat.코드} ${o.mat.이름 ?? ""}`.trim(), + closeLabel: tc("In_Close"), + width: "1100px", + beforeClose: () => (stop(), true), + mount: (body) => body.append(host), + }); + draw(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts new file mode 100644 index 000000000..c93137a80 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts @@ -0,0 +1,484 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Table.ts + * M02 일위대가(서고 · 사전조합) 상세 = 일위대가표만(PLAN 723-2 · 내역 16 · 17 · 요구서_내역16 2장 3~5). + * · 차례 = 정보(이름 · 구분 · 단위 · 본뜬) → 일위대가표(엑셀 꼴 격자 · `Labor_Grid.laborBlock`) · 오른쪽 원문 패널 + * · 옵션 값 = 규격 칸 · 「빈 값 n」 = 비고 칸 붉게(입력 빈 로직 줄) + * · 수량 칸 = 확인용 임시 입력(723-1 · 휘발 · 문서 · 저장 · dirty 안 건드림) → 그 줄 금액 · 합계 미리 보기 + * (짝 셈 `Labor_Calc.settle` · 서버 안 부름) · 문서 안 줄 수량은 늘 null(쓰는 쪽 알약으로 이음) + * · 머리 단추 = [로직 추가] [자재 추가] · 줄 우클릭 = 위로 · 아래로 · 별칭 · 삭제 + * · 줄 누름 = 줄 모달(`Labor_RowModal` — 옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출) · + * 사전조합 참조 줄 = 서고 원본 모달(`Labor_RefEdit`) · 사용자 자재 줄 = 자재 모달(세 칸) + * · 금액 = 서버 calc(최신 응답만 · 저장 안 함) · [저장]은 UI_Main(서버가 수량 걷고 다시 셈) + * 구조물 조합 일위대가(카드 · 열 상세)는 그대로 `Labor_Detail`(면적 잇기 · 수량산출). + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { createHopyoGrid, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid"; +import { openTreeMenu, type TreeMenuItem } from "@ui/ui_template_tree"; +import { askText, showConfirmDialog } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { getActiveLayer, getActiveOwner, type ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import { lineId, settle, type LaborFixed, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { + calcLabor, + fetchLogicInputs, + normalizeLabor, + type LaborCalc, + type LaborDoc, + type LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail"; +import { laborBlock } from "./M02_MasterTemplete_Labor_Grid"; +import { pasteInto } from "./M02_MasterTemplete_Labor_Paste"; +import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; +import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; +import { openMatModal, openRowModal } from "./M02_MasterTemplete_Labor_RowModal"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { insideKeys } from "./M02_Labor_LogicMats_Model"; +import { valueLine } from "./M02_Labor_Values_Model"; +import { addMaterialButton } from "./M02_Material_Pick"; +import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; +import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "@ui/ui_template_general_layout.css"; +import "./M02_MasterTemplete_Labor.css"; + +const TEXT = { + TempHint: [ + "수량 칸 = 확인용 임시 입력(저장 안 함) — 실제 수량은 구조물 조합 · 프로젝트에서 이음", + "Quantity = temporary check only (not saved) — real quantities link in structures · projects", + ], + Alias: ["별칭", "Alias"], + AliasAsk: ["별칭(비우면 지움)", "Alias (empty = clear)"], + RemoveAsk: ["「{value}」 줄을 지울까?", "Delete line “{value}”?"], + Empty: ["빈 값 {n}", "{n} empty"], + NotNumber: ["수가 아님", "Not a number"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +/** 비율 고정 줄(705-3) — 수량 = 율(문서에 남음 · 임시 입력 아님) */ +const isRate = (row: LaborRow): boolean => !!row.고정?.비율; +const matKey = (code: string): string => `자재|${code}`; + +export function mountLaborTable( + host: HTMLElement, + doc: unknown, + opt: LaborDetailOptions, +): LaborDetailHandle { + const cur = normalizeLabor(doc, opt.column); + // 723-1 — 옛 문서 손 수량은 안 씀(서버도 저장 때 걷음) · 비율 줄 율만 + for (const r of cur.담은로직) if (!isRate(r)) r.수량 = null; + for (const m of cur.자재) m.수량 = null; + const saved = JSON.stringify(cur); + const edit = !opt.readOnly; + const logics = new Map(); + /** 임시 수량(휘발) — 줄 id · 「자재|코드」 → 수 */ + const temp = new Map(); + let calc: LaborCalc | null = null; + let last: LaborCalc | null = null; + let fail: string | null = null; + let seq = 0; + let timer = 0; + let alive = true; + let picked: string | null = null; + const dirty = (): boolean => JSON.stringify(cur) !== saved; + /** 줄 모달이 받는 셈 답(다시 셀 때마다) */ + const watchers = new Set<(c: LaborCalc | null) => void>(); + const watch = (fn: (c: LaborCalc | null) => void): (() => void) => { + watchers.add(fn); + fn(calc ?? last); + return () => watchers.delete(fn); + }; + + /** 셈 몸 = 문서 + 임시 수량(저장 안 함) */ + const withTemp = (): LaborDoc => ({ + ...cur, + 담은로직: cur.담은로직.map((r) => { + const v = temp.get(lineId(r) ?? ""); + return v === undefined ? r : { ...r, 수량: { 손: v } }; + }), + 자재: cur.자재.map((m) => { + const v = temp.get(matKey(m.코드)); + return v === undefined ? m : { ...m, 수량: { 손: v } }; + }), + }); + + const gridHost = el("div", { attrs: { "data-labor-grid": "", "data-labor-table": "" } }); + const source = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); + const sourceHost = el("div", { attrs: { "data-labor-source": "" } }); + const side = createSidePanels({ + prefKey: "m02-labor-side", + follow: false, + panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }], + onChange: () => showSource(), + }); + function showSource(): void { + if (side.current() !== "source") return; + if (!picked) return void sourceHost.replaceChildren(createText(tc("Labor_SourceHint"))); + source.show(logics.get(picked)?.출처 ?? ""); + if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root); + } + + /** 일위대가표 — 줄 누름 · 우클릭 · 수량 칸(임시) · 빈 값 붉게 */ + const draw = (): void => { + const body = withTemp(); + const block = laborBlock(body, calc ?? last, logics); + const empties = new Set(); + block.lines.forEach((ln, n) => { + const row = body.담은로직[n]; + if (!row) return void (ln.note = ln.note === tc("Grid_NoQty") ? "" : ln.note); // 자재 줄 + const empty = row.로직 ? valueLine(logics.get(row.로직)?.입력 ?? [], row.입력값).empty : 0; + if (empty) empties.add(n); + if (empty) ln.note = t("Empty").replace("{n}", String(empty)); + else if (ln.note === tc("Grid_NoQty")) ln.note = ""; // 수량 없음 = 서고에선 보통(723-1) + }); + const have = calc ?? last; + // 빈 줄 = 단가 모름(까닭) · 빈 값만 — 임시 수량 없는 줄은 안 셈 + block.blank = have ? block.lines.filter((l) => l.prices.sum == null).length + empties.size : 0; + if (fail && block.lines[0]) block.lines[0].note = fail; + const g = hopyoGrid([block]); + const at = (i: number): number | null => g.at.get(i)?.[1] ?? null; + const grid = createHopyoGrid( + [block], + { + onRowClick: (i) => { + const n = at(i); + if (n !== null) openAt(n); + }, + editable: (i, key) => { + const n = at(i); + if (key !== "qty" || n === null) return false; + const row = cur.담은로직[n]; + return !row || !isRate(row); // 임시 입력이라 읽기만 층도 됨 + }, + onEdit: (i, _key, text) => { + const n = at(i); + if (n !== null) setTemp(n, text); + }, + cellClass: (i, key) => { + const n = at(i); + if (key === "note" && n !== null && empties.has(n)) return "ui-hopyo-grid__blank"; + return hopyoCellClass(g.rows, i, key); + }, + }, + edit ? (text) => void pasteInto(cur, text, afterPaste) : undefined, + ); + grid.root.addEventListener("contextmenu", (ev) => { + const tr = (ev.target as Element).closest("tr[data-i]"); + const n = tr ? at(Number(tr.dataset.i)) : null; + if (n === null || !edit) return; + ev.preventDefault(); + openTreeMenu(ev, menuAt(n)); + }); + gridHost.replaceChildren(grid.root); + showSource(); + for (const fn of watchers) fn(calc ?? last); + }; + + /** 서버 다시 셈 — 늦게 온 답은 버림 */ + const recalc = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(async () => { + const mine = ++seq; + calc = null; + fail = null; + draw(); + try { + const body = withTemp(); + const got = opt.calc + ? await opt.calc(body) + : await calcLabor(opt.layer, opt.column, body, undefined, opt.owner); + if (!alive || mine !== seq) return; + calc = last = got; + } catch (error) { + if (!alive || mine !== seq) return; + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + draw(); + }, 150); + }; + /** `qtyOnly` = 수량만(임시) — 짝 셈으로 바로 */ + const changed = (qtyOnly = false): void => { + opt.onChanged(dirty()); + if (qtyOnly && calc) { + calc = settle(calc, withTemp(), {}); + draw(); + } else recalc(); + }; + + /** 임시 수량(휘발) — 빈 글 = 지움 · 수 아님 = 알림 */ + function setTemp(n: number, text: string): void { + const row = cur.담은로직[n]; + const key = row ? (lineId(row) ?? "") : matKey(cur.자재[n - cur.담은로직.length]?.코드 ?? ""); + const raw = text.replace(/,/g, "").trim(); + if (!raw) temp.delete(key); + else if (Number.isFinite(Number(raw))) temp.set(key, Number(raw)); + else return showToast(t("NotNumber"), "error"); + calc = calc && settle(calc, withTemp(), {}); + draw(); + } + /** 붙여넣기(705-2) 뒤 — 엑셀 수량은 임시 칸으로(문서엔 안 남김) */ + function afterPaste(): void { + for (const r of cur.담은로직) { + const v = r.수량?.손; + if (isRate(r) || v == null) continue; + temp.set(lineId(r) ?? "", v); + r.수량 = null; + } + void loadLogics().then(() => changed()); + } + + const loadLogics = async (): Promise => { + const want = cur.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k)); + if (!want.length) return; + try { + const got = await fetchLogicInputs(want); + for (const l of got.로직) logics.set(l.로직, l); + } catch (error) { + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + }; + + const lineOf = (row: LaborRow) => (calc ?? last)?.줄.find((l) => lineId(l) === lineId(row)); + const nameOf = (row: LaborRow): string => + row.별칭 || + row.고정?.이름 || + (row.참조 ? lineOf(row)?.이름 : logics.get(row.로직)?.이름) || + row.참조?.키 || + row.로직; + + /** 줄 누름 — 줄 갈래마다 */ + function openAt(n: number): void { + const row = cur.담은로직[n]; + if (!row) return openMat(n - cur.담은로직.length); + picked = row.로직 || null; + showSource(); + if (row.참조 && row.참조.키.startsWith("SC-")) + return void openRefEdit({ + ref: row.참조, + title: nameOf(row), + readOnly: !edit || row.참조.층 !== opt.layer, + closed: () => alive && recalc(), + }); + if (row.고정) return; // 고정 줄 = 숫자 그대로(이름 · 별칭은 우클릭) + openRowModal({ + row, + logic: logics.get(row.로직) ?? null, + title: nameOf(row), + head: [String((cur as { 이름?: string }).이름 ?? ""), cur.단위 ? `1 ${cur.단위} 당` : ""] + .filter(Boolean) + .join(" · "), + unit: lineOf(row)?.분모 ?? "", + watch, + scope: { layer: opt.layer, owner: library.owner }, + readOnly: !edit, + inject: !!opt.calc, + userMats: cur.자재.map((m) => m.코드), + done: (again) => changed(!again), + replace: + edit && opt.layer === "personal" && !library.owner + ? (next: LaborRow) => ((cur.담은로직[n] = next), changed()) + : undefined, + }); + } + function openMat(i: number): void { + const mat = cur.자재[i]; + if (!mat) return; + openMatModal({ + mat, + watch, + readOnly: !edit, + done: (money) => changed(!money), + }); + } + + /** 우클릭 — 위로 · 아래로 · 별칭 · 삭제 */ + function menuAt(n: number): TreeMenuItem[] { + const rows = cur.담은로직.length; + const isMat = n >= rows; + const name = isMat ? (cur.자재[n - rows]?.이름 ?? "") : nameOf(cur.담은로직[n]); + const items: TreeMenuItem[] = []; + if (!isMat) { + if (n > 0) items.push({ label: tc("Up"), run: () => move(n, n - 1) }); + if (n < rows - 1) items.push({ label: tc("Down"), run: () => move(n, n + 1) }); + items.push({ label: t("Alias"), run: () => void alias(n) }); + } + items.push({ + label: tc("Remove"), + run: () => + void showConfirmDialog(t("RemoveAsk").replace("{value}", name), tc("Remove")).then( + (ok) => ok && remove(n), + ), + }); + return items; + } + function move(from: number, to: number): void { + cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1)); + changed(); + } + async function alias(n: number): Promise { + const row = cur.담은로직[n]; + const got = await askText({ message: t("AliasAsk"), value: row.별칭 ?? "" }); + if (got === null || got === undefined) return; + row.별칭 = got.trim() || null; + opt.onChanged(dirty()); + draw(); + } + function remove(n: number): void { + const rows = cur.담은로직.length; + if (n < rows) { + temp.delete(lineId(cur.담은로직[n]) ?? ""); + cur.담은로직.splice(n, 1); + } else { + temp.delete(matKey(cur.자재[n - rows]?.코드 ?? "")); + cur.자재.splice(n - rows, 1); + } + changed(); + } + + /** 서고 고르기 — 참조 줄 · 고정 줄(721-2 새 고르기 창 · 인력 · 재료) */ + const library = { + layer: opt.layer, + owner: + opt.owner !== undefined + ? opt.owner + : opt.layer === getActiveLayer() + ? getActiveOwner() + : null, + pick: (ref: LaborRef, name: string, 별칭: string | null = null) => { + const id = lineId({ 참조: ref }); + if (cur.담은로직.some((r) => lineId(r) === id)) + return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); + cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: null }); + changed(); + }, + fixed: (fx: LaborFixed, name: string) => { + const id = lineId({ 고정: fx }); + if (cur.담은로직.some((r) => lineId(r) === id)) + return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); + cur.담은로직.push({ 로직: "", 고정: fx, 별칭: null, 메모: null, 입력값: {}, 수량: null }); + changed(); + }, + }; + const addLogic = createButton({ + label: tc("AddLogic"), + variant: "filled", + onClick: () => + openLogicPicker((x) => { + if (cur.담은로직.some((r) => r.로직 === x.키)) + return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); + cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); + void loadLogics().then(() => changed()); + }, withHopyo(library)), + }); + const addMat = addMaterialButton( + tc("Labor_AddMat"), + (m) => { + const why = insideKeys((calc ?? last)?.줄).has(m.코드) + ? "Labor_InLogicMat" + : cur.자재.some((x) => x.코드 === m.코드) && "Labor_AlreadyMat"; + if (why) return showToast(tc(why).replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error"); + cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); + changed(); + }, + opt.layer === "personal" ? { owner: library.owner } : undefined, + ); + + // 정보 칸 — 이름 · 구분 · 상세구분 · 단위 · 본뜬 + const nameField = createInputField({ type: "text", label: tc("Lib_Name") }); + nameField.root.dataset.laborField = "이름"; + nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? ""); + nameField.input.readOnly = !edit; + nameField.input.addEventListener("input", () => { + (cur as { 이름?: string }).이름 = nameField.input.value.trim(); + opt.onChanged(dirty()); + }); + const unit = el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }); + const unit0 = cur.단위; + const drawUnit = (): void => + unit.replaceChildren( + el("label", { className: "ui-field__label", text: tc("Labor_Unit") }), + createUnitPick({ + value: cur.단위 || NO_UNIT, + extra: [unit0, cur.단위].filter((u): u is string => !!u), + readOnly: !edit, + ariaLabel: tc("Labor_Unit"), + onPick: (u) => { + cur.단위 = u === NO_UNIT ? null : u; + opt.onChanged(dirty()); + drawUnit(); + draw(); + }, + }), + ); + drawUnit(); + const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); + origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); + origin.input.readOnly = true; + + host.replaceChildren( + el("div", { + className: "ui-side-panels-row", + children: [ + el("div", { + className: "m01-logic__editor m01-combo m02-labor__edit", + attrs: { "data-labor": opt.column }, + children: [ + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01lab__info", + children: [ + nameField.root, + ...libMeta(cur, edit, () => opt.onChanged(dirty())), + unit, + origin.root, + ], + }), + ], + }), + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [ + el("h3", { text: tc("Grid_Title") }), + ...(edit ? [addLogic, addMat] : []), + ], + }), + createText(t("TempHint"), "hint"), + gridHost, + ], + }), + ], + }), + side.root, + ], + }), + ); + draw(); + void loadLogics().then(() => alive && recalc()); + + return { + getDoc: () => { + const out: Record = { ...cur, 종류: "사전조합" }; + delete out.열; + return out as unknown as LaborDoc; + }, + saveArea: async () => false, + destroy: () => { + alive = false; + window.clearTimeout(timer); + host.replaceChildren(); + }, + }; +} diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 2713c1f34..bfa0df359 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -35,11 +35,17 @@ def _save(folder, doc): def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # noqa: F811 - detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() - assert 'section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),' in detail - assert 'lib ? [] : [section(tc("Labor_Mats")' not in detail - assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail - assert "delete out.자재" not in detail and "delete out.열" in detail + """723-2 — 서고 = 일위대가표만(`Labor_Table`) · [자재 추가] 머리 · 세 칸 = 줄 모달(`Labor_RowModal`).""" + read = lambda name: open(os.path.join(SRC, name), encoding="utf-8").read() # noqa: E731 + detail, lib = ( + read("M02_MasterTemplete_Labor_Detail.ts"), + read("M02_MasterTemplete_Labor_Table.ts"), + ) + modal = read("M02_MasterTemplete_Labor_RowModal.ts") + assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail + assert "...(edit ? [addLogic, addMat] : [])" in lib + assert "setLogicMark(row, at, key, v);" in modal and "setMark(o.mat, key, v);" in modal + assert "delete out.자재" not in lib and "delete out.열" in lib def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811