feat(m02-df): 흐름도 검증 칸(끊긴 곳 · 재료 자리 · 조건 자리) · 출력 끝수 기본 · 합계 끝수
- 새 Verify: 끊긴 곳 목록(on.select) · 변수 예시값 · 재료 선택 자리(찾기 · 쪽 · 고르기) · 재료 조건 자리(잡힌 품목 · 0 · 여럿) · 이어지지 않은 다른 로직 값 - 새 Round · Material: 금액 단위 표준에서 끝수 기본(금액란 0.1원 · 계금 1원 미만 버림) · 재료 찾기 · 단가 - OutputPanel 끝수 고르기 · Summary 합계 끝수 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user