Files
Aislo/M02_MasterTemplete/M02_MyMachine_Form.ts
T
eomsangdonandClaude Opus 5.5 f822a4c36a feat(840-2): 기계 보기 공용 — M01 기계 상세 · 내 기계를 왼쪽 제어 구역 + 오른쪽 시간당 경비 산출근거로 · 「복사해 내 기계로」 걷음 · 기계 줄 풀기 안 내놓음
- 기계 보기 부품(A00_Common/line_stack_machine_sheet) — 836-1 제어 구역 그대로(기계 표 꼴 훅 sheet · 잠금 lock) + 기본 정보 · 손료 근거 접이 카드 + 비슷한 기계에서 시작 · 값 바꾸면 브라우저 계산기로 즉시
- 서버 POST /api/m02/machine-sheet(M02_MyMachine_Sheet) — 식 고름 + 식 문서 + 처음 줄 값 + 연결 기록 · 쓰기 없음 · 파이썬 bind ↔ TS bindDoc 짝
- 덮음 ↔ 기계 줄(common_util_machine_sheet) — 고침 없음 = 줄 바이트 같음 · 내 기계 바로 · 직접 자리 맞춤
- 내 기계 = M01 에 없는 기계만 — 화면 만들기 · 고치기에서 M01 과 같은 이름 · 규격 409(불러오기 자동 생성은 그대로) · [새 기계] · 고침 열림
- 옛 기계 양식 부품(ui_template_machine_form) 지움 — 쓰는 곳 0(부품 모음 견본 줄도 걷음)
- 낡은 시험 test_821_3 test_stack_line_copy — 836-2 자동 따라옴(내 기계 고치면 SK 금액 따라감 · 갱신 할 것 없음)에 맞춤
- 지킴 시험 test_840_2 — M01 기계 655 대 박은 식 셈 = 요소 셈 · 줄 그대로 · 굴착기 덮음 · 내 기계 꼴 · M01 같은 이름 막음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 15:52:13 +09:00

263 lines
10 KiB
TypeScript

/* =============================================================================
* 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<MyMachineFormHandle> {
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<MachineSimilar[]> =>
(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 };
}