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>
This commit is contained in:
2026-11-12 13:39:59 +09:00
co-authored by Claude Opus 5.5
parent f8e6d0d14e
commit 6e0d86c961
5 changed files with 711 additions and 0 deletions
+2
View File
@@ -32,6 +32,7 @@ import { ui_locales_b3 } from "./ui_template_locale_b3";
import { ui_locales_e04 } from "./ui_template_locale_e04";
import { ui_locales_b5 } from "./ui_template_locale_b5";
import { ui_locales_m1 } from "./ui_template_locale_m1";
import { ui_locales_machine } from "./ui_template_locale_machine";
import { ui_locales_m2 } from "./ui_template_locale_m2";
import { ui_locales_m2_judge } from "./ui_template_locale_m2_judge";
import { ui_locales_de } from "./ui_template_locale_de";
@@ -77,6 +78,7 @@ export const ui_locales = {
...ui_locales_e04,
...ui_locales_b5,
...ui_locales_m1,
...ui_locales_machine,
...ui_locales_m2,
...ui_locales_m2_judge,
...ui_locales_de,
+72
View File
@@ -0,0 +1,72 @@
/* =============================================================================
* ui_template_locale_machine.ts
* 기계 양식 부품(`ui_template_machine_form.ts`) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에.
* ========================================================================== */
export const ui_locales_machine = {
MF_Name: ["이름", "Name"],
MF_Spec: ["규격", "Spec"],
MF_Price: ["가격", "Price"],
MF_Coef: ["손료계수", "Ownership coef."],
MF_CoefDirect: ["바로 넣기", "Enter directly"],
MF_CoefPumsem: ["품셈 방식", "By standard"],
MF_Life: ["내용시간", "Service life"],
MF_Yearly: ["연간표준가동시간", "Annual hours"],
MF_Dep: ["상각비율", "Depreciation ratio"],
MF_Rep: ["정비비율", "Maintenance ratio"],
MF_Mgmt: ["연간관리비율", "Annual mgmt ratio"],
MF_DepCoef: ["상각비계수", "Depreciation coef."],
MF_RepCoef: ["정비비계수", "Maintenance coef."],
MF_MgmtCoef: ["관리비계수", "Mgmt coef."],
MF_Fuel: ["주연료", "Main fuel"],
MF_FuelKind: ["연료 종류", "Fuel"],
MF_Misc: ["잡품", "Sundries"],
MF_Crew: ["조종원", "Operator"],
MF_Job: ["직종", "Trade"],
MF_Power: ["전력", "Power"],
MF_None: ["없음", "None"],
MF_Build: ["구성", "Components"],
MF_HourlyDirect: ["시간당 값 직접", "Hourly values"],
MF_Labor: ["노무비", "Labor"],
MF_Material: ["재료비", "Material"],
MF_Expense: ["경비", "Expense"],
MF_Total: ["계", "Total"],
MF_Loss: ["손료", "Ownership"],
MF_NumberOnly: ["숫자만", "Numbers only"],
MF_Range: ["보통 범위 {value} 밖", "Outside usual range {value}"],
MF_Preview: ["시간당 경비 미리보기", "Hourly cost preview"],
MF_PreviewNote: [
"전국평균 단가 · 넣는 즉시 셈 · 저장 때 서버가 다시 셈",
"National average · instant · server recalculates on save",
],
MF_NoPrice: ["단가 없음", "no price"],
MF_Similar: ["비슷한 기계에서 시작", "Start from a similar machine"],
MF_SimilarFind: ["기계 이름 · 규격으로 찾기", "Find by name or spec"],
MF_SimilarNone: ["찾은 기계 없음", "No machines found"],
MF_SimilarCopied: ["{value} 값을 복사함", "Copied values from {value}"],
MF_Changed: ["고친 칸", "edited"],
M01_MachineNew: ["새 기계", "New machine"],
M01_MachineBack: ["목록으로", "Back to list"],
M01_MachineHourly: ["시간당 경비", "Hourly cost"],
M01_MachineMeta: ["원문 · 구분", "Source · group"],
M01_MachineUses: ["쓰는 곳", "Used by"],
M01_MachineNoUses: ["쓰는 곳 없음", "Not used"],
M01_MachineSame: [
"같은 이름 · 규격 기계가 있음 — {value}",
"A machine with the same name and spec exists — {value}",
],
M01_MachineDelete: ["지우기", "Delete"],
M01_MachineDeleteAsk: [
"이 기계를 지울까? 쓰는 곳 {value} — [저장] 해야 지워짐",
"Delete this machine? Used by {value} — applied on [Save]",
],
M01_MachineLeave: [
"저장 안 한 고침이 있음. 나갈까? (고침은 초안에 남음)",
"Unsaved edits. Leave? (edits stay in the draft)",
],
M01_MachineNotFound: ["그 기계가 없음", "Machine not found"],
M01_MachineReadOnly: ["읽기만 — 시스템 관리자만 고침", "Read only — system admins edit"],
M01_MachineErrors: ["고칠 칸이 있음", "Fix the marked fields"],
M01_MachineDetailGroup: ["상세구분", "Sub-group"],
M01_MachineSourceNo: ["원문번호", "Source no."],
} as const;
+112
View File
@@ -0,0 +1,112 @@
/* 기계 양식(821-2) — 왼쪽 칸 · 오른쪽 시간당 계산식 · 좁은 폭(720 아래)은 한 줄로 */
.ui-machine-form {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.ui-machine-form__fields {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.ui-machine-form__row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--spacing-8) var(--spacing-12);
}
.ui-machine-form__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.ui-machine-form__head {
display: flex;
align-items: center;
gap: var(--spacing-12);
}
.ui-machine-form__coefs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--spacing-8);
}
.ui-machine-form__coefs .ui-input {
background: var(--color-surface-sunken);
color: var(--color-text-secondary);
}
.ui-machine-form .is-changed .ui-input,
.ui-machine-form .is-changed .ui-select {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.ui-machine-form__warn {
min-height: 0;
color: var(--color-warning);
font-size: var(--text-caption);
}
.ui-machine-form__warn:empty {
display: none;
}
.ui-machine-form__notice:empty {
display: none;
}
.ui-machine-form__similar {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-8) var(--spacing-12);
}
.ui-machine-form__similar > summary {
cursor: pointer;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.ui-machine-form__similar .ui-find__list {
max-height: 240px;
overflow: auto;
}
.ui-machine-form__preview {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
position: sticky;
top: 0;
}
.ui-machine-form__preview .ui-calc-row--formula {
grid-template-columns: 64px minmax(0, 1fr) 96px;
}
.ui-machine-form__rule {
border: 0;
border-top: 1px solid var(--color-border);
margin: var(--spacing-4) 0;
width: 100%;
}
@media (max-width: 720px) {
.ui-machine-form {
grid-template-columns: minmax(0, 1fr);
}
.ui-machine-form__preview {
position: static;
}
}
+506
View File
@@ -0,0 +1,506 @@
/* =============================================================================
* 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,
};
}
+19
View File
@@ -54,6 +54,7 @@ import { createRangeBar } from "./ui_template_range_bar";
import { createStat, statGrid } from "./ui_template_stat";
import { toastNode } from "./ui_template_toast";
import { FRAME_DEMOS } from "./ui_template_ui_kit_frames";
import { createMachineForm } from "./ui_template_machine_form";
/** 상태 줄 다섯 칸 */
export const KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const;
@@ -321,6 +322,22 @@ function tabsDemo(s: KitState): HTMLElement | null {
return box;
}
/** 기계 양식 견본 — 굴착기 1.0㎥ 값(821-2) */
const kitMachine = (readOnly: boolean): HTMLElement =>
createMachineForm({
name: "굴착기(무한궤도)",
spec: "1.0㎥",
values: {
...{ 가격: 142069, 내용시간: 10000, 연간표준가동시간: 1250, 상각비율: 0.9, 정비비율: 0.7 },
...{ 연간관리비율: 0.1, 상각비계수: 900, 정비비계수: 700, 관리비계수: 485, 손료계수: 2085 },
...{ 주연료: 19.5, 주연료종류: "경유", 잡재료율: 22, 조종원: 1, 조종원직종: "1048" },
},
fuels: [{ value: "경유", text: "경유", price: 1846.39 }],
jobs: [{ value: "1048", text: "건설기계운전사", price: 283297 }],
readOnly,
findSimilar: async () => [],
}).root;
// prettier-ignore
export const KIT_PARTS: KitPart[] = [
// ── 552-2 기본 ─────────────────────────────────────────────
@@ -386,6 +403,8 @@ export const KIT_PARTS: KitPart[] = [
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
demo: (s) => (s === "default" ? kitCalc() : null) },
{ key: "machine-form", name: "기계 양식", group: "compound", kinds: "이름 · 규격 · 가격 · 손료계수(바로 넣기 · 품셈 방식) · 주연료 · 잡품 · 조종원 · 전력 · 시간당 값 직접 · 옆 시간당 계산식 · 비슷한 기계에서 시작 · 고친 칸 · 숫자만", file: "ui_template_machine_form.ts",
demo: (s) => (s === "default" || s === "disabled" ? kitMachine(s === "disabled") : null) },
{ key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts",
demo: (s) => (s === "default" ? kitEquation() : null) },
{ key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },