Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* M01_LB_Steps_Cell.ts
|
||
* 로직 만들기 판 문장 보기 공용 — 글 [한, 영] 짝 · 작은 칸(숫자 · 글 · 고르기) · 값이 오는 곳 고르기
|
||
* ========================================================================== */
|
||
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { el } from "@ui/ui_template_elements";
|
||
import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api";
|
||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||
import type { LbCalc, LbExpr, LbLogic, LbLookup, LbRef } from "./M01_LB_Blocks";
|
||
|
||
export const TEXT = {
|
||
Blocked: ["이 화면에서 읽지 못하는 줄이 있음", "Some lines cannot be read on this screen"],
|
||
Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"],
|
||
BaseNote: ["표준 로직은 읽기만 — 복사해서 고침", "Standard logic is read-only — copy to edit"],
|
||
S1: ["① 입력값", "① Input values"],
|
||
S2: ["② 표 조회", "② Table lookup"],
|
||
S3: ["③ 계산", "③ Calculation"],
|
||
S4: ["④ 항목 연결", "④ Items"],
|
||
S5: ["⑤ 결과값", "⑤ Result"],
|
||
None: ["없음", "none"],
|
||
PickList: ["선택 목록", "choose from"],
|
||
Number: ["숫자", "number"],
|
||
Range: ["범위", "range"],
|
||
Unit: ["단위", "unit"],
|
||
Eg: ["예: 3", "e.g. 3"],
|
||
NotNumber: ["숫자만 넣음", "Numbers only"],
|
||
LkFrom: [" 에서 ", " : "],
|
||
LkIs: [" 이 ", " is "],
|
||
LkRow: [" 인 줄의 ", " in the row, take "],
|
||
LkNum: ["표 조회 ", "Lookup "],
|
||
SrcInput: ["입력값 · ", "input · "],
|
||
SrcCalc: ["계산 · ", "calc · "],
|
||
SrcFixed: ["고정값", "fixed value"],
|
||
SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"],
|
||
Other: ["다른 일위대가 결과", "Other unit price result"],
|
||
Hard: ["이 화면에서 못 고치는 값", "Value not editable here"],
|
||
Mul: ["×", "×"],
|
||
Div: ["÷", "÷"],
|
||
Plus: ["+", "+"],
|
||
Minus: ["−", "−"],
|
||
Mark1: ["에 ", ": add "],
|
||
Mark2: [" % 를 더 얹음", " % on top"],
|
||
Steps: ["여러 단계로 된 계산 — 읽기만", "Multi-step calculation — read only"],
|
||
Cond1: ["위에서 처음 맞는 줄의 값을 씀", "The first matching line from the top is used"],
|
||
Subject: ["가", "is"],
|
||
Otherwise: ["그 외 →", "Otherwise →"],
|
||
Big: ["가운데 큰 값", "the larger"],
|
||
Small: ["가운데 작은 값", "the smaller"],
|
||
And: ["와", "and"],
|
||
RoundA: ["을 소수 ", "round to "],
|
||
RoundB: [" 자리까지 ", " decimals, "],
|
||
Up: ["올림", "round up"],
|
||
Down: ["버림", "round down"],
|
||
Half: ["반올림", "round off"],
|
||
OpEq: ["이면", "equal to"],
|
||
OpNe: ["이 아니면", "not equal to"],
|
||
OpGt: ["보다 크면", "greater than"],
|
||
OpGe: ["이상이면", "at least"],
|
||
OpLt: ["보다 작으면", "less than"],
|
||
OpLe: ["이하이면", "at most"],
|
||
SlotTable: ["표에서 찾은 값", "Value from table"],
|
||
SlotCalc: ["계산한 값", "Calculated value"],
|
||
PlaceQty: ["수량", "amount"],
|
||
PlaceRate: ["비율", "rate"],
|
||
PlaceResult: ["결과값", "result"],
|
||
Calc: ["계산 ", "Calc "],
|
||
Tag_person: ["노무", "Labor"],
|
||
Tag_material: ["재료", "Material"],
|
||
Tag_machine: ["장비", "Equipment"],
|
||
Tag_logic: ["일위대가", "Unit price"],
|
||
Tag_percent: ["비율", "Rate"],
|
||
Tag_locked: ["기타", "Other"],
|
||
Pre_person: [" 이 ", " works "],
|
||
Pre_pick: [" 를 ", " uses "],
|
||
Pre_percent: [" 의 ", " of "],
|
||
Post_percent: [" % ", " % "],
|
||
Repick: ["다른 것으로 다시 고르기", "Choose another"],
|
||
Remove: ["지움", "Remove"],
|
||
Pending: ["계산 중", "calculating"],
|
||
Won: ["원", "KRW"],
|
||
Add_person: ["+ 노무", "+ Labor"],
|
||
Add_material: ["+ 재료", "+ Material"],
|
||
Add_machine: ["+ 장비", "+ Equipment"],
|
||
Add_logic: ["+ 일위대가", "+ Unit price"],
|
||
Add_percent: ["+ 비율", "+ Rate"],
|
||
Base_노무비: ["노무비", "labor cost"],
|
||
Base_재료비: ["재료비", "material cost"],
|
||
Base_경비: ["경비", "expenses"],
|
||
Base_사람줄합: ["노무 줄의 합", "sum of labor lines"],
|
||
ResultMoney: ["결과 = 금액", "Result = amount"],
|
||
ResultValue: [
|
||
"결과 = 값 — 다른 일위대가가 불러 쓸 수 있음",
|
||
"Result = value — other unit prices can use it",
|
||
],
|
||
Total: ["합계", "Total"],
|
||
} as const;
|
||
|
||
export const tx = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||
|
||
export type Ref = LbRef | LbExpr;
|
||
|
||
export interface Ctx {
|
||
lb: LbLogic;
|
||
ans: CalcAnswer | null;
|
||
ro: boolean;
|
||
on: { change(lb: LbLogic): void; tableName?(table: string): string | null };
|
||
lines: Map<string, CalcLine>;
|
||
/** 바꾸기 — 못 하면 쉬운 말 한 줄 */
|
||
commit(make: () => LbLogic): void;
|
||
}
|
||
|
||
/* ------------------------------ 작은 칸 ------------------------------ */
|
||
|
||
export const span = (cls: string, text: string): HTMLSpanElement =>
|
||
el("span", { className: cls, text });
|
||
export const chip = (text: string, title?: string): HTMLSpanElement => {
|
||
const c = span("m01-lbsteps-chip", text);
|
||
if (title) c.title = title;
|
||
return c;
|
||
};
|
||
export const note = (text: string): HTMLSpanElement => span("m01-lbsteps-note", text);
|
||
export const sentence = (...parts: (HTMLElement | string)[]): HTMLElement =>
|
||
el("div", { className: "m01-lbsteps-sentence", children: parts });
|
||
|
||
export const parseNum = (text: string): number | null => {
|
||
const t = text.replace(/[,\s]/g, "");
|
||
return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : null;
|
||
};
|
||
|
||
export function numBox(ctx: Ctx, value: number | null, commit: (n: number) => void): HTMLElement {
|
||
if (ctx.ro) return chip(value === null ? "" : String(value));
|
||
const input = el("input", {
|
||
className: "ui-input m01-lbsteps-num",
|
||
attrs: { type: "text", inputmode: "decimal", placeholder: tx("Eg") },
|
||
});
|
||
input.value = value === null ? "" : String(value);
|
||
input.addEventListener("change", () => {
|
||
const n = parseNum(input.value);
|
||
input.classList.toggle("is-bad", n === null);
|
||
input.title = n === null ? tx("NotNumber") : "";
|
||
if (n !== null && n !== value) commit(n);
|
||
});
|
||
return input;
|
||
}
|
||
|
||
export function textBox(ctx: Ctx, value: string, commit: (v: string) => void): HTMLElement {
|
||
if (ctx.ro) return chip(value);
|
||
const input = el("input", { className: "ui-input m01-lbsteps-text", attrs: { type: "text" } });
|
||
input.value = value;
|
||
input.addEventListener("change", () => input.value !== value && commit(input.value.trim()));
|
||
return input;
|
||
}
|
||
|
||
export function selectBox(
|
||
ctx: Ctx,
|
||
options: { value: string; text: string }[],
|
||
current: string,
|
||
commit: (v: string) => void,
|
||
aria: string,
|
||
): HTMLElement {
|
||
const hit = options.find((o) => o.value === current);
|
||
if (ctx.ro) return chip(hit?.text ?? "");
|
||
const select = el("select", {
|
||
className: "ui-select m01-lbsteps-select",
|
||
attrs: { "aria-label": aria },
|
||
});
|
||
for (const o of options) select.append(el("option", { attrs: { value: o.value }, text: o.text }));
|
||
select.value = hit ? current : (options[0]?.value ?? "");
|
||
select.addEventListener("change", () => commit(select.value));
|
||
return select;
|
||
}
|
||
|
||
export const show = (v: unknown): string => (v === undefined || v === null ? "" : formatNumber(v));
|
||
export const valueNote = (v: unknown): HTMLElement | string =>
|
||
v === undefined || v === null ? "" : span("m01-lbsteps-value", `= ${show(v)}`);
|
||
|
||
/* ------------------------------ 값 찾기 ------------------------------ */
|
||
|
||
export const middleOf = (ctx: Ctx, name: string | null): unknown =>
|
||
name !== null && ctx.ans?.ok ? ctx.ans.middle[name] : undefined;
|
||
|
||
/** 표 조회 값 — 그 값 하나만 이름 붙은 계산이 되어 있을 때만 앎 */
|
||
export function lookupValue(ctx: Ctx, lk: LbLookup): unknown {
|
||
const calc = ctx.lb.calcs.find(
|
||
(c) =>
|
||
c.kind === "값" &&
|
||
c.name !== null &&
|
||
c.parts.length === 1 &&
|
||
isRef(c.parts[0], "lookup", lk.id),
|
||
);
|
||
return middleOf(ctx, calc?.name ?? null);
|
||
}
|
||
|
||
export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k && r.id === id;
|
||
|
||
export const lookupNo = (ctx: Ctx, id: string): number =>
|
||
ctx.lb.lookups.findIndex((l) => l.id === id) + 1;
|
||
|
||
export function refText(ctx: Ctx, r: Ref): string {
|
||
switch (r.k) {
|
||
case "input":
|
||
return r.name;
|
||
case "fixed":
|
||
return String(r.value);
|
||
case "calc":
|
||
return r.name;
|
||
case "lookup": {
|
||
const lk = ctx.lb.lookups.find((l) => l.id === r.id);
|
||
return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`;
|
||
}
|
||
case "logic":
|
||
return `${tx("Other")} ${r.key}${r.field ? ` · ${r.field}` : ""}`;
|
||
case "cost":
|
||
return tx(`Base_${r.name}`);
|
||
default:
|
||
return tx("Hard");
|
||
}
|
||
}
|
||
|
||
export function refValue(ctx: Ctx, r: Ref): unknown {
|
||
if (r.k === "calc") return middleOf(ctx, r.name);
|
||
if (r.k === "fixed") return r.value;
|
||
if (r.k === "lookup") {
|
||
const lk = ctx.lb.lookups.find((l) => l.id === r.id);
|
||
return lk ? lookupValue(ctx, lk) : undefined;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** 값이 오는 곳 고르기 — 입력값 · 표 조회 · 다른 계산 · 고정값 */
|
||
export function refBox(
|
||
ctx: Ctx,
|
||
ref: Ref,
|
||
self: LbCalc | null,
|
||
pick: (r: LbRef) => void,
|
||
): HTMLElement {
|
||
const label = refText(ctx, ref);
|
||
const editable = ref.k === "input" || ref.k === "calc" || ref.k === "lookup" || ref.k === "fixed";
|
||
if (ctx.ro || !editable) {
|
||
return el("span", {
|
||
className: "m01-lbsteps-ref",
|
||
children: [chip(label), valueNote(refValue(ctx, ref))],
|
||
});
|
||
}
|
||
const options = [
|
||
...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })),
|
||
...ctx.lb.lookups.map((l) => ({
|
||
value: `l:${l.id}`,
|
||
text: refText(ctx, { k: "lookup", id: l.id }),
|
||
})),
|
||
...ctx.lb.calcs
|
||
.filter((c) => c.name !== null && c !== self)
|
||
.map((c) => ({ value: `c:${c.name}`, text: `${tx("SrcCalc")}${c.name}` })),
|
||
{ value: "f", text: tx("SrcFixed") },
|
||
];
|
||
const current =
|
||
ref.k === "input"
|
||
? `i:${ref.name}`
|
||
: ref.k === "calc"
|
||
? `c:${ref.name}`
|
||
: ref.k === "lookup"
|
||
? `l:${ref.id}`
|
||
: "f";
|
||
const toRef = (v: string): LbRef =>
|
||
v === "f"
|
||
? { k: "fixed", value: 0 }
|
||
: v.startsWith("i:")
|
||
? { k: "input", name: v.slice(2) }
|
||
: v.startsWith("l:")
|
||
? { k: "lookup", id: v.slice(2) }
|
||
: { k: "calc", name: v.slice(2) };
|
||
const parts: (HTMLElement | string)[] = [
|
||
selectBox(ctx, options, current, (v) => pick(toRef(v)), tx("S3")),
|
||
];
|
||
if (ref.k === "fixed") {
|
||
parts.push(
|
||
numBox(ctx, typeof ref.value === "number" ? ref.value : null, (n) =>
|
||
pick({ k: "fixed", value: n }),
|
||
),
|
||
);
|
||
} else parts.push(valueNote(refValue(ctx, ref)));
|
||
return el("span", { className: "m01-lbsteps-ref", children: parts });
|
||
}
|