Files
Aislo/ui_template/ui_template_machine_form.ts
T
eomsangdonandClaude Opus 5.5 6e0d86c961 feat(821-2): 기계 양식 부품 — 칸 여섯 · 손료계수 두 방식 · 시간당 값 직접 · 옆 계산식 즉시 · 비슷한 기계에서 시작
- ui_template_machine_form.ts · .css — 목록 · 찾기 · 저장은 부르는 쪽이 줌(M01 · M02 에 안 묶임) · 숫자만 · 범위 경고 · 고친 칸 노랑 · 같은 이름 알림 자리 · 좁은 폭 한 줄
- 품셈 방식 = 비율 칸 · 계수 넷 읽기 전용(입력이 처음과 같으면 저장 값 그대로)
- 사전 ui_template_locale_machine.ts · 부품 모음 견본 한 칸

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

507 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* ui_template_machine_form.ts
* 부품 「기계 양식」(PLAN 821-2 · 요구서 796 1 장) — M01 기계 상세 · M02 내 기계 · 불러오기가 같이 씀.
*
* 칸 = 이름 · 규격 / 가격(천원) / 손료계수(바로 넣기 · 품셈 방식) / 주연료(종류 고르기 + ℓ/hr) /
* 잡품 %(주연료의) / 조종원(직종 고르기 + 인) / 전력(kWh · 선택) · 「시간당 값 직접」 꼴(allowHourly)
* 옆 = 시간당 경비 계산식 즉시(`common_util_machine_coef` · 정본은 서버 식)
* 찾기 = 「비슷한 기계에서 시작」(findSimilar 를 주면) — 고르면 값 · 이름 · 규격 복사
* 상태 = 숫자만(아니면 오류) · 범위 밖 경고 · 고친 칸 노랑 · 같은 이름 알림 자리(setNotice) · readOnly
* 자료 = 목록(연료 · 직종 · 단가) · 찾기 · 저장은 부르는 쪽이 줌 — 이 부품은 M01 · M02 에 안 묶임
* 값 묶음 = 기계 줄 `값` 꼴 그대로(칸 이름 같음) · 안 보이는 칸(선원 등)은 손대지 않음
* ========================================================================== */
import {
MACHINE_COEFS,
MACHINE_INPUTS,
machineCoefOf,
machineHourly,
} from "../common_util/common_util_machine_coef";
import { createCalcFormula, createCalcFormulaRow } from "./ui_template_calc_row";
import { el, createInputField, createSelectField } from "./ui_template_elements";
import { createFindList } from "./ui_template_find_list";
import { t as L } from "./ui_template_locale";
import { createSegment } from "./ui_template_segment";
import { createText } from "./ui_template_text";
import "./ui_template_machine_form.css";
export type MachineValues = Record<string, unknown>;
/** 고르기 목록 한 칸 — 연료(값 = 종류 · 값 단가 원/ℓ) · 직종(값 = 코드 · 단가 원/일) */
export interface MachinePick {
value: string;
text: string;
price: number | null;
}
export interface MachineSimilar {
key: string;
name: string;
spec: string;
/** 흐린 꼬리표(구분 · 내 기계 등) */
tag?: string;
values: MachineValues;
}
export interface MachineFormOptions {
name?: string;
spec?: string;
values?: MachineValues;
/** 손료계수 방식 기본 — 값에 품셈 입력 다섯이 있으면 늘 품셈 방식 */
coefMode?: "direct" | "pumsem";
/** 「시간당 값 직접」 꼴 허용(내 기계) */
allowHourly?: boolean;
fuels: MachinePick[];
jobs: MachinePick[];
/** 전력 단가(원/kWh) · 시간환산(조종원 하루 → 한 시간) */
power?: number;
hour?: number;
readOnly?: boolean;
findSimilar?: (q: string) => Promise<MachineSimilar[]>;
onSimilar?: (item: MachineSimilar) => void;
onChange?: () => void;
}
export interface MachineFormState {
name: string;
spec: string;
values: MachineValues;
/** 숫자 아닌 칸 등 — 있으면 저장 막음 */
errors: string[];
changed: boolean;
}
export interface MachineFormHandle {
root: HTMLElement;
state: () => MachineFormState;
/** 같은 이름 · 규격 알림 등(빈 글 = 지움) */
setNotice: (text: string) => void;
}
/** 시간당 값 직접 칸 · 비목 */
export const HOURLY_KEY = "시간당";
const HOURLY_COSTS = ["노무비", "재료비", "경비"] as const;
const HOURLY_LABEL = { 노무비: "MF_Labor", 재료비: "MF_Material", 경비: "MF_Expense" } as const;
interface NumSpec {
key: string;
label: string;
unit: string;
min: number;
max: number;
}
const n = (key: string, label: string, unit: string, min: number, max: number): NumSpec => ({
key,
label,
unit,
min,
max,
});
const PRICE = n("가격", "MF_Price", "천원", 0, 5_000_000);
const COEF = n("손료계수", "MF_Coef", "10⁻⁷/hr", 1, 100_000);
const PUMSEM = [
n("내용시간", "MF_Life", "시간", 100, 100_000),
n("연간표준가동시간", "MF_Yearly", "시간", 100, 8_760),
n("상각비율", "MF_Dep", "", 0, 1),
n("정비비율", "MF_Rep", "", 0, 3),
n("연간관리비율", "MF_Mgmt", "", 0, 1),
];
const FUEL = n("주연료", "MF_Fuel", "ℓ/hr", 0, 2_000);
const MISC = n("잡재료율", "MF_Misc", "%", 0, 100);
const CREW = n("조종원", "MF_Crew", "인", 0, 20);
const POWER = n("전력", "MF_Power", "kWh", 0, 10_000);
const COEF_LABEL: Record<string, string> = {
상각비계수: "MF_DepCoef",
정비비계수: "MF_RepCoef",
관리비계수: "MF_MgmtCoef",
손료계수: "MF_Coef",
};
const tr = (key: string): string => L(key as "MF_Name");
const fmt = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
const shown = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 글 → 수 · 빈 글 = null · 수 아님 = NaN */
function parseNum(text: string): number | null {
const s = text.replace(/,/g, "").trim();
if (!s) return null;
return /^-?\d+(\.\d+)?$/.test(s) ? Number(s) : Number.NaN;
}
export function createMachineForm(opts: MachineFormOptions): MachineFormHandle {
const init: MachineValues = structuredClone(opts.values ?? {});
let values: MachineValues = structuredClone(init);
let name = opts.name ?? "";
let spec = opts.spec ?? "";
const startName = name;
const startSpec = spec;
const hasPumsem = MACHINE_INPUTS.some((k) => typeof init[k] === "number");
let mode: "direct" | "pumsem" = hasPumsem ? "pumsem" : (opts.coefMode ?? "direct");
let hourlyMode = init[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly);
const errors = new Map<string, string>();
const root = el("div", { className: "ui-machine-form" });
const fields = el("div", { className: "ui-machine-form__fields" });
const side = el("aside", { className: "ui-machine-form__preview" });
const notice = el("div", { className: "ui-machine-form__notice" });
/** 칸 값을 값 묶음에 — 빈 값은 원래 칸이 있었으면 null · 없었으면 칸을 뺌 */
const put = (key: string, value: unknown): void => {
if (value === null || value === "") {
if (key in init) values[key] = null;
else delete values[key];
} else values[key] = value;
};
const changed = (): boolean => name !== startName || spec !== startSpec || !same(values, init);
/** 품셈 입력이 처음과 같으면 저장된 계수 그대로(원문 표 값) · 다르면 계수 짝으로 셈 */
const syncCoefs = (): void => {
if (mode !== "pumsem") return;
const sameInputs = MACHINE_INPUTS.every((k) => same(values[k], init[k]));
const got = sameInputs ? null : machineCoefOf(values);
for (const k of MACHINE_COEFS) {
if (sameInputs) put(k, init[k] ?? null);
else put(k, got ? got[k] : null);
}
};
const fire = (): void => {
syncCoefs();
paintPreview();
paintCoefs();
opts.onChange?.();
};
/** 숫자 칸 — 숫자만 · 범위 밖 경고 · 고친 칸 노랑 */
const numField = (spec_: NumSpec, read = false, hold?: MachineValues): HTMLElement => {
const bag = hold ?? values;
const field = createInputField({
label: tr(spec_.label),
value: shown(bag[spec_.key]),
unit: spec_.unit || undefined,
size: 28,
});
field.input.inputMode = "decimal";
field.input.dataset.key = spec_.key;
const warn = el("span", { className: "ui-machine-form__warn" });
field.root.append(warn);
const mark = (): void => {
const now = hold ? hold[spec_.key] : values[spec_.key];
const was = hold
? (init[HOURLY_KEY] as MachineValues | undefined)?.[spec_.key]
: init[spec_.key];
field.root.classList.toggle("is-changed", !same(now ?? null, was ?? null));
};
if (read || opts.readOnly) field.input.readOnly = true;
field.input.addEventListener("input", () => {
const v = parseNum(field.input.value);
warn.textContent = "";
if (Number.isNaN(v)) {
errors.set(spec_.key, `${tr(spec_.label)} · ${L("MF_NumberOnly")}`);
field.setError(L("MF_NumberOnly"));
} else {
errors.delete(spec_.key);
field.setError();
if (v !== null && (v < spec_.min || v > spec_.max))
warn.textContent = L("MF_Range").replace(
"{value}",
`${fmt(spec_.min)}~${fmt(spec_.max)}`,
);
if (hold) {
if (v === null) delete hold[spec_.key];
else hold[spec_.key] = v;
values[HOURLY_KEY] = { ...hold };
} else put(spec_.key, v);
}
mark();
fire();
});
mark();
return field.root;
};
/** 고르기 칸 — 없음 + 목록 · 목록에 없는 지금 값도 한 칸(단가 없음) */
const pickField = (key: string, label: string, list: MachinePick[]): HTMLElement => {
const now = shown(values[key]);
const options = [
{ value: "", text: L("MF_None") },
...list.map((p) => ({ value: p.value, text: p.text })),
];
if (now && !list.some((p) => p.value === now))
options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` });
const field = createSelectField({ label: tr(label), options, value: now, size: 28 });
field.select.dataset.key = key;
field.select.disabled = Boolean(opts.readOnly);
const mark = (): void => {
field.root.classList.toggle("is-changed", !same(values[key] ?? null, init[key] ?? null));
};
field.select.addEventListener("change", () => {
put(key, field.select.value || null);
mark();
fire();
});
mark();
return field.root;
};
const textField = (
label: string,
get: () => string,
set: (v: string) => void,
was: string,
): HTMLElement => {
const field = createInputField({ label: tr(label), value: get(), size: 28 });
field.input.readOnly = Boolean(opts.readOnly);
field.input.addEventListener("input", () => {
set(field.input.value);
field.root.classList.toggle("is-changed", field.input.value !== was);
opts.onChange?.();
});
return field.root;
};
/* --- 손료계수 꼴 --- */
const coefBox = el("div", { className: "ui-machine-form__group" });
const coefOut = el("div", { className: "ui-machine-form__coefs" });
const paintCoefs = (): void => {
if (mode !== "pumsem") return;
coefOut.replaceChildren(
...MACHINE_COEFS.map((k) => {
const f = createInputField({ label: tr(COEF_LABEL[k]), value: shown(values[k]), size: 28 });
f.input.readOnly = true;
f.input.tabIndex = -1;
f.root.classList.toggle("is-changed", !same(values[k] ?? null, init[k] ?? null));
return f.root;
}),
);
};
const paintCoefBox = (): void => {
const pumsem = mode === "pumsem";
coefBox.replaceChildren(
el("div", {
className: "ui-machine-form__row",
children: pumsem ? PUMSEM.map((s) => numField(s)) : [numField(COEF)],
}),
...(pumsem ? [coefOut] : []),
);
paintCoefs();
};
const coefSeg = createSegment({
options: [
{ value: "direct", label: L("MF_CoefDirect") },
{ value: "pumsem", label: L("MF_CoefPumsem") },
],
value: mode,
size: 24,
ariaLabel: L("MF_Coef"),
onChange: (v) => {
mode = v as "direct" | "pumsem";
if (mode === "direct") {
for (const k of [...MACHINE_INPUTS, ...MACHINE_COEFS.slice(0, 3)]) delete values[k];
if (values["손료계수"] === undefined && init["손료계수"] !== undefined)
values["손료계수"] = init["손료계수"];
} else for (const k of MACHINE_INPUTS) if (init[k] !== undefined) values[k] = init[k];
paintCoefBox();
fire();
},
});
if (opts.readOnly) coefSeg.root.querySelectorAll("button").forEach((b) => (b.disabled = true));
/* --- 구성 / 시간당 값 직접 --- */
const body = el("div", { className: "ui-machine-form__body" });
const paintBody = (): void => {
if (hourlyMode) {
const hold = { ...((values[HOURLY_KEY] as MachineValues | undefined) ?? {}) };
body.replaceChildren(
el("div", {
className: "ui-machine-form__row",
children: HOURLY_COSTS.map((k) =>
numField(n(k, HOURLY_LABEL[k], "원/hr", 0, 10_000_000), false, hold),
),
}),
);
return;
}
paintCoefBox();
body.replaceChildren(
numField(PRICE),
el("div", {
className: "ui-machine-form__group",
children: [
el("div", {
className: "ui-machine-form__head",
children: [el("strong", { text: L("MF_Coef") }), coefSeg.root],
}),
coefBox,
],
}),
el("div", {
className: "ui-machine-form__row",
children: [
pickField("주연료종류", "MF_FuelKind", opts.fuels),
numField(FUEL),
numField(MISC),
],
}),
el("div", {
className: "ui-machine-form__row",
children: [pickField("조종원직종", "MF_Job", opts.jobs), numField(CREW), numField(POWER)],
}),
);
};
const kindSeg = opts.allowHourly
? createSegment({
options: [
{ value: "build", label: L("MF_Build") },
{ value: "hourly", label: L("MF_HourlyDirect") },
],
value: hourlyMode ? "hourly" : "build",
size: 24,
onChange: (v) => {
hourlyMode = v === "hourly";
if (hourlyMode) values[HOURLY_KEY] ??= {};
else if (init[HOURLY_KEY] === undefined) delete values[HOURLY_KEY];
paintBody();
fire();
},
})
: null;
/* --- 비슷한 기계에서 시작 --- */
const similarBox = (): HTMLElement[] => {
if (!opts.findSimilar || opts.readOnly) return [];
const find = createFindList({ placeholder: L("MF_SimilarFind") });
const wrap = el("details", {
className: "ui-machine-form__similar",
children: [el("summary", { text: L("MF_Similar") }), find.root],
});
let seq = 0;
let timer: number | undefined;
const look = async (): Promise<void> => {
const mine = ++seq;
const got = await opts.findSimilar!(find.search.value.trim()).catch(() => []);
if (mine !== seq) return;
find.hint.textContent = got.length ? "" : L("MF_SimilarNone");
find.setRows(
got.map((m) => ({
key: m.key,
name: `${m.name} ${m.spec}`.trim(),
tag: m.tag,
sub: m.key,
onPick: () => {
values = { ...structuredClone(m.values) };
name = m.name;
spec = m.spec;
mode = MACHINE_INPUTS.some((k) => typeof values[k] === "number") ? "pumsem" : mode;
coefSeg.set(mode);
hourlyMode = values[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly);
kindSeg?.set(hourlyMode ? "hourly" : "build");
wrap.open = false;
paintAll();
setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim()));
opts.onSimilar?.(m);
fire();
},
})),
);
};
find.search.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void look(), 250);
});
wrap.addEventListener("toggle", () => wrap.open && void look());
return [wrap];
};
/* --- 미리보기 --- */
const paintPreview = (): void => {
const out = (v: number, key = false): HTMLElement =>
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: fmt(v) });
const line = (label: string, text: string, v: number, key = false): HTMLElement =>
createCalcFormulaRow(label, text ? createCalcFormula(text) : null, out(v, key));
const head = [
el("strong", { text: L("MF_Preview") }),
createText(L("MF_PreviewNote"), "muted"),
];
if (hourlyMode) {
const h = (values[HOURLY_KEY] as Record<string, number> | undefined) ?? {};
const [a, b, c] = HOURLY_COSTS.map((k) => Number(h[k]) || 0);
side.replaceChildren(
...head,
line(L("MF_Labor"), "", a),
line(L("MF_Material"), "", b),
line(L("MF_Expense"), "", c),
line(L("MF_Total"), "", a + b + c, true),
);
return;
}
const fuel = opts.fuels.find((f) => f.value === values["주연료종류"])?.price ?? 0;
const wage = opts.jobs.find((j) => j.value === String(values["조종원직종"] ?? ""))?.price ?? 0;
const hour = opts.hour ?? 1 / 8;
const p = machineHourly(values, fuel, wage, opts.power ?? 0, hour);
const num = (k: string): string => fmt(Number(values[k]) || 0);
const rows = [
line(L("MF_Loss"), `${num("가격")}천원 × 1,000 × ${num("손료계수")} × 10^-7^`, p.손료),
line(L("MF_Fuel"), `${num("주연료")}ℓ × ${fmt(fuel)}원`, p.주연료),
line(L("MF_Misc"), `${fmt(p.주연료)} × ${num("잡재료율")}%`, p.잡품),
line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원),
...(values["전력"]
? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, p.전력)]
: []),
];
side.replaceChildren(
...head,
...rows,
el("hr", { className: "ui-machine-form__rule" }),
line(L("MF_Labor"), "", p.노무비),
line(L("MF_Material"), "", p.재료비),
line(L("MF_Expense"), "", p.경비),
line(L("MF_Total"), "", p.계, true),
);
};
const setNotice = (text: string): void => {
notice.replaceChildren(...(text ? [createText(text, "warning")] : []));
};
const top = el("div", { className: "ui-machine-form__row" });
const paintAll = (): void => {
errors.clear();
syncCoefs();
top.replaceChildren(
textField(
"MF_Name",
() => name,
(v) => (name = v),
startName,
),
textField(
"MF_Spec",
() => spec,
(v) => (spec = v),
startSpec,
),
);
paintBody();
paintPreview();
};
fields.append(...similarBox(), notice, top, ...(kindSeg ? [kindSeg.root] : []), body);
root.append(fields, side);
root.classList.toggle("is-readonly", Boolean(opts.readOnly));
paintAll();
return {
root,
state: () => ({
name: name.trim(),
spec: spec.trim(),
values: structuredClone(values),
errors: [...errors.values()],
changed: changed(),
}),
setNotice,
};
}