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

This commit is contained in:
2026-10-01 19:49:19 +09:00
7 changed files with 931 additions and 146 deletions
@@ -0,0 +1,121 @@
/* =============================================================================
* M02_DF_Material.ts
* 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials?q&sub&detail&page&size`
* → {total, page, size, items}(아직 없으면 M01 재료 찾기로 대신) · 단가는 `cfValues`(재료 ref = 키)
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 5. 이 폴더째 지워도 됨.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { searchElements } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { cfValues } from "./M02_DF_Api";
export interface CfMat {
ref: string;
name: string;
spec: string;
unit: string;
}
export interface CfMatPage {
total: number;
page: number;
size: number;
items: CfMat[];
}
const text = (v: unknown): string =>
typeof v === "string" || typeof v === "number" ? String(v) : "";
const norm = (it: Record<string, unknown>): CfMat => ({
ref: text(it.ref ?? it.키),
name: text(it.이름 ?? it.name),
spec: text(it.규격 ?? it.spec),
unit: text(it.단위 ?? it.unit),
});
/** 재료 찾기 — q = 찾는 말 · sub = 구분 · detail = 상세구분 · page 는 1 부터 */
export async function findMaterials(cond: {
q?: string;
sub?: string;
detail?: string;
page?: number;
size?: number;
}): Promise<CfMatPage> {
const page = cond.page ?? 1;
const size = cond.size ?? 10;
try {
const qs = new URLSearchParams({
q: cond.q ?? "",
sub: cond.sub ?? "",
detail: cond.detail ?? "",
page: String(page),
size: String(size),
});
const r = await fetch(`${API_BASE_URL}/m02/df/materials?${qs.toString()}`, {
credentials: "include",
});
if (r.ok) {
const d = (await r.json()) as {
total: number;
page?: number;
size?: number;
items: Record<string, unknown>[];
};
return {
total: d.total,
page: d.page ?? page,
size: d.size ?? size,
items: d.items.map(norm),
};
}
} catch {
/* 서버 길이 아직 없으면 M01 재료 찾기로 */
}
const got = await searchElements("재료", cond.q ?? "");
const all = got.items.filter((it) => {
const x = it as unknown as Record<string, unknown>;
return (!cond.sub || x.구분 === cond.sub) && (!cond.detail || x.상세구분 === cond.detail);
});
const from = (page - 1) * size;
return {
total: all.length,
page,
size,
items: all.slice(from, from + size).map((it) => norm(it as unknown as Record<string, unknown>)),
};
}
const names = new Map<string, CfMat | null>();
/** 재료 키 → 이름 · 규격 (한 번 물은 것은 다시 안 물음) */
export async function materialOf(key: string): Promise<CfMat | null> {
if (!key) return null;
if (names.has(key)) return names.get(key) ?? null;
let hit: CfMat | null = null;
try {
const got = await searchElements("재료", key);
const it = got.items.find((x) => x.ref === key);
hit = it ? norm(it as unknown as Record<string, unknown>) : null;
} catch {
hit = null;
}
names.set(key, hit);
return hit;
}
const prices = new Map<string, number | null>();
/** 재료 키 → 단가(원) — 못 구하면 null */
export async function priceOf(key: string): Promise<number | null> {
if (!key) return null;
if (prices.has(key)) return prices.get(key) ?? null;
let v: number | null = null;
try {
const a = (await cfValues([{ id: "p", group: "재료", ref: key }]))[0];
v = a?.ok && typeof a.value === "number" ? a.value : null;
} catch {
v = null;
}
prices.set(key, v);
return v;
}
@@ -83,3 +83,8 @@
color: var(--color-danger);
font-size: var(--text-caption);
}
.m02-dfout__round {
margin: var(--spacing-8) 0;
color: var(--color-text-body);
}
@@ -6,10 +6,11 @@
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSelectField, el } from "@ui/ui_template_elements";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval";
import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_DF_Model";
import { portsOut, type CfBox, type CfCost, type CfLogic, type CfRound } from "./M02_DF_Model";
import { defaultItemRound, roundText } from "./M02_DF_Round";
import "./M02_DF_OutputPanel.css";
const TEXT = {
@@ -32,6 +33,14 @@ const TEXT = {
Table: ["표", "Table"],
Calc: ["계산 중이 아니에요", "Not calculated"],
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
Round: ["끝수", "Rounding"],
RoundPlaces: ["자리", "Places"],
RoundMethod: ["방법", "Method"],
RoundDefault: ["끝수 기본", "Default rounding"],
RoundStd: ["일위대가표 금액란", "unit-price sheet amount column"],
RoundNone: ["끝수 없음(값 그대로)", "No rounding (value as is)"],
RoundReset: ["기본으로", "Back to default"],
RoundUser: ["고른 끝수", "Chosen rounding"],
PlainHint: [
"원가 합계에 안 넣고 값으로만 보여요",
"Shown as a value, not added to the cost total",
@@ -152,7 +161,7 @@ export function renderOutputPanel(
emit(() =>
withBox(lg, box.id, (b) => ({
...b,
output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost },
output: { ...b.output, unit: b.output?.unit ?? b.unit, cost: v as CfCost },
})),
),
});
@@ -167,7 +176,7 @@ export function renderOutputPanel(
withBox(lg, box.id, (b) => ({
...b,
unit: next,
output: { cost: b.output?.cost ?? "노무비", unit: next },
output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next },
})),
);
});
@@ -177,6 +186,76 @@ export function renderOutputPanel(
children: [el("span", { text: label }), control],
});
/* 끝수 — 기본 = 일위대가표 금액란 0.1원 미만 버림(표준 표를 읽음) · 값(비용 아님)은 정하지 않으면 그대로 */
const round = box.output?.round;
const stepName = (n: number): string => (n === 0 ? "1" : String(Number((10 ** -n).toFixed(n))));
let fallback: CfRound = { places: 1, method: "버림" };
const roundNote = el("span", { className: "m02-dfout__muted" });
const noteOf = (r: CfRound | undefined): string =>
r
? `${to("RoundUser")}: ${roundText(r)}`
: cost === "값"
? to("RoundNone")
: `${to("RoundDefault")}: ${to("RoundStd")} ${roundText(fallback)}`;
roundNote.textContent = noteOf(round);
if (cost !== "값")
void defaultItemRound().then((r) => {
fallback = r;
roundNote.textContent = noteOf(round);
});
const setRound = (next: CfRound | undefined): void =>
emit(() =>
withBox(lg, box.id, (b) => {
const output = { ...(b.output ?? { cost, unit: b.unit }) };
if (next) output.round = next;
else delete output.round;
return { ...b, output };
}),
);
const placesField = createSelectField({
options: [
{ value: "", text: cost === "값" ? to("RoundNone") : to("RoundDefault") },
...[0, 1, 2, 3, 4].map((n) => ({ value: String(n), text: `${stepName(n)}원 단위` })),
],
value: round ? String(round.places) : "",
compact: true,
ariaLabel: to("RoundPlaces"),
onChange: (v) =>
setRound(v === "" ? undefined : { places: Number(v), method: round?.method ?? "버림" }),
});
const methodField = createSelectField({
options: (["버림", "올림", "반올림"] as const).map((m) => ({ value: m, text: `미만 ${m}` })),
value: round?.method ?? fallback.method,
compact: true,
disabled: !round,
ariaLabel: to("RoundMethod"),
onChange: (v) => round && setRound({ places: round.places, method: v as CfRound["method"] }),
});
const roundBlock = el("div", {
className: "m02-dfout__round",
children: [
el("strong", { text: to("Round") }),
el("div", {
className: "m02-dfout__fields",
children: [
field(to("RoundPlaces"), placesField.root),
field(to("RoundMethod"), methodField.root),
...(round
? [
createButton({
label: to("RoundReset"),
size: "sm",
variant: "ghost",
onClick: () => setRound(undefined),
}),
]
: []),
],
}),
roundNote,
],
});
host.replaceChildren(
el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }),
el("div", {
@@ -189,6 +268,7 @@ export function renderOutputPanel(
}),
],
}),
roundBlock,
...(cost === "값" ? [el("p", { className: "m02-dfout__muted", text: to("PlainHint") })] : []),
msg,
);
+101
View File
@@ -0,0 +1,101 @@
/* =============================================================================
* M02_DF_Round.ts
* 끝수 기본값 — 금액 단위 표준(산림품셈 1-2-2 「금액의 단위표준」)을 읽어
* 출력(비용) 기본 = 일위대가표 금액란 · 원가 합계 기본 = 일위대가표 계금 (모두 미만 버림)
* 표 읽기 = 서버 `GET /api/m02/df/standards` → {unit, money, saved} · 아직 없으면 `_standard/금액단위표준.json`
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. 이 폴더째 지워도 됨.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { CfRound } from "./M02_DF_Model";
/** 표 한 줄 — 원문 그대로(종목 · 단위 · 지위 · 비고) */
export interface CfMoneyRow {
종목: string;
단위?: string;
지위: number;
비고?: string;
}
const FILES = import.meta.glob<{ 줄?: CfMoneyRow[] }>("./_standard/금액단위표준.json", {
eager: true,
import: "default",
});
/** 표 모양 둘(`{줄: […]}` · 줄 목록)을 줄 목록으로 */
const rowsOf = (x: unknown): CfMoneyRow[] => {
const list = Array.isArray(x) ? x : (x as { 줄?: unknown } | null)?.줄;
return Array.isArray(list) ? (list as CfMoneyRow[]) : [];
};
let loaded: Promise<CfMoneyRow[]> | null = null;
/** 금액 단위 표준 줄 — 사용자가 고쳐 저장한 것이 있으면 그것 · 서버가 없으면 처음 값 */
export function loadMoneyStandard(): Promise<CfMoneyRow[]> {
loaded ??= (async () => {
try {
const r = await fetch(`${API_BASE_URL}/m02/df/standards`, { credentials: "include" });
if (r.ok) {
const d = (await r.json()) as { money?: unknown; saved?: { money?: unknown } | null };
const rows = rowsOf(d.saved?.money ?? d.money);
if (rows.length) return rows;
}
} catch {
/* 서버가 없으면 처음 값 */
}
return rowsOf(Object.values(FILES)[0]);
})();
return loaded;
}
/** 표를 다시 읽게(표를 고친 뒤) */
export function resetStandard(): void {
loaded = null;
}
/** 표 줄 → 끝수 — 지위 0.1 = 소수 1자리 · 1 이상 = 0자리 · 비고 「미만버림」 */
export function roundOf(row: CfMoneyRow): CfRound {
const step = row.지위 > 0 ? row.지위 : 1;
const method =
/올림/.test(row.비고 ?? "") && !/버림/.test(row.비고 ?? "")
? "올림"
: /반올림/.test(row.비고 ?? "")
? "반올림"
: "버림";
return { places: step >= 1 ? 0 : Math.round(-Math.log10(step)), method };
}
const find = (rows: CfMoneyRow[], word: string): CfMoneyRow | undefined =>
rows.find((r) => r.종목.includes("일위대가표") && r.종목.includes(word));
/** 출력(비용) 기본 — 일위대가표 금액란 0.1원 미만 버림 */
export async function defaultItemRound(): Promise<CfRound> {
const row = find(await loadMoneyStandard(), "금액란");
return row ? roundOf(row) : { places: 1, method: "버림" };
}
/** 원가 합계 기본 — 일위대가표 계금 1원 미만 버림 */
export async function defaultTotalRound(): Promise<CfRound> {
const row = find(await loadMoneyStandard(), "계금");
return row ? roundOf(row) : { places: 0, method: "버림" };
}
/** 끝수 적용 — 소수 자리 · 방법(부동소수 오차는 아주 작은 값으로 눌러 줌) */
export function roundBy(r: CfRound, v: number): number {
const k = 10 ** r.places;
const x = v * k;
const eps = Math.abs(x) * 1e-12 + 1e-9;
const f =
r.method === "올림"
? Math.ceil(x - eps)
: r.method === "반올림"
? Math.round(x + eps)
: Math.floor(x + eps);
return f / k;
}
/** 「0.1원 미만 버림」 꼴 쉬운 말 */
export function roundText(r: CfRound): string {
const step = r.places > 0 ? Number((10 ** -r.places).toFixed(r.places)) : 1;
return `${step}원 미만 ${r.method}`;
}
+25 -142
View File
@@ -7,11 +7,13 @@
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSelectField, el } from "@ui/ui_template_elements";
import { el } from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { checkLogic, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
import type { CfBox, CfLogic, CfVariable } from "./M02_DF_Model";
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model";
import { showValue } from "./M02_DF_OutputPanel";
import { defaultTotalRound, roundBy, roundText } from "./M02_DF_Round";
import { renderVerify, type CfSel } from "./M02_DF_Verify";
import "./M02_DF_Summary.css";
const TEXT = {
@@ -27,18 +29,7 @@ const TEXT = {
HValue: ["값", "Value"],
HUnit: ["단위", "Unit"],
Plain: ["값(비용 아님)", "Values (not costs)"],
Sample: ["예시값", "Sample values"],
SampleHint: [
"여기서 바꾸면 변수 상자의 예시값도 바뀌어요",
"Changing here changes the variable box too",
],
Problems: ["고칠 것", "To fix"],
NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"],
NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
Pick: ["고르세요", "Choose"],
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
RoundTotal: ["합계 끝수", "Total rounding"],
Plain3: ["값", "Value"],
} as const;
@@ -54,67 +45,16 @@ const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense"
const costText = (c: string): string => (c === "값" ? ts("Plain3") : c);
/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */
function emptyPlaces(lg: CfLogic): string[] {
const outs = lg.boxes.filter((b) => b.kind === "output");
if (!outs.length) return [ts("NoOutput")];
return outs
.filter((o) => !lg.links.some((l) => l.to.box === o.id))
.map((o) => ts("NoLink", { box: o.name }));
}
/** 변수 상자의 예시값 칸 하나 */
function sampleField(
v: CfVariable,
put: (value: number | string) => void,
msg: HTMLElement,
): HTMLElement {
const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name });
let control: HTMLElement;
if (v.type === "choice" && v.choices?.length) {
control = createSelectField({
options: [
{ value: "", text: ts("Pick") },
...v.choices.map((c) => ({ value: String(c), text: String(c) })),
],
value: v.example === null ? "" : String(v.example),
compact: true,
ariaLabel: v.name,
onChange: (s) => {
const hit = v.choices?.find((c) => String(c) === s);
if (hit !== undefined) put(hit);
},
}).root;
} else {
const box = el("input", {
className: "m02-dfsum__input",
attrs: {
type: "text",
inputmode: v.type === "number" ? "decimal" : "text",
"aria-label": v.name,
},
});
if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) });
box.value = v.example === null ? "" : String(v.example);
box.addEventListener("change", () => {
const raw = box.value.trim();
if (raw === "") return;
if (v.type !== "number") return put(raw);
const n = Number(raw);
if (Number.isNaN(n)) msg.textContent = ts("Bad");
else put(n);
});
control = box;
}
return el("label", { className: "m02-dfsum__field", children: [label, control] });
}
/** 원가 합계 끝수 — 기본 = 일위대가표 계금 1원 미만 버림(표를 읽어 오면 그 값) */
let totalRound: CfRound = { places: 0, method: "버림" };
void defaultTotalRound().then((r) => (totalRound = r));
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */
export function renderSummary(
host: HTMLElement,
lg: CfLogic,
result: CfEvalResult,
on?: { change(lg: CfLogic): void },
on?: { change(lg: CfLogic): void; select?(sel: CfSel): void },
opts?: { values?: CfValues },
): void {
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
@@ -127,14 +67,14 @@ export function renderSummary(
className: "m02-dfsum__big",
children: [
el("span", { text: money ? perText : ts("Total") }),
el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }),
el("strong", { text: `${formatMoney(roundBy(totalRound, cost.계))} ${ts("Won")}` }),
],
});
const costs = el("dl", {
className: "m02-dfsum__costs",
children: COSTS.flatMap((c) => [
el("dt", { text: ts(COST_KEY[c]) }),
el("dd", { text: formatMoney(cost[c]) }),
el("dd", { text: formatMoney(roundBy(totalRound, cost[c])) }),
]),
});
@@ -185,52 +125,14 @@ export function renderSummary(
],
});
/* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */
let checked: string[] = [];
try {
checked = checkLogic(lg, opts?.values ?? {});
} catch {
checked = [];
}
const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])];
/* 예시값 — 변수 상자의 변수마다 */
const msg = el("p", { className: "m02-dfsum__msg" });
const fields = lg.boxes
.filter((b) => b.kind === "input" && b.input?.mode === "variable")
.flatMap((b) =>
(b.input?.variables ?? []).map((v, i) =>
sampleField(
v,
(value) => {
msg.textContent = "";
if (!on) return;
try {
on.change({
...lg,
boxes: lg.boxes.map((x) =>
x.id === b.id && x.input
? {
...x,
input: {
...x.input,
variables: x.input.variables.map((y, j) =>
j === i ? { ...y, example: value } : y,
),
},
}
: x,
),
});
} catch {
msg.textContent = ts("Cant");
}
},
msg,
),
),
);
const verifyHost = el("div", { className: "m02-dfsum__verify" });
renderVerify(
verifyHost,
lg,
result,
{ change: (next) => on?.change(next), select: on?.select },
{ values: opts?.values },
);
const plainValues = Object.entries(cost.값들);
host.replaceChildren(
big,
@@ -251,29 +153,10 @@ export function renderSummary(
}),
]
: []),
...(fields.length
? [
el("div", {
className: "m02-dfsum__strip",
children: [
el("strong", { text: ts("Sample") }),
el("div", { className: "m02-dfsum__fields", children: fields }),
el("p", { className: "m02-dfsum__muted", text: ts("SampleHint") }),
msg,
],
}),
]
: []),
...(problems.length
? [
el("div", {
className: "m02-dfsum__problems",
children: [
el("strong", { text: ts("Problems") }),
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
],
}),
]
: []),
el("p", {
className: "m02-dfsum__muted",
text: `${ts("RoundTotal")}: ${roundText(totalRound)}`,
}),
verifyHost,
);
}
@@ -0,0 +1,118 @@
.m02-dfver__block {
margin: 0 0 var(--spacing-12);
color: var(--color-text-body);
}
.m02-dfver__breaks {
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-danger);
background: var(--color-surface);
}
.m02-dfver__title {
font-size: var(--text-body);
}
.m02-dfver__group {
margin: var(--spacing-8) 0;
}
.m02-dfver__fields {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-16);
margin: var(--spacing-4) 0;
}
.m02-dfver__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-dfver__input {
box-sizing: border-box;
width: 160px;
max-width: 100%;
height: var(--control-height-sm);
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m02-dfver__item {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-4) 0;
}
.m02-dfver__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m02-dfver__now {
color: var(--color-text-body);
}
.m02-dfver__price {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.m02-dfver__warn {
color: var(--color-danger);
}
.m02-dfver__panel {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.m02-dfver__search,
.m02-dfver__pager {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
margin: var(--spacing-4) 0;
}
.m02-dfver__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-4) 0;
padding: 0;
list-style: none;
}
.m02-dfver__list .ui-btn {
max-width: 100%;
text-align: left;
white-space: normal;
}
.m02-dfver__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m02-dfver__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
@@ -0,0 +1,477 @@
/* =============================================================================
* M02_DF_Verify.ts
* 흐름도 검증 칸(오른쪽 아래) — 끊긴 곳 목록(누르면 그 상자 · 선을 고름) + 「설계 때 정할 것」 전부:
* 변수 예시값 · 재료 선택 자리(범위 안 자재 목록에서 고르기 · 찾기 · 쪽) · 재료 조건 자리(지금 잡힌 품목 · 0 · 여럿 까닭)
* · 이어지지 않은 다른 로직의 받는 알약. 바꾸면 on.change(고친 다이어그램) · 줄을 누르면 on.select.
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 4 · 5 · 6 · 8 · 13. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { breaks, type CfBreak, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material";
import {
portsIn,
type CfBox,
type CfElementRef,
type CfLogic,
type CfVariable,
} from "./M02_DF_Model";
import "./M02_DF_Verify.css";
const TEXT = {
Breaks: ["끊긴 곳", "Broken spots"],
BreaksHint: ["누르면 그 상자 · 선을 골라요", "Click to select the box or line"],
Design: ["설계 때 정할 것", "To decide at design time"],
DesignHint: ["여기서 바꾸면 해당 상자도 바뀌어요", "Changing here changes the box too"],
Vars: ["변수 예시값", "Variable samples"],
Slots: ["재료 선택 자리", "Material choice"],
Rules: ["재료 조건 자리", "Material by condition"],
Logics: ["이어지지 않은 다른 로직 값", "Unlinked values of other logics"],
Choose: ["고르기", "Choose"],
Close: ["닫기", "Close"],
Find: ["찾기", "Find"],
Prev: ["이전", "Prev"],
Next: ["다음", "Next"],
Search: ["재료 이름 · 규격", "Material name or size"],
Page: ["쪽 {p} / {n} · 모두 {t}", "Page {p} / {n} · {t} in all"],
NoHit: ["찾은 재료가 없어요", "No material found"],
NoDefault: ["아직 고르지 않았어요", "Nothing chosen yet"],
Price: ["단가", "Unit price"],
Won: ["원", "KRW"],
Loading: ["찾는 중", "Searching"],
NeedValue: ["조건 값이 아직 없어요", "The condition value is not there yet"],
Caught: ["잡힌 품목", "Matched item"],
Zero: ["맞는 재료 없음(0)", "No matching material (0)"],
Many: ["여럿 맞음({n}) — 첫째를 씀", "{n} match — the first is used"],
Cond: ["{field}에 「{v}」 {op}", "{field} {op} “{v}”"],
Pick: ["고르세요", "Choose"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
OtherLogic: [
"이어 주지 않아서 그 로직의 예시값을 써요",
"Not linked — the logic's own sample is used",
],
NoLine: ["「{box}」 의 받는 자리 「{pill}」 에 선이 없어요", "“{pill}” of “{box}” has no line"],
NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"],
} as const;
type TextKey = keyof typeof TEXT;
const tv = (key: TextKey, fill: Record<string, string | number> = {}): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(/\{(\w+)\}/g, (m, k: string) =>
k in fill ? String(fill[k]) : m,
);
/** 고른 상자 · 알약 · 선 — 다이어그램이 이 모양으로 고름 */
export interface CfSel {
box: string;
port?: string;
link?: string;
}
export interface CfVerifyOn {
change(lg: CfLogic): void;
select?(sel: CfSel): void;
}
const PAGE = 10;
/** 끊긴 곳 — Model 쪽 `breaks` 가 차면 그것 · 아직이면 빈 받는 자리와 계산 오류만 */
function brokenSpots(lg: CfLogic, result: CfEvalResult, values: CfValues): CfBreak[] {
try {
return breaks(lg, values);
} catch {
const out: CfBreak[] = [];
if (!lg.boxes.some((b) => b.kind === "output"))
out.push({ kind: "오류", box: "", text: tv("NoOutput") });
for (const b of lg.boxes)
for (const p of portsIn(b))
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
out.push({
kind: "빈 받는 자리",
box: b.id,
port: p.name,
text: tv("NoLine", { box: b.name, pill: p.name }),
});
for (const e of result.errors) out.push({ kind: "오류", box: "", text: e });
return out;
}
}
/** 재료 요소 하나 고치기 — 나머지는 그대로 */
function withElement(
lg: CfLogic,
boxId: string,
name: string,
patch: Partial<CfElementRef>,
): CfLogic {
return {
...lg,
boxes: lg.boxes.map((b) =>
b.id === boxId && b.input
? {
...b,
input: {
...b.input,
elements: b.input.elements.map((e) => (e.name === name ? { ...e, ...patch } : e)),
},
}
: b,
),
};
}
const matText = (m: CfMat): string =>
`${m.name}${m.spec ? ` ${m.spec}` : ""}${m.unit ? ` · ${m.unit}` : ""}`;
/** 변수 예시값 칸 */
function sampleField(
v: CfVariable,
put: (value: number | string) => void,
msg: HTMLElement,
): HTMLElement {
const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name });
let control: HTMLElement;
if (v.type === "choice" && v.choices?.length) {
control = createSelectField({
options: [
{ value: "", text: tv("Pick") },
...v.choices.map((c) => ({ value: String(c), text: String(c) })),
],
value: v.example === null ? "" : String(v.example),
compact: true,
ariaLabel: v.name,
onChange: (s) => {
const hit = v.choices?.find((c) => String(c) === s);
if (hit !== undefined) put(hit);
},
}).root;
} else {
const box = el("input", {
className: "m02-dfver__input",
attrs: {
type: "text",
inputmode: v.type === "number" ? "decimal" : "text",
"aria-label": v.name,
},
});
if (v.range) box.placeholder = tv("Range", { a: v.range[0], b: v.range[1] });
box.value = v.example === null ? "" : String(v.example);
box.addEventListener("change", () => {
const raw = box.value.trim();
if (raw === "") return;
if (v.type !== "number") return put(raw);
const n = Number(raw);
if (Number.isNaN(n)) msg.textContent = tv("Bad");
else put(n);
});
control = box;
}
return el("label", { className: "m02-dfver__field", children: [label, control] });
}
/** 재료 선택 자리 한 줄 — 지금 품목 · 단가 · 「고르기」(찾기 · 쪽) */
function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTMLElement {
const now = el("span", { className: "m02-dfver__now", text: e.default ? "…" : tv("NoDefault") });
const price = el("span", { className: "m02-dfver__price" });
if (e.default) {
void materialOf(e.default).then((m) => (now.textContent = m ? matText(m) : (e.default ?? "")));
void priceOf(e.default).then((p) => {
price.textContent = p === null ? "" : `${tv("Price")} ${formatMoney(p)} ${tv("Won")}`;
});
}
const panel = el("div", { className: "m02-dfver__panel" });
panel.hidden = true;
const q = el("input", {
className: "m02-dfver__input",
attrs: { type: "text", placeholder: tv("Search"), "aria-label": tv("Search") },
});
q.value = e.scope?.찾는말 ?? "";
const list = el("ul", { className: "m02-dfver__list" });
const pager = el("div", { className: "m02-dfver__pager" });
let page = 1;
const load = async (): Promise<void> => {
list.replaceChildren(el("li", { className: "m02-dfver__muted", text: tv("Loading") }));
const got = await findMaterials({
q: q.value.trim(),
sub: e.scope?.구분,
detail: e.scope?.상세구분,
page,
size: PAGE,
});
const pages = Math.max(1, Math.ceil(got.total / PAGE));
list.replaceChildren(
...(got.items.length
? got.items.map((m) =>
el("li", {
children: [
createButton({
label: matText(m),
variant: "ghost",
size: "sm",
onClick: () => on.change(withElement(lg, box.id, e.name, { default: m.ref })),
}),
],
}),
)
: [el("li", { className: "m02-dfver__muted", text: tv("NoHit") })]),
);
pager.replaceChildren(
createButton({
label: tv("Prev"),
size: "sm",
variant: "ghost",
disabled: page <= 1,
onClick: () => {
page--;
void load();
},
}),
el("span", {
className: "m02-dfver__muted",
text: tv("Page", { p: got.page, n: pages, t: got.total }),
}),
createButton({
label: tv("Next"),
size: "sm",
variant: "ghost",
disabled: page >= pages,
onClick: () => {
page++;
void load();
},
}),
);
};
q.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
page = 1;
void load();
}
});
const toggle = createButton({
label: tv("Choose"),
size: "sm",
onClick: () => {
panel.hidden = !panel.hidden;
toggle.querySelector(".ui-btn__label")!.textContent = panel.hidden
? tv("Choose")
: tv("Close");
if (!panel.hidden) void load();
},
});
panel.append(
el("div", {
className: "m02-dfver__search",
children: [
q,
createButton({
label: tv("Find"),
size: "sm",
onClick: () => {
page = 1;
void load();
},
}),
],
}),
list,
pager,
);
return el("div", {
className: "m02-dfver__item",
children: [
el("div", {
className: "m02-dfver__line",
children: [el("strong", { text: e.slot ?? e.name }), now, price, toggle],
}),
panel,
],
});
}
/** 재료 조건 자리 한 줄 — 지금 잡힌 품목 · 맞는 것 0 · 여럿이면 까닭 */
function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElement {
const rule = e.rule!;
const raw = result.ports[rule.from.box]?.[rule.from.name];
const value = Array.isArray(raw) || raw === null || raw === undefined ? "" : String(raw);
const state = el("span", {
className: "m02-dfver__now",
text: value ? tv("Loading") : tv("NeedValue"),
});
const note = el("span", { className: "m02-dfver__muted" });
if (value) {
void findMaterials({
q: e.scope?.찾는말,
sub: e.scope?.구분,
detail: e.scope?.상세구분,
size: 100,
}).then((got) => {
const hit = got.items.filter((m) => {
const t = rule.field === "규격" ? m.spec : m.name;
return rule.op === "같음" ? t === value : t.includes(value);
});
const first = hit[0];
state.className = `m02-dfver__now${hit.length === 1 ? "" : " m02-dfver__warn"}`;
state.textContent = !first ? tv("Zero") : `${tv("Caught")}: ${matText(first)}`;
note.textContent = hit.length > 1 ? tv("Many", { n: hit.length }) : "";
});
}
const from = lg.boxes.find((b) => b.id === rule.from.box)?.name ?? "";
return el("div", {
className: "m02-dfver__item",
children: [
el("div", {
className: "m02-dfver__line",
children: [el("strong", { text: e.slot ?? e.name }), state, note],
}),
el("span", {
className: "m02-dfver__muted",
text: `${from ? `${from} · ` : ""}${tv("Cond", { field: rule.field, v: value || rule.from.name, op: rule.op })}`,
}),
],
});
}
/** 검증 칸 — values = 받은 입력 값(끊긴 곳 검사가 씀) */
export function renderVerify(
host: HTMLElement,
lg: CfLogic,
result: CfEvalResult,
on: CfVerifyOn,
opts: { values?: CfValues } = {},
): void {
const msg = el("p", { className: "m02-dfver__msg" });
const safe = (make: () => CfLogic): void => {
msg.textContent = "";
try {
on.change(make());
} catch {
msg.textContent = tv("Cant");
}
};
/* 끊긴 곳 */
const spots = brokenSpots(lg, result, opts.values ?? {});
const spotBlock = spots.length
? [
el("div", {
className: "m02-dfver__block m02-dfver__breaks",
children: [
el("strong", { text: tv("Breaks") }),
el("ul", {
className: "m02-dfver__list",
children: spots.map((s) =>
el("li", {
children: [
createButton({
label: s.text,
variant: "ghost",
size: "sm",
disabled: !s.box || !on.select,
onClick: () => on.select?.({ box: s.box, port: s.port, link: s.link }),
}),
],
}),
),
}),
el("p", { className: "m02-dfver__muted", text: tv("BreaksHint") }),
],
}),
]
: [];
/* 설계 때 정할 것 */
const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input);
const vars = inputs.flatMap((b) =>
(b.input?.variables ?? []).map((v, i) =>
sampleField(
v,
(value) =>
safe(() => ({
...lg,
boxes: lg.boxes.map((x) =>
x.id === b.id && x.input
? {
...x,
input: {
...x.input,
variables: x.input.variables.map((y, j) =>
j === i ? { ...y, example: value } : y,
),
},
}
: x,
),
})),
msg,
),
),
);
const slots = inputs.flatMap((b) =>
(b.input?.elements ?? [])
.filter((e) => e.mode === "choice")
.map((e) => slotRow(b, e, { ...on, change: (n) => safe(() => n) }, lg)),
);
const rules = inputs.flatMap((b) =>
(b.input?.elements ?? [])
.filter((e) => e.mode === "rule" && e.rule)
.map((e) => ruleRow(e, lg, result)),
);
const logics = inputs.flatMap((b) =>
(b.input?.logic?.designs ?? [])
.filter((d) => !lg.links.some((l) => l.to.box === b.id && l.to.name === d.name))
.map((d) =>
el("li", {
children: [
createButton({
label: `${b.name} · ${d.name}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`,
variant: "ghost",
size: "sm",
disabled: !on.select,
onClick: () => on.select?.({ box: b.id, port: d.name }),
}),
],
}),
),
);
const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] =>
body.length
? [
el("div", {
className: "m02-dfver__group",
children: [el("strong", { text: tv(title) }), ...body],
}),
]
: [];
const design = [
...group(
"Vars",
[el("div", { className: "m02-dfver__fields", children: vars })].filter(() => vars.length),
),
...group("Slots", slots),
...group("Rules", rules),
...group(
"Logics",
logics.length ? [el("ul", { className: "m02-dfver__list", children: logics })] : [],
),
];
host.replaceChildren(
...spotBlock,
...(design.length
? [
el("div", {
className: "m02-dfver__block",
children: [
el("strong", { className: "m02-dfver__title", text: tv("Design") }),
...design,
el("p", { className: "m02-dfver__muted", text: tv("DesignHint") }),
msg,
],
}),
]
: []),
);
}