/* ============================================================================= * line_stack_machine.ts * 줄 쌓기 기계 창(PLAN 827-1 · 요구 810 · 811) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움. * 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당) * 오른쪽 = 공통 기계 양식(`ui_template_machine_form` · 읽기 전용) — 지금 줄 = 이 단가산출 사본에 박힌 값 * (원본 지금 값과 다르면 「새 값 있음」) · 고른 줄 = 그 기계 지금 값 * [이 기계로 바꾸기] = `pick`(부른 쪽 `repick` · 가져온 식 823-1 규칙 그대로) * [고쳐 쓰기](개인 층 · 쓰기 권한 · `ctx.canEdit`) = 양식 편집 → [확인] = 서버 확인(쓰기 없음) → `draft` * (부른 쪽이 문서 칸 `내기계초안` 에 · 디스크는 단가산출 [저장] 때 서버) · 그 밖 = 까닭 한 줄 * M01 기계에서 시작 = 새 내 기계(본뜬 = 그 키) · 내 기계 = 그 기계 고침 · 초안 = 그 초안 고침 · 즉시 저장 없음 * ========================================================================== */ import { createButton } from "@ui/ui_template_button"; import { createDataTable } from "@ui/ui_template_data_table"; import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { createMachineForm, HOURLY_KEY, type MachineValues } from "@ui/ui_template_machine_form"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { isDraftNo, newTag, type DraftItem } from "../common_util/common_util_line_stack_draft"; import type { DocRecord } from "../common_util/common_util_formula_doc_cache"; import type { Picked } from "../common_util/common_util_line_stack_pick"; import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; import { checkDraft, fetchMyMachineLists, listMyMachines, machineArg, machineRows, readMyMachine, type MachineBrief, type MyHourly, type MyMachineRow, } from "../M02_MasterTemplete/M02_MyMachine_Api"; import { bodyOfForm, hourlyText, valuesOf } from "../M02_MasterTemplete/M02_MyMachine_Form"; import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text"; /** 사본 줄(서버 `copy_row`) — 키 · 값 · 방식(내 기계) · 시간당(직접) */ interface CopyRowData { 키: string; 원문번호?: string; 이름?: string; 규격?: string; 값?: MachineValues; 방식?: string; 시간당?: Record; } export interface CopyAnswer { 사본: CopyRowData | null; 지금: CopyRowData | null; 새값: boolean; } export interface MachineCtx { /** 층 · 주인 — null = M01 단가산출 기초(M01 기계만) */ scope: { layer: Layer; owner: string | null } | null; /** [고쳐 쓰기] 됨(개인 층 SK · 쓰기 권한) */ canEdit: boolean; /** 못 고칠 까닭(M01 · 회사 · 마스터 층) */ why: string; /** 이 SK 키(쓰는 곳 셈에서 뺌) */ me?: string; /** 이 SK 사본에 박힌 기계 줄(키 있는 M02 SK) */ copyRow?: (no: string) => Promise; } export interface MachineWindowOptions { ctx: MachineCtx; drafts: DraftItem[]; /** 지금 줄 기계 입력 글(따옴표 뗌 · 원문번호 · 개인 EQP… · 개인 EQP초안-N) · 없음 = null */ now: string | null; query: string; /** 없으면 보기만(옵션 가로 조회) */ pick?: (p: Picked) => void; /** 고쳐 쓰기 [확인] — 초안 · 줄 연결 · 그 식 셈 기록(계산기 캐시 · 즉시 셈) */ draft?: (d: DraftItem, p: Picked, record: DocRecord) => void; } type Kind = "now" | "draft" | "mine" | "m01"; interface Cand { kind: Kind; /** 기계 입력 글 */ no: string; 이름: string; 규격: string; 계: number | null; 구분: string; draft?: DraftItem; mine?: MyMachineRow; /** M01 키(EQ…) */ key?: string; } interface Shown { values: MachineValues; name: string; spec: string; lines: HTMLElement[]; logic?: string | null; /** [고쳐 쓰기] 대상 */ target: { 표지?: string; 키?: string; 본뜬?: string }; /** 내 기계 = 쓰는 곳 셈 키 */ mineKey?: string; } const kindOf = (no: string): Kind => isDraftNo(no) ? "draft" : no.startsWith("개인 ") ? "mine" : "m01"; /** 사본 · 원본 줄 → 양식 값 — 직접 = 시간당 · 바로(셈 꼴) = 계수 셋 · 빈 연료 · 조종원 0 뺌 */ function formValues(row: CopyRowData): MachineValues { if (row.방식 === "직접") { const v = row.시간당 ?? row.값 ?? {}; return { [HOURLY_KEY]: { 노무비: v.노무비, 재료비: v.재료비, 경비: v.경비 } }; } const v: MachineValues = { ...(row.값 ?? {}) }; if (row.방식 === "바로") { for (const k of ["상각비계수", "정비비계수", "관리비계수"]) delete v[k]; for (const k of ["주연료", "잡재료율", "조종원"]) if (v[k] === 0) delete v[k]; for (const k of ["주연료종류", "조종원직종"]) if (v[k] === "") delete v[k]; } return v; } /** 내 기계 목록 줄 → 양식 값(직접 = 서버 시간당 노 · 재 · 경) */ const mineValues = (m: MyMachineRow): MachineValues => valuesOf({ 방식: m.방식, 값: m.값, 시간당: { 노무비: m.시간당.노무비 ?? 0, 재료비: m.시간당.재료비 ?? 0, 경비: m.시간당.경비 ?? 0, }, }); const hourOf = (h: MyHourly | undefined): number | null => (h?.ok ? (h.계 ?? null) : null); const same = (a: Cand | undefined, b: Cand | null): boolean => !!a && !!b && a.kind === b.kind && a.no === b.no && a.key === b.key; export function openMachineWindow(o: MachineWindowOptions): void { const { ctx } = o; const owner = ctx.scope?.owner ?? null; const personal = ctx.scope?.layer === "personal"; let mines: MyMachineRow[] = []; let m01: { ref: string; 이름: string; 규격?: string | null; 구분?: string | null }[] = []; const briefs = new Map(); let cands: Cand[] = []; let sel: Cand | null = null; let editing = false; let formDirty = (): boolean => false; let seq = 0; let showSeq = 0; let timer = 0; let close = (): void => {}; const search = createInputField({ type: "search", placeholder: mt("Find") }); search.input.value = o.query; const hint = createText(""); const right = el("div", { className: "lstk-mwin__form", attrs: { "data-mwin-form": "" } }); const table = createDataTable({ caption: mt("Win_Title"), columns: [ { key: "품명", label: mt("Name"), width: 150 }, { key: "규격", label: mt("Spec"), width: 80 }, { key: "금액", label: mt("Hourly"), kind: "number", decimals: 0, width: 84 }, { key: "구분", label: mt("Win_Kind"), width: 110 }, ], rows: [], rowClass: (i) => (same(cands[i], sel) ? "is-title" : null), onRowClick: (i) => { if (cands[i]) void choose(cands[i]); }, }); /* --- 목록 --- */ const nowCand = (): Cand | null => { if (!o.now) return null; const d = o.drafts.find((x) => `개인 ${x.표지}` === o.now); const m = mines.find((x) => x.원문번호 === o.now); const b = [...briefs.values()].find((x) => x.원문번호 === o.now); const name = d?.줄.이름 ?? m?.이름 ?? b?.이름 ?? o.now; return { kind: "now", no: o.now, 이름: name, 규격: d?.줄.규격 ?? m?.규격 ?? b?.규격 ?? "", 계: d ? hourOf(d.시간당) : m ? hourOf(m.시간당) : hourOf(b?.시간당), 구분: mt("Win_Now"), }; }; const draw = (): void => { const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean); const hit = (t: string): boolean => words.every((w) => t.toLowerCase().includes(w)); const now = nowCand(); cands = [ ...(now ? [now] : []), ...o.drafts .filter((d) => hit(`${d.줄.이름} ${d.줄.규격}`)) .map((d): Cand => ({ kind: "draft", no: `개인 ${d.표지}`, 이름: d.줄.이름, 규격: d.줄.규격, 계: hourOf(d.시간당), 구분: mt("Win_Draft"), draft: d, })), ...mines .filter((r) => hit(`${r.이름} ${r.규격} ${r.키}`)) .map((r): Cand => ({ kind: "mine", no: r.원문번호, 이름: r.이름, 규격: r.규격, 계: hourOf(r.시간당), 구분: `${mt("Pick_Group")} · ${mt(r.방식)}`, mine: r, })), ...m01.map((x): Cand => ({ kind: "m01", no: briefs.get(x.ref)?.원문번호 ?? "", 이름: x.이름, 규격: x.규격 ?? "", 계: hourOf(briefs.get(x.ref)?.시간당), 구분: [mt("Win_M01"), x.구분].filter(Boolean).join(" · "), key: x.ref, })), ]; hint.textContent = `${cands.length}`; table.setRows(cands.map((c) => ({ 품명: c.이름, 규격: c.규격, 금액: c.계, 구분: c.구분 }))); void fill(); }; /** M01 줄 시간당 — 보이는 앞 40 줄만 뒤에서(색인 메모 · 없는 것만) */ const fill = async (): Promise => { const want = cands .filter((c) => c.kind === "m01" && c.key && !briefs.has(c.key)) .slice(0, 40) .map((c) => c.key!); if (!want.length) return; const mine = seq; const got = await machineRows(want).catch(() => []); for (const b of got) briefs.set(b.키, b); if (mine === seq && got.length) draw(); }; const find = async (): Promise => { const mine = ++seq; const q = search.input.value.trim(); const got = await call<{ items: typeof m01 }>( `/logic-lib/elements?group=${encodeURIComponent("기계")}&q=${encodeURIComponent(q)}&limit=200`, ).catch(() => ({ items: [] })); if (mine !== seq) return; m01 = got.items; draw(); }; search.input.addEventListener("input", () => { draw(); window.clearTimeout(timer); timer = window.setTimeout(() => void find(), 250); }); /* --- 오른쪽 --- */ const briefOf = async (no: string, key?: string): Promise => { const at = key ?? no; const have = key ? briefs.get(key) : [...briefs.values()].find((b) => b.원문번호 === no); if (have) return have; const [got] = await machineRows([at]).catch(() => []); if (got) briefs.set(got.키, got); return got; }; /** 고른 줄 → 양식 값 · 글 · 바꿀 연결 · 고칠 대상 */ const resolve = async (c: Cand): Promise => { const k = c.kind === "now" ? kindOf(c.no) : c.kind; const lines: HTMLElement[] = []; if (k === "draft") { const d = c.draft ?? o.drafts.find((x) => `개인 ${x.표지}` === c.no); if (!d) throw new Error(mt("Hourly_None")); lines.push(createText(mt("Win_Draft"), "muted")); if (d.시간당) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(d.시간당 as MyHourly)}`)); const target = { 표지: d.표지, ...(d.키 ? { 키: d.키 } : {}), ...(d.본뜬 ? { 본뜬: d.본뜬 } : {}), }; return { values: valuesOf({ 방식: d.줄.방식 as never, 값: (d.줄.값 ?? {}) as MachineValues, 시간당: d.줄.시간당 as never, }), name: d.줄.이름, spec: d.줄.규격, lines, logic: d.시간당?.로직, target, mineKey: d.키, }; } // 지금 줄 = 이 단가산출 사본 값(있으면) const copy = c.kind === "now" && ctx.copyRow ? await ctx.copyRow(c.no).catch(() => null) : null; const row = copy?.사본 ?? null; if (row) { lines.push(createText(mt("Win_Copy"), "muted")); if (copy?.새값) lines.push(createText(mt("Win_Newer"), "warning")); } if (k === "mine") { const m = c.mine ?? mines.find((r) => r.원문번호 === c.no); const key = m?.키 ?? c.no.replace(/^개인 /, ""); const was = o.drafts.find((d) => d.키 === key); // 이미 고친 초안 = 그 초안을 다시 고침 if (!row && m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`)); return { values: row ? formValues(row) : m ? mineValues(m) : {}, name: row?.이름 ?? m?.이름 ?? c.이름, spec: row?.규격 ?? m?.규격 ?? c.규격, lines, logic: m?.시간당.로직, target: { 키: key, ...(was ? { 표지: was.표지 } : {}) }, mineKey: key, }; } const b = await briefOf(c.no, c.key); if (!row && b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`)); if (!row && !b) throw new Error(mt("Hourly_None")); return { values: row ? formValues(row) : { ...(b?.값 ?? {}) }, name: row?.이름 ?? b?.이름 ?? c.이름, spec: row?.규격 ?? b?.규격 ?? c.규격, lines, logic: b?.시간당.ok ? b.시간당.로직 : null, target: { 본뜬: row?.키 ?? b?.키 }, }; }; const pickedOf = (c: Cand, s: Shown): Picked | null => { if (!s.logic) return null; const no = c.kind === "mine" && c.mine ? machineArg(c.mine.원문번호) : `'${c.no}'`; return { 키: s.logic, 이름: s.name, 입력: { 기계: no }, ...(s.spec ? { 규격: s.spec } : {}) }; }; const choose = async (c: Cand): Promise => { if (editing && formDirty() && !(await showConfirmDialog(mt("Leave_Ask")))) return; editing = false; sel = c; table.setRows(cands.map((x) => ({ 품명: x.이름, 규격: x.규격, 금액: x.계, 구분: x.구분 }))); await show(c); }; async function show(c: Cand, edit = false, blank = false): Promise { const mine = ++showSeq; right.replaceChildren(createText(mt("Server_Hourly"), "muted")); let s: Shown; try { s = blank ? { values: {}, name: "", spec: "", lines: [], target: {} } : await resolve(c); } catch (error) { if (mine === showSeq) right.replaceChildren( createText(error instanceof Error ? error.message : mt("Failed"), "error"), ); return; } const lists = await fetchMyMachineLists(); if (mine !== showSeq) return; editing = edit; const notice = el("div", { className: "lstk-mwin__notice" }); const form = createMachineForm({ name: s.name, spec: s.spec, values: s.values, coefMode: "direct", allowHourly: true, fuels: lists.fuels, jobs: lists.jobs, power: lists.power, hour: lists.hour, readOnly: !edit, onChange: () => notice.replaceChildren(), }); formDirty = () => edit && form.state().changed; const bar: HTMLElement[] = []; if (!edit) { const p = pickedOf(c, s); if (o.pick && c.kind !== "now") { const b = createButton({ label: mt("Win_Swap"), variant: "filled", size: 28, disabled: !p, onClick: () => { if (!p) return; close(); o.pick!(p); }, }); b.dataset.mwinSwap = ""; if (!p) b.title = mt("Pick_Direct"); bar.push(b); } if (o.draft && ctx.canEdit) { const b = createButton({ label: mt("Win_Edit"), variant: c.kind === "now" ? "filled" : "ghost", size: 28, iconStart: "pencil", onClick: () => void show(c, true), }); b.dataset.mwinEdit = ""; bar.push(b); } else if (o.draft && ctx.why) bar.push(createText(ctx.why, "muted")); } else { const ok = createButton({ label: mt("Win_Ok"), variant: "filled", size: 28 }); ok.dataset.mwinOk = ""; ok.addEventListener("click", () => void confirm(form.state(), s, notice, ok)); bar.push( ok, createButton({ label: mt("Win_Cancel"), variant: "ghost", size: 28, onClick: () => void (blank ? choose(c) : show(c)), }), ); } const extra: HTMLElement[] = []; if (edit) extra.push(createText(mt("Win_Edit_Note"), "muted")); if (edit && s.target.키) { const uses = el("div"); extra.push(uses); void readMyMachine(owner, s.target.키) .then((one) => { const n = (one.쓰는곳 ?? []).filter((u) => u.키 !== ctx.me).length; uses.replaceChildren(createText(mt("Win_Uses").replace("{value}", String(n)), "warning")); }) .catch(() => undefined); } right.replaceChildren( el("div", { className: "ui-toolbar", children: [el("strong", { text: `${s.name} ${s.spec}`.trim() || mt("New") }), ...bar], }), ...s.lines, ...extra, notice, form.root, ); right.dataset.mwinMode = edit ? "edit" : "view"; } /** [확인] — 서버 확인(쓰기 없음) → 초안 · 줄 연결 */ async function confirm( state: ReturnType["state"]>, s: Shown, notice: HTMLElement, btn: HTMLButtonElement, ): Promise { const { body, errors } = bodyOfForm(state); notice.replaceChildren(...errors.map((e) => createText(e, "error"))); if (!body) return; btn.disabled = true; try { const tag = s.target.표지 ?? newTag(); const got = await checkDraft(owner, body, { ...s.target, 표지: tag }); if (!got.시간당.ok || !got.시간당.로직) throw new Error(hourlyText(got.시간당) || mt("Pick_Direct")); const item: DraftItem = { 표지: tag, ...(s.target.키 ? { 키: s.target.키 } : {}), ...(!s.target.키 && s.target.본뜬 ? { 본뜬: s.target.본뜬 } : {}), 줄: body, 시간당: got.시간당, }; const p: Picked = { 키: got.시간당.로직, 이름: body.이름, 입력: { 기계: `'개인 ${tag}'` }, ...(body.규격 ? { 규격: body.규격 } : {}), }; formDirty = () => false; close(); o.draft!(item, p, got.연결); } catch (error) { notice.replaceChildren( createText(error instanceof Error ? error.message : mt("Failed"), "error"), ); } finally { btn.disabled = false; } } const blankBtn = createButton({ label: mt("Win_Blank"), variant: "ghost", onClick: () => { const c: Cand = { kind: "draft", no: "", 이름: "", 규격: "", 계: null, 구분: "" }; sel = null; void show(c, true, true); }, }); blankBtn.dataset.mwinBlank = ""; ({ close } = openModal({ title: mt("Win_Title"), closeLabel: mt("Close"), width: "1100px", wide: true, ...(o.draft && ctx.canEdit ? { actions: [blankBtn] } : {}), beforeClose: async () => !formDirty() || showConfirmDialog(mt("Leave_Ask")), mount: (body) => body.append( el("div", { className: "lstk-mwin", attrs: { "data-machine-window": "" }, children: [ el("div", { className: "lstk-mwin__list", children: [ el("div", { className: "ui-toolbar", children: [search.root, hint] }), table.root, ], }), right, ], }), ), })); right.replaceChildren(createText(mt("Win_Hint"), "muted")); void (async () => { if (personal) mines = await listMyMachines(owner).catch(() => []); if (o.now && kindOf(o.now) === "m01") await briefOf(o.now); await find(); const first = cands[0]; if (first && first.kind === "now") await choose(first); })().catch((error) => showToast(error instanceof Error ? error.message : mt("Failed"), "error")); }