- 좌측 「내 기계」 · 「내 품목」 칸(펼치면 가운데 화면) — 목록(이름 · 규격 · 방식 · 시간당 · 쓰는 곳 · 넣은 · 고친 날 · 찾기) → 양식(821-2 부품 · 바로 넣기 기본 · 저장 때만 서버) - 단가산출 줄 쌓기 기계 고르기에 「내 기계」 묶음 · [새 기계 만들기] · 품셈 기계 줄 「복사해 내 기계로」 - 수식 머리 배지 — 값만 바뀜 = 「새 값 있음」 · 지운 키 = 「지워짐 n」 · [단가 갱신] 확인 창에 바뀐 자원 줄 - 기계 양식 state 에 방식 · 시간당 직접 꼴 · 확인창 글 줄바꿈 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
10 KiB
TypeScript
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();
|
|
});
|
|
},
|
|
});
|
|
}
|