- 목록 = 이름 · 규격 · 구분 · 가격 · 손료계수 · 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고 (단위 · 출처 열 없음 · 가로 넘침 없음) · 다른 요소 표 그대로 - 상세 = 기계 양식 + 원문번호 · 원문 · 구분 · 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인) · 주소 ?machine= 로 바로 열림 · 뒤로 가기 = 목록 · 저장 안 한 고침 나가기 경고 - 고침 = 초안 → 페이지 [저장](자동저장 없음) · 서버 M01_MasterData_Machine.fix = 품셈 입력이 바뀌면 계수 짝으로 다시 셈 · 단위 · 출처 칸 채움(새 칸 = 마스터 단위 · 「자체」) - API /api/m01/machine/lists · hourly · uses · find · row (시스템 관리자만) · 시험 test_821_2_machine_save.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
/* =============================================================================
|
||
* 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(
|
||
Object.fromEntries(
|
||
[...Object.keys(init), ...Object.keys(values)]
|
||
.filter((k, i, all) => all.indexOf(k) === i && k in values)
|
||
.map((k) => [k, values[k]]),
|
||
),
|
||
),
|
||
errors: [...errors.values()],
|
||
changed: changed(),
|
||
}),
|
||
setNotice,
|
||
};
|
||
}
|