- 넣을 값 카드(M02_Labor_Values)는 담은 로직 입력 전부를 보임(자리 「선택」 감싸기 없앰) · 이름 「넣을 값」 - 고르기 모달 면적산출 알약 = 복사본 일위대가 알약(가져온 때 그대로) - 입력값.json · 고른자재.json · /calc 는 그대로 · storage 층 inputs/ 파일은 안 건드림(읽기만 끊음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* M02_Labor_Values.ts
|
|
* 넣을 값 칸(39-3) — 값 칸 규칙 한 벌:
|
|
* 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder).
|
|
* · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀)
|
|
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(담은 로직 입력 전부)
|
|
* ========================================================================== */
|
|
|
|
import { createButton, el } from "@ui/ui_template_elements";
|
|
import type { ItemInput, ItemInputs, ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
|
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
|
|
import {
|
|
choicesOf,
|
|
inputKey,
|
|
isOutside,
|
|
rangeOf,
|
|
storedOf,
|
|
type ValueDoc,
|
|
} from "./M02_Labor_Values_Model";
|
|
import "./M02_Labor_Values.css";
|
|
|
|
const fill = (text: string, values: Record<string, string | number>): string =>
|
|
text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""));
|
|
|
|
const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" };
|
|
|
|
const autoValue = (input: ItemInput): string | number | null => {
|
|
const auto = input.자동값;
|
|
return typeof auto === "string" || typeof auto === "number" ? auto : null;
|
|
};
|
|
|
|
export interface ValueControl {
|
|
control: HTMLInputElement | HTMLSelectElement;
|
|
/** 범위 밖 글(빈 글이면 비움) */
|
|
error: HTMLElement;
|
|
/** 지금 범위 밖인가 — 부르는 쪽이 셈(값 카드 「막힌 칸 수」) */
|
|
wasBad: boolean;
|
|
}
|
|
|
|
/** 값 칸 하나 — `value` = 저장 값(null = 자동값) · 바뀔 때마다 `onChange(저장 값, 범위 밖)` */
|
|
export function createValueControl(
|
|
input: ItemInput,
|
|
value: string | number | null,
|
|
onChange: (value: string | number | null, bad: boolean) => void,
|
|
): ValueControl {
|
|
const auto = autoValue(input);
|
|
const range = rangeOf(input);
|
|
const choices = choicesOf(input);
|
|
const control = (
|
|
choices.length
|
|
? el("select", { className: "ui-select ui-select--sm m02-inputval__control" })
|
|
: el("input", {
|
|
className: "ui-input m02-inputval__control",
|
|
attrs: {
|
|
type: range ? "number" : "text",
|
|
...(range?.min !== undefined ? { min: String(range.min) } : {}),
|
|
...(range?.max !== undefined ? { max: String(range.max) } : {}),
|
|
...(range ? { step: "any" } : {}),
|
|
placeholder: auto === null ? "" : String(auto),
|
|
},
|
|
})
|
|
) as HTMLInputElement | HTMLSelectElement;
|
|
if (control instanceof HTMLSelectElement) {
|
|
control.append(
|
|
el("option", {
|
|
attrs: { value: "" },
|
|
text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`,
|
|
}),
|
|
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
|
|
);
|
|
}
|
|
const empty = value === null || value === "";
|
|
control.value = empty ? "" : String(value);
|
|
control.classList.toggle("is-auto", empty);
|
|
const error = el("span", { className: "m02-inputval__error" });
|
|
const out: ValueControl = { control, error, wasBad: false };
|
|
control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", () => {
|
|
const raw = control.value;
|
|
const bad = isOutside(input, raw);
|
|
control.setAttribute("aria-invalid", bad ? "true" : "false");
|
|
error.textContent = bad
|
|
? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` })
|
|
: "";
|
|
control.classList.toggle("is-auto", raw === "");
|
|
onChange(storedOf(input, raw), bad);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ─────────────────────────────
|
|
* 같은 카드 틀 · 같은 입력 목록에서 **값**을 받음: 담은 로직 입력 전부를
|
|
* 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님
|
|
* (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음.
|
|
* mode "view" 보기만 + [넣을 값 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
|
|
|
|
export interface InputsValueData {
|
|
list: ItemInputs;
|
|
values: ValueDoc;
|
|
}
|
|
|
|
export interface InputsValueOptions {
|
|
mode: "view" | "value";
|
|
/** mode "view" — [넣을 값 수정] */
|
|
onEdit?: () => void;
|
|
/** mode "value" — 값이 바뀔 때마다 */
|
|
onChange?: (values: ValueDoc) => void;
|
|
}
|
|
|
|
export interface InputsValueHandle {
|
|
root: HTMLElement;
|
|
/** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */
|
|
set: (data: InputsValueData | null, note?: string, retry?: () => void) => void;
|
|
/** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */
|
|
loading: () => () => void;
|
|
/** 지금 값(확인한 것 · 값이 든 것만) */
|
|
getValues: () => ValueDoc;
|
|
/** 범위 밖 값이 없나 */
|
|
isValid: () => boolean;
|
|
/** [넣을 값 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
|
|
setEditLabel: (label: string) => void;
|
|
}
|
|
|
|
const VALUE_TEXT = {
|
|
title: "넣을 값",
|
|
missing: "빠진 로직 {value}",
|
|
edit: "넣을 값 수정",
|
|
none: "넣을 값이 없음 — 담은 로직에 입력이 없음",
|
|
auto: "자동값",
|
|
unconfirmed: "확인 안 함",
|
|
confirmed: "✓ 확인함",
|
|
confirmBox: "확인",
|
|
summary: "넣을 값 {value}",
|
|
noList: "입력 목록을 못 읽음 — {value}",
|
|
};
|
|
|
|
type ValueEntry = ValueDoc[string][string];
|
|
|
|
export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle {
|
|
let data: InputsValueData | null = null;
|
|
let values: ValueDoc = {};
|
|
let invalid = 0;
|
|
|
|
const summary = el("span", { className: "m02-struct__no" });
|
|
const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" });
|
|
edit.hidden = opts.mode !== "view";
|
|
if (opts.onEdit) edit.addEventListener("click", opts.onEdit);
|
|
const body = el("div", { className: "m02-struct__sheet m02-inputval" });
|
|
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [넣을 값 수정] · 상태 글은 목록 칸에
|
|
const shell = cardShell({
|
|
title: VALUE_TEXT.title,
|
|
sub: [summary],
|
|
body: el("div", { className: "m02-inputval__wrap", children: [body] }),
|
|
stateHost: body,
|
|
});
|
|
shell.tools.append(edit);
|
|
const root = shell.root;
|
|
|
|
/** 로직마다 묶은 입력 — 입력이 없는 로직은 뺌 */
|
|
const groups = (): { logic: ItemLogic; inputs: ItemInput[] }[] =>
|
|
(data?.list.로직 ?? [])
|
|
.map((logic) => ({ logic, inputs: logic.입력 }))
|
|
.filter((group) => group.inputs.length > 0);
|
|
const one = (logic: string, name: string): ValueEntry =>
|
|
values[logic]?.[name] ?? { 값: null, 확인: false };
|
|
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
|
|
const put = (logic: string, name: string, next: ValueEntry): void => {
|
|
(values[logic] ??= {})[name] = next;
|
|
};
|
|
const autoOf = autoValue;
|
|
|
|
const paintSummary = (): void => {
|
|
let total = 0;
|
|
let confirmed = 0;
|
|
for (const { logic, inputs } of groups())
|
|
for (const input of inputs) {
|
|
total += 1;
|
|
if (one(logic.로직, input.이름).확인) confirmed += 1;
|
|
}
|
|
summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : "";
|
|
};
|
|
|
|
/** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */
|
|
const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => {
|
|
const stored = one(logic.로직, input.이름);
|
|
const auto = autoOf(input);
|
|
const state = el("span", { className: "m02-inputval__state" });
|
|
const paintState = (): void => {
|
|
const now = one(logic.로직, input.이름);
|
|
state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed;
|
|
state.classList.toggle("is-confirmed", now.확인);
|
|
};
|
|
if (opts.mode === "view") {
|
|
const text = isEmpty(stored.값) ? auto : stored.값;
|
|
const shown = el("span", {
|
|
className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`,
|
|
text: text === null ? "—" : String(text),
|
|
attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {},
|
|
});
|
|
paintState();
|
|
return el("div", { className: "m02-inputval__cell", children: [shown, state] });
|
|
}
|
|
|
|
const emit = (): void => {
|
|
paintState();
|
|
paintSummary();
|
|
opts.onChange?.(values);
|
|
};
|
|
const range = rangeOf(input);
|
|
const field = createValueControl(input, stored.값, (value, bad) => {
|
|
if (bad !== field.wasBad) invalid += bad ? 1 : -1;
|
|
field.wasBad = bad;
|
|
// 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님)
|
|
put(logic.로직, input.이름, { 값: bad ? null : value, 확인: value !== null && !bad });
|
|
box.checked = value !== null && !bad;
|
|
emit();
|
|
});
|
|
const { control, error } = field;
|
|
const box = el("input", {
|
|
attrs: {
|
|
type: "checkbox",
|
|
title: VALUE_TEXT.confirmBox,
|
|
"aria-label": VALUE_TEXT.confirmBox,
|
|
},
|
|
}) as HTMLInputElement;
|
|
box.checked = stored.확인;
|
|
// 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림
|
|
box.addEventListener("change", () => {
|
|
const now = one(logic.로직, input.이름);
|
|
if (box.checked && isEmpty(now.값) && auto !== null) {
|
|
control.value = String(auto);
|
|
control.classList.remove("is-auto");
|
|
put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true });
|
|
} else if (box.checked && isEmpty(now.값)) {
|
|
box.checked = false; // 받아들일 값이 없음
|
|
} else {
|
|
put(logic.로직, input.이름, { ...now, 확인: box.checked });
|
|
}
|
|
emit();
|
|
});
|
|
paintState();
|
|
return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] });
|
|
};
|
|
|
|
const paint = (note?: string, retry?: () => void): void => {
|
|
invalid = 0;
|
|
paintSummary();
|
|
if (!data) {
|
|
if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry);
|
|
return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none);
|
|
}
|
|
const shown = groups();
|
|
const missing = data.list.빠진로직.length
|
|
? [
|
|
el("p", {
|
|
className: "m02-inputval__missing",
|
|
text: fill(VALUE_TEXT.missing, { value: data.list.빠진로직.join(", ") }),
|
|
}),
|
|
]
|
|
: [];
|
|
if (!shown.length) {
|
|
body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing);
|
|
return;
|
|
}
|
|
body.replaceChildren(
|
|
...shown.map(({ logic, inputs }) =>
|
|
el("section", {
|
|
className: "m02-inputval__logic",
|
|
attrs: { "data-logic": logic.로직 },
|
|
children: [
|
|
el("h4", {
|
|
className: "m02-inputval__logic-name",
|
|
text: logic.이름 || logic.로직,
|
|
attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` },
|
|
}),
|
|
...inputs.map((input) =>
|
|
el("div", {
|
|
className: "m02-inputval__row",
|
|
attrs: { "data-input": inputKey(logic.로직, input.이름) },
|
|
children: [
|
|
el("span", {
|
|
className: "m02-inputval__name",
|
|
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
|
|
attrs: input.설명 ? { title: input.설명 } : {},
|
|
}),
|
|
valueCell(logic, input),
|
|
],
|
|
}),
|
|
),
|
|
],
|
|
}),
|
|
),
|
|
...missing,
|
|
);
|
|
};
|
|
|
|
return {
|
|
root,
|
|
set: (next, note, retry) => {
|
|
data = next;
|
|
values = next ? structuredClone(next.values) : {};
|
|
paint(note, retry);
|
|
},
|
|
loading: () => shell.loading(),
|
|
getValues: () => {
|
|
// 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함)
|
|
const out: ValueDoc = {};
|
|
for (const [logic, byName] of Object.entries(values))
|
|
for (const [name, entry] of Object.entries(byName))
|
|
if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry;
|
|
return out;
|
|
},
|
|
isValid: () => invalid === 0,
|
|
setEditLabel: (label) => {
|
|
(edit.querySelector(".ui-btn__label") ?? edit).textContent = label;
|
|
},
|
|
};
|
|
}
|