Files
Aislo/M02_MasterTemplete/M02_MyMachine_Form.ts
T
eomsangdonandClaude Opus 5.5 9b5275a78e feat(821-3): M02 내 기계 화면 · 기계 고르기 내 기계 묶음 · 복사해 내 기계로 · 새 값 · 지워짐 배지 · 내 품목
- 좌측 「내 기계」 · 「내 품목」 칸(펼치면 가운데 화면) — 목록(이름 · 규격 · 방식 · 시간당 · 쓰는 곳 · 넣은 · 고친 날 · 찾기) → 양식(821-2 부품 · 바로 넣기 기본 · 저장 때만 서버)
- 단가산출 줄 쌓기 기계 고르기에 「내 기계」 묶음 · [새 기계 만들기] · 품셈 기계 줄 「복사해 내 기계로」
- 수식 머리 배지 — 값만 바뀜 = 「새 값 있음」 · 지운 키 = 「지워짐 n」 · [단가 갱신] 확인 창에 바뀐 자원 줄
- 기계 양식 state 에 방식 · 시간당 직접 꼴 · 확인창 글 줄바꿈 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:21:32 +09:00

296 lines
10 KiB
TypeScript

/* =============================================================================
* M02_MyMachine_Form.ts
* M02 내 기계 양식 한 장(PLAN 821-3 · 요구서 796 1 · 3 장) — 기계 양식 부품(`ui_template_machine_form` · 821-2) +
* [저장] · [지우기] · 「개인 값」 표지 · 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳.
* 기본 = 손료계수 「바로 넣기」 · 「시간당 값 직접」 꼴 · 직종 · 연료 = M01 목록 고르기 · 옆 계산식 즉시(계수 짝 TS)
* [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임
* 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사
* 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」
* 부르는 곳 = 내 기계 화면(`M02_MyMachine_View`) · 기계 고르기 창 「새 기계 만들기」 · 줄 쌓기 「복사해 내 기계로」(창 `openMyMachineModal`)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import {
createMachineForm,
HOURLY_KEY,
type MachineSimilar,
type MachineValues,
} from "@ui/ui_template_machine_form";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import {
createMyMachine,
deleteMyMachine,
fetchMyMachineLists,
findSimilar,
readMyMachine,
updateMyMachine,
type MyHourly,
type MyMachine,
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;
/** 새 기계 바탕(복사해 내 기계로) — 값 · 이름 · 규격 · 본뜬 키 */
seed?: SimilarRow;
/** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */
openSimilar?: boolean;
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")}`;
/** 줄(저장 꼴) → 양식 값 묶음 — 직접 = 시간당 칸 하나 */
export function valuesOf(row: Pick<MyMachine, "방식" | "값" | "시간당">): MachineValues {
return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 };
}
/** 서버 시간당 한 줄 글 */
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?.줄;
const seed = opt.seed;
let base = seed && seed.출처 === "M01" ? seed.키 : undefined; // 서버 `본뜬`
const found = new Map<string, SimilarRow>();
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 form = createMachineForm({
name: row?.이름 ?? seed?.이름 ?? "",
spec: row?.규격 ?? seed?.규격 ?? "",
values: row
? valuesOf(row)
: seed
? valuesOf({ 방식: seed.방식 ?? "바로", 값: seed.값 ?? {}, 시간당: seed.시간당 })
: {},
coefMode: "direct",
allowHourly: true,
fuels: lists.fuels,
jobs: lists.jobs,
power: lists.power,
hour: lists.hour,
findSimilar: row
? undefined
: async (q): Promise<MachineSimilar[]> =>
(await findSimilar(opt.owner, q)).map((r) => {
found.set(r.키, r);
return {
key: r.키,
name: r.이름,
spec: r.규격,
tag: r.출처 === "개인" ? mt("Mine") : r.구분,
values: valuesOf({ 방식: r.방식 ?? "바로", 값: r.값 ?? {}, 시간당: r.시간당 }),
};
}),
onSimilar: (item) => {
base = item.key;
checkSame();
},
onChange: () => {
opt.onDirty?.(dirty());
checkSame();
},
});
let saved = !opt.key && seed ? false : true;
const dirty = (): boolean => form.state().changed || !saved;
/* --- 같은 이름 · 규격 알림(M01 · 내 기계) --- */
let timer: number | undefined;
let seq = 0;
const sameText = (hits: { 키: string; 출처: string }[]): string =>
hits.length
? mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · "))
: "";
const checkSame = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
const s = form.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,
);
form.setNotice(sameText(hits));
}, 300);
};
/* --- 저장 --- */
const bodyOf = (): MyMachineBody | null => {
const s = form.state();
const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])];
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (errors.length) return null;
if (s.hourly)
return {
이름: s.name,
규격: s.spec,
방식: "직접",
시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"],
};
const { [HOURLY_KEY]: _drop, ...vals } = s.values;
return { 이름: s.name, 규격: s.spec, 방식: s.mode === "pumsem" ? "품셈" : "바로", 값: vals };
};
const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 });
saveBtn.dataset.mymachSave = "";
saveBtn.addEventListener("click", async () => {
const body = bodyOf();
if (!body) 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 = false;
}
});
/* --- 지우기 --- */
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) form.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,
form.root,
...(opt.key ? [usesBox] : []),
],
});
host.replaceChildren(root);
if (!row && (opt.openSimilar ?? !seed))
root.querySelector<HTMLDetailsElement>("details")?.setAttribute("open", "");
if (seed) {
form.setNotice(mt("Copied").replace("{value}", `${seed.이름} ${seed.규격}`.trim()));
opt.onDirty?.(true);
}
return { root, dirty };
}
/** 창으로 띄움 — 저장하면 닫고 `done(답)` · 저장 안 하고 닫으면 물음 */
export function openMyMachineModal(
owner: string | null,
done: (got: MyMachineOne) => void,
seed?: SimilarRow,
): void {
let handle: MyMachineFormHandle | null = null;
const { close } = openModal({
title: seed ? mt("Copy_Title") : mt("New"),
closeLabel: mt("Close"),
width: "1100px",
wide: true,
beforeClose: async () => !handle?.dirty() || showConfirmDialog(mt("Leave_Ask")),
mount: (body) => {
void mountMyMachineForm(body, {
owner,
seed,
onSaved: (got) => {
close();
done(got);
},
})
.then((h) => (handle = h))
.catch((error) => {
showToast(error instanceof Error ? error.message : mt("Failed"), "error");
close();
});
},
});
}