Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-10-01 23:49:27 +09:00
9 changed files with 727 additions and 29 deletions
@@ -24,6 +24,9 @@ import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Ap
import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model";
import {
GROUPS,
RULE_GROUPS,
matchesRow,
removeElementNamed,
M01_OF,
addElement,
elementOf,
@@ -36,6 +39,7 @@ import {
type M01Row,
} from "./M02_DF_InputPanel_Build";
import { renderLogicInput } from "./M02_DF_InputPanel_Logic";
import { renderGroupRule } from "./M02_DF_InputPanel_Rule";
import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick";
import { renderMaterial } from "./M02_DF_InputPanel_Material";
import { renderTablePick } from "./M02_DF_InputPanel_Table";
@@ -63,6 +67,10 @@ const apiAdd = cfUserElementAdd as unknown as (e: Omit<UserEl, "id">) => Promise
const TEXT = {
Kind: ["무엇을 넣을까요?", "What goes in?"],
Variable: ["변수", "Variable"],
ModeFixed: ["고정", "Fixed"],
ModeRule: ["조건 찾기", "Find by rule"],
RuleTag: ["조건 찾기", "Rule"],
RuleLine: ["{f} 이(가) 알약 「{n}」 값과 같음", "{f} equals pill “{n}”"],
Logic: ["다른 로직", "Another logic"],
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
@@ -193,10 +201,52 @@ function renderGroup(
on: { change(lg: CfLogic): void },
): void {
const chosen = el("div", { className: "m02-dfip__chosen" });
const find = el("div", { className: "m02-dfip__find" });
host.replaceChildren(chosen, find, newForm(lg, box, group, on));
const body = el("div", { className: "m02-dfip__stack" });
let mode: "fixed" | "rule" = "fixed";
const draw = (): void => {
if (mode === "rule")
renderGroupRule(body, lg, box.id, group, (l, e) => addElement(l, box.id, e), rulePreview, on);
else {
const find = el("div", { className: "m02-dfip__find" });
body.replaceChildren(find, newForm(lg, box, group, on));
void drawFind(find, lg, box, group, on);
}
};
// 인력 · 기계 · 유가전력은 방식(고정 / 조건 찾기)을 고름 — 재료는 재료 판 · 그 밖은 고정만
const chips = el("div", { className: "m02-dfip__chips" });
const drawChips = (): void =>
chips.replaceChildren(
...(["fixed", "rule"] as const).map((m) =>
createButton({
label: m === "fixed" ? t("ModeFixed") : t("ModeRule"),
variant: mode === m ? "filled" : "pill",
size: "sm",
onClick: () => ((mode = m), drawChips(), draw()),
}),
),
);
host.replaceChildren(chosen, ...(RULE_GROUPS.includes(group) ? [chips] : []), body);
drawChosen(chosen, lg, box, group, on);
void drawFind(find, lg, box, group, on);
if (RULE_GROUPS.includes(group)) drawChips();
draw();
}
/** 조건 찾기 미리 보기 — 알약 값과 이름(키)이 같은 마스터 줄 */
async function rulePreview(
group: CfGroup,
field: "이름" | "키",
value: unknown,
): Promise<{ found: number; names: string[]; unit: string }> {
if (value === null || value === undefined || value === "")
return { found: 0, names: [], unit: "" };
const page = await fetchRows(await fileOf(group), 1, 50, String(value), false, "", "");
const hit = (page.rows as M01Row[]).filter((r) => matchesRow(r, field, value));
const units = new Set(hit.map((r) => elementOf(group, r).unit));
return {
found: hit.length,
names: hit.slice(0, 3).map((r) => String(r["이름"] ?? "")),
unit: units.size === 1 ? [...units][0] : "",
};
}
/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */
@@ -214,26 +264,48 @@ function drawChosen(
...(els.length
? els.map((e) => {
const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") });
values.set(e.ref, val);
const rule = e.mode === "rule";
if (!rule) values.set(e.ref, val);
return el("div", {
className: "m02-dfip__item",
children: [
el("span", { className: "m02-dfip__name", text: e.name }),
el("span", {
className: "m02-dfip__name",
children: [
...(rule ? [el("span", { className: "m02-dfip__tag", text: t("RuleTag") })] : []),
el("strong", { text: ` ${e.name}` }),
...(rule && e.rule
? [
el("div", {
className: "m02-dfip__muted",
text: t("RuleLine", { f: e.rule.field, n: e.rule.from.name }),
}),
]
: []),
],
}),
val,
el("span", { className: "m02-dfip__muted", text: e.unit || "-" }),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeElement(lg, box.id, e.ref)),
onClick: () =>
on.change(
rule
? removeElementNamed(lg, box.id, e.name)
: removeElement(lg, box.id, e.ref),
),
}),
],
});
})
: [el("p", { className: "m02-dfip__muted", text: t("NoneChosen") })]),
);
if (!els.length) return;
const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
const items = els
.filter((e) => e.mode !== "rule")
.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
if (!items.length) return;
void apiValues(items, lg.userElements).then(
(answers) =>
answers.forEach((a) => {
@@ -162,16 +162,21 @@ export function matches(
return rule.op === "같음" ? target === v : target.includes(v);
}
/** 지금 잡히는 품목 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림 */
/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */
export function catchNote(
found: number,
firstName = "",
noun = "재료",
): { kind: "none" | "one" | "many"; text: string } {
if (found === 0) return { kind: "none", text: "맞는 재료가 없음 — 범위나 규칙을 넓혀 봄" };
if (found === 1) return { kind: "one", text: `재료 하나가 잡힘 — ${firstName}` };
if (found === 0)
return {
kind: "none",
text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`,
};
if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` };
return {
kind: "many",
text: `재료 ${found} 개가 잡힘 — 둘 이상이면 가장 싼 값을 씀 · 규칙을 더 좁히면 하나로 잡힘`,
text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`,
};
}
@@ -329,6 +334,28 @@ export function masterRef(
: ref;
}
/** 옛 로직 줄(입력 · 자동값)에서 바로 읽은 다른 로직 입력 값 — 흐름 변환이 아직 비어 있을 때 · 받는 알약 = 입력마다 · 예시값 = 자동값 */
export function rowRef(
key: string,
name: string,
resultUnit: string,
inputs: { 이름: string; 단위?: string }[],
auto: Record<string, unknown>,
): CfLogicRef {
const examples: Record<string, number | string> = {};
for (const i of inputs) {
const v = auto[i.이름];
if (typeof v === "number" || (typeof v === "string" && v !== "")) examples[i.이름] = v;
}
return {
key,
name,
outputs: COST_OUTPUTS.map((n) => ({ name: n, unit: resultUnit || "-" })),
designs: inputs.map((i) => ({ name: i.이름, unit: i.단위 || "-" })),
...(Object.keys(examples).length ? { examples } : {}),
};
}
/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */
export async function wouldCycle(
selfKey: string | null,
@@ -566,3 +593,36 @@ export function reconcileUnit(lg: CfLogic, id: string): CfLogic {
? lg
: { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) };
}
/* ── 조건 찾기(rule) — 인력 · 기계 · 유가전력도 씀: 이름 또는 키가 변수 알약 값과 같은 줄(옛 로직 LB:{직종} · EQ:{기계} 꼴) ── */
/** 조건 찾기를 쓰는 그룹 — 재료는 재료 판(규격 · 이름 포함) · 선택 자리(choice)는 재료만 */
export const RULE_GROUPS: CfGroup[] = ["인력", "기계", "유가전력"];
export type GroupRuleField = "이름" | "키";
/** 이 줄이 규칙에 맞나 — 이름 또는 키(원문번호도)가 알약 값과 같음(띄어쓰기 · 대소문자는 가리지 않음) */
export function matchesRow(row: M01Row, field: GroupRuleField, value: unknown): boolean {
const v = squash(value);
if (!v) return false;
const targets = field === "키" ? [row["키"], row["원문번호"]] : [row["이름"]];
return targets.some((x) => squash(x) === v);
}
/** 조건 찾기 요소 — 품목 키는 비우고 알약 이름 = 자리 이름 · 규칙 = 같음(Model 규칙 꼴에 「키」 칸을 더해 씀) */
export function groupRuleElement(
name: string,
field: GroupRuleField,
from: CfMaterialRule["from"],
): CfElementRef {
const rule = { field, op: "같음", from } as unknown as CfMaterialRule;
return { ref: "", name: name.trim(), slot: name.trim(), spec: "", unit: "-", mode: "rule", rule };
}
/** 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string | null {
if (!el.name) return "자리 이름을 적어 주세요";
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
return null;
}
@@ -14,7 +14,11 @@ import {
el,
showToast,
} from "@ui/ui_template_elements";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import {
fetchAuto,
fetchLogic,
fetchLogics,
} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
import {
@@ -23,6 +27,7 @@ import {
logicChapters,
logicRefOf,
masterRef,
rowRef,
pageRange,
setKind,
setLogicInput,
@@ -235,9 +240,14 @@ export function renderLogicInput(
if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error");
const got = await flowOf(i);
readNotes.set(i.키, { converted: got.converted, notes: got.notes });
const picked = got.converted
? masterRef(i.키, got.flow, true, i.결과단위)
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
// 옛 로직을 흐름도로 바꾼 것이 아직 비어 있으면 옛 줄의 입력 · 자동값에서 바로 읽음
const empty = got.converted && got.flow.boxes.length === 0;
const row = empty ? await fetchLogic(i.키).catch(() => null) : null;
const picked = row
? rowRef(i.키, i.이름, i.결과단위, row.logic.입력 ?? [], await fetchAuto(i.키))
: got.converted
? masterRef(i.키, got.flow, true, i.결과단위)
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
const done = setLogicInput(lg, boxId, picked);
// 기본 이름이면 고른 로직 이름으로
on.change({
@@ -0,0 +1,138 @@
/* =============================================================================
* M02_DF_InputPanel_Rule.ts
* 입력 판 — 인력 · 기계 · 유가전력의 「조건 찾기」: 이름 또는 키가 이 로직 변수 알약 값과 같은 줄을 잡음
* (옛 로직의 LB:{직종} · EQ:{기계} 꼴) · 지금 잡히는 줄 미리 보기(0 · 여럿이면 까닭) · 글 [한, 영] 짝은 아래
* 선택 자리(choice)는 재료만 — 이 판에 없음
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import type { CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model";
import {
catchNote,
checkGroupRule,
groupRuleElement,
variablePills,
type GroupRuleField,
} from "./M02_DF_InputPanel_Build";
const TEXT = {
Help: [
"변수 알약 값과 이름(또는 키)이 같은 줄을 잡음 — 예: 직종 알약 값이 「석공」이면 석공 줄",
"Catches the row whose name (or key) equals a variable pill's value",
],
SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"],
Field: ["같아야 하는 칸", "Column"],
FieldName: ["이름", "Name"],
FieldKey: ["키(원문번호)", "Key (source no.)"],
Pill: ["변수 알약", "Variable pill"],
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
Preview: ["지금 잡히는 줄", "Rows caught now"],
Put: ["넣기", "Add"],
Failed: ["불러오지 못함", "Could not load"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
/** 미리 보기 길 — 그룹 · 칸 · 알약 값으로 마스터 줄을 읽어 잡힌 수 · 이름 · 단위(부르는 쪽이 줌) */
export type RulePreview = (
group: CfGroup,
field: GroupRuleField,
value: unknown,
) => Promise<{ found: number; names: string[]; unit: string }>;
/** 조건 찾기 요소 추가 판 */
export function renderGroupRule(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
addEl: (lg: CfLogic, el: CfElementRef) => CfLogic,
preview: RulePreview,
on: { change(lg: CfLogic): void },
): void {
const els = lg.boxes.find((b) => b.id === boxId)?.input?.elements ?? [];
const pills = variablePills(lg);
const name = createInputField({
type: "text",
label: t("SlotName"),
compact: true,
placeholder: group === "인력" ? "예: 직종" : "예: 기종",
});
const field = createSelectField({
label: t("Field"),
options: [
{ value: "이름", text: t("FieldName") },
{ value: "키", text: t("FieldKey") },
],
value: "이름",
compact: true,
onChange: () => void refresh(),
});
const pill = createSelectField({
label: t("Pill"),
options: pills.map((p, i) => ({
value: String(i),
text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`,
})),
value: "0",
compact: true,
onChange: () => void refresh(),
});
const note = el("p", { className: "m02-dfip__note" });
const build = (): CfElementRef =>
groupRuleElement(
name.input.value,
field.select.value as GroupRuleField,
pills[Number(pill.select.value)]?.port ?? { box: "", name: "" },
);
let seq = 0;
const refresh = async (): Promise<void> => {
const mine = ++seq;
const value = pills[Number(pill.select.value)]?.example;
note.textContent = `${t("Preview")}: …`;
try {
const p = await preview(group, field.select.value as GroupRuleField, value);
if (mine !== seq) return;
const n = catchNote(p.found, p.names[0], "줄");
note.textContent = `${t("Preview")}: ${n.text}`;
note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`;
} catch {
if (mine === seq) note.textContent = t("Failed");
}
};
const add = createButton({
label: t("Put"),
size: "sm",
onClick: () => {
const cand = build();
const why = checkGroupRule(cand, els);
name.setError(why ?? undefined);
if (why) return void showToast(why, "error");
// 잡히는 줄이 하나면 그 단위를 알약 단위로
void preview(
group,
field.select.value as GroupRuleField,
pills[Number(pill.select.value)]?.example,
).then(
(p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)),
() => on.change(addEl(lg, cand)),
);
},
});
host.replaceChildren(
el("p", { className: "m02-dfip__muted", text: t("Help") }),
name.root,
...(pills.length
? [field.root, pill.root, note, add]
: [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })]),
);
if (pills.length) void refresh();
}