/* ============================================================================= * M02_MyMachine_Form.ts * M02 내 기계 한 대(PLAN 821-3 · 840-2 · 요구 862) — 기계 보기(`A00_Common/line_stack_machine_sheet` · M01 기계 상세와 * 같은 꼴: 왼쪽 제어 구역 + 기본 정보 · 손료 근거 · 오른쪽 시간당 경비 산출근거) + [저장] · [지우기] · 「개인 값」 표지 · * 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳. * 내 기계 = M01 에 없는 기계를 등록하는 자리 — 이름 · 규격이 M01 기계와 같으면 알림 + 서버가 저장 막음 · * M01 기계 값 고침은 단가산출 안 기계 제어 구역(836-1) · 「복사해 내 기계로」 걷음(840-2) * [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임 * 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 줄 * 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」 * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { createMachineSheet, type MachineSheetState, type MachineSimilar, } from "../A00_Common/line_stack_machine_sheet"; import type { MachineRow } from "../common_util/common_util_machine_sheet"; import { createMyMachine, deleteMyMachine, fetchMyMachineLists, findSimilar, readMyMachine, updateMyMachine, type MyHourly, type MyMachineBody, type MyMachineOne, type SimilarRow, } from "./M02_MyMachine_Api"; import { mt } from "./M02_MyMachine_Text"; import "./M02_MyMachine.css"; export interface MyMachineFormOptions { owner: string | null; /** 고칠 내 기계 키 — 없으면 새 기계 */ key?: string; onSaved: (got: MyMachineOne) => void; onDeleted?: () => void; onBack?: () => void; onDirty?: (dirty: boolean) => void; } export interface MyMachineFormHandle { root: HTMLElement; dirty: () => boolean; } const won = (v: number | undefined): string => `${(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}${mt("Won")}`; const RATIOS = ["내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율"]; /** 찾은 줄 → 내 기계 줄 꼴(M01 줄 = 비율 다섯 있으면 품셈 · 없으면 바로 — 손료계수 = 계수 셋 합) */ export function rowOfSimilar(r: SimilarRow): MachineRow { if (r.출처 === "개인") return { 이름: r.이름, 규격: r.규격, 방식: r.방식 ?? "바로", 값: { ...(r.값 ?? {}) }, 시간당: r.시간당 }; // prettier-ignore const v = { ...(r.값 ?? {}) }; const how = RATIOS.every((k) => typeof v[k] === "number" && v[k]) ? "품셈" : "바로"; if (how === "바로" && typeof v.손료계수 !== "number") v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce( (s, k) => s + (Number(v[k]) || 0), 0, ); return { 이름: r.이름, 규격: r.규격, 방식: how, 값: v }; } /** 보기 상태 → 보낼 몸(틀림 = 글 목록) */ export function bodyOfSheet(s: MachineSheetState): { body: MyMachineBody | null; errors: string[]; } { const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])]; if (errors.length) return { body: null, errors }; const head = { 이름: s.name, 규격: s.spec }; if (s.mode === "직접") return { body: { ...head, 방식: "직접", 시간당: (s.row.시간당 ?? {}) as MyMachineBody["시간당"] }, errors }; // prettier-ignore return { body: { ...head, 방식: s.mode, 값: s.row.값 ?? {} }, errors }; } /** 서버 시간당 한 줄 글 */ export function hourlyText(h: MyHourly | undefined): string { if (!h?.ok) return `${mt("Hourly_None")}${h?.reason ? ` — ${h.reason}` : ""}`; return `${won(h.계)} (${mt("Labor")} ${won(h.노무비)} · ${mt("Material")} ${won(h.재료비)} · ${mt("Expense")} ${won(h.경비)})`; } export async function mountMyMachineForm( host: HTMLElement, opt: MyMachineFormOptions, ): Promise { const [lists, one] = await Promise.all([ fetchMyMachineLists(), opt.key ? readMyMachine(opt.owner, opt.key) : Promise.resolve(null), ]); const row = one?.줄; let base: string | undefined; // 서버 `본뜬` const notice = el("div", { className: "m02-mymach__notice" }); const server = el("div", { className: "m02-mymach__server" }); const usesBox = el("div", { className: "m02-mymach__uses" }); const start: MachineRow = row ? { 키: row.키, 원문번호: row.원문번호, 이름: row.이름, 규격: row.규격, 방식: row.방식, 값: row.값, ...(row.시간당 ? { 시간당: row.시간당 } : {}) } // prettier-ignore : { 이름: "", 규격: "", 방식: "바로", 값: {} }; let m01Same = false; const sheet = createMachineSheet({ orig: start, mine: true, readOnly: false, lists, findSimilar: row ? undefined : async (q): Promise => (await findSimilar(opt.owner, q)).map((r) => ({ key: r.키, name: r.이름, spec: r.규격, tag: r.출처 === "개인" ? mt("Mine") : r.구분, row: rowOfSimilar(r), })), onSimilar: (item) => { base = item.key; checkSame(); }, onChange: () => { notice.replaceChildren(); // 지난 저장 막힘 글 지움(다시 [저장] 때 새로) opt.onDirty?.(dirty()); checkSame(); }, }); let saved = true; const dirty = (): boolean => sheet.state().changed || !saved; /* --- 같은 이름 · 규격 알림(M01 = 막음 · 내 기계 = 알림) --- */ let timer: number | undefined; let seq = 0; const sameText = (hits: { 키: string; 출처: string }[]): string => hits.length ? [ ...(hits.some((h) => h.출처 === "M01") ? [L("MS_M01Same")] : []), mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · ")), ].join(" — ") : ""; const checkSame = (): void => { window.clearTimeout(timer); timer = window.setTimeout(async () => { const mine = ++seq; const s = sheet.state(); const rows = s.name ? await findSimilar(opt.owner, s.name).catch(() => []) : []; if (mine !== seq) return; const hits = rows.filter( (r) => r.키 !== opt.key && r.이름.trim() === s.name && (r.규격 ?? "").trim() === s.spec, ); m01Same = hits.some((h) => h.출처 === "M01"); saveBtn.disabled = m01Same; sheet.setNotice(sameText(hits)); }, 300); }; /* --- 저장 --- */ const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 }); saveBtn.dataset.mymachSave = ""; saveBtn.addEventListener("click", async () => { const { body, errors } = bodyOfSheet(sheet.state()); notice.replaceChildren(...errors.map((e) => createText(e, "error"))); if (!body || m01Same) return; saveBtn.disabled = true; try { const got = opt.key ? await updateMyMachine(opt.owner, opt.key, body) : await createMyMachine(opt.owner, body, base); saved = true; opt.onDirty?.(false); showToast(mt("Saved"), "success"); opt.onSaved(got); } catch (error) { notice.replaceChildren( createText(error instanceof Error ? error.message : mt("Failed"), "error"), ); } finally { saveBtn.disabled = m01Same; } }); /* --- 지우기 --- */ const delBtn = createButton({ label: mt("Delete"), variant: "danger-ghost", size: 28 }); delBtn.hidden = !opt.key; delBtn.addEventListener("click", async () => { if (!opt.key) return; const uses = (await readMyMachine(opt.owner, opt.key).catch(() => one))?.쓰는곳 ?? []; const ask = [ mt("Delete_Ask").replace("{value}", String(uses.length)), ...uses.map((u) => `${u.키} ${u.이름}`), mt("Delete_Keep"), ].join("\n"); if (!(await showConfirmDialog(ask, mt("Delete")))) return; try { await deleteMyMachine(opt.owner, opt.key); saved = true; opt.onDirty?.(false); showToast(mt("Deleted"), "success"); opt.onDeleted?.(); } catch (error) { showToast(error instanceof Error ? error.message : mt("Failed"), "error"); } }); const back = opt.onBack ? [ createButton({ label: mt("Back"), variant: "ghost", size: 28, iconStart: "chevronLeft", onClick: opt.onBack, }), ] : []; const title = row ? `${row.이름} ${row.규격}`.trim() : mt("New"); server.replaceChildren( ...(one ? [createText(`${mt("Server_Hourly")} ${hourlyText(one.시간당)}`)] : []), ); if (one?.같은이름.length) { m01Same = one.같은이름.some((h) => h.출처 === "M01"); saveBtn.disabled = m01Same; sheet.setNotice(sameText(one.같은이름)); } const uses = one?.쓰는곳 ?? []; usesBox.replaceChildren( el("strong", { text: `${mt("Uses")} ${uses.length}` }), ...(uses.length ? [el("ul", { children: uses.map((u) => el("li", { text: `${u.키} ${u.이름}` })) })] : [createText(mt("No_Uses"), "muted")]), ); const root = el("div", { className: "m02-mymach", attrs: { "data-mymach-form": opt.key ?? "new" }, children: [ el("div", { className: "ui-toolbar m02-mymach__bar", children: [ ...back, el("strong", { text: title }), el("span", { className: "ui-text ui-text--muted", text: opt.key ?? "" }), createBadge(mt("Own_Value"), "info"), saveBtn, delBtn, ], }), createText(mt("Own_Note"), "muted"), notice, server, sheet.root, ...(opt.key ? [usesBox] : []), ], }); host.replaceChildren(root); return { root, dirty }; }