Compare commits
250
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d39eece45e | ||
|
|
53aeccc179 | ||
|
|
389ab245e7 | ||
|
|
87433105f7 | ||
|
|
eb64bf64ac | ||
|
|
ef5fb0632f | ||
|
|
fcf5f457f0 | ||
|
|
1fee1e950c | ||
|
|
e699d95857 | ||
|
|
ef3cc96509 | ||
|
|
af2d9826cf | ||
|
|
abe722f378 | ||
|
|
944f52cd6f | ||
|
|
fd188fdf2b | ||
|
|
89292a75df | ||
|
|
e5e34d82fb | ||
|
|
53a33b07c1 | ||
|
|
ebcec5ffa0 | ||
|
|
47f4a729b4 | ||
|
|
640903d6ef | ||
|
|
1beaefa529 | ||
|
|
2885a51854 | ||
|
|
db1d976ea8 | ||
|
|
cf7f62069e | ||
|
|
7480aebc96 | ||
|
|
04caf618bb | ||
|
|
d4fbe755b0 | ||
|
|
6f678fb91b | ||
|
|
c18c3036eb | ||
|
|
25bbc084ec | ||
|
|
af576e5a7a | ||
|
|
6a9a98d6d3 | ||
|
|
ca11090104 | ||
|
|
d7bc5cac83 | ||
|
|
9c43296145 | ||
|
|
feae4e60d7 | ||
|
|
f9cafaa89b | ||
|
|
71e7165250 | ||
|
|
0eb7822772 | ||
|
|
a0f0c15caf | ||
|
|
6c14e73a5c | ||
|
|
c11baa441c | ||
|
|
26bd46041e | ||
|
|
3e64aa2a79 | ||
|
|
d59874d4b4 | ||
|
|
a77d257b1c | ||
|
|
1a7e6d5eb7 | ||
|
|
889931cc55 | ||
|
|
973872fe43 | ||
|
|
ff9d2941c1 | ||
|
|
3a7ddb8583 | ||
|
|
1bb9ba389e | ||
|
|
824168c1ca | ||
|
|
970fd441ba | ||
|
|
dcae2eb705 | ||
|
|
9f969f0c3f | ||
|
|
f5545dd62b | ||
|
|
2057e0ec02 | ||
|
|
0b94ece486 | ||
|
|
32208c3577 | ||
|
|
cbd02c09e9 | ||
|
|
6a47356e67 | ||
|
|
8406fd2367 | ||
|
|
c80b5ae9d0 | ||
|
|
5799ccbe90 | ||
|
|
c07cd1d133 | ||
|
|
a003587aca | ||
|
|
1f7525e502 | ||
|
|
d98e2204e8 | ||
|
|
040fc6458e | ||
|
|
54fb55487c | ||
|
|
d51c0b2200 | ||
|
|
9cf93a7488 | ||
|
|
329dcaa61c | ||
|
|
154a044427 | ||
|
|
f7038cc280 | ||
|
|
d1bd9d765a | ||
|
|
d7e6fe5cf8 | ||
|
|
2376e70fbb | ||
|
|
6c7618c54a | ||
|
|
b84d56f13b | ||
|
|
43784a4c09 | ||
|
|
d257d7783c | ||
|
|
4baf69803e | ||
|
|
47f260e012 | ||
|
|
4185bf8baf | ||
|
|
90478a43c3 | ||
|
|
6fa3a3ce7b | ||
|
|
2a41b989a2 | ||
|
|
bb5afa4125 | ||
|
|
2732cf0f41 | ||
|
|
7191dbabcd | ||
|
|
43c2bbb8a4 | ||
|
|
0254be9532 | ||
|
|
59f0dd90a8 | ||
|
|
f47991f698 | ||
|
|
a262b43e83 | ||
|
|
5e72aff136 | ||
|
|
f6405eaee4 | ||
|
|
972002417a | ||
|
|
9eae77d8f2 | ||
|
|
c530ac694c | ||
|
|
cc1c1c659d | ||
|
|
132371f2f3 | ||
|
|
36589d1a36 | ||
|
|
fb54335811 | ||
|
|
7d6d9769d5 | ||
|
|
041d4e653e | ||
|
|
e4394d2cd8 | ||
|
|
fd6e2e6933 | ||
|
|
f746154016 | ||
|
|
33655934f0 | ||
|
|
aac94d6508 | ||
|
|
c47e6dc832 | ||
|
|
c788960bf2 | ||
|
|
f6e7095e3e | ||
|
|
d02fa37723 | ||
|
|
be43dad64e | ||
|
|
8d4d23e783 | ||
|
|
1d1f27bdff | ||
|
|
7d9837a081 | ||
|
|
e171dda4e3 | ||
|
|
ceb62d65c5 | ||
|
|
709880953f | ||
|
|
319b0798a6 | ||
|
|
430aaa9aae | ||
|
|
cf411c5f0a | ||
|
|
137cfb701d | ||
|
|
94c7d29b11 | ||
|
|
07a292a009 | ||
|
|
c9bdf6d891 | ||
|
|
d13b5b1753 | ||
|
|
b608d2aa3d | ||
|
|
0959ec3c98 | ||
|
|
29e60a3ad6 | ||
|
|
c48580d6c9 | ||
|
|
0792a17e2c | ||
|
|
f02c39c98a | ||
|
|
04b5592091 | ||
|
|
3e1dc294b2 | ||
|
|
02bd87bb4e | ||
|
|
eaadb48a2b | ||
|
|
2bcb8bf9b9 | ||
|
|
600d7b0ab1 | ||
|
|
16d7eede50 | ||
|
|
cc259d1121 | ||
|
|
f915e0c33b | ||
|
|
ba95a6d606 | ||
|
|
fb460cd0a3 | ||
|
|
20ef5a6a7f | ||
|
|
d2a8889475 | ||
|
|
cb0a4fbfec | ||
|
|
acf6b33921 | ||
|
|
5112bc1425 | ||
|
|
46b3c93f66 | ||
|
|
771e89c983 | ||
|
|
083ad3b46b | ||
|
|
2cf1f17c2a | ||
|
|
539aa04712 | ||
|
|
83d3d43f10 | ||
|
|
b6a3392bc4 | ||
|
|
ad01861c48 | ||
|
|
db02fa9994 | ||
|
|
785f5793a7 | ||
|
|
cfe78e2e3b | ||
|
|
12950205f6 | ||
|
|
0098ba58e2 | ||
|
|
078fe9034f | ||
|
|
a496ca3832 | ||
|
|
cb78132f10 | ||
|
|
9787b4b1f6 | ||
|
|
7422a70ac4 | ||
|
|
c1a4b9a178 | ||
|
|
448b92276f | ||
|
|
aea492c0d7 | ||
|
|
d870615279 | ||
|
|
600fd82241 | ||
|
|
899ebd64f9 | ||
|
|
5302684c93 | ||
|
|
4f33cafb12 | ||
|
|
0f635cef0f | ||
|
|
eb3dba0e9e | ||
|
|
5f9b5432ec | ||
|
|
7d6c9f3244 | ||
|
|
af80736561 | ||
|
|
e6d36c3cd7 | ||
|
|
69eca5b224 | ||
|
|
a8eaaca2db | ||
|
|
2ccd151728 | ||
|
|
e4b7322bb6 | ||
|
|
23f2a6f6a9 | ||
|
|
95c7acb7d4 | ||
|
|
cffeb84ce4 | ||
|
|
b3bd093d42 | ||
|
|
3a10256e69 | ||
|
|
35d3cc936c | ||
|
|
a0461bf042 | ||
|
|
bc41c8716f | ||
|
|
50d66fae78 | ||
|
|
2f20e29d31 | ||
|
|
1d02b0205a | ||
|
|
ab5a07ff34 | ||
|
|
a8c20c3f19 | ||
|
|
f20cbe44d1 | ||
|
|
922c9a05a1 | ||
|
|
4e50e2fe94 | ||
|
|
b279b964ce | ||
|
|
b6d98ac9d5 | ||
|
|
9380080afe | ||
|
|
19d7093050 | ||
|
|
8c1da2c29e | ||
|
|
4c6bb9d70a | ||
|
|
864ff1f55b | ||
|
|
6cea0c97f2 | ||
|
|
042d574fee | ||
|
|
19097b7255 | ||
|
|
c03929e369 | ||
|
|
170086a5b1 | ||
|
|
06d47eda29 | ||
|
|
0df87683e3 | ||
|
|
b14c4fccaf | ||
|
|
a138efa5f4 | ||
|
|
ed2d95745e | ||
|
|
0880665f28 | ||
|
|
c4c3be0a7c | ||
|
|
8f9251d627 | ||
|
|
064bc6df4c | ||
|
|
2a90c78aac | ||
|
|
d0f96ff1d6 | ||
|
|
8253fb8424 | ||
|
|
fe9f6af161 | ||
|
|
bd702cf948 | ||
|
|
a5157dd3a9 | ||
|
|
74ab66b00e | ||
|
|
4e08509700 | ||
|
|
43e061ba2a | ||
|
|
32c200264e | ||
|
|
373e2121b4 | ||
|
|
26d1b41f3f | ||
|
|
51c864d212 | ||
|
|
254d275d21 | ||
|
|
5246cfdfed | ||
|
|
f5f9ad1075 | ||
|
|
28ed218d34 | ||
|
|
4634a9a871 | ||
|
|
616dfa698a | ||
|
|
e1a1db7797 | ||
|
|
d654e055c3 | ||
|
|
598b80eb26 | ||
|
|
d7d1b695f3 |
@@ -0,0 +1,89 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_copy.ts
|
||||
* 수식 문서 사본 몫(PLAN 802-4b-2 · 요구 655-2 · 656 · 659-3) — M02 단가산출(SK) 수식 탭 머리
|
||||
* 「M01 새 판 있음」 배지(값 줄 · 부름 문서가 지금 M01 과 다름) · [단가 갱신](자원 단가만 · 식 그대로) ·
|
||||
* [재생성](지금 M01 문서로 다시 찍음 · 버리는 것 알림) — 누를 때만(자동 갱신 없음)
|
||||
* 흐름 = 미리 셈(`{길}/preview` {할일}) → 확인 창(계 전 → 후 · 바뀐 자원 · 버리는 것) → 저장(`{길}/save` {할일, 판})
|
||||
* 층 쓰기 권한 없으면 「읽기만」 배지
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { ft } from "./formula_doc_text";
|
||||
|
||||
export type CopyJob = "단가갱신" | "재생성";
|
||||
|
||||
/** 사본 표지(서버 `M02_Doc_Copy.state` + 시점 · 따로) */
|
||||
export interface CopyMark {
|
||||
M01새판?: boolean;
|
||||
지운키?: string[];
|
||||
단가새값?: boolean;
|
||||
문서새판?: string[];
|
||||
따로?: boolean;
|
||||
}
|
||||
|
||||
export interface CopyOptions {
|
||||
/** SK 키 · 꼬리(`""` · `/preview` · `/table?key=…`) → 길(`/m02/layers/{층}/sangeun/{키}/doc…` · 주인 물음) */
|
||||
route: (key: string, path: string) => string;
|
||||
/** 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음) */
|
||||
onSaved?: (version: string) => void;
|
||||
}
|
||||
|
||||
type Money = Record<string, number | null> | null;
|
||||
|
||||
export interface JobReply {
|
||||
전?: Money;
|
||||
후?: Money;
|
||||
바뀐자원?: unknown[];
|
||||
버림?: { 문서고침?: boolean; 끊음?: string[] };
|
||||
}
|
||||
|
||||
/** 「M01 새 판 있음」 — 값 줄 · 단가 · 부름 문서 어느 하나라도 지금 M01 과 다름 */
|
||||
export function isStale(m: CopyMark | undefined): boolean {
|
||||
return !!m && (!!m.M01새판 || !!m.단가새값 || !!m.지운키?.length || !!m.문서새판?.length);
|
||||
}
|
||||
|
||||
export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[] {
|
||||
const out: HTMLElement[] = [];
|
||||
if (isStale(m)) {
|
||||
const b = createBadge(ft("New_M01"), "danger");
|
||||
b.dataset.copyStale = "";
|
||||
out.push(b);
|
||||
}
|
||||
if (!edit) out.push(createBadge(ft("Read_Only"), "neutral"));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLElement[] {
|
||||
if (!edit) return [];
|
||||
return (["단가갱신", "재생성"] as const).map((job) => {
|
||||
const b = createButton({
|
||||
label: job === "단가갱신" ? ft("Price") : ft("Regen"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => run(job),
|
||||
});
|
||||
b.dataset.copyJob = job;
|
||||
return b;
|
||||
});
|
||||
}
|
||||
|
||||
const sum = (m: Money | undefined): string =>
|
||||
typeof m?.계 === "number" ? `${eqText(m.계)} 원` : "—";
|
||||
|
||||
/** 할일 알림 — 계 전 → 후 · 바뀐 자원 수 · 재생성이 버리는 것(이름) → 확인이면 true */
|
||||
export function askJob(job: CopyJob, got: JobReply, names: Map<string, string>): Promise<boolean> {
|
||||
const bits = [
|
||||
job === "단가갱신" ? ft("Price_Ask") : ft("Regen_Ask"),
|
||||
`${ft("Before_After")} ${sum(got.전)} → ${sum(got.후)}`,
|
||||
];
|
||||
if (job === "단가갱신") bits.push(`${ft("Changed")} ${got.바뀐자원?.length ?? 0}`);
|
||||
const lost = [
|
||||
...(got.버림?.문서고침 ? [ft("Regen_Edit")] : []),
|
||||
...(got.버림?.끊음 ?? []).map((k) => `${ft("Cut")} ${names.get(k) || k}`),
|
||||
];
|
||||
if (job === "재생성" && lost.length) bits.push(`${ft("Regen_Lost")} — ${lost.join(" · ")}`);
|
||||
return showConfirmDialog(bits.join(" · "), job === "단가갱신" ? ft("Price") : ft("Regen"));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_foot.ts
|
||||
* 수식 탭 맨 아래 합계 상자(PLAN 803-2a · 요구 667-2) — 단가산출 상세 탭과 같은 부품(`buildFoot` · 노무비 · 재료비 · 경비 · 계)
|
||||
* 값 = 계산기 셈 그대로(여기서 다시 셈 안 함) · 셈 못 함 = 「—」
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import { buildFoot } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Foot";
|
||||
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
|
||||
|
||||
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
|
||||
export function docFoot(a: DocAnswer): HTMLElement {
|
||||
const done = !!a.셈됨 && typeof a.계 === "number";
|
||||
return buildFoot(
|
||||
{ labor: num(a.노무비), material: num(a.재료비), cost: num(a.경비) },
|
||||
done,
|
||||
num(a.계),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_options.ts
|
||||
* 수식 문서 옵션 가로(PLAN 802-3 · 802-4b-2 · 요구 655 「옵션 선택은 상단에 수평」) — 선택 · 입력 기호마다 칸 하나
|
||||
* 값 = 화면 값(`given`) › 문서 값 · 고침은 부른 쪽 `set`(M01 = 화면 값만 · M02 사본 = 문서 값) · `lock` = 읽기만
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
|
||||
export function buildOptions(
|
||||
doc: FormulaDoc | null,
|
||||
given: Record<string, unknown>,
|
||||
lock: boolean,
|
||||
set: (key: string, v: DocVar, value: unknown) => void,
|
||||
): HTMLElement[] {
|
||||
const out: HTMLElement[] = [];
|
||||
for (const v of doc?.변수 ?? []) {
|
||||
const kind = v.연결?.갈래;
|
||||
const k = v.열쇠;
|
||||
if (!k || (kind !== "선택" && kind !== "입력")) continue;
|
||||
const label = v.이름 || varId(v);
|
||||
const now = k in given ? given[k] : v.연결?.값;
|
||||
if (kind === "선택") {
|
||||
const list = (v.연결?.목록 ?? []).map(String);
|
||||
const f = createSelectField({
|
||||
label,
|
||||
options: list.map((x) => ({ value: x, text: x })),
|
||||
value: String(now ?? ""),
|
||||
compact: true,
|
||||
size: 28,
|
||||
disabled: lock,
|
||||
onChange: (x) => set(k, v, x),
|
||||
});
|
||||
f.root.dataset.option = k;
|
||||
out.push(f.root);
|
||||
} else {
|
||||
const f = createInputField({
|
||||
label,
|
||||
value: now === undefined || now === null ? "" : String(now),
|
||||
compact: true,
|
||||
size: 28,
|
||||
unit: v.단위 || undefined,
|
||||
});
|
||||
f.input.disabled = lock;
|
||||
f.input.addEventListener("change", () => {
|
||||
const t = f.input.value.trim();
|
||||
const n = Number(t.replace(/,/g, ""));
|
||||
set(k, v, t !== "" && Number.isFinite(n) ? n : t);
|
||||
});
|
||||
f.root.dataset.option = k;
|
||||
out.push(f.root);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_render.ts
|
||||
* 수식 문서 닿는 기호(PLAN 802-3 · 804-6) — 줄 쌓기 산출근거(`line_stack_out.ts`)가 씀
|
||||
* reach = 지금 탄 갈래로 닿는 기호(만약 안 탄 갈래 · 숨김 자리 인자 빼고)
|
||||
* 옛 수식 문서 탭의 조판 그림(renderNode · 값 식 · 수 바꿈)은 804-6 에 걷음
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
linkNames,
|
||||
varId,
|
||||
type FormulaDoc,
|
||||
type Node,
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import {
|
||||
offLine,
|
||||
takenOf,
|
||||
tryParse,
|
||||
type Values,
|
||||
} from "../common_util/common_util_formula_doc_view";
|
||||
|
||||
/** 탄 갈래로 부르는 이름(만약 안 탄 갈래 · 숨김 자리 인자 빼고) */
|
||||
function namesTaken(n: Node, vals: Values, out: string[] = []): string[] {
|
||||
const add = (x: string): void => {
|
||||
if (!out.includes(x)) out.push(x);
|
||||
};
|
||||
switch (n[0]) {
|
||||
case "var":
|
||||
add(n[1]);
|
||||
break;
|
||||
case "bin":
|
||||
namesTaken(n[2], vals, out);
|
||||
namesTaken(n[3], vals, out);
|
||||
break;
|
||||
case "neg":
|
||||
case "pct":
|
||||
case "field":
|
||||
namesTaken(n[1], vals, out);
|
||||
break;
|
||||
case "call": {
|
||||
const pick = takenOf(n, vals);
|
||||
const items = pick ? [n[2][0], n[2][pick]] : n[2];
|
||||
items.forEach((x) => namesTaken(x, vals, out));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const RESERVED_MONEY = new Set(["노무비", "재료비", "경비", "번지", "단계"]);
|
||||
|
||||
/** 정의 영역에 보일 기호 — 식 줄 · 금액 줄 · 결과에서 지금 탄 갈래로 닿는 것(보이는 차례) */
|
||||
export function reach(doc: FormulaDoc, vals: Values, showOff = false): string[] {
|
||||
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
|
||||
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
|
||||
const money = new Set((doc.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean));
|
||||
const out: string[] = [];
|
||||
const visit = (name: string): void => {
|
||||
if (!name || out.includes(name) || RESERVED_MONEY.has(name) || money.has(name)) return;
|
||||
out.push(name);
|
||||
if (lines.has(name)) {
|
||||
const n = tryParse(lines.get(name));
|
||||
if (n) namesTaken(n, vals).forEach(visit);
|
||||
}
|
||||
const v = vars.get(name);
|
||||
if (v?.연결 && v.연결.갈래 !== "식") linkNames(v.연결).forEach(visit);
|
||||
};
|
||||
const from = (text: unknown): void => {
|
||||
const n = tryParse(text);
|
||||
if (n) namesTaken(n, vals).forEach(visit);
|
||||
};
|
||||
for (const l of doc.식 ?? []) {
|
||||
visit(String(l.기호 ?? ""));
|
||||
from(l.식);
|
||||
}
|
||||
for (const l of doc.금액 ?? []) {
|
||||
if (!showOff && offLine(l, vals, lines)) continue;
|
||||
from(l.수량);
|
||||
from(l.단가);
|
||||
}
|
||||
if (doc.결과) visit(String(doc.결과));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_text.ts
|
||||
* 수식 문서 화면 글(PLAN 802-3 · 803-2a) — [국문, 영문]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Sym_Head: ["기호 식", "Symbol equation"],
|
||||
Val_Head: ["값 식", "Value equation"],
|
||||
Defs_Head: ["기호 정의", "Symbols"],
|
||||
New_Group: ["새 묶음", "New group"],
|
||||
Unnamed: ["이름 없음", "Unnamed"],
|
||||
Subtotal: ["소계", "Subtotal"],
|
||||
Sum: ["계", "Sum"],
|
||||
Total: ["합계", "Total"],
|
||||
Need: ["정의 필요", "Needs definition"],
|
||||
Unused: ["쓰지 않는 기호", "Unused symbols"],
|
||||
Show_Off: ["꺼진 줄 보기", "Show off lines"],
|
||||
Hide_Off: ["꺼진 줄 숨김", "Hide off lines"],
|
||||
Save: ["수식 저장", "Save equation"],
|
||||
Discard: ["고침 버림", "Discard"],
|
||||
Saved: ["수식 저장됨", "Equation saved"],
|
||||
Save_Failed: ["저장 못 함", "Save failed"],
|
||||
Load_Failed: ["수식 문서를 못 읽음", "Failed to load"],
|
||||
Leave: ["저장 안 한 수식 고침이 있음 — 버리고 나갈까요?", "Discard unsaved edits?"],
|
||||
Leave_Ok: ["버리고 나감", "Discard"],
|
||||
Result: ["결과", "Result"],
|
||||
Result_Sym: ["결과 기호", "Result symbol"],
|
||||
Dirty: ["저장 안 한 고침", "Unsaved edits"],
|
||||
Sign: ["기호", "Symbol"],
|
||||
Low: ["아래첨자", "Subscript"],
|
||||
Up: ["위첨자", "Superscript"],
|
||||
Name: ["국문 이름", "Name"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Link: ["연결", "Link"],
|
||||
Drop_Var: ["정의 지움", "Delete definition"],
|
||||
Value: ["값", "Value"],
|
||||
List: ["목록(· 로 나눔)", "Options (· separated)"],
|
||||
Default: ["기본", "Default"],
|
||||
Low_Bound: ["아래 끝", "Min"],
|
||||
High_Bound: ["위 끝", "Max"],
|
||||
Find: ["찾기", "Search"],
|
||||
Column: ["칸", "Column"],
|
||||
Cond: ["조건", "Condition"],
|
||||
Inputs: ["넘길 입력", "Inputs"],
|
||||
Region: ["지역 기호", "Region symbol"],
|
||||
Make_Line: ["식 줄 만들기", "Add line"],
|
||||
By_Line: ["식 줄로 정의", "Defined by a line"],
|
||||
Pick_First: ["찾아 고름", "Search and pick"],
|
||||
Material: ["재료 고르기", "Material pick"],
|
||||
None: ["없음", "None"],
|
||||
Open_Sub: ["식 펼침", "Show equation"],
|
||||
Bad_Sign: ["기호 꼴 아님 · 같은 기호 있음", "Bad or duplicate symbol"],
|
||||
More: ["더 있음 — 찾는 말을 더 넣으세요", "More — refine search"],
|
||||
Nothing: ["찾은 것 없음", "Nothing found"],
|
||||
Linked: ["연결", "Linked"],
|
||||
Cut: ["끊음", "Unlinked"],
|
||||
Unlink: ["연결 끊기", "Unlink"],
|
||||
Relink: ["다시 잇기", "Relink"],
|
||||
Ask_Relink: [
|
||||
"이 기초에서 고친 것을 버리고 부르던 문서로 돌아감",
|
||||
"Discard edits to this basis and follow the copy again",
|
||||
],
|
||||
New_M01: ["M01 새 판 있음", "New M01 version"],
|
||||
Price: ["단가 갱신", "Refresh prices"],
|
||||
Regen: ["재생성", "Regenerate"],
|
||||
Price_Ask: ["자원 단가만 지금 M01 값으로 — 식 · 연결 그대로", "Prices only — equation unchanged"],
|
||||
Regen_Ask: ["지금 M01 수식 문서로 다시 찍음", "Re-copy from the current M01 equation"],
|
||||
Regen_Lost: ["버리는 것", "Discarded"],
|
||||
Regen_Edit: ["수식 고침", "Equation edits"],
|
||||
Changed: ["바뀐 자원", "Changed resources"],
|
||||
Before_After: ["계", "Sum"],
|
||||
Job_Done: ["적용함", "Applied"],
|
||||
Dirty_First: [
|
||||
"저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저",
|
||||
"Save or discard edits first",
|
||||
],
|
||||
No_Key: [
|
||||
"저장한 단가산출만 수식 문서를 엶 — [단가산출 상세] 탭",
|
||||
"Saved items only — use the detail tab",
|
||||
],
|
||||
Read_Only: ["읽기만", "Read only"],
|
||||
Tools: ["수식 도구", "Equation tools"],
|
||||
Tool_Frac: ["분수", "Fraction"],
|
||||
Tool_Sub: ["아래첨자", "Subscript"],
|
||||
Tool_Sup: ["위첨자", "Superscript"],
|
||||
Tool_Paren: ["( )", "( )"],
|
||||
Tool_If: ["만약", "If"],
|
||||
Tool_Round: ["반올림", "Round"],
|
||||
Tool_Group: ["묶음", "Group"],
|
||||
} as const;
|
||||
|
||||
export type TextKey = keyof typeof TEXT;
|
||||
|
||||
export const ft = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
@@ -0,0 +1,397 @@
|
||||
/* 줄 쌓기 탭(804-2) — 위 옵션 · 가운데 왼쪽 편집 | 오른쪽 산출근거 · 맨 아래 합계 상자 · 색은 공통 값만 */
|
||||
.lstk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.lstk__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.lstk__bar,
|
||||
.lstk__badges,
|
||||
.lstk__tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.lstk__areas {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
|
||||
gap: var(--spacing-12);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.lstk:not([data-edit="1"]) .lstk__areas {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.lstk:not([data-edit="1"]) .lstk__left {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 합계 상자(요구 687-3) — 스크롤해도 화면 아래 고정 */
|
||||
.lstk__foot {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
z-index: 2;
|
||||
padding-block: var(--spacing-8);
|
||||
background: var(--color-surface);
|
||||
border-block-start: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.lstk__foot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 산출근거(요구 683) — 왼쪽을 내려도 화면에 고정 · 길면 자기 스크롤(높이 = 스크롤 영역 − 합계 상자 · fit()) */
|
||||
.lstk[data-edit="1"] .lstk__right {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
max-height: var(--lstk-view, 100vh);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래(고정 없음) */
|
||||
@container (max-width: 1040px) {
|
||||
.lstk__areas {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.lstk[data-edit="1"] .lstk__right {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 왼쪽 편집 ── */
|
||||
.lstk-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
/* 항목(804-5) — 머리 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 = 한 단 더 들여씀 */
|
||||
.lstk-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.lstk-item.is-sub {
|
||||
margin-inline-start: var(--spacing-24);
|
||||
}
|
||||
|
||||
.lstk-item + .lstk-item.is-sub {
|
||||
margin-block-start: calc(-1 * var(--spacing-8));
|
||||
}
|
||||
|
||||
/* 그룹 머리 한 줄(요구 687-6 · 688) — 이름 칸이 늘고 줄어 [+] · X 는 늘 오른쪽 끝(접어도 그대로) */
|
||||
.lstk-ihead {
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-inline-start: 4px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.lstk-item.is-sub > .lstk-ihead {
|
||||
border-inline-start-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.lstk-ihead > .lstk-f-l {
|
||||
flex: 1 1 auto;
|
||||
min-width: 4em;
|
||||
}
|
||||
|
||||
.lstk-count {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lstk-no {
|
||||
flex: none;
|
||||
min-width: 1.5em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.lstk-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-inline-start: var(--spacing-12);
|
||||
padding-inline-start: var(--spacing-12);
|
||||
border-inline-start: 2px solid var(--color-border-strong);
|
||||
}
|
||||
|
||||
.lstk-ihead,
|
||||
.lstk-fhead,
|
||||
.lstk-frow,
|
||||
.lstk-find {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lstk-frow {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 줄 카드 — 금액 · 글 = 한 줄(요구 687-7 · [손잡이][종류][이름][수량][단위][비목][X] · 좁으면 접힘) · 공식 = 머리 줄 + 식 줄 */
|
||||
.lstk-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.lstk-line.k-공식 {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
padding-inline-start: var(--spacing-24);
|
||||
}
|
||||
|
||||
.lstk-line > .lstk-x,
|
||||
.lstk-fhead > .lstk-x {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.lstk-line > .lstk-f-l,
|
||||
.lstk-line > .lstk-qty {
|
||||
flex: 1 1 8em;
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
.lstk-line > .lstk-f-m {
|
||||
flex: 1 1 7em;
|
||||
min-width: 6em;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.lstk-line .lstk-kind {
|
||||
flex: none;
|
||||
min-width: 2.5em;
|
||||
}
|
||||
|
||||
.lstk-wide {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.lstk-qty.ui-btn {
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lstk-upick {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 보이는 글을 눌러 고침(요구 687-14) */
|
||||
.lstk-tap {
|
||||
cursor: text;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.lstk-tap:hover,
|
||||
.lstk-tap:focus-visible {
|
||||
background: var(--color-surface-sunken);
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/* 끌기(804-5) — 손잡이 · 끄는 것 반투명 · 놓을 자리 = 위 · 아래 선 · 빈 항목 = 점선 */
|
||||
.lstk-grip {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.lstk-line > .lstk-grip {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.lstk-line.k-공식 > .lstk-grip {
|
||||
position: absolute;
|
||||
inset-block-start: var(--spacing-8);
|
||||
inset-inline-start: var(--spacing-4);
|
||||
}
|
||||
|
||||
.lstk-edit[data-dragging] {
|
||||
cursor: grabbing;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.is-dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.lstk-line[data-drop="before"],
|
||||
.lstk-item[data-drop="before"] {
|
||||
box-shadow: 0 -3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.lstk-line[data-drop="after"],
|
||||
.lstk-item[data-drop="after"] {
|
||||
box-shadow: 0 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.lstk-item[data-drop="in"] .lstk-empty {
|
||||
outline: 2px dashed var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lstk-res {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.lstk-kind {
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.lstk-fhead[data-tpl="changed"] .lstk-kind {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.lstk-f-s {
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
.lstk-f-m {
|
||||
width: 9em;
|
||||
}
|
||||
|
||||
.lstk-f-l {
|
||||
flex: 1 1 12em;
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
.lstk-sym {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.lstk-val,
|
||||
.lstk-unit,
|
||||
.lstk-tag,
|
||||
.lstk-hint {
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.lstk-tag {
|
||||
padding: 0 var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.lstk-empty {
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.lstk-miss,
|
||||
.lstk-tex .lstk-miss {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── 오른쪽 산출근거 ── */
|
||||
.lstk-out .lstk-h {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 var(--spacing-4);
|
||||
padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-16));
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.lstk-out tr.head,
|
||||
.lstk-out tr.item,
|
||||
.lstk-out tr.total {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.lstk-out tr.subsum,
|
||||
.lstk-out tr.total {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.lstk-out tr.is-sub .lstk-h {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 펼친 문서 제목 = 보통 글(링크 꼴 없음 · 요구 687-11) */
|
||||
.lstk-out tr.subhead .lstk-h {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.lstk-ftitle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lstk-gap {
|
||||
display: inline-block;
|
||||
width: var(--spacing-24);
|
||||
}
|
||||
|
||||
.lstk-pack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.lstk-tex .katex {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
/* 짝 강조(679 · 684) — 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 = 같은 바탕 채우기 */
|
||||
.lstk-line.is-pair,
|
||||
.lstk-item.is-pair > .lstk-ihead,
|
||||
.lstk-out tr.is-pair > td {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.lstk-out tr[data-loc] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lstk-tpick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 옵션 가로(옛 수식 문서 CSS 에서 옮김 · 804-6) */
|
||||
.fdoc__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fdoc__options > * {
|
||||
width: auto;
|
||||
min-width: 8em;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/* =============================================================================
|
||||
* line_stack_drag.ts
|
||||
* 줄 쌓기 왼쪽 끌어 옮기기(PLAN 804-5 · 요구 682-4) — 손잡이(⋮⋮)를 누르고 끌면 놓을 자리에 선 표시 · 놓으면 `drop`
|
||||
* 줄 = 항목 안 · 항목 사이 어디든(빈 항목 = 그 안) → `k.p`(항목 k 의 p 자리) · 항목 = 항목 사이 → `p`
|
||||
* 포인터 끌기(마우스 · 터치 같음) · Esc = 그만 · 자리 표시 = 클래스(`data-drop`)만 — 끄는 동안 배치 안 바뀜
|
||||
* ========================================================================== */
|
||||
|
||||
export interface DragOpts {
|
||||
/** 끄는 것 — 줄 카드 · 항목 상자 */
|
||||
what: "line" | "item";
|
||||
/** 끌기 영역(`.lstk-edit`) */
|
||||
root: HTMLElement;
|
||||
/** 끄는 요소(반투명) */
|
||||
self: HTMLElement;
|
||||
/** 놓음 — line `k.p` · item `p` */
|
||||
drop: (to: string) => void;
|
||||
}
|
||||
|
||||
const mid = (r: DOMRect): number => r.top + r.height / 2;
|
||||
|
||||
/** 포인터 y 의 놓을 자리 + 표시할 요소 · 꼴 */
|
||||
function spotOf(o: DragOpts, y: number): { to: string; at: HTMLElement; side: string } | null {
|
||||
const boxes = [...o.root.querySelectorAll<HTMLElement>(":scope > .lstk-item")];
|
||||
if (!boxes.length) return null;
|
||||
if (o.what === "item") {
|
||||
const k = boxes.findIndex((b) => y < mid(b.getBoundingClientRect()));
|
||||
return k < 0
|
||||
? { to: String(boxes.length), at: boxes[boxes.length - 1], side: "after" }
|
||||
: { to: String(k), at: boxes[k], side: "before" };
|
||||
}
|
||||
// 줄 — 포인터가 든 항목(위 · 아래 밖 = 처음 · 끝 항목)
|
||||
let k = boxes.findIndex((b) => y < b.getBoundingClientRect().bottom);
|
||||
if (k < 0) k = boxes.length - 1;
|
||||
const cards = [...boxes[k].querySelectorAll<HTMLElement>(":scope .lstk-lines > .lstk-line")];
|
||||
if (!cards.length) return { to: `${k}.0`, at: boxes[k], side: "in" };
|
||||
const p = cards.findIndex((c) => y < mid(c.getBoundingClientRect()));
|
||||
return p < 0
|
||||
? { to: `${k}.${cards.length}`, at: cards[cards.length - 1], side: "after" }
|
||||
: { to: `${k}.${p}`, at: cards[p], side: "before" };
|
||||
}
|
||||
|
||||
/** 손잡이에 끌기를 붙임 */
|
||||
export function attachDrag(grip: HTMLElement, o: DragOpts): void {
|
||||
grip.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
grip.setPointerCapture(e.pointerId);
|
||||
let spot: ReturnType<typeof spotOf> = null;
|
||||
const clear = (): void => {
|
||||
for (const x of o.root.querySelectorAll("[data-drop]")) x.removeAttribute("data-drop");
|
||||
};
|
||||
o.self.classList.add("is-dragging");
|
||||
o.root.dataset.dragging = o.what;
|
||||
const move = (ev: PointerEvent): void => {
|
||||
clear();
|
||||
spot = spotOf(o, ev.clientY);
|
||||
if (spot) spot.at.dataset.drop = spot.side;
|
||||
};
|
||||
const end = (ok: boolean): void => {
|
||||
grip.removeEventListener("pointermove", move);
|
||||
grip.removeEventListener("pointerup", up);
|
||||
grip.removeEventListener("pointercancel", cancel);
|
||||
window.removeEventListener("keydown", esc);
|
||||
clear();
|
||||
o.self.classList.remove("is-dragging");
|
||||
delete o.root.dataset.dragging;
|
||||
if (ok && spot) o.drop(spot.to);
|
||||
};
|
||||
const up = (): void => end(true);
|
||||
const cancel = (): void => end(false);
|
||||
const esc = (ev: KeyboardEvent): void => {
|
||||
if (ev.key === "Escape") end(false);
|
||||
};
|
||||
grip.addEventListener("pointermove", move);
|
||||
grip.addEventListener("pointerup", up);
|
||||
grip.addEventListener("pointercancel", cancel);
|
||||
window.addEventListener("keydown", esc);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
/* =============================================================================
|
||||
* line_stack_edit.ts
|
||||
* 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(띠 · 번호 · 제목 · 가나다 · [+ 줄] · 접기 · X) 안 줄 카드(⋮⋮ · X)
|
||||
* 차림(804-5 · 요구 682) = 항목 머리 띠 · 줄 = 들여쓰기 + 왼쪽 세로 선 · 순서 = 손잡이 끌기(`line_stack_drag.ts`) ·
|
||||
* X = 확인 없이 지우고 알림 눌러 되돌리기 한 번 · 자원 이름 = 단추(누르면 찾아 넣기 창 → 바꿈)
|
||||
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
|
||||
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
|
||||
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
|
||||
* 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 =
|
||||
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { createRowMenu } from "@ui/ui_template_context_menu";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
|
||||
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
|
||||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import {
|
||||
dropSyms,
|
||||
docOf,
|
||||
freshId,
|
||||
kindOf,
|
||||
moveItem,
|
||||
moveLine,
|
||||
newVar,
|
||||
ownedOf,
|
||||
stackOf,
|
||||
type MoneyKind,
|
||||
type StackDoc,
|
||||
type StackItem,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import { placeNames } from "../common_util/common_util_line_stack_copy";
|
||||
import {
|
||||
addPicked,
|
||||
aliasText,
|
||||
repick,
|
||||
shownUnit,
|
||||
tableAlias,
|
||||
} from "../common_util/common_util_line_stack_pick";
|
||||
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
|
||||
import { reach } from "./formula_doc_render";
|
||||
import { attachDrag } from "./line_stack_drag";
|
||||
import { openStackPick, type StackTake } from "./line_stack_pick";
|
||||
import { buildBlock } from "./line_stack_rows";
|
||||
import { lt, type LsKey } from "./line_stack_text";
|
||||
|
||||
export interface EditHooks {
|
||||
doc: StackDoc;
|
||||
answer: DocAnswer;
|
||||
items: StackItem[];
|
||||
change: (fn: (d: StackDoc, items: StackItem[]) => void) => void;
|
||||
/** 품셈 표 하나(표 고르기 창) */
|
||||
table: (key: string) => Promise<DocTable | null>;
|
||||
subDoc: (key: string) => FormulaDoc | null;
|
||||
name: (key: string) => string;
|
||||
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
|
||||
open: Set<string>;
|
||||
/** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */
|
||||
pair: (loc: string) => void;
|
||||
/** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */
|
||||
take?: StackTake;
|
||||
}
|
||||
|
||||
const field = (
|
||||
label: string,
|
||||
value: unknown,
|
||||
set: (t: string) => void,
|
||||
size: "s" | "m" | "l" = "m",
|
||||
): HTMLElement => {
|
||||
const f = createInputField({
|
||||
placeholder: label,
|
||||
value: value === undefined || value === null ? "" : String(value),
|
||||
compact: true,
|
||||
size: 24,
|
||||
});
|
||||
f.root.classList.add(`lstk-f-${size}`);
|
||||
f.input.setAttribute("aria-label", label);
|
||||
f.input.title = label;
|
||||
f.input.addEventListener("change", () => set(f.input.value));
|
||||
return f.root;
|
||||
};
|
||||
const btn = (label: string, onClick: () => void) =>
|
||||
createButton({ label, variant: "ghost", size: 24, onClick });
|
||||
|
||||
const KIND_KEY: Record<MoneyKind, LsKey> = {
|
||||
인력: "K_인력",
|
||||
재료: "K_재료",
|
||||
기계: "K_기계",
|
||||
단가산출: "K_단가산출",
|
||||
율: "K_율",
|
||||
};
|
||||
|
||||
/** 금액 줄 몸 — 한 줄(804-7 · 요구 687-7): [종류][이름 단추 | 이름 칸 · 단가 식][수량][단위][비목] · 좁으면 접힘 */
|
||||
function moneyBody(h: EditHooks, i: number): HTMLElement[] {
|
||||
const d = h.doc;
|
||||
const line = (d.금액 ?? [])[i];
|
||||
const kind = kindOf(d, i, h.subDoc);
|
||||
const own = ownedOf(d).get(i);
|
||||
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
|
||||
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
|
||||
h.change((doc) => fn(doc.금액![i]));
|
||||
// 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감
|
||||
const alias = tableAlias(d);
|
||||
const shown = (t: unknown): string => aliasText(String(t ?? ""), alias);
|
||||
const real = (t: string): string => aliasText(t.trim(), alias, true);
|
||||
const head: HTMLElement[] = [];
|
||||
if (own && v) {
|
||||
// 이름 단추(요구 687-9) — 누르면 찾아 넣기 창이 그 이름을 찾은 채 열림 · 고르면 바꿈
|
||||
const link = v.연결 ?? {};
|
||||
const mat = link.재료 as Record<string, unknown> | undefined;
|
||||
const size = (line as { 규격?: string }).규격;
|
||||
const name = String(line.이름 ?? "") || String(mat?.검색어 ?? mat?.이름 ?? "");
|
||||
const res = link.키
|
||||
? link.갈래 === "단가산출"
|
||||
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
|
||||
: h.name(link.키)
|
||||
: "";
|
||||
const pick = createButton({
|
||||
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
|
||||
variant: "ghost",
|
||||
tone: "link",
|
||||
size: 24,
|
||||
iconStart: "search",
|
||||
onClick: () =>
|
||||
openStackPick(
|
||||
kind,
|
||||
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
|
||||
undefined,
|
||||
String(mat?.검색어 ?? line.이름 ?? ""),
|
||||
),
|
||||
});
|
||||
pick.classList.add("lstk-res");
|
||||
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
|
||||
pick.dataset.repick = String(i);
|
||||
head.push(pick);
|
||||
} else
|
||||
head.push(
|
||||
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
|
||||
field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"),
|
||||
);
|
||||
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
|
||||
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
|
||||
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
|
||||
const read = (t: unknown): string =>
|
||||
shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) =>
|
||||
places?.[p] ? `${places[p]} ` : all,
|
||||
);
|
||||
const unit = el("span", { className: "lstk-upick" });
|
||||
const drawUnit = (): void =>
|
||||
unit.replaceChildren(
|
||||
createUnitPick({
|
||||
value: String(line.단위 ?? "") || NO_UNIT,
|
||||
size: 24,
|
||||
free: true,
|
||||
ariaLabel: lt("Unit"),
|
||||
extra: [...new Set((d.금액 ?? []).map((m) => String(m.단위 ?? "")).filter(Boolean))],
|
||||
onPick: (u) => put((l) => (u === NO_UNIT ? delete l.단위 : (l.단위 = u))),
|
||||
}),
|
||||
);
|
||||
drawUnit();
|
||||
return [
|
||||
el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }),
|
||||
...head,
|
||||
// 율 · 식 줄(자원 없음)에 수량이 없으면 칸 안 보임(한 줄 유지)
|
||||
...(own || String(line.수량 ?? "") ? [qtyBox(h, i, shown, real)] : []),
|
||||
unit,
|
||||
...(line.비목 === "묶음"
|
||||
? []
|
||||
: [
|
||||
createSelectField({
|
||||
options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })),
|
||||
value: line.비목 ?? "노무비",
|
||||
compact: true,
|
||||
size: 24,
|
||||
ariaLabel: lt("Cost"),
|
||||
onChange: (x) => put((l) => (l.비목 = x)),
|
||||
}).root,
|
||||
]),
|
||||
...(places
|
||||
? [
|
||||
el("div", {
|
||||
className: "lstk-hint lstk-wide",
|
||||
text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(why ? [el("div", { className: "lstk-miss lstk-wide", text: why })] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** 수량 — 기호 하나면 값 + 출처 꼬리표(「0.39 ← 표 · 잡석 수량」 · 요구 687-12 · 기호는 마우스 글) · 누르면 그 자리에서 식 고침 */
|
||||
function qtyBox(
|
||||
h: EditHooks,
|
||||
i: number,
|
||||
shown: (t: unknown) => string,
|
||||
real: (t: string) => string,
|
||||
): HTMLElement {
|
||||
const line = (h.doc.금액 ?? [])[i];
|
||||
const text = String(line.수량 ?? "");
|
||||
const v = h.doc.변수?.find((x) => varId(x) === text);
|
||||
const at = `qty:${i}`;
|
||||
const edit = (): HTMLElement => {
|
||||
const f = field(lt("Qty"), shown(text), (t) =>
|
||||
h.change((doc) => {
|
||||
h.open.delete(at);
|
||||
const l = doc.금액![i];
|
||||
if (t.trim()) l.수량 = real(t);
|
||||
else delete l.수량;
|
||||
}),
|
||||
);
|
||||
f.classList.add("lstk-qty");
|
||||
f.dataset.qty = String(i);
|
||||
return f;
|
||||
};
|
||||
if (!v || h.open.has(at)) return edit();
|
||||
const val = (h.answer.값 ?? {})[text];
|
||||
const link = v.연결 ?? {};
|
||||
const kind = link.갈래 ?? "식";
|
||||
const from =
|
||||
v.이름 || (link.키 || link.표 ? h.name(String(link.키 ?? link.표)) : "") || shown(text);
|
||||
const b = createButton({
|
||||
label: `${isNum(val) ? eqText(val) : "—"}${v.단위 && shownUnit(v.단위) ? ` ${shownUnit(v.단위)}` : ""} ← ${kind} · ${from}`,
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => {
|
||||
h.open.add(at);
|
||||
h.change(() => undefined);
|
||||
requestAnimationFrame(() =>
|
||||
document.querySelector<HTMLInputElement>(`[data-qty="${i}"] input`)?.focus(),
|
||||
);
|
||||
},
|
||||
});
|
||||
b.classList.add("lstk-qty");
|
||||
b.title = `${lt("Qty")} = ${shown(text)}`;
|
||||
b.dataset.qty = String(i);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** [+ 줄] 고르개 값 → 새 줄 */
|
||||
function addLine(h: EditHooks, at: number, kind: string): void {
|
||||
if (["인력", "재료", "기계", "단가산출"].includes(kind))
|
||||
return openStackPick(
|
||||
kind as MoneyKind,
|
||||
(p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)),
|
||||
h.take,
|
||||
);
|
||||
h.change((d, items) => {
|
||||
const it = items[at];
|
||||
const n = (d.금액 ??= []).length;
|
||||
const money = (line: MoneyLine): void => {
|
||||
d.금액!.push(line);
|
||||
it.줄.push({ 꼴: "금액", i: n });
|
||||
};
|
||||
if (kind === "율")
|
||||
money({ 이름: "공구손료 및 경장비", 단위: "식", 비목: "경비", 단가: "노무비 × 3 %" });
|
||||
else if (kind === "공식") {
|
||||
const id = freshId(d, "x");
|
||||
(d.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
|
||||
it.줄.push({ 꼴: "공식", 기호: [id] });
|
||||
h.open.add(`${at}.${it.줄.length - 1}`);
|
||||
} else if (kind === "글") it.줄.push({ 꼴: "글", 글: "" });
|
||||
});
|
||||
}
|
||||
|
||||
/** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */
|
||||
function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
|
||||
const line = items[i].줄[j];
|
||||
if (line.꼴 === "금액") {
|
||||
const own = ownedOf(d).get(line.i);
|
||||
if (own) dropSyms(d, [own]);
|
||||
} else if (line.꼴 === "공식") dropSyms(d, line.기호);
|
||||
items[i].줄.splice(j, 1);
|
||||
}
|
||||
|
||||
export function buildEdit(h: EditHooks): HTMLElement {
|
||||
// [+] 줄 더함 메뉴(요구 687-13 · 18) — 인력 · 재료 · 기계 · 단가산출 · 율 · 식 · 근거 글
|
||||
const add = (at: number): HTMLElement => {
|
||||
const kinds: [string, LsKey][] = [
|
||||
["인력", "K_인력"],
|
||||
["재료", "K_재료"],
|
||||
["기계", "K_기계"],
|
||||
["단가산출", h.take ? "K_복사" : "K_단가산출"],
|
||||
["율", "Add_율"],
|
||||
["공식", "Add_식"],
|
||||
["글", "K_글"],
|
||||
];
|
||||
const menu = createRowMenu(
|
||||
kinds.map(([k, label]) => ({ label: lt(label), onClick: () => addLine(h, at, k) })),
|
||||
{ icon: "plus", title: lt("Add_Line"), size: 24 },
|
||||
);
|
||||
menu.classList.add("lstk-add");
|
||||
return menu;
|
||||
};
|
||||
// X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만)
|
||||
const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => {
|
||||
const snap = JSON.stringify(h.doc);
|
||||
let after = "";
|
||||
h.change((d, items) => {
|
||||
fn(d, items);
|
||||
after = JSON.stringify(docOf(d, items));
|
||||
});
|
||||
showToast(`${what || lt("K_줄")} ${lt("Dropped")}`, "info", 6000, () =>
|
||||
h.change((d, items) => {
|
||||
if (JSON.stringify(docOf(d, items)) !== after)
|
||||
return void showToast(lt("Undo_Late"), "warning");
|
||||
const back = JSON.parse(snap) as StackDoc;
|
||||
for (const k of Object.keys(d)) delete (d as Record<string, unknown>)[k];
|
||||
Object.assign(d, back);
|
||||
items.splice(0, items.length, ...stackOf(back));
|
||||
}),
|
||||
);
|
||||
};
|
||||
const x = (title: string, onClick: () => void): HTMLElement => {
|
||||
const b = createIconButton({ icon: "x", title, size: 24, onClick });
|
||||
b.classList.add("lstk-x");
|
||||
return b;
|
||||
};
|
||||
const grip = (): HTMLElement =>
|
||||
el("span", { className: "ui-list-row__grip lstk-grip", attrs: { title: lt("Drag") } });
|
||||
const root = el("div", { className: "lstk-edit" });
|
||||
/** 항목 접힘 표시(화면 상태)를 항목 차례 바뀜에 맞춤 */
|
||||
const refold = (fn: (flags: boolean[]) => void): void => {
|
||||
const flags = h.items.map((_, k) => h.open.has(`fold:${k}`));
|
||||
fn(flags);
|
||||
h.items.forEach((_, k) => h.open.delete(`fold:${k}`));
|
||||
flags.forEach((f, k) => f && h.open.add(`fold:${k}`));
|
||||
};
|
||||
// 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 옛 가나다 항목 = 가. 나. · 체크 걷음 요구 687-5)
|
||||
let n = 0;
|
||||
let sub = 0;
|
||||
const nos = h.items.map((it) => {
|
||||
if (!it.제목.trim()) return "";
|
||||
const no = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"}.` : `${++n}.`;
|
||||
if (!it.가나다) sub = 0;
|
||||
return no;
|
||||
});
|
||||
// 만약(IF) 안 탄 갈래(요구 687-10) — 갈래를 다 열면 닿고 지금 값으로는 안 닿는 기호 · 꺼진 금액 줄 = 숨김(산출근거와 같음)
|
||||
const vals = (h.answer.값 ?? {}) as Values;
|
||||
const taken = new Set(reach(h.doc, vals));
|
||||
const every = new Set(reach(h.doc, {}, true));
|
||||
const steps = lineMap(h.doc);
|
||||
const off = (line: StackItem["줄"][number]): boolean =>
|
||||
line.꼴 === "금액"
|
||||
? offLine((h.doc.금액 ?? [])[line.i] ?? {}, vals, steps)
|
||||
: line.꼴 === "공식" &&
|
||||
line.기호.length > 0 &&
|
||||
line.기호.every((id) => every.has(id) && !taken.has(id));
|
||||
const items = h.items.map((it, i) => {
|
||||
const folded = h.open.has(`fold:${i}`);
|
||||
const itemGrip = grip();
|
||||
// 그룹 머리 한 줄(요구 687-6 · 688) — 손잡이 · 접기 · 번호 · 이름 · [+] · X · 접어도 자리 그대로
|
||||
const head = el("div", {
|
||||
className: "lstk-ihead",
|
||||
children: [
|
||||
itemGrip,
|
||||
createIconButton({
|
||||
icon: folded ? "chevronRight" : "chevronDown",
|
||||
title: folded ? lt("Unfold_Item") : lt("Fold_Item"),
|
||||
size: 24,
|
||||
onClick: () => {
|
||||
if (folded) h.open.delete(`fold:${i}`);
|
||||
else h.open.add(`fold:${i}`);
|
||||
h.change(() => undefined);
|
||||
},
|
||||
}),
|
||||
el("b", { className: "lstk-no", text: nos[i] }),
|
||||
field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"),
|
||||
el("span", {
|
||||
className: "lstk-hint lstk-count",
|
||||
text: folded ? `${lt("K_줄")} ${it.줄.length}` : "",
|
||||
}),
|
||||
add(i),
|
||||
x(lt("Del_Item"), () =>
|
||||
drop(it.제목.trim() || lt("Item"), (d, items) => {
|
||||
refold((f) => f.splice(i, 1));
|
||||
for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j);
|
||||
items.splice(i, 1);
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
const lines = it.줄.flatMap((line, j) => {
|
||||
if (off(line)) return [];
|
||||
const lineGrip = grip();
|
||||
const del = x(lt("Del"), () =>
|
||||
drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) =>
|
||||
dropLine(d, items, i, j),
|
||||
),
|
||||
);
|
||||
const body =
|
||||
line.꼴 === "금액"
|
||||
? moneyBody(h, line.i)
|
||||
: line.꼴 === "글"
|
||||
? [
|
||||
el("span", { className: "lstk-kind", text: lt("K_글") }),
|
||||
field(
|
||||
lt("Text_Ph"),
|
||||
line.글,
|
||||
(t) => h.change((_, items) => ((items[i].줄[j] as { 글: string }).글 = t)),
|
||||
"l",
|
||||
),
|
||||
]
|
||||
: buildBlock(h, i, j, line, (id) => every.has(id) && !taken.has(id));
|
||||
// 금액 · 글 = 한 줄(X 끝) · 공식 묶음 = 머리 줄 끝에 X
|
||||
const card = el("div", {
|
||||
className: `lstk-line k-${line.꼴}`,
|
||||
children:
|
||||
line.꼴 === "공식"
|
||||
? [lineGrip, ...body.slice(0, 1).map((hd) => (hd.append(del), hd)), ...body.slice(1)]
|
||||
: [lineGrip, ...body, del],
|
||||
});
|
||||
card.dataset.loc = `${i}.${j}`;
|
||||
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
|
||||
attachDrag(lineGrip, {
|
||||
what: "line",
|
||||
root,
|
||||
self: card,
|
||||
drop: (to) => {
|
||||
const [k, p] = to.split(".").map(Number);
|
||||
h.change((_, items) => moveLine(items, i, j, k, p));
|
||||
},
|
||||
});
|
||||
return [card];
|
||||
});
|
||||
const box = el("div", {
|
||||
className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`,
|
||||
attrs: { "data-loc": String(i) },
|
||||
children: [
|
||||
head,
|
||||
...(folded
|
||||
? []
|
||||
: [
|
||||
el("div", {
|
||||
className: "lstk-lines",
|
||||
children: lines.length
|
||||
? lines
|
||||
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })],
|
||||
}),
|
||||
]),
|
||||
],
|
||||
});
|
||||
head.addEventListener("pointerdown", () => h.pair(String(i)));
|
||||
attachDrag(itemGrip, {
|
||||
what: "item",
|
||||
root,
|
||||
self: box,
|
||||
drop: (to) => {
|
||||
const p = Number(to);
|
||||
refold((f) => moveItem(f, i, p));
|
||||
h.change((_, items) => moveItem(items, i, p));
|
||||
},
|
||||
});
|
||||
return box;
|
||||
});
|
||||
root.append(
|
||||
...items,
|
||||
btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/* =============================================================================
|
||||
* line_stack_out.ts
|
||||
* 줄 쌓기 산출근거 출력(PLAN 804-2 · 요구 670-1 · 677) — 경쟁사 「단가산출」 시트 꼴 표(산출근거 · 합계 · 노무비 · 재료비 · 경비)
|
||||
* 공식 줄 = 「기호 = 기호식 = 값식 = 결과 단위」(값만인 줄은 넷까지 한 줄) · 금액 줄 = 「이름 : 단가 × 수량 단위 = 금액」
|
||||
* 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 제목 줄(「굴착기 0.6㎥」) 누름 = 그 문서(기계 시간당 경비 등)를
|
||||
* 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 비목 수는 글(링크 꼴 없음 · 요구 687-11) ·
|
||||
* 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만
|
||||
* 값 = 계산기 셈 답 그대로(`calcDoc` · 줄 `answer.줄` · 합 `노무비` …) — 여기서 다시 셈 안 함 · 조판 = KaTeX(`line_stack_tex.ts`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
|
||||
import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
|
||||
import {
|
||||
lineMap,
|
||||
moneyScope,
|
||||
offLine,
|
||||
type Values,
|
||||
} from "../common_util/common_util_formula_doc_view";
|
||||
import { reach } from "./formula_doc_render";
|
||||
import {
|
||||
kindOf,
|
||||
nodeOf,
|
||||
priceSym,
|
||||
stackOf,
|
||||
subInputs,
|
||||
type StackDoc,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import { placeNames } from "../common_util/common_util_line_stack_copy";
|
||||
import { shownUnit, stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick";
|
||||
import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex";
|
||||
import { lt } from "./line_stack_text";
|
||||
|
||||
type Cols = Partial<Record<"sum" | (typeof COSTS)[number], number>>;
|
||||
interface Row {
|
||||
h: (string | HTMLElement)[];
|
||||
cols?: Cols;
|
||||
cls?: string;
|
||||
won?: boolean;
|
||||
depth: number;
|
||||
/** 들여쓰기 수준(804-3) — 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1 */
|
||||
lv: number;
|
||||
/** 왼쪽 짝 자리(679) — 항목 `k` · 줄 `k.j`(펼친 블록 = 그 줄) */
|
||||
loc?: string;
|
||||
}
|
||||
export interface OutHooks {
|
||||
/** 줄 누름 — 왼쪽 짝 자리(679) */
|
||||
pair?: (loc: string) => void;
|
||||
cache: DocCache;
|
||||
/** 펼친 자리(`부모/기호`) */
|
||||
open: Set<string>;
|
||||
toggle: (at: string) => void;
|
||||
/** 캐시에 없는 연결 — 뒤에서 서버에 물음 */
|
||||
miss: () => void;
|
||||
}
|
||||
|
||||
const COST_SHOW: Record<string, string> = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" };
|
||||
const won = (n: number): string => eqText(Math.round(n));
|
||||
const one = (n: number): string =>
|
||||
n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
const MAX_DEPTH = 3;
|
||||
const LONG_SUM = 4; // 합계 덧셈 항이 이보다 많으면 항목 소계끼리(경쟁사 「노무비 : 2,523.3 + 919.5 = 3,442」)
|
||||
|
||||
/** 식 글 → 조판(못 풀면 글) */
|
||||
function exprNode(
|
||||
text: unknown,
|
||||
side: "sym" | "val",
|
||||
vals: Values,
|
||||
inline = false,
|
||||
cost?: string,
|
||||
more: Partial<TexCtx> = {},
|
||||
) {
|
||||
const n = nodeOf(text);
|
||||
return n
|
||||
? katexNode(tex(n, { side, vals, inline, cost, ...more }))
|
||||
: el("span", { text: String(text ?? "") });
|
||||
}
|
||||
const hasVar = (text: unknown): boolean => {
|
||||
const n = nodeOf(text);
|
||||
return !!n && JSON.stringify(n).includes('["var"');
|
||||
};
|
||||
|
||||
function rows(
|
||||
doc: StackDoc,
|
||||
a: DocAnswer,
|
||||
h: OutHooks,
|
||||
depth: number,
|
||||
at: string,
|
||||
base = 0,
|
||||
): Row[] {
|
||||
const out: Row[] = [];
|
||||
let lv = base + 1; // 지금 쌓는 줄 수준
|
||||
let loc: string | undefined; // 지금 쌓는 왼쪽 자리(맨 위 문서만)
|
||||
const vals = (a.값 ?? {}) as Values;
|
||||
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
|
||||
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
|
||||
// 펼친 문서 줄 = 금액 칸 없이(부른 쪽 칸과 섞이지 않게)
|
||||
const push = (r: Omit<Row, "depth" | "lv">): void =>
|
||||
void out.push({ ...r, depth, lv, loc, ...(depth ? { cols: undefined } : {}) });
|
||||
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
|
||||
const steps = lineMap(doc);
|
||||
const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5)
|
||||
const unit = (id: string): string => shownUnit(vars.get(id)?.단위);
|
||||
const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683)
|
||||
const subOf = (id: string): FormulaDoc | null => {
|
||||
const k = vars.get(id)?.연결;
|
||||
if (k?.갈래 !== "단가산출" || !k.키 || k.키.includes("{")) return null;
|
||||
try {
|
||||
return h.cache.links.doc(k.키) ?? null;
|
||||
} catch {
|
||||
if (h.cache.takeMissing()) h.miss();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
/** ▸ 단추 — 펼친 자리면 그 문서 줄을 뒤에 끼움 */
|
||||
const opener = (id: string, label: string): HTMLElement => {
|
||||
const path = `${at}/${id}`;
|
||||
const b = createButton({
|
||||
label: `${label} ${h.open.has(path) ? "▾" : "▸"}`,
|
||||
variant: "link",
|
||||
size: 20,
|
||||
onClick: () => h.toggle(path),
|
||||
});
|
||||
b.dataset.expand = path;
|
||||
return b;
|
||||
};
|
||||
/** 기계 · 단가산출 제목 줄 — 접기 단추 + 제목(누르면 펼침 · 요구 687-11) */
|
||||
const fold = (id: string, title: string): HTMLElement[] => {
|
||||
const path = `${at}/${id}`;
|
||||
const on = h.open.has(path);
|
||||
const b = createIconButton({
|
||||
icon: on ? "chevronDown" : "chevronRight",
|
||||
title: lt(on ? "Fold_Sub" : "Unfold_Sub"),
|
||||
size: 20,
|
||||
onClick: (e) => (e.stopPropagation(), h.toggle(path)),
|
||||
});
|
||||
b.dataset.expand = path;
|
||||
const t = el("span", { className: "lstk-ftitle", text: title });
|
||||
t.addEventListener("click", (e) => (e.stopPropagation(), h.toggle(path)));
|
||||
return [b, t];
|
||||
};
|
||||
const expand = (id: string): void => {
|
||||
const path = `${at}/${id}`;
|
||||
const sub = subOf(id);
|
||||
if (!h.open.has(path) || !sub || depth >= MAX_DEPTH) return;
|
||||
const got = calcDoc(sub, h.cache.links, subInputs(vars.get(id)?.연결, vals));
|
||||
if (h.cache.takeMissing()) h.miss();
|
||||
out.push({
|
||||
depth: depth + 1,
|
||||
lv: lv + 1,
|
||||
loc,
|
||||
h: [`${sub.이름 ?? ""}${sub.결과단위 ? ` [${sub.결과단위}]` : ""}`],
|
||||
cls: "subhead",
|
||||
});
|
||||
out.push(...rows(sub, got, h, depth + 1, path, lv + 1).map((r) => ({ ...r, loc })));
|
||||
};
|
||||
|
||||
/** 공식 묶음 — 값만인 줄은 넷까지 한 줄 */
|
||||
const block = (ids: string[]): void => {
|
||||
let pack: HTMLElement[] = [];
|
||||
const flush = (): void => {
|
||||
if (pack.length)
|
||||
push({ h: pack.flatMap((x, k) => (k ? [el("span", { className: "lstk-gap" }), x] : [x])) });
|
||||
pack = [];
|
||||
};
|
||||
for (const id of ids.filter((x) => shown.has(x))) {
|
||||
const v = vals[id];
|
||||
const t = lines.get(id);
|
||||
const head = katexNode(symTex(alias[id] ?? id));
|
||||
if (t !== undefined && hasVar(t)) {
|
||||
flush();
|
||||
const res = isNum(v)
|
||||
? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`)
|
||||
: missing(a, id);
|
||||
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
|
||||
const n = nodeOf(t)!;
|
||||
const sym = tex(n, { side: "sym", vals, alias });
|
||||
const val = tex(n, { side: "val", vals, alias });
|
||||
const same = isNum(v) && val === valTex(v);
|
||||
push({
|
||||
h: [
|
||||
head,
|
||||
...(sym === val ? [] : [" = ", katexNode(sym)]),
|
||||
...(same ? [] : [" = ", katexNode(val)]),
|
||||
" = ",
|
||||
res,
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sub = subOf(id);
|
||||
if (sub || (v && typeof v === "object")) {
|
||||
flush();
|
||||
const label =
|
||||
v && typeof v === "object"
|
||||
? COSTS.map((c) => `${COST_SHOW[c]} ${eqText((v as Values)[c])}`).join(" · ")
|
||||
: isNum(v)
|
||||
? `${eqText(v)} ${unit(id) || sub?.결과단위 || ""}`
|
||||
: "—";
|
||||
push({
|
||||
h: [head, " = ", sub ? opener(id, label) : label, sub ? ` ${sub.이름 ?? ""}` : ""],
|
||||
});
|
||||
expand(id);
|
||||
continue;
|
||||
}
|
||||
const expr =
|
||||
t !== undefined && !isNum(Number(t))
|
||||
? [" = ", exprNode(t, "sym", vals, false, undefined, { alias })]
|
||||
: [];
|
||||
const cell = el("span", {
|
||||
className: "lstk-pack",
|
||||
children: [
|
||||
head,
|
||||
...expr,
|
||||
" = ",
|
||||
isNum(v) ? `${eqText(v)} ${unit(id)}` : typeof v === "string" ? v : missing(a, id),
|
||||
],
|
||||
});
|
||||
pack.push(cell);
|
||||
if (pack.length >= 4) flush();
|
||||
}
|
||||
flush();
|
||||
};
|
||||
|
||||
const money = doc.금액 ?? [];
|
||||
const lineRows = (i: number): { sub: boolean; cols: Cols } => {
|
||||
const line = money[i];
|
||||
const r = (a.줄?.[i] ?? {}) as {
|
||||
비목?: Record<string, number>;
|
||||
금액?: number | null;
|
||||
까닭?: string;
|
||||
단가?: unknown;
|
||||
};
|
||||
const scope: Values = { ...vals, ...moneyScope(a.줄, i) };
|
||||
const at: Record<string, Values> = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 804-5)
|
||||
money.slice(0, i).forEach((m, k) => {
|
||||
const got = a.줄?.[k] as { 금액?: unknown; 비목?: Values } | undefined;
|
||||
if (m.기호) scope[m.기호] = got?.금액; // 앞 금액 줄 기호 = 그 줄 금액
|
||||
if (m.번지)
|
||||
for (const c of COSTS) {
|
||||
const box = (at[String(m.번지)] ??= { 노무비: 0, 재료비: 0, 경비: 0 });
|
||||
if (isNum(got?.비목?.[c]))
|
||||
box[c] = Math.round(((box[c] as number) + (got!.비목![c] as number)) * 1e6) / 1e6;
|
||||
}
|
||||
});
|
||||
if (Object.keys(at).length) scope.번지 = at;
|
||||
const name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
|
||||
const qty =
|
||||
line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null;
|
||||
const qtyNode = (cost?: string): (string | HTMLElement)[] =>
|
||||
qty === null
|
||||
? []
|
||||
: [
|
||||
" × ",
|
||||
exprNode(qty, "val", scope, true, cost, { alias }),
|
||||
line.단위 ? ` ${line.단위}` : "",
|
||||
];
|
||||
if (r.까닭 && r.금액 === null) {
|
||||
push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] });
|
||||
return { sub: false, cols: {} };
|
||||
}
|
||||
if (line.비목 === "묶음") {
|
||||
const s = priceSym(line);
|
||||
const bundle = (r.단가 ?? {}) as Values;
|
||||
const cols: Cols = {};
|
||||
const title = name || (s ? (subOf(s)?.이름 ?? s) : "");
|
||||
push({ h: s && subOf(s) ? fold(s, title) : [title], cls: "lname" });
|
||||
for (const c of COSTS) {
|
||||
const part = r.비목?.[c];
|
||||
if (!isNum(part) || (!part && !bundle[c])) continue;
|
||||
cols[c] = part;
|
||||
push({
|
||||
h: [`${COST_SHOW[c]} : `, eqText(bundle[c]), ...qtyNode(c), ` = ${one(part)}`],
|
||||
});
|
||||
}
|
||||
if (s) expand(s);
|
||||
return { sub: true, cols };
|
||||
}
|
||||
const amt = r.금액 ?? 0;
|
||||
const price =
|
||||
hasVar(line.단가) && kindOf(doc, i) === "율"
|
||||
? [
|
||||
exprNode(line.단가, "sym", scope, true, undefined, { place, alias }),
|
||||
" = ",
|
||||
exprNode(line.단가, "val", scope, true, undefined, { alias }),
|
||||
]
|
||||
: [exprNode(line.단가, "val", scope, true, undefined, { alias })];
|
||||
const c = String(line.비목);
|
||||
push({
|
||||
h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`],
|
||||
cols: { sum: amt, [c]: amt },
|
||||
});
|
||||
return { sub: false, cols: { [c]: amt } };
|
||||
};
|
||||
|
||||
const items = stackOf(doc);
|
||||
const terms: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
|
||||
const perItem: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(804-5)
|
||||
let n = 0;
|
||||
let sub = 0;
|
||||
const levels = stackLevels(items);
|
||||
for (const [k, it] of items.entries()) {
|
||||
lv = base + levels[k].item;
|
||||
loc = depth ? undefined : String(k);
|
||||
if (it.제목.trim()) {
|
||||
const label = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
|
||||
if (!it.가나다) sub = 0;
|
||||
push({ h: [label + it.제목], cls: it.가나다 ? "item sub" : "item" });
|
||||
}
|
||||
const sum: Record<string, number> = { 노무비: 0, 재료비: 0, 경비: 0 };
|
||||
const own: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
|
||||
let hasSub = false;
|
||||
lv = base + levels[k].line;
|
||||
for (const [j, l] of it.줄.entries()) {
|
||||
loc = depth ? undefined : `${k}.${j}`;
|
||||
if (l.꼴 === "글") push({ h: [l.글] });
|
||||
else if (l.꼴 === "공식") block(l.기호);
|
||||
else {
|
||||
if (offLine(money[l.i], vals, steps)) continue; // 선택으로 꺼진 줄(수식 탭과 같음)
|
||||
const got = lineRows(l.i);
|
||||
hasSub ||= got.sub;
|
||||
if ((a.줄?.[l.i] as { 합밖?: boolean } | undefined)?.합밖) continue;
|
||||
for (const [c, v] of Object.entries(got.cols)) {
|
||||
sum[c] = Math.round((sum[c] + (v ?? 0)) * 10) / 10;
|
||||
if (v) own[c].push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
loc = depth ? undefined : String(k);
|
||||
if (hasSub && COSTS.some((c) => sum[c])) {
|
||||
const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10;
|
||||
push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" });
|
||||
}
|
||||
for (const c of COSTS) {
|
||||
terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c]));
|
||||
if (sum[c]) perItem[c].push(sum[c]);
|
||||
}
|
||||
}
|
||||
lv = base + 1;
|
||||
loc = undefined;
|
||||
if (doc.결과) {
|
||||
const r = a.결과;
|
||||
push({
|
||||
h: [
|
||||
katexNode(symTex(doc.결과)),
|
||||
` = ${isNum(r) ? `${eqText(r)} ${doc.결과단위 ?? ""}` : "—"}`,
|
||||
],
|
||||
cls: "total",
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (!money.length) return out;
|
||||
push({ h: [`${++n}. ${lt("Total")}`], cls: "item" });
|
||||
lv = base + 2;
|
||||
for (const c of COSTS) {
|
||||
const t = terms[c].length > LONG_SUM ? perItem[c] : terms[c];
|
||||
const v = a[c];
|
||||
const body = !isNum(v)
|
||||
? "—"
|
||||
: t.length > 1
|
||||
? `${t.map(one).join(" + ")} = ${won(v)}`
|
||||
: t.length
|
||||
? won(v)
|
||||
: "";
|
||||
push({ h: [`${COST_SHOW[c]} : ${body}`] });
|
||||
}
|
||||
push({
|
||||
h: [`${lt("Sum")} : ${isNum(a.계) ? won(a.계) : "—"}`],
|
||||
cols: isNum(a.계)
|
||||
? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 }
|
||||
: {},
|
||||
cls: "total",
|
||||
won: true,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const missing = (a: DocAnswer, id: string): HTMLElement =>
|
||||
el("span", { className: "lstk-miss", text: `— ${a.빈값?.[id] ?? ""}`.trim() });
|
||||
|
||||
/** 산출근거 표 */
|
||||
export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement {
|
||||
const columns: Column[] = [
|
||||
{ label: lt("Basis") },
|
||||
{ label: lt("Total"), width: "92px", align: "end" },
|
||||
{ label: COST_SHOW.노무비, width: "84px", align: "end" },
|
||||
{ label: COST_SHOW.재료비, width: "84px", align: "end" },
|
||||
{ label: COST_SHOW.경비, width: "84px", align: "end" },
|
||||
];
|
||||
const { root, tbody } = tableShell(columns, { density: "data" });
|
||||
const head: Row = {
|
||||
h: [`${doc.이름 ?? ""}${doc.결과단위 ? ` [${doc.결과단위}]` : ""}`],
|
||||
cols: isNum(a.계)
|
||||
? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 }
|
||||
: {},
|
||||
cls: "head",
|
||||
won: true,
|
||||
depth: 0,
|
||||
lv: 0,
|
||||
};
|
||||
for (const r of [head, ...rows(doc, a, h, 0, "")]) {
|
||||
const cell = (k: "sum" | (typeof COSTS)[number]): string => {
|
||||
const v = r.cols?.[k];
|
||||
if (v === undefined || (r.won && !v && k !== "sum")) return "";
|
||||
return r.won ? won(v) : one(v);
|
||||
};
|
||||
const first = el("div", { className: "lstk-h", children: r.h });
|
||||
first.style.setProperty("--lstk-depth", String(r.lv));
|
||||
first.dataset.lv = String(r.lv);
|
||||
const tr = tableRow(columns, [first, cell("sum"), ...COSTS.map(cell)]);
|
||||
tr.className = `lstk-orow ${r.cls ?? ""} ${r.depth ? "is-sub" : ""}`;
|
||||
if (r.loc !== undefined) {
|
||||
tr.dataset.loc = r.loc;
|
||||
tr.addEventListener("click", () => h.pair?.(r.loc!));
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
root.classList.add("lstk-out");
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* =============================================================================
|
||||
* line_stack_pick.ts
|
||||
* 줄 쌓기 고르기 창(PLAN 804-3 · 804-4 · 요구 678-1 · 678-3 · 680-1 · 681)
|
||||
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
|
||||
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
|
||||
* M01 단가산출 기초 줄 쌓기 = 「M01 전체」 탭만 — M01 은 원천 기준값이라 M02 단가산출 · 일위대가를 부르지 않음(요구 680-1)
|
||||
* M02 단가산출 줄 쌓기(`take` 있음) · 다른 단가산출 = 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 뺌) —
|
||||
* 고름 = 복사해 펼침(`take(src)` · src = M01 키 | `참조|층|주인|키`) · 참조 줄 없음(요구 681)
|
||||
* 표 고르기 = 품셈 표(조건 칸 + 값 칸) · 값 칸 누름 = 그 줄 · 칸 · 지금 닿는 칸 강조 ·
|
||||
* 옵션에 매인 조건 = 그 줄만 고를 수 있음(다른 줄은 막힘 + 「위 옵션에서 고름」 안내)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import type { MoneyKind } from "../common_util/common_util_line_stack";
|
||||
import type { Picked } from "../common_util/common_util_line_stack_pick";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { openFindPicker, type FindKind } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
|
||||
import { lt } from "./line_stack_text";
|
||||
|
||||
const KINDS: Partial<Record<MoneyKind, FindKind>> = {
|
||||
인력: "labor",
|
||||
재료: "material",
|
||||
기계: "machine",
|
||||
단가산출: "logic",
|
||||
};
|
||||
|
||||
/** M02 단가산출 복사 길(804-4) — 그 SK 층 · 키 · 복사해 넣기 */
|
||||
export interface StackTake {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
me: string;
|
||||
take: (src: string) => void;
|
||||
}
|
||||
|
||||
/** 찾아 넣기 — 고르면 `done(고른 것)` · 닫으면 아무것도 안 함 · `m02` 있고 다른 단가산출 = 복사해 펼침 ·
|
||||
* `query` = 찾기 칸에 넣고 연 결과(이름 단추 · 요구 687-9) */
|
||||
export function openStackPick(
|
||||
kind: MoneyKind,
|
||||
done: (p: Picked) => void,
|
||||
m02?: StackTake,
|
||||
query = "",
|
||||
): void {
|
||||
const want = KINDS[kind];
|
||||
if (!want) return;
|
||||
const spec = (e: { 규격: string }) => (e.규격 ? { 규격: e.규격 } : {});
|
||||
const copy = kind === "단가산출" && m02 ? m02 : null;
|
||||
openFindPicker({
|
||||
layer: copy?.layer ?? "system",
|
||||
owner: copy?.owner ?? null,
|
||||
title: `${lt("Find_Title")} — ${lt(copy ? "K_복사" : (`K_${kind}` as never))}`,
|
||||
kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)
|
||||
query: query.trim() || undefined,
|
||||
ref: copy
|
||||
? (r) =>
|
||||
r.키 === copy.me && r.층 === copy.layer && (!copy.owner || r.주인 === copy.owner)
|
||||
? showToast(lt("Self_Take"), "warning")
|
||||
: copy.take(`참조|${r.층}|${r.주인 ?? ""}|${r.키}`)
|
||||
: undefined,
|
||||
logic: (x) =>
|
||||
copy
|
||||
? copy.take(x.키)
|
||||
: done({ 키: x.키, 이름: x.이름, 단위: x.결과단위.replace(/^원\/?/, "") }),
|
||||
element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...spec(e) }),
|
||||
machine: (e) =>
|
||||
e.로직 && e.원문번호
|
||||
? done({ 키: e.로직, 이름: e.이름, 입력: { 기계: `'${e.원문번호}'` }, ...spec(e) })
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const isNum = (v: unknown): boolean =>
|
||||
typeof v === "number" || (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)));
|
||||
|
||||
const cellText = (v: unknown): string =>
|
||||
Array.isArray(v)
|
||||
? `${v[0] ?? ""} ~ ${v[1] ?? ""}`
|
||||
: isNum(v)
|
||||
? eqText(Number(v))
|
||||
: String(v ?? "");
|
||||
|
||||
export interface TablePick {
|
||||
table: DocTable;
|
||||
/** 지금 닿는 줄(-1 = 없음) · 칸 */
|
||||
now: number;
|
||||
col: string;
|
||||
/** 옵션에 매인 조건 — {조건 칸: 옵션 열쇠들} */
|
||||
bound: Record<string, string[]>;
|
||||
done: (row: Record<string, unknown>, col: string) => void;
|
||||
}
|
||||
|
||||
/** 표 고르기 창 — 값 칸 누름 = 고름 */
|
||||
export function openTablePick(o: TablePick): void {
|
||||
const t = o.table;
|
||||
const conds = Object.keys(t.조건 ?? {});
|
||||
const valueCols = [
|
||||
...new Set([...Object.keys(t.값칸 ?? {}), ...t.줄.flatMap((r) => Object.keys(r))]),
|
||||
].filter((k) => !conds.includes(k));
|
||||
const locked = Object.keys(o.bound).length > 0;
|
||||
const columns: Column[] = [
|
||||
...conds.map((c) => ({ label: c })),
|
||||
...valueCols.map((c) => {
|
||||
const unit = (t.값칸 ?? {})[c];
|
||||
return { label: unit ? `${c} (${String(unit)})` : c, align: "end" as const };
|
||||
}),
|
||||
];
|
||||
openModal({
|
||||
title: [t.원문번호, (t as { 이름?: string }).이름].filter(Boolean).join(" "),
|
||||
closeLabel: lt("Close"),
|
||||
width: "960px",
|
||||
mount: (body, close) => {
|
||||
const { root, tbody } = tableShell(columns, { density: "data" });
|
||||
t.줄.forEach((row, k) => {
|
||||
const off = locked && k !== o.now;
|
||||
const tr = tableRow(columns, [
|
||||
...conds.map((c) => cellText(row[c])),
|
||||
...valueCols.map((c) =>
|
||||
isNum(row[c]) // 수만 고름(글 칸 = 단위 등은 보기만 · M02 길은 수를 글로 줌)
|
||||
? createButton({
|
||||
label: cellText(row[c]),
|
||||
variant: k === o.now && c === o.col ? "filled" : "ghost",
|
||||
size: 24,
|
||||
disabled: off,
|
||||
onClick: () => {
|
||||
close();
|
||||
o.done(row, c);
|
||||
},
|
||||
})
|
||||
: cellText(row[c]),
|
||||
),
|
||||
]);
|
||||
tr.dataset.row = String(k);
|
||||
tbody.append(tr);
|
||||
});
|
||||
const note = locked
|
||||
? [
|
||||
createText(
|
||||
`${lt("Tbl_Bound")} — ${[...new Set(Object.values(o.bound).flat())].join(" · ")}`,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "lstk-tpick",
|
||||
attrs: { "data-table-pick": t.키 ?? "" },
|
||||
children: [...note, createText(lt("Tbl_Hint")), root],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/* =============================================================================
|
||||
* line_stack_rows.ts
|
||||
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
|
||||
* 보임 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
|
||||
* 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 ·
|
||||
* 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
|
||||
* 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로)
|
||||
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
|
||||
* 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」
|
||||
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import {
|
||||
DocError,
|
||||
rename,
|
||||
splitId,
|
||||
varId,
|
||||
type FormulaDoc,
|
||||
} from "../common_util/common_util_formula_doc";
|
||||
import { isNum } from "../common_util/common_util_formula_doc_calc";
|
||||
import {
|
||||
dropSyms,
|
||||
freshId,
|
||||
newVar,
|
||||
setRow,
|
||||
shapeOf,
|
||||
unfold,
|
||||
type StackItem,
|
||||
type StackLine,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import {
|
||||
aliasText,
|
||||
boundConds,
|
||||
currentRow,
|
||||
pickCell,
|
||||
shownUnit,
|
||||
tableAlias,
|
||||
} from "../common_util/common_util_line_stack_pick";
|
||||
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
|
||||
import type { EditHooks } from "./line_stack_edit";
|
||||
import { openTablePick } from "./line_stack_pick";
|
||||
import { lt } from "./line_stack_text";
|
||||
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
type Block = Extract<StackLine, { 꼴: "공식" }>;
|
||||
|
||||
const LINK_TAG: Record<string, string> = {
|
||||
표: "표",
|
||||
요소: "요소",
|
||||
단가산출: "단가산출",
|
||||
선택: "선택",
|
||||
입력: "입력",
|
||||
};
|
||||
|
||||
const input = (
|
||||
value: string,
|
||||
placeholder: string,
|
||||
cls: string,
|
||||
set: (t: string) => void,
|
||||
): HTMLElement => {
|
||||
const f = createInputField({ value, placeholder, compact: true, size: 24 });
|
||||
f.root.classList.add(cls);
|
||||
f.input.addEventListener("change", () => set(f.input.value));
|
||||
return f.root;
|
||||
};
|
||||
|
||||
export function buildBlock(
|
||||
h: EditHooks,
|
||||
i: number,
|
||||
j: number,
|
||||
line: Block,
|
||||
hide: (id: string) => boolean = () => false,
|
||||
): HTMLElement[] {
|
||||
const d = h.doc;
|
||||
const at = `${i}.${j}`;
|
||||
const editing = (id: string): boolean => h.open.has(`${at}:${id}`);
|
||||
/** 그 줄 고침 켬 · 끔(화면 상태) */
|
||||
const flip = (id: string, on: boolean): void => {
|
||||
if (on) h.open.add(`${at}:${id}`);
|
||||
else h.open.delete(`${at}:${id}`);
|
||||
};
|
||||
const vals = (h.answer.값 ?? {}) as Record<string, unknown>;
|
||||
const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v]));
|
||||
const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")]));
|
||||
const blockOf = (items: StackItem[]): Block => items[i].줄[j] as Block;
|
||||
// 표 값 기호 = 화면 이름(요구 683 · 문서 기호 그대로) — 칸 글은 화면 이름 ↔ 기호로 오감
|
||||
const alias = tableAlias(d);
|
||||
const show = (id: string): string => alias[id] ?? id;
|
||||
const shownText = (t: string): string => aliasText(t, alias);
|
||||
const realText = (t: string): string => aliasText(t, alias, true);
|
||||
const valText = (id: string): string => {
|
||||
const v = vals[id];
|
||||
if (isNum(v) || typeof v === "string")
|
||||
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
|
||||
if (v && typeof v === "object")
|
||||
return ["노무비", "재료비", "경비"]
|
||||
.map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`)
|
||||
.join(" · ");
|
||||
return `— ${h.answer.빈값?.[id] ?? ""}`.trim();
|
||||
};
|
||||
|
||||
// ── 이름표 ──
|
||||
const tplName = (k: string): string =>
|
||||
k.startsWith("my:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685)
|
||||
const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴;
|
||||
const tableOnly = line.기호.every((id) => vars.get(id)?.연결?.갈래 === "표" && !lines.has(id));
|
||||
const label = !line.틀
|
||||
? lt(tableOnly && line.기호.length ? "K_표값" : "K_공식")
|
||||
: changed
|
||||
? `${lt("My_Tpl")} (${tplName(line.틀)} ${lt("Changed_From")})`
|
||||
: tplName(line.틀);
|
||||
const head = el("div", {
|
||||
className: "lstk-fhead",
|
||||
children: [
|
||||
el("span", { className: "lstk-kind", text: label }),
|
||||
createIconButton({
|
||||
icon: "plus",
|
||||
title: lt("Add_Row"),
|
||||
size: 24,
|
||||
onClick: () =>
|
||||
h.change((doc, items) => {
|
||||
const id = freshId(doc, "x");
|
||||
(doc.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
|
||||
blockOf(items).기호.push(id);
|
||||
flip(id, true);
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
|
||||
|
||||
// ── 표 값 — [표 · 칸] → 표 고르기 ──
|
||||
const tableBtn = (id: string, link: NonNullable<FormulaDoc["변수"]>[number]["연결"] & object) => {
|
||||
const b = createButton({
|
||||
label:
|
||||
[
|
||||
lt("Tbl"),
|
||||
...Object.values(link.조건 ?? {}).flatMap((t) =>
|
||||
/^'.*'$/.test(String(t)) ? [String(t).slice(1, -1)] : [],
|
||||
),
|
||||
link.칸,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ") + " ▾", // 고른 줄(글 조건) · 칸
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () =>
|
||||
void h.table(String(link.표 ?? "")).then((t) => {
|
||||
if (!t) return void showToast(lt("Tbl_Missing"), "warning");
|
||||
openTablePick({
|
||||
table: t,
|
||||
now: currentRow(t, link, vals),
|
||||
col: String(link.칸 ?? ""),
|
||||
bound: boundConds(d, link),
|
||||
done: (row, col) => h.change((doc) => pickCell(doc, id, t, row, col)),
|
||||
});
|
||||
}),
|
||||
});
|
||||
b.dataset.tablePick = id;
|
||||
b.title = h.name(String(link.표 ?? "")) || String(link.표 ?? "");
|
||||
return b;
|
||||
};
|
||||
|
||||
// ── 줄 ──
|
||||
const renameRow = (old: string, now: string): void => {
|
||||
const t = now.trim();
|
||||
if (!t || t === old) return;
|
||||
try {
|
||||
const copy = clone(d);
|
||||
const [s, lo, up] = splitId(t);
|
||||
rename(copy, old, s, lo, up);
|
||||
} catch (e) {
|
||||
if (e instanceof DocError) return void showToast(lt("Bad_Name"), "warning");
|
||||
throw e;
|
||||
}
|
||||
h.change((doc, items) => {
|
||||
const [s, lo, up] = splitId(t);
|
||||
rename(doc, old, s, lo, up);
|
||||
for (const it of items)
|
||||
for (const l of it.줄) if (l.꼴 === "공식") l.기호 = l.기호.map((x) => (x === old ? t : x));
|
||||
});
|
||||
};
|
||||
const unfoldRow = (id: string, sub: FormulaDoc, key: string): void =>
|
||||
h.change((doc, items) => {
|
||||
const moved = unfold(doc, sub, id);
|
||||
const blk = blockOf(items);
|
||||
blk.기호 = blk.기호.filter((x) => x !== id);
|
||||
items[i].줄.splice(j + 1, 0, { 꼴: "공식", 기호: moved, 틀: key, 틀꼴: shapeOf(doc, moved) });
|
||||
if (!blk.기호.length) items[i].줄.splice(j, 1);
|
||||
});
|
||||
/** 누르면 그 줄 고침(보이는 글을 고침) */
|
||||
const tap = (node: HTMLElement, id: string): HTMLElement => {
|
||||
node.classList.add("lstk-tap");
|
||||
node.tabIndex = 0;
|
||||
node.setAttribute("role", "button");
|
||||
node.title = lt("Edit_Hint");
|
||||
const go = (): void => {
|
||||
flip(id, true);
|
||||
h.change(() => undefined);
|
||||
};
|
||||
node.addEventListener("click", go);
|
||||
node.addEventListener("keydown", (e) => e.key === "Enter" && go());
|
||||
return node;
|
||||
};
|
||||
/** 고친 뒤 닫음 */
|
||||
const done = (id: string, fn: () => void): void => {
|
||||
flip(id, false);
|
||||
fn();
|
||||
};
|
||||
const rows = line.기호
|
||||
.filter((id) => !hide(id))
|
||||
.map((id) => {
|
||||
const v = vars.get(id);
|
||||
const link = v?.연결;
|
||||
const expr = lines.get(id);
|
||||
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
|
||||
const tag =
|
||||
link?.갈래 === "표" && !expr
|
||||
? tableBtn(id, link)
|
||||
: link && LINK_TAG[link.갈래 ?? ""] && !expr
|
||||
? el("span", {
|
||||
className: "lstk-tag",
|
||||
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
|
||||
})
|
||||
: null;
|
||||
const tools: HTMLElement[] = [];
|
||||
if (sub?.결과 && link?.키)
|
||||
tools.push(
|
||||
createButton({
|
||||
label: lt("Unfold"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => unfoldRow(id, sub, link.키!),
|
||||
}),
|
||||
);
|
||||
if (link?.갈래 === "단가산출" && !link.키)
|
||||
tools.push(
|
||||
createButton({
|
||||
label: lt("Find_Title"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () =>
|
||||
openFindPicker({
|
||||
layer: "system",
|
||||
owner: null,
|
||||
title: lt("K_단가산출"),
|
||||
kinds: ["logic"],
|
||||
results: true,
|
||||
logic: (x) =>
|
||||
h.change((doc) => {
|
||||
const y = doc.변수?.find((z) => varId(z) === id);
|
||||
if (y) y.연결 = { 갈래: "단가산출", 키: x.키, 입력: {} };
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const name = tap(el("b", { className: "lstk-sym", text: show(id) }), id);
|
||||
const valueOf =
|
||||
expr ??
|
||||
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
|
||||
let body: (HTMLElement | string)[];
|
||||
const open = editing(id);
|
||||
if (open)
|
||||
body = [
|
||||
input(show(id), lt("Row_Name"), "lstk-f-s", (t) =>
|
||||
done(id, () => renameRow(id, t.trim() === show(id) ? id : t)),
|
||||
),
|
||||
"=",
|
||||
input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) =>
|
||||
done(id, () => h.change((doc) => setRow(doc, id, realText(t)))),
|
||||
),
|
||||
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
|
||||
done(id, () =>
|
||||
h.change((doc) => {
|
||||
let x = doc.변수?.find((y) => varId(y) === id);
|
||||
if (!x) (doc.변수 ??= []).push((x = newVar(id, { 갈래: "식" })));
|
||||
x.단위 = t.trim();
|
||||
}),
|
||||
),
|
||||
),
|
||||
el("span", { className: "lstk-val", text: `= ${valText(id)}` }),
|
||||
createButton({
|
||||
label: "×",
|
||||
variant: "danger-ghost",
|
||||
size: 24,
|
||||
onClick: () => done(id, () => h.change((doc) => dropSyms(doc, [id]))),
|
||||
}),
|
||||
createIconButton({
|
||||
icon: "check",
|
||||
title: lt("Close"),
|
||||
size: 24,
|
||||
onClick: () => done(id, () => h.change(() => undefined)),
|
||||
}),
|
||||
];
|
||||
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
|
||||
body = [
|
||||
name,
|
||||
"=",
|
||||
...(expr
|
||||
? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id), "="]
|
||||
: []),
|
||||
el("span", { className: "lstk-val", text: valText(id) }),
|
||||
];
|
||||
else
|
||||
body = [
|
||||
name,
|
||||
"=",
|
||||
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
|
||||
h.change((doc) => setRow(doc, id, realText(t))),
|
||||
),
|
||||
...(shownUnit(v?.단위)
|
||||
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
|
||||
: []),
|
||||
];
|
||||
const row = el("div", {
|
||||
className: `lstk-frow ${open ? "is-open" : ""}`,
|
||||
children: [...body, ...(tag ? [tag] : []), ...tools],
|
||||
});
|
||||
row.dataset.sym = id;
|
||||
return row;
|
||||
});
|
||||
return [head, ...rows];
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/* =============================================================================
|
||||
* line_stack_screen.ts
|
||||
* 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈)
|
||||
* 위 = 옵션 가로(`formula_doc_options.ts`) · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지(층 쓰기)
|
||||
* `page` 있음(M01 804-7 · M02 804-8) = 저장 · 되돌림 단추 없음 — 페이지 [저장] · [되돌림]이 `save` · `discard` 를 부름 ·
|
||||
* 고침이 바뀌면 `page()` · `page` 없음(일위대가 줄 모달 등) = 위에 [고침 버림] · [수식 저장]
|
||||
* 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침)
|
||||
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 · 화면 아래 고정 · 요구 687-3)
|
||||
* 정본 = 수식 문서(탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` · 804-6 옛 수식 문서 탭 걷음 = 이 화면이 「수식」 탭
|
||||
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
|
||||
* 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
|
||||
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
|
||||
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import {
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import {
|
||||
docOf,
|
||||
stackOf,
|
||||
type StackDoc,
|
||||
type StackItem,
|
||||
} from "../common_util/common_util_line_stack";
|
||||
import {
|
||||
askJob,
|
||||
copyBadges,
|
||||
copyTools,
|
||||
type CopyJob,
|
||||
type CopyMark,
|
||||
type CopyOptions,
|
||||
type JobReply,
|
||||
} from "./formula_doc_copy";
|
||||
import { docFoot } from "./formula_doc_foot";
|
||||
import { buildOptions } from "./formula_doc_options";
|
||||
import { ft } from "./formula_doc_text";
|
||||
import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy";
|
||||
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { buildEdit } from "./line_stack_edit";
|
||||
import { buildOut } from "./line_stack_out";
|
||||
import { lt } from "./line_stack_text";
|
||||
import "./line_stack.css";
|
||||
|
||||
export interface StackScreenOptions {
|
||||
/** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links */
|
||||
base: string;
|
||||
admin: boolean;
|
||||
/** sessionStorage 열쇠 */
|
||||
cache: string;
|
||||
copy?: CopyOptions;
|
||||
/** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */
|
||||
scope?: { layer: Layer; owner: string | null };
|
||||
/** 페이지가 저장 · 되돌림을 맡음(M01 804-7 · M02 804-8) — 고침 있음이 바뀔 때 부름 · 화면 위 [고침 버림] · [수식 저장] 없음 */
|
||||
page?: () => void;
|
||||
}
|
||||
|
||||
type Docs = Record<string, FormulaDoc>;
|
||||
interface Loaded {
|
||||
문서: StackDoc;
|
||||
셈: DocAnswer;
|
||||
연결: DocRecord;
|
||||
새?: boolean;
|
||||
끊음?: Docs;
|
||||
사본?: CopyMark;
|
||||
판?: string;
|
||||
}
|
||||
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
|
||||
export function createStackScreen(opt: StackScreenOptions) {
|
||||
const head = el("div", { className: "lstk__head" });
|
||||
const left = el("div", { className: "lstk__left" });
|
||||
const right = el("div", { className: "lstk__right" });
|
||||
const foot = el("div", { className: "lstk__foot" });
|
||||
const root = el("div", {
|
||||
className: "lstk",
|
||||
attrs: { "data-line-stack": "" },
|
||||
children: [head, el("div", { className: "lstk__areas", children: [left, right] }), foot],
|
||||
});
|
||||
// 산출근거 고정 높이(요구 683) — 스크롤 조상(없으면 창) 높이 · 넓을 때만 고정(line_stack.css)
|
||||
const fit = (): void => {
|
||||
let p = root.parentElement;
|
||||
while (p && !/(auto|scroll)/.test(getComputedStyle(p).overflowY)) p = p.parentElement;
|
||||
const view = (p?.clientHeight ?? innerHeight) - foot.offsetHeight;
|
||||
root.style.setProperty("--lstk-view", `${Math.max(240, view)}px`);
|
||||
};
|
||||
window.addEventListener("resize", fit);
|
||||
const cp = opt.copy;
|
||||
let key = "";
|
||||
let shown: string | null = null; // 연 키(같은 키 다시 그림 = 안 읽음 · 탭 바꿈 = force)
|
||||
let saved = "";
|
||||
let doc: StackDoc | null = null;
|
||||
let answer: DocAnswer | null = null;
|
||||
let cache = createDocCache();
|
||||
let given: Record<string, unknown> = {};
|
||||
let cut: Docs = {};
|
||||
let mark: CopyMark | undefined;
|
||||
let version = "";
|
||||
let seq = 0;
|
||||
let asked = "";
|
||||
let ctl: AbortController | null = null;
|
||||
const tables = new Map<string, Promise<DocTable | null>>(); // 표 고르기(문서 열 때 비움)
|
||||
const opened = new Set<string>(); // 산출근거 펼친 자리
|
||||
let paired = ""; // 짝 강조 자리(679 — 항목 `k` · 줄 `k.j`)
|
||||
const blocks = new Set<string>(); // 풀린 공식 묶음
|
||||
|
||||
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
|
||||
const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal,
|
||||
});
|
||||
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
|
||||
if (!r.ok) {
|
||||
const why = data.detail;
|
||||
throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), {
|
||||
status: r.status,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// ── 캐시 ──
|
||||
const drafts = (): Record<string, FormulaDoc> => {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const dirty = (): boolean => !!doc && JSON.stringify(doc) !== saved;
|
||||
const keep = (): void => {
|
||||
const all = drafts();
|
||||
if (dirty()) all[key] = doc!;
|
||||
else delete all[key];
|
||||
try {
|
||||
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
||||
} catch {
|
||||
/* 캐시가 막혀도 화면 안 고침은 남음 */
|
||||
}
|
||||
opt.page?.();
|
||||
};
|
||||
|
||||
// ── 셈 ──
|
||||
const usable = (): Record<string, unknown> => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const v of doc?.변수 ?? []) {
|
||||
const k = v.열쇠;
|
||||
if (!k || !(k in given)) continue;
|
||||
const kind = v.연결?.갈래;
|
||||
if (kind === "입력") out[k] = given[k];
|
||||
if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k])))
|
||||
out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k]));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const recalc = (): void => {
|
||||
if (!doc) return;
|
||||
answer = calcDoc(doc, cache.links, usable());
|
||||
if (cache.takeMissing()) ask();
|
||||
};
|
||||
/** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함 */
|
||||
const ask = (): void => {
|
||||
const snap = JSON.stringify([doc, usable()]);
|
||||
if (snap === asked) return;
|
||||
asked = snap;
|
||||
ctl?.abort();
|
||||
ctl = new AbortController();
|
||||
const mine = ++seq;
|
||||
void call<{ 연결: DocRecord }>(
|
||||
"/preview",
|
||||
cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() },
|
||||
ctl.signal,
|
||||
)
|
||||
.then((got) => {
|
||||
if (mine !== seq) return;
|
||||
cache.merge(got.연결);
|
||||
cache.merge({ 문서: cut });
|
||||
recalc();
|
||||
draw();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
const subDoc = (k: string): FormulaDoc | null => {
|
||||
try {
|
||||
return cache.links.doc(k) ?? null;
|
||||
} catch {
|
||||
if (cache.takeMissing()) ask();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** 표 하나(`/table`) — 받은 표는 계산기 캐시에도 */
|
||||
const tableOf = (k: string): Promise<DocTable | null> => {
|
||||
if (!tables.has(k))
|
||||
tables.set(
|
||||
k,
|
||||
call<DocTable & { 끊김?: string }>(`/table?key=${encodeURIComponent(k)}`)
|
||||
.then((t) => {
|
||||
if (t.끊김) return null;
|
||||
cache.putTable(k, t);
|
||||
return t;
|
||||
})
|
||||
.catch(() => null),
|
||||
);
|
||||
return tables.get(k)!;
|
||||
};
|
||||
|
||||
/** 고침 — 문서 · 차림을 고친 뒤 문서로 굳힘 */
|
||||
const change = (fn: (d: StackDoc, items: StackItem[]) => void): void => {
|
||||
if (!doc) return;
|
||||
const items = stackOf(doc);
|
||||
fn(doc, items);
|
||||
doc = docOf(doc, items);
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
};
|
||||
|
||||
/** 짝 강조 — 왼쪽 카드 · 오른쪽 줄(같은 자리) · from = 누른 쪽(반대쪽을 보이는 자리로) */
|
||||
const pair = (loc: string, from?: "left" | "right"): void => {
|
||||
paired = loc;
|
||||
for (const x of root.querySelectorAll(".is-pair")) x.classList.remove("is-pair");
|
||||
if (!loc) return;
|
||||
const sel = `[data-loc="${CSS.escape(loc)}"]`;
|
||||
const l = left.querySelector<HTMLElement>(sel);
|
||||
const r = [...right.querySelectorAll<HTMLElement>(sel)];
|
||||
l?.classList.add("is-pair");
|
||||
r.forEach((x) => x.classList.add("is-pair"));
|
||||
if (from === "right") l?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
if (from === "left") r[0]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
};
|
||||
|
||||
/** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [저장] 때 서버가 다시 셈) */
|
||||
const takeIn = async (src: string): Promise<void> => {
|
||||
try {
|
||||
const got = await call<Taken>(`/take?src=${encodeURIComponent(src)}`);
|
||||
change((d, items) => addTaken(d, items, got));
|
||||
} catch (e) {
|
||||
showToast(`${lt("Take_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
|
||||
}
|
||||
};
|
||||
const takeCtx = () =>
|
||||
cp && opt.scope && key
|
||||
? { ...opt.scope, me: key, take: (src: string) => void takeIn(src) }
|
||||
: undefined;
|
||||
|
||||
// ── 그림 ──
|
||||
const edit = (): boolean => opt.admin && (!!key || !cp);
|
||||
const draw = (): void => {
|
||||
if (!doc || !answer) {
|
||||
[head, left, right, foot].forEach((x) => x.replaceChildren());
|
||||
return;
|
||||
}
|
||||
const options = buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
|
||||
const at = doc?.변수?.indexOf(v) ?? -1;
|
||||
if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x }));
|
||||
given[k] = x;
|
||||
recalc();
|
||||
draw();
|
||||
});
|
||||
const busy = dirty();
|
||||
const tools = [
|
||||
...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : []),
|
||||
...(edit() && key && !opt.page
|
||||
? [
|
||||
createButton({
|
||||
label: ft("Discard"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
disabled: !busy,
|
||||
onClick: () => void discard(),
|
||||
}),
|
||||
createButton({
|
||||
label: ft("Save"),
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
disabled: !busy,
|
||||
onClick: () => void save(),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
head.replaceChildren(
|
||||
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
|
||||
el("div", {
|
||||
className: "lstk__bar",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "lstk__badges",
|
||||
children: [
|
||||
...(busy ? [createBadge(ft("Dirty"), "warning")] : []),
|
||||
...(cp ? copyBadges(mark, opt.admin) : []),
|
||||
],
|
||||
}),
|
||||
el("div", { className: "lstk__tools", children: tools }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const items = stackOf(doc);
|
||||
root.dataset.edit = edit() ? "1" : "";
|
||||
left.replaceChildren(
|
||||
...(edit()
|
||||
? [
|
||||
buildEdit({
|
||||
doc,
|
||||
answer,
|
||||
items,
|
||||
change,
|
||||
table: tableOf,
|
||||
subDoc,
|
||||
name: (k) => cache.names.get(k) ?? "",
|
||||
open: blocks,
|
||||
pair: (loc) => pair(loc, "left"),
|
||||
take: takeCtx(),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
right.replaceChildren(
|
||||
items.length || doc.결과
|
||||
? buildOut(doc, answer, {
|
||||
cache,
|
||||
open: opened,
|
||||
toggle: (at) => {
|
||||
if (opened.has(at)) opened.delete(at);
|
||||
else opened.add(at);
|
||||
draw();
|
||||
},
|
||||
miss: ask,
|
||||
pair: (loc) => pair(loc, "right"),
|
||||
})
|
||||
: el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }),
|
||||
);
|
||||
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
|
||||
pair(paired);
|
||||
fit();
|
||||
};
|
||||
|
||||
const take = (got: Loaded): void => {
|
||||
cache.merge(got.연결);
|
||||
saved = JSON.stringify(got.문서);
|
||||
doc = clone(got.문서);
|
||||
cut = clone(got.끊음 ?? {});
|
||||
mark = got.사본;
|
||||
version = got.판 ?? "";
|
||||
};
|
||||
|
||||
/** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */
|
||||
const runJob = async (job: CopyJob): Promise<void> => {
|
||||
if (!doc || !cp) return;
|
||||
if (dirty()) return void showToast(ft("Dirty_First"), "warning");
|
||||
showLoadingOverlay();
|
||||
let got: JobReply;
|
||||
try {
|
||||
got = await call<JobReply>("/preview", { 할일: job });
|
||||
} catch (e) {
|
||||
hideLoadingOverlay();
|
||||
return void showToast(
|
||||
`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
hideLoadingOverlay();
|
||||
if (await askJob(job, got, cache.names)) await save({ 할일: job, 판: version }, ft("Job_Done"));
|
||||
};
|
||||
|
||||
let loading: Promise<void> = Promise.resolve();
|
||||
const save = async (job?: Record<string, unknown>, done = ft("Saved")): Promise<boolean> => {
|
||||
await loading; // 새 로직 저장 뒤 여는 중이면 다 읽고(캐시 이어 받음)
|
||||
if (!doc || (!job && !dirty())) return true;
|
||||
const send = job ?? (cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc });
|
||||
const was = saved;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const got = await call<Loaded>("/save", send).catch(async (e: { status?: number }) => {
|
||||
// 사본 판 바뀜(페이지 [저장] 등) — 문서가 받은 그대로면 새 판으로 한 번 더
|
||||
if (!cp || e.status !== 409) throw e;
|
||||
const now = await call<Loaded>("");
|
||||
if (JSON.stringify(now.문서) !== was) throw e;
|
||||
return call<Loaded>("/save", { ...send, 판: (version = now.판 ?? "") });
|
||||
});
|
||||
take(got);
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
if (cp && got.판) cp.onSaved?.(got.판);
|
||||
if (done) showToast(done, "success");
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
|
||||
return false;
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
const discard = async (): Promise<void> => {
|
||||
if (!doc || !dirty()) return void draw();
|
||||
doc = JSON.parse(saved) as StackDoc;
|
||||
keep();
|
||||
recalc();
|
||||
draw();
|
||||
};
|
||||
|
||||
/** 문서 열기 — 서버 · 캐시에서 다시 읽음(수식 탭 고침 · 저장을 이어 받음) */
|
||||
const open = async (next: string): Promise<void> => {
|
||||
const was = key;
|
||||
shown = next;
|
||||
if (next !== key) {
|
||||
paired = "";
|
||||
opened.clear();
|
||||
blocks.clear();
|
||||
given = {};
|
||||
}
|
||||
key = next;
|
||||
doc = answer = null;
|
||||
asked = "";
|
||||
cache = createDocCache();
|
||||
tables.clear();
|
||||
[cut, mark, version] = [{}, undefined, ""];
|
||||
draw();
|
||||
if (!next) {
|
||||
if (cp)
|
||||
return void right.replaceChildren(
|
||||
el("p", { className: "ui-text ui-text--muted", text: lt("Unsaved_New") }),
|
||||
);
|
||||
doc = (drafts()[""] as StackDoc | undefined) ?? blankDoc();
|
||||
saved = JSON.stringify(blankDoc());
|
||||
recalc();
|
||||
return draw();
|
||||
}
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
|
||||
if (mine !== seq || key !== next) return;
|
||||
take(got);
|
||||
const all = drafts();
|
||||
if (!was && got.새 && all[""] && !all[next]) {
|
||||
all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 };
|
||||
delete all[""];
|
||||
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
||||
}
|
||||
if (all[next]) doc = clone(all[next] as StackDoc);
|
||||
recalc();
|
||||
draw();
|
||||
opt.page?.(); // 캐시에서 이어 받은 고침 — 페이지 [저장] · [되돌림] 켬
|
||||
} catch (e) {
|
||||
if (mine === seq)
|
||||
head.replaceChildren(
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const canLeave = async (): Promise<boolean> => {
|
||||
if (!dirty()) return true;
|
||||
if (!(key in drafts())) return true; // 다른 화면에서 이미 버림
|
||||
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
|
||||
doc = JSON.parse(saved) as StackDoc;
|
||||
keep();
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
/** 로직 · SK 키로 그림(빈 키 = 저장 전 새 것) — 같은 키는 그대로 · `force` = 다시 읽음(탭 바꿈) */
|
||||
draw: (next: string, force = false): void => {
|
||||
if (force || next !== shown) loading = open(next);
|
||||
},
|
||||
canLeave,
|
||||
dirty,
|
||||
/** 페이지 [저장](804-7) — 고침 있으면 서버 정본 저장 · 알림은 페이지가(`done` 빈 글) · 실패 = false */
|
||||
save: (): Promise<boolean> => save(undefined, ""),
|
||||
/** 페이지 [되돌림] — 저장 뒤 상태로(캐시 지움) */
|
||||
discard: (): Promise<void> => discard(),
|
||||
/** 시험 · 확인용 */
|
||||
state: () => ({ doc, answer, dirty: dirty(), items: doc ? stackOf(doc) : [] }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* =============================================================================
|
||||
* line_stack_tex.ts
|
||||
* 줄 쌓기 산출근거 수식 조판(PLAN 804-2 · 요구 677) — 식 나무 → TeX → KaTeX(MIT) · 분수 · 첨자 · 괄호
|
||||
* 기호 식(side "sym") = 기호(첨자) · 값 식(side "val") = 값(모르면 기호 빨강) · 만약 = 탄 갈래만 · 반올림 · 올림 · 버림 감싸개 숨김
|
||||
* 금액 줄 꼴(`inline`) = ÷ 한 줄(경쟁사 「노무비 : 59,020 ÷ Q」) · 공식 줄 = 분수
|
||||
* 규칙은 수식 탭 그림(`formula_doc_render.ts` renderNode)과 같음 — 셈 정본은 계산기
|
||||
* ========================================================================== */
|
||||
|
||||
import katex from "katex";
|
||||
import "../config/node_modules/katex/dist/katex.min.css";
|
||||
import { eqText } from "@ui/ui_template_equation";
|
||||
import { splitId, type Node } from "../common_util/common_util_formula_doc";
|
||||
import { isNum } from "../common_util/common_util_formula_doc_calc";
|
||||
import { leafNum, takenOf, type Values } from "../common_util/common_util_formula_doc_view";
|
||||
|
||||
export interface TexCtx {
|
||||
side: "sym" | "val";
|
||||
vals: Values;
|
||||
inline?: boolean;
|
||||
/** 묶음 값의 비목(기계 · 단가산출 줄 노 · 재 · 경) */
|
||||
cost?: string;
|
||||
/** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */
|
||||
place?: Record<string, string>;
|
||||
/** 표 값 기호 화면 이름(요구 683 — 「Q_수량2^표」 → 「Q_작업반장」) */
|
||||
alias?: Record<string, string>;
|
||||
}
|
||||
|
||||
const HIDE = new Set(["반올림", "올림", "버림"]);
|
||||
const OPS: Record<string, string> = {
|
||||
"+": "+",
|
||||
"-": "-",
|
||||
"*": "\\times",
|
||||
"/": "\\div",
|
||||
"<": "<",
|
||||
"<=": "\\le",
|
||||
">": ">",
|
||||
">=": "\\ge",
|
||||
"==": "=",
|
||||
"!=": "\\ne",
|
||||
그리고: "\\;\\text{그리고}\\;",
|
||||
또는: "\\;\\text{또는}\\;",
|
||||
};
|
||||
const PREC: Record<string, number> = {
|
||||
또는: 1,
|
||||
그리고: 2,
|
||||
"<": 3,
|
||||
"<=": 3,
|
||||
">": 3,
|
||||
">=": 3,
|
||||
"==": 3,
|
||||
"!=": 3,
|
||||
"+": 4,
|
||||
"-": 4,
|
||||
"*": 5,
|
||||
"/": 5,
|
||||
};
|
||||
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
|
||||
|
||||
/** TeX 글 안전 — \text{} 안 */
|
||||
const txt = (s: string): string => `\\text{${s.replace(/[\\{}$&#^_%~]/g, (c) => `\\${c}`)}}`;
|
||||
const word = (s: string): string =>
|
||||
/^[A-Za-z]$/.test(s) ? s : /^[A-Za-z]+$/.test(s) ? `\\mathit{${s}}` : txt(s);
|
||||
|
||||
/** 기호 글(`Q_작업^표`) → TeX(첨자) */
|
||||
export function symTex(id: string): string {
|
||||
const [s, lo, up] = splitId(id);
|
||||
return `${word(s)}${lo ? `_{${word(lo)}}` : ""}${up ? `^{${word(up)}}` : ""}`;
|
||||
}
|
||||
|
||||
/** 값 → TeX(천 자리 쉼표) */
|
||||
export const valTex = (v: unknown): string => {
|
||||
const t = eqText(v);
|
||||
return isNum(v) ? t.replace(/,/g, "{,}") : txt(t);
|
||||
};
|
||||
|
||||
/** 칸 값 — `번지.복사1.노무비` 처럼 겹친 칸까지 */
|
||||
const look = (n: Node, vals: Values): unknown => {
|
||||
if (n[0] === "var") return vals[n[1]];
|
||||
if (n[0] !== "field") return undefined;
|
||||
const v = look(n[1], vals);
|
||||
return v && typeof v === "object" ? (v as Values)[n[2]] : undefined;
|
||||
};
|
||||
|
||||
const paren = (t: string): string => `\\left(${t}\\right)`;
|
||||
const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`;
|
||||
|
||||
export function tex(n: Node, c: TexCtx): string {
|
||||
const go = (x: Node): string => tex(x, c);
|
||||
switch (n[0]) {
|
||||
case "num":
|
||||
return valTex(n[1]);
|
||||
case "str":
|
||||
return txt(`'${n[1]}'`);
|
||||
case "var": {
|
||||
const id = c.alias?.[n[1]] ?? n[1];
|
||||
if (c.side === "sym") return symTex(id);
|
||||
let v = c.vals[n[1]];
|
||||
if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost];
|
||||
return v === null || v === undefined || typeof v === "object" ? miss(symTex(id)) : valTex(v);
|
||||
}
|
||||
case "field": {
|
||||
const [, of, key] = n;
|
||||
if (c.side === "val") {
|
||||
const got = look(n, c.vals);
|
||||
if (isNum(got)) return valTex(got);
|
||||
}
|
||||
if (of[0] === "field" && of[1][0] === "var" && of[1][1] === "번지" && c.place?.[of[2]])
|
||||
return txt(`${c.place[of[2]]} ${key}`);
|
||||
return `${n[1][0] === "var" ? symTex(n[1][1]) : go(n[1])}.${txt(n[2])}`;
|
||||
}
|
||||
case "neg":
|
||||
return `-${n[1][0] === "bin" ? paren(go(n[1])) : go(n[1])}`;
|
||||
case "pct":
|
||||
return `${prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1])}\\,\\%`;
|
||||
case "call": {
|
||||
const [, name, items] = n;
|
||||
if (name === "만약") {
|
||||
const pick = takenOf(n, c.vals);
|
||||
if (pick) return go(items[pick]);
|
||||
}
|
||||
if (HIDE.has(name) && items.length >= 1 && items.length <= 2) return go(items[0]);
|
||||
const fn = name === "최소" ? "\\min" : name === "최대" ? "\\max" : txt(name);
|
||||
return `${fn}${paren(items.map(go).join(",\\,"))}`;
|
||||
}
|
||||
case "bin": {
|
||||
const [, o, a, b] = n;
|
||||
const na = leafNum(a, c.vals);
|
||||
const nb = leafNum(b, c.vals);
|
||||
if ((o === "+" || o === "-") && nb === 0) return go(a);
|
||||
if ((o === "*" || o === "/") && nb === 1) return go(a);
|
||||
if ((o === "+" && na === 0) || (o === "*" && na === 1)) return go(b);
|
||||
if (o === "/" && !c.inline) return `\\dfrac{${go(a)}}{${go(b)}}`;
|
||||
const mine = PREC[o];
|
||||
const left = prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(go(a)) : go(a);
|
||||
const right = prec(b) <= mine ? paren(go(b)) : go(b);
|
||||
return `${left} ${OPS[o] ?? txt(o)} ${right}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TeX → 조판 요소(못 그리면 글 그대로) */
|
||||
export function katexNode(t: string): HTMLElement {
|
||||
const box = document.createElement("span");
|
||||
box.className = "lstk-tex";
|
||||
katex.render(t, box, {
|
||||
throwOnError: false,
|
||||
strict: "ignore",
|
||||
trust: (x) => x.command === "\\htmlClass",
|
||||
output: "html",
|
||||
});
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/* =============================================================================
|
||||
* line_stack_text.ts
|
||||
* 줄 쌓기 화면 글(PLAN 804-2) — [국문, 영문] · 수식 탭과 같은 글은 `formula_doc_text.ts`
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Tab: ["줄 쌓기", "Line stack"],
|
||||
Basis: ["산 출 근 거", "Basis"],
|
||||
Total: ["합 계", "Total"],
|
||||
Sum: ["계", "Sum"],
|
||||
Sub_Sum: ["소 계", "Subtotal"],
|
||||
Add_Item: ["+ 그룹", "+ Group"],
|
||||
Add_Line: ["줄 더함", "Add line"],
|
||||
Add_율: ["율 — 그룹 소계의 %", "Rate — % of subtotal"],
|
||||
Add_식: ["식 — 공식 틀", "Formula"],
|
||||
Del_Item: ["그룹 지움", "Delete group"],
|
||||
Del: ["지움", "Delete"],
|
||||
Item: ["그룹", "Group"],
|
||||
K_줄: ["줄", "Line"],
|
||||
Drag: ["끌어 옮기기", "Drag to move"],
|
||||
Fold_Item: ["그룹 접기", "Collapse group"],
|
||||
Unfold_Item: ["그룹 펼치기", "Expand group"],
|
||||
Unfold_Sub: ["시간당 경비 · 산출 펼치기", "Show breakdown"],
|
||||
Fold_Sub: ["접기", "Hide breakdown"],
|
||||
Dropped: ["지움 — 눌러 되돌리기", "deleted — click to undo"],
|
||||
Undo_Late: ["그 뒤 고친 것이 있어 못 되돌림", "Changed since — cannot undo"],
|
||||
Title: ["그룹 이름", "Group title"],
|
||||
K_인력: ["인력", "Labor"],
|
||||
K_재료: ["재료", "Material"],
|
||||
K_기계: ["기계", "Machine"],
|
||||
K_단가산출: ["단가산출", "Unit price"],
|
||||
K_복사: ["단가산출 · 일위대가 복사", "Copy unit price · combo"],
|
||||
Self_Take: ["지금 단가산출 자신은 못 가져옴", "Cannot copy itself"],
|
||||
Take_Failed: ["복사 못 함", "Copy failed"],
|
||||
K_율: ["율", "Rate"],
|
||||
K_공식: ["공식", "Formula"],
|
||||
K_표값: ["표 값", "Table values"],
|
||||
K_글: ["근거 글", "Basis note"],
|
||||
Name: ["이름", "Name"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Cost: ["비목", "Cost"],
|
||||
Qty: ["수량", "Qty"],
|
||||
Price: ["단가", "Price"],
|
||||
Machine: ["기계", "Machine"],
|
||||
Find: ["찾기", "Find"],
|
||||
Pick: ["고름 ▾", "Pick ▾"],
|
||||
None_Found: ["찾은 것 없음", "Nothing found"],
|
||||
Unfold: ["식으로 풀기", "Unfold"],
|
||||
Edit_Hint: ["눌러 고침", "Click to edit"],
|
||||
Add_Row: ["식 줄 더함", "Add row"],
|
||||
New_Formula: ["새 공식", "New formula"],
|
||||
My_Formula: ["내 공식", "My formula"],
|
||||
My_Tpl: ["내 틀", "My template"],
|
||||
Changed_From: ["에서 변경", "changed"],
|
||||
Row_Name: ["이름", "Name"],
|
||||
Row_Expr: ["값 또는 식", "Value or expr"],
|
||||
Bad_Name: ["못 쓰는 이름 · 같은 이름 있음", "Bad or duplicate name"],
|
||||
Empty_Item: ["줄 없음 — [+]", "No lines — [+]"],
|
||||
Empty: ["빈 단가산출 — [+ 그룹] 으로 시작", "Empty — start with [+ Group]"],
|
||||
Text_Ph: ["< 품셈 근거 > · ※ 메모 · 별산", "Note"],
|
||||
Unsaved_New: ["저장한 단가산출만 — 페이지 [저장] 뒤", "Saved unit prices only"],
|
||||
Find_Title: ["찾아 넣기", "Find and add"],
|
||||
Repick: ["찾아 바꾸기", "Find and change"],
|
||||
Close: ["닫기", "Close"],
|
||||
Tbl: ["표", "Table"],
|
||||
Tbl_Missing: ["표를 못 읽음", "Table not found"],
|
||||
Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"],
|
||||
Tbl_Bound: [
|
||||
"이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)",
|
||||
"Row follows the option above (change it there; pick a column here)",
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type LsKey = keyof typeof TEXT;
|
||||
|
||||
export const lt = (key: LsKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
@@ -1,101 +0,0 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/sheet_formula_cell.ts
|
||||
* 줄 식 시트 식 칸 한 벌(PLAN 792 · 794-1) — M02 단가산출 시트 · M01 로직 시트가 같이 씀.
|
||||
* 칠 때마다 검사(`check` · 150 ms 쉼 · 앞 요청 끊음) → 틀리면 빨간 칸 + 까닭 · 알림 한 줄
|
||||
* 바꿀 때(Enter · 떠남) — 빈 칸 · 원래 식 그대로 = `put(null)` · 되면 `put(엔진 식)` · 틀리면 안 넣음
|
||||
* 치는 중 글 = `drafts`(다시 그려도 안 잃음) · 부품 = 입력 · 글 · 서버 검사 길은 부르는 쪽 몫
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
|
||||
const TEXT = {
|
||||
Formula: ["식", "Formula"],
|
||||
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
|
||||
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
|
||||
Checking: ["검사 중", "Checking…"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
|
||||
/** 검사 답 — 되나 · 까닭 · 알림(없으면 빈) · 엔진 식 */
|
||||
export interface FormulaVerdict {
|
||||
되나: boolean;
|
||||
까닭: string[];
|
||||
알림?: string[];
|
||||
식: string | null;
|
||||
}
|
||||
|
||||
export interface FormulaCellOptions {
|
||||
/** 시트 칸 열쇠(초점 되찾기 · 치는 중 글) */
|
||||
key: string;
|
||||
/** 칸 그릇 이름표 값(`data-sheet-fx`) */
|
||||
spot: string;
|
||||
shown: string;
|
||||
/** 원래 식 글 — 그대로 두면 `put(null)` */
|
||||
origin: string;
|
||||
drafts: Map<string, string>;
|
||||
check: (text: string, signal: AbortSignal) => Promise<FormulaVerdict>;
|
||||
put: (engine: string | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function createFormulaCell(o: FormulaCellOptions): HTMLElement {
|
||||
const box = createInputField({
|
||||
value: o.drafts.get(o.key) ?? o.shown,
|
||||
compact: true,
|
||||
size: 24,
|
||||
placeholder: t("Hint"),
|
||||
});
|
||||
box.input.setAttribute("aria-label", t("Formula"));
|
||||
box.input.dataset.sheetCell = o.key;
|
||||
box.input.spellcheck = false;
|
||||
const note = createText("", "muted", 12);
|
||||
note.dataset.sheetCheck = o.key;
|
||||
let timer = 0;
|
||||
let ctl: AbortController | null = null;
|
||||
const run = (text: string): Promise<FormulaVerdict | null> => {
|
||||
ctl?.abort();
|
||||
const mine = (ctl = new AbortController());
|
||||
note.textContent = t("Checking");
|
||||
return o.check(text, mine.signal).then(
|
||||
(got) => {
|
||||
if (mine !== ctl) return got;
|
||||
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
|
||||
note.textContent = (got.알림 ?? []).join(" · ");
|
||||
return got;
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (mine !== ctl || mine.signal.aborted) return null;
|
||||
box.setError(error instanceof Error ? error.message : String(error));
|
||||
note.textContent = "";
|
||||
return null;
|
||||
},
|
||||
);
|
||||
};
|
||||
box.input.addEventListener("input", () => {
|
||||
o.drafts.set(o.key, box.input.value);
|
||||
window.clearTimeout(timer);
|
||||
const text = box.input.value.trim();
|
||||
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
|
||||
timer = window.setTimeout(() => void run(text), 150);
|
||||
});
|
||||
box.input.addEventListener("change", async () => {
|
||||
window.clearTimeout(timer);
|
||||
const text = box.input.value.trim();
|
||||
if (!text || text === o.origin) {
|
||||
o.drafts.delete(o.key);
|
||||
return o.put(null);
|
||||
}
|
||||
const got = await run(text);
|
||||
if (!got?.되나 || !got.식) return void (note.textContent = t("Bad"));
|
||||
o.drafts.delete(o.key);
|
||||
o.put(got.식);
|
||||
});
|
||||
return el("div", {
|
||||
className: o.className ?? "m02-sks__fx",
|
||||
attrs: { "data-sheet-fx": o.spot },
|
||||
children: [box.root, note],
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,7 @@ from B09_Estimation.B09_Estimation_Store import folder as store_folder
|
||||
from B09_Estimation.B09_Estimation_Store import ref_rows
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Doc_B09 as doc_b09
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
MANIFEST_NAME,
|
||||
@@ -241,6 +242,7 @@ def _restore(files: dict[str, Any], kind: str, old: Any) -> None:
|
||||
|
||||
def _write(project_root: str | Path, name: str, files: dict, manifest: dict, values: dict) -> None:
|
||||
target = library_dir(project_root) / name
|
||||
files = doc_b09.attach(files) # 수식 문서 사본(802-4a · 만든 때 · 재생성 때 새로)
|
||||
old = _apply(files, str(manifest.get("종류")), values)
|
||||
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
|
||||
shutil.rmtree(target)
|
||||
|
||||
@@ -90,9 +90,8 @@ def _sk_line(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**one, "켜쌓기율": doc.get("켜쌓기율"), "고름": picks or None}
|
||||
|
||||
|
||||
def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]:
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) ·
|
||||
겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌)."""
|
||||
def _folders(project_root: str | Path, keep: set[str] | None = None) -> list[Path]:
|
||||
"""연결된 구조물 사본(구조물 id 차례) → 설계내역서 서고 사본(204-2 · `keep` 만)."""
|
||||
from B09_Estimation.B09_Estimation_Bill_Library import copies
|
||||
|
||||
base = structures_dir(project_root)
|
||||
@@ -101,9 +100,31 @@ def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None)
|
||||
for _owner, linked in sorted(linked_snapshots(project_root).items())
|
||||
if "일위대가" in linked.get("부분", [])
|
||||
]
|
||||
return [*folders, *(f for f, _ in copies(project_root) if keep is None or f.name in keep)]
|
||||
|
||||
|
||||
def frozen_rows(project_root: str | Path) -> dict[str, dict[str, Any]]:
|
||||
"""사본 단가 기준 시점 자원 줄(795-4) — 연결 사본 `단가.json` 의 노임 · 자재 · 기계 · 유가전력 ·
|
||||
환율 줄 `{키: 줄}`(만든 때 값 · 같은 키 = 앞 사본 것)."""
|
||||
from M02_MasterTemplete.M02_Sangeun_Copy import RES
|
||||
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for folder in _folders(project_root):
|
||||
bundle = read_json(folder / PRICE_FILE) or {}
|
||||
for name, data in bundle.items():
|
||||
if name.startswith("_") or not isinstance(data, dict):
|
||||
continue
|
||||
for row in data.get("줄") or data.get("표") or []:
|
||||
if str(row.get("키"))[:2] in RES:
|
||||
out.setdefault(str(row["키"]), row)
|
||||
return out
|
||||
|
||||
|
||||
def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]:
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) ·
|
||||
겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌)."""
|
||||
out: list[str] = []
|
||||
libs = [f for f, _ in copies(project_root) if keep is None or f.name in keep]
|
||||
for folder in [*folders, *libs]:
|
||||
for folder in _folders(project_root, keep):
|
||||
for key in used_keys(folder):
|
||||
if key not in out:
|
||||
out.append(key)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나).
|
||||
|
||||
원가 단가 = 프로젝트 한 벌 — 원가산출에서 M01 단가를 한 번 복사해 굳힘(`price_book.json`) ·
|
||||
원가 단가 = 프로젝트 한 벌 — 원가산출에서 한 번 굳힘(`price_book.json`) ·
|
||||
값 = 사본 단가 기준 시점(795-4 · 구조물 지정 때 `단가.json` 자원 줄 · 없는 자원만 지금 M01 +
|
||||
`now_keys`) ·
|
||||
[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가).
|
||||
|
||||
줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음:
|
||||
@@ -44,6 +46,20 @@ def _master():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def basis(frozen: dict[str, dict] | None = None):
|
||||
"""값 원천 — 사본 단가 기준 시점 줄(795-4 · `Linked.frozen_rows`) 먼저 · 없는 키 = 지금 M01."""
|
||||
if not frozen:
|
||||
return _master()
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
|
||||
return skc.Copy(_master(), skc._rows({"로직": list(frozen.values())}))
|
||||
|
||||
|
||||
def now_keys(frozen: dict[str, dict] | None, keys: list[str]) -> list[str]:
|
||||
"""사본 기준 시점에 없는 자원(795-4) — 지금 M01 값으로 넣은 키 · `frozen` None = 안 셈."""
|
||||
return [] if frozen is None else [k for k in keys if k not in frozen]
|
||||
|
||||
|
||||
def _row(master, key: str) -> dict[str, Any] | None:
|
||||
return master.index.get(key[:2], {}).get(key)
|
||||
|
||||
@@ -182,10 +198,13 @@ def _convert(master, key: str) -> dict[str, Any]:
|
||||
return material_input(row)
|
||||
|
||||
|
||||
def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
def refresh_book(
|
||||
keys: list[str], book: dict[str, Any] | None = None, frozen: dict[str, dict] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘 ·
|
||||
M01 원본이 지워진 키(296) = 옛 책 줄 그대로(지우지도 바꾸지도 않음)."""
|
||||
master = _master()
|
||||
M01 원본이 지워진 키(296) = 옛 책 줄 그대로(지우지도 바꾸지도 않음) ·
|
||||
`frozen` = 첫 굳힘(795-4) — 사본 단가 기준 시점 값 · 없는 자원 = 지금 값(`now_keys`)."""
|
||||
master = basis(frozen)
|
||||
want = demand(master, keys)
|
||||
rows = {
|
||||
key: _convert(master, key)
|
||||
@@ -199,6 +218,7 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st
|
||||
"driver_factor": driver_factor(master),
|
||||
"fx": fx_inputs(master),
|
||||
"labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」
|
||||
"now_keys": now_keys(frozen, list(rows)),
|
||||
}
|
||||
|
||||
|
||||
@@ -241,17 +261,22 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None:
|
||||
return " · ".join(p for p in parts if p) or None
|
||||
|
||||
|
||||
def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]:
|
||||
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘."""
|
||||
def extend_book(
|
||||
book: dict[str, Any] | None, keys: list[str], frozen: dict[str, dict] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘 ·
|
||||
새 줄 값 = 사본 단가 기준 시점(795-4 · `frozen`) · 없는 자원만 지금 M01(`now_keys`)."""
|
||||
if book is None:
|
||||
return refresh_book(keys)
|
||||
master = _master()
|
||||
return refresh_book(keys, None, frozen or {})
|
||||
master = basis(frozen)
|
||||
want = demand(master, keys)
|
||||
rows = dict(book["rows"])
|
||||
for key in [*want["labor"], *want["machines"], *want["materials"]]:
|
||||
if key not in rows:
|
||||
rows[key] = _convert(master, key)
|
||||
return {**book, "rows": rows}
|
||||
added = [
|
||||
key for key in [*want["labor"], *want["machines"], *want["materials"]] if key not in rows
|
||||
]
|
||||
rows.update({key: _convert(master, key) for key in added})
|
||||
late = [*(book.get("now_keys") or []), *now_keys(frozen or {}, added)]
|
||||
return {**book, "rows": rows, "now_keys": list(dict.fromkeys(late))}
|
||||
|
||||
|
||||
def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
@@ -280,16 +305,24 @@ def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]
|
||||
|
||||
|
||||
def build_inputs(
|
||||
book: dict[str, Any] | None, keys: list[str], b08_sheets: dict[str, Any] | None = None
|
||||
book: dict[str, Any] | None,
|
||||
keys: list[str],
|
||||
b08_sheets: dict[str, Any] | None = None,
|
||||
frozen: dict[str, dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄은 지금 M01 값 · `fresh` 표시)."""
|
||||
master = _master()
|
||||
"""표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄 = [저장] 때 넣을 값 · `fresh` 표시 ·
|
||||
795-4 사본 단가 기준 시점 `frozen` · 그것도 없으면 지금 M01 · `now` 표시) ·
|
||||
`now` = 사본 기준 시점에 없는 자원 키(책 `now_keys` + 안 굳힌 줄)."""
|
||||
master = basis(frozen)
|
||||
want = demand(master, keys)
|
||||
saved = (book or {}).get("rows") or {}
|
||||
late = [k for k in (book or {}).get("now_keys") or [] if k in saved]
|
||||
|
||||
def pick(key: str) -> dict[str, Any]:
|
||||
if key in saved:
|
||||
return saved[key]
|
||||
if frozen is not None and key not in frozen:
|
||||
late.append(key)
|
||||
return {**_convert(master, key), "fresh": True}
|
||||
|
||||
factor = (book or {}).get("driver_factor") if book else driver_factor(master)
|
||||
@@ -309,4 +342,5 @@ def build_inputs(
|
||||
"driver_factor": factor,
|
||||
"fx": (book or {}).get("fx") if book else fx_inputs(master),
|
||||
"missing": want["missing"],
|
||||
"now": list(dict.fromkeys(late)), # 위 줄들 pick 뒤 — 사전 차례대로 셈
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""B09 [업데이트] 한 곳(PLAN 703-3) — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]을 함께.
|
||||
"""B09 [단가 갱신] · [재생성](PLAN 795-4 · 옛 [업데이트] 703-3) — 사본은 만든 때 그대로 ·
|
||||
[단가 갱신](기본) = 단가 책만 지금 M01 값 · 미리 보기 `price_preview` = 바뀐 자원 · 총괄 전 → 후 ·
|
||||
[재생성](사용자가 따로) = 아래 옛 [업데이트] — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용].
|
||||
|
||||
미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본
|
||||
(연결된 것 중 원본이 바뀐 것만 · 원본 없음 = 그대로) · 지킬 고친 칸(덮개 칸 수 · 702-4 —
|
||||
@@ -12,6 +14,7 @@ M02 단가산출(SK · 719-2) — 구조물 · 서고 사본의 굳힌 SK 를
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -23,6 +26,49 @@ from M02_MasterTemplete import M02_Item_Sangeun as item_sk
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, linked_snapshots, structures_dir
|
||||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, build_source, read_json
|
||||
|
||||
REGEN = "재생성" # POST …/estimation/prices · preview 몸 `job`(795-4)
|
||||
|
||||
|
||||
def _value(row: dict[str, Any] | None) -> Any:
|
||||
return None if row is None else row.get("price", row.get("prices"))
|
||||
|
||||
|
||||
def price_preview(
|
||||
old: dict[str, Any] | None, new: dict[str, Any], before: dict, after: dict, cells: int
|
||||
) -> dict[str, Any]:
|
||||
"""[단가 갱신] 미리 보기(795-4) — {할일, 바뀐자원 [{키, 이름, 전, 후}], 계수, 환율, 전, 후
|
||||
(총괄 grand · 빈 줄 있으면 None), 대가 {전, 후}(일위대가 블록 합), 고친칸, 노임판} ·
|
||||
사본(로직)은 안 바뀜."""
|
||||
diff = prices.book_diff(old, new)
|
||||
was = json.loads(json.dumps((old or {}).get("rows") or {}, ensure_ascii=False))
|
||||
now = json.loads(json.dumps(new["rows"], ensure_ascii=False))
|
||||
rows = [
|
||||
{"키": key, "이름": row.get("name") or "", "전": _value(was.get(key)), "후": _value(row)}
|
||||
for key, row in now.items()
|
||||
if was.get(key) != row
|
||||
]
|
||||
|
||||
def grand(sheets: dict) -> Any:
|
||||
return (((sheets.get("total") or {}).get("subtotals")) or {}).get("grand")
|
||||
|
||||
def daega(sheets: dict) -> float:
|
||||
return sum(float(b.get("sum") or 0) for b in sheets.get("daega_blocks") or [])
|
||||
|
||||
return {
|
||||
"할일": "단가갱신",
|
||||
"바뀐자원": rows,
|
||||
"계수": diff["factor"],
|
||||
"환율": diff["fx"],
|
||||
"전": grand(before),
|
||||
"후": grand(after),
|
||||
"대가": {"전": daega(before), "후": daega(after)},
|
||||
"고친칸": cells,
|
||||
"노임판": {
|
||||
"전": edition_text((old or {}).get("labor_edition")),
|
||||
"후": edition_text(new.get("labor_edition")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def changed_copies(root: str | Path) -> list[dict[str, Any]]:
|
||||
"""연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`."""
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
[초기화](요구 298-2 C1 · C3 · C4 · C5 · 301 · 본문 없음) — 사용자 값 전부(내역 · 자재 ·
|
||||
원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 →
|
||||
답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음
|
||||
POST /api/projects/{id}/estimation/prices {user?}
|
||||
[업데이트] — 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) →
|
||||
수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은
|
||||
수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 · 덮개 그대로 ·
|
||||
답 notes 첫 줄 = 바뀐 수
|
||||
POST /api/projects/{id}/estimation/prices/preview {user?}
|
||||
[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 ·
|
||||
노임 판(쓰지 않음)
|
||||
POST /api/projects/{id}/estimation/prices {user?, job?}
|
||||
[단가 갱신](795-4 · 기본) — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · 사본(로직) 그대로 ·
|
||||
job 「재생성」 = 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) 먼저 ·
|
||||
user = 화면 캐시(296 · 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) ·
|
||||
M01 원본이 지워진 줄 = 굳힌 값 그대로 · 덮개 그대로 · 답 notes 첫 줄 = 바뀐 수
|
||||
POST /api/projects/{id}/estimation/prices/preview {user?, job?}
|
||||
미리 보기(쓰지 않음) — 단가 갱신 = 바뀐 자원 · 총괄 금액 전 → 후(795-4) ·
|
||||
재생성 = 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판(703-3)
|
||||
POST /api/projects/{id}/estimation/calc {user}
|
||||
조작 중 서버 셈(702-3 · 단가연결 즉시 금액) — 화면 캐시로 GET 꼴 셈 · 저장 안 함
|
||||
POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks}
|
||||
@@ -75,6 +75,7 @@ from B08_Quantity.B08_Quantity_Router_Milestone import site_info
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Daega_Paste as daega_paste
|
||||
from B09_Estimation import B09_Estimation_Daega_Personal as personal
|
||||
from B09_Estimation import B09_Estimation_Linked as linked
|
||||
from B09_Estimation import B09_Estimation_Past as past
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Refresh as refresh
|
||||
@@ -234,10 +235,12 @@ async def payload(
|
||||
draft: dict[str, Any] | None = None,
|
||||
reuse_b08: bool = False,
|
||||
keys: list[str] | None = None,
|
||||
book: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""셈 답 한 벌 — keys = 방금 센 단가 수요(같은 저장 값 · [저장]) · 마스터 자국은 한 번만."""
|
||||
"""셈 답 한 벌 — keys = 방금 센 단가 수요(같은 저장 값 · [저장]) · 마스터 자국은 한 번만 ·
|
||||
book = 안 쓴 단가 책(795-4 미리 보기 · 없으면 저장본)."""
|
||||
with m01_cache.steady():
|
||||
return await _payload(project_id, root, draft, reuse_b08, keys)
|
||||
return await _payload(project_id, root, draft, reuse_b08, keys, book)
|
||||
|
||||
|
||||
async def _payload(
|
||||
@@ -246,6 +249,7 @@ async def _payload(
|
||||
draft: dict[str, Any] | None,
|
||||
reuse_b08: bool,
|
||||
keys: list[str] | None,
|
||||
book: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
notes: list[str] = []
|
||||
b08 = await _b08_for(project_id, notes, reuse_b08)
|
||||
@@ -254,8 +258,11 @@ async def _payload(
|
||||
notes.append(
|
||||
"연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김"
|
||||
)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
|
||||
book = book or await asyncio.to_thread(store.read_book, root)
|
||||
frozen = await asyncio.to_thread(linked.frozen_rows, root) # 795-4 사본 단가 기준 시점
|
||||
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"], frozen)
|
||||
if late := inputs["now"]:
|
||||
notes.append(f"사본 기준 시점에 없는 자원 {len(late)} — 지금 M01 값: {' · '.join(late)}")
|
||||
hauls = await asyncio.to_thread(haul_routes, root)
|
||||
# 702-3 draft = 화면 캐시로 셈(저장 안 함) · 없으면 저장 값
|
||||
user = await asyncio.to_thread(store.read_user, root) if draft is None else draft
|
||||
@@ -309,7 +316,8 @@ async def _save(project_id: UUID, root: Path, body: dict[str, Any]) -> dict[str,
|
||||
await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본
|
||||
keys = await _demand(root)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.extend_book, book, keys)
|
||||
frozen = await asyncio.to_thread(linked.frozen_rows, root) # 795-4 새 줄 = 사본 기준 시점
|
||||
book = await asyncio.to_thread(prices.extend_book, book, keys, frozen)
|
||||
await asyncio.to_thread(store.write_book, root, book)
|
||||
# [저장](B1) 단계 표시는 셈과 함께 · 수요 = 방금 센 것(저장 값 · 사본 그대로)
|
||||
out, _ = await asyncio.gather(
|
||||
@@ -352,12 +360,15 @@ async def refresh_prices(
|
||||
body: dict[str, Any] | None = Body(None),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""[업데이트] 한 곳(703-3) — 원본이 바뀐 구조물 사본 재적용 → 단가 책 다시 굳힘 ·
|
||||
덮개 그대로."""
|
||||
"""[단가 갱신](795-4) — 단가 책 다시 굳힘 · 사본 · 덮개 그대로 ·
|
||||
job 「재생성」 = 원본이 바뀐 구조물 사본 재적용(703-3) 먼저."""
|
||||
root = await _project_root(project_id)
|
||||
raw = (body or {}).get("user")
|
||||
draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함)
|
||||
again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session))
|
||||
regen = (body or {}).get("job") == refresh.REGEN
|
||||
again = {"다시": [], "막힘": []}
|
||||
if regen:
|
||||
again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session))
|
||||
keys = await _demand(root, draft)
|
||||
old = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.refresh_book, keys, old)
|
||||
@@ -366,9 +377,9 @@ async def refresh_prices(
|
||||
diff = prices.book_diff(old, book)
|
||||
kept = draft or out.get("user") or {}
|
||||
cells = refresh.cover_cells(kept.get("daega_cover")) + refresh.cover_cells(kept.get("sg_cover"))
|
||||
head = f"재생성 — 구조물 사본 {len(again['다시'])} 다시 가져옴 · " if regen else "단가 갱신 — "
|
||||
out["notes"] = [
|
||||
f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 "
|
||||
f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로",
|
||||
f"{head}단가 줄 {diff['new'] + diff['changed']} 바뀜 · 고친 칸 {cells} 그대로",
|
||||
*(f"재적용 못 함 {why}" for why in again["막힘"]),
|
||||
*(out.get("notes") or []),
|
||||
]
|
||||
@@ -379,14 +390,21 @@ async def refresh_prices(
|
||||
async def refresh_preview(
|
||||
project_id: UUID, body: dict[str, Any] | None = Body(None)
|
||||
) -> dict[str, Any]:
|
||||
"""[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판."""
|
||||
"""미리 보기 — 단가 갱신(795-4) = 바뀐 자원 · 총괄 금액 전 → 후 ·
|
||||
재생성(703-3) = 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판."""
|
||||
root = await _project_root(project_id)
|
||||
raw = (body or {}).get("user")
|
||||
draft = store.clean_user(raw) if isinstance(raw, dict) else None
|
||||
keys = await _demand(root, draft)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
user = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
|
||||
return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover"))
|
||||
if (body or {}).get("job") == refresh.REGEN:
|
||||
return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover"))
|
||||
new = await asyncio.to_thread(prices.refresh_book, keys, book)
|
||||
before = await payload(project_id, root, draft, True, keys)
|
||||
after = await payload(project_id, root, draft, True, keys, new)
|
||||
cells = refresh.cover_cells(user.get("daega_cover")) + refresh.cover_cells(user.get("sg_cover"))
|
||||
return refresh.price_preview(book, new, before["sheets"], after["sheets"], cells)
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/calc")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Check.ts
|
||||
* B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용).
|
||||
* B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [단가 갱신] · 연결 점검(옛 「연결 점검」 탭 내용).
|
||||
*
|
||||
* [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
|
||||
* [단가 갱신](795-4 · 옛 [업데이트]) = 자원 단가만 지금 M01 값 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
|
||||
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
|
||||
* 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) →
|
||||
* 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] =
|
||||
* 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함
|
||||
* 미리 보기 고리 = 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) →
|
||||
* 「총괄 금액 전 → 후 · 바뀐 자원」 창 → [적용] = 원래 단추 일(단가만 · 사본 · 덮개 그대로) ·
|
||||
* 창의 [재생성] = 재생성 미리 보기(703-3 · 다시 가져올 사본 · 고친 칸 · 노임 판) → [적용] =
|
||||
* `job: 재생성`(사본 재적용 + 단가) · 닫기 = 안 함
|
||||
* 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 +
|
||||
* 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
|
||||
* ========================================================================== */
|
||||
@@ -62,15 +63,15 @@ export function checkItems(
|
||||
return { items, others, notes };
|
||||
}
|
||||
|
||||
/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */
|
||||
/** [단가 갱신] 호버 글 — 굳힌 시각 + 낡은 까닭 */
|
||||
export function refreshTip(payload: Row | null): string {
|
||||
const made = payload?.book?.made;
|
||||
const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘";
|
||||
const stale = payload?.stale;
|
||||
return `${when} · 누르면 미리 보기 → [적용] = 지금 M01 값으로 새로 굳힘 · 원본 바뀐 구조물 사본 재적용 · 고친 칸 그대로${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
|
||||
return `${when} · 누르면 미리 보기 → [적용] = 자원 단가만 지금 M01 값 · 사본(로직) · 고친 칸 그대로 · 로직까지 새로 = 미리 보기의 [재생성]${stale ? ` · 단가 갱신 가능 — ${stale}` : ""}`;
|
||||
}
|
||||
|
||||
/** [업데이트] 미리 보기 글 줄(703-3) */
|
||||
/** [재생성] 미리 보기 글 줄(703-3 옛 [업데이트]) */
|
||||
export function previewLines(got: Row): string[] {
|
||||
const copies: Row[] = got.사본 || [];
|
||||
const sks: Row[] = got.단가산출 || [];
|
||||
@@ -87,46 +88,91 @@ export function previewLines(got: Row): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** [업데이트] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일 · 닫기 = 안 함 */
|
||||
const won = (v: unknown): string => (v == null ? "—" : Number(v).toLocaleString("ko-KR"));
|
||||
|
||||
/** 자원 단가 값 — 노임 · 기계 = 수 · 자재 = 출처별 값 */
|
||||
function priceText(v: unknown): string {
|
||||
if (v == null) return "새 줄";
|
||||
if (typeof v !== "object") return won(v);
|
||||
const got = Object.entries(v as Row).filter(([, x]) => x != null);
|
||||
return got.length ? got.map(([k, x]) => `${k} ${won(x)}`).join(" / ") : "—";
|
||||
}
|
||||
|
||||
/** [단가 갱신] 미리 보기 글 줄(795-4) — 총괄 금액 전 → 후 · 바뀐 자원 */
|
||||
export function priceLines(got: Row): string[] {
|
||||
const rows: Row[] = got.바뀐자원 || [];
|
||||
const edition = got.노임판 || {};
|
||||
const shown = 30;
|
||||
return [
|
||||
`총괄 금액 ${won(got.전)} → ${won(got.후)} 원 · 대가 합 ${won(got.대가?.전)} → ${won(got.대가?.후)} 원`,
|
||||
`바뀐 자원 ${rows.length}${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`,
|
||||
...rows
|
||||
.slice(0, shown)
|
||||
.map((r) => `· ${r.이름 || r.키} ${priceText(r.전)} → ${priceText(r.후)}`),
|
||||
...(rows.length > shown ? [`… 외 ${rows.length - shown}`] : []),
|
||||
`고친 칸 ${got.고친칸 ?? 0} · 구조물 사본(로직) 그대로 — 로직까지 새로는 [재생성]`,
|
||||
`노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`,
|
||||
];
|
||||
}
|
||||
|
||||
const REGEN = "재생성";
|
||||
|
||||
/** [단가 갱신] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일(`data-b09-job` = 할일) · 닫기 = 안 함 ·
|
||||
* [재생성] = 재생성 미리 보기 → [적용] = 사본 재적용 + 단가(795-4 · 사용자가 따로 누를 때만) */
|
||||
function attachPreview(refresh: HTMLElement, payload: () => Row | null): void {
|
||||
let pass = false;
|
||||
const open = (job?: string): void => {
|
||||
const projectId = currentProjectId();
|
||||
const user = readState<Row>("b09-estimation", projectId) ?? payload()?.user ?? {};
|
||||
void fetch(`${API_BASE_URL}/projects/${projectId}/estimation/prices/preview`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(job ? { user, job } : { user }),
|
||||
})
|
||||
.then(async (response) => {
|
||||
const got = (await response.json()) as Row;
|
||||
if (!response.ok) throw new Error(String(got?.detail ?? response.status));
|
||||
openModal({
|
||||
title: job ? "재생성 미리 보기" : "단가 갱신 미리 보기",
|
||||
closeLabel: "닫기",
|
||||
width: "40rem",
|
||||
mount: (body, close, head) => {
|
||||
const apply = createButton({
|
||||
label: "적용",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
close();
|
||||
pass = true;
|
||||
if (job) refresh.dataset.b09Job = job;
|
||||
refresh.click();
|
||||
delete refresh.dataset.b09Job;
|
||||
},
|
||||
});
|
||||
apply.dataset.b09RefreshApply = "";
|
||||
if (!job) {
|
||||
const regen = createButton({
|
||||
label: REGEN,
|
||||
variant: "ghost",
|
||||
onClick: () => (close(), open(REGEN)),
|
||||
});
|
||||
regen.dataset.b09Regen = "";
|
||||
head.append(regen);
|
||||
}
|
||||
head.append(apply);
|
||||
const lines = job ? previewLines(got) : priceLines(got);
|
||||
body.append(...lines.map((line) => createText(line)));
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error"));
|
||||
};
|
||||
refresh.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (pass) return void (pass = false);
|
||||
event.stopImmediatePropagation();
|
||||
const projectId = currentProjectId();
|
||||
const user = readState<Row>("b09-estimation", projectId) ?? payload()?.user ?? {};
|
||||
void fetch(`${API_BASE_URL}/projects/${projectId}/estimation/prices/preview`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user }),
|
||||
})
|
||||
.then(async (response) => {
|
||||
const got = (await response.json()) as Row;
|
||||
if (!response.ok) throw new Error(String(got?.detail ?? response.status));
|
||||
openModal({
|
||||
title: "업데이트 미리 보기",
|
||||
closeLabel: "닫기",
|
||||
width: "40rem",
|
||||
mount: (body, close, head) => {
|
||||
const apply = createButton({
|
||||
label: "적용",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
close();
|
||||
pass = true;
|
||||
refresh.click();
|
||||
},
|
||||
});
|
||||
apply.dataset.b09RefreshApply = "";
|
||||
head.append(apply);
|
||||
body.append(...previewLines(got).map((line) => createText(line)));
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error"));
|
||||
open();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 새 산근(로직 연결) = 뒤에서 서버 셈(702-3 · `…_UI_Live`) ·
|
||||
* [저장](702-3 · 왼쪽 아래 [초기화] 옆) = PUT → 서버 정본으로 다시 셈 · 저장 안 하고 단계 옮김 · [>] = 물음(저장 · 버림 · 머묾)
|
||||
* [>] = 물음 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾
|
||||
* [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [업데이트] = POST …/prices
|
||||
* [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [단가 갱신] = POST …/prices(795-4)
|
||||
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -372,15 +372,16 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
: Promise.resolve(true);
|
||||
DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3)
|
||||
DRAFT.blocked = () => (payload ? unfinished(sheets) : null); // 701-3 인쇄 · 확정 = 빈 줄 0
|
||||
// 795-4 [단가 갱신] = 값만 · 미리 보기 창 [재생성] = `data-b09-job`(B09_Estimation_UI_Check)
|
||||
const refresh = createButton({
|
||||
label: "업데이트",
|
||||
label: "단가 갱신",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void run(
|
||||
"/estimation/prices",
|
||||
"POST",
|
||||
"단가를 지금 M01 값으로 새로 굳혔습니다.",
|
||||
{ user },
|
||||
{ user, ...(refresh.dataset.b09Job ? { job: refresh.dataset.b09Job } : {}) },
|
||||
true,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -55,14 +55,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.labor || [],
|
||||
"단가(원/인)",
|
||||
`이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [업데이트]`,
|
||||
`이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [단가 갱신]`,
|
||||
);
|
||||
|
||||
export const buildExpense: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.expense || [],
|
||||
"기준가(천원)",
|
||||
`이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [업데이트]`,
|
||||
`이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [단가 갱신]`,
|
||||
);
|
||||
|
||||
function madeText(payload: Row): string {
|
||||
|
||||
@@ -287,7 +287,7 @@ function blockCard(ctx: TabContext, block: Row, noOf: Record<string, number>): H
|
||||
? [
|
||||
edit,
|
||||
createText(
|
||||
`품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [업데이트] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`,
|
||||
`품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [단가 갱신] · [재생성] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`,
|
||||
"muted",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api";
|
||||
|
||||
/** 줄·표 한 개 — 파일 그대로(한글 칸). */
|
||||
@@ -120,6 +121,15 @@ export const fetchGroups = async (): Promise<GroupInfo[]> =>
|
||||
export const fetchFiles = async (group: string): Promise<FileInfo[]> =>
|
||||
(await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files;
|
||||
|
||||
/** 조달청 제비율 엑셀 책(요율 화면 · 읽기만) — 대응 = RT 키 → 대응 칸(시트 id · 첫 칸 · 칠할 범위). */
|
||||
export interface RateSheetData {
|
||||
원천: { 파일: string; sha256: string; 시트: string[] };
|
||||
대응: Record<string, { 시트: string; 칸: string; 범위: string }>;
|
||||
책: Workbook;
|
||||
}
|
||||
|
||||
export const fetchRateSheet = (): Promise<RateSheetData> => get<RateSheetData>("/rate-sheet", {});
|
||||
|
||||
export const fetchRows = (
|
||||
file: string,
|
||||
page: number,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""M01 단가산출 기초 수식 문서 화면 서버 몫(PLAN 802-3 · 요구 664 · 요구서_664 3 장).
|
||||
|
||||
문서 = `resources/master_data/수식문서/수식_<로직 파일 꼬리>.json`(`master_doc_link.FOLDER`) ·
|
||||
셈 = `master_doc_calc.calc`(파이썬 정본 · TS 짝 `common_util_formula_doc_calc.ts`) ·
|
||||
연결 기록 = `master_doc_record.Rec` — 셈이 쓴 요소 값 · 표 · 단가산출 문서를 화면 캐시로 실음
|
||||
(화면은 같은 계산기로 즉시 셈 · 캐시에 없는 연결만 뒤에서 미리 셈).
|
||||
- 읽기 {키} → {문서, 셈, 연결, 파일, 새} — 문서가 없으면 빈 문서(이름 · 단위 = 로직 줄)
|
||||
- 미리 셈 {문서, 입력} → {셈, 연결} — 쓰기 없음
|
||||
- 저장 {문서} → {문서, 셈, 연결, 파일} — 서버가 정본으로 다시 셈 · 문서 파일에 씀(브라우저 셈 값 안 받음)
|
||||
권한 = 라우터를 `main.py` 가 `system_admin_only` 로 붙임. M02 는 같은 꼴로 따로(802-4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
import master_doc as md # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤
|
||||
import master_doc_calc as mc # noqa: E402
|
||||
import master_doc_link as ml # noqa: E402
|
||||
import master_doc_record as mr # noqa: E402
|
||||
import master_equation_link as link # noqa: E402
|
||||
|
||||
OWN_FILE = "수식_자체.json"
|
||||
_memo: dict = {}
|
||||
|
||||
|
||||
def folder() -> Path:
|
||||
return ml.FOLDER
|
||||
|
||||
|
||||
def _stamp() -> tuple:
|
||||
return tuple((p.name, p.stat().st_mtime_ns) for p in sorted(folder().glob("수식_*.json")))
|
||||
|
||||
|
||||
def docs() -> dict[str, dict]:
|
||||
"""저장 문서 전부 {키: 문서} — 파일 시각이 같으면 다시 안 읽음."""
|
||||
stamp = (str(folder()), _stamp())
|
||||
if _memo.get("stamp") != stamp:
|
||||
_memo.update(stamp=stamp, docs=ml.load_all(folder()))
|
||||
return _memo["docs"]
|
||||
|
||||
|
||||
def _dec(doc) -> dict:
|
||||
"""화면 JSON(float) → Decimal 문서 — 저장 문서와 같은 수 꼴로 셈."""
|
||||
return json.loads(json.dumps(doc, ensure_ascii=False), parse_float=Decimal, parse_int=Decimal)
|
||||
|
||||
|
||||
def _where(key: str) -> str | None:
|
||||
for path in sorted(folder().glob("수식_*.json")):
|
||||
if any(str(d.get("키")) == key for d in ml.load_file(path).get("줄") or []):
|
||||
return path.name
|
||||
return None
|
||||
|
||||
|
||||
def _run(doc: dict, given: dict | None = None) -> dict:
|
||||
whole = store.loaded()[1]
|
||||
every = docs()
|
||||
own = str(doc.get("키") or "")
|
||||
links = ml.Links(whole, {**every, own: doc} if own else every)
|
||||
rec = mr.Rec(links)
|
||||
rec.prefetch(doc)
|
||||
answer = mc.calc(doc, rec, _dec(given or {}))
|
||||
return {"셈": answer, "연결": rec.out()}
|
||||
|
||||
|
||||
def _check(doc) -> dict:
|
||||
if not isinstance(doc, dict) or doc.get("꼴") != "수식문서":
|
||||
raise store.StoreError(400, "수식 문서 꼴 아님")
|
||||
if not str(doc.get("키") or "").strip():
|
||||
raise store.StoreError(400, "문서 키 없음")
|
||||
for k in ("변수", "식", "금액"):
|
||||
if not isinstance(doc.get(k) or [], list):
|
||||
raise store.StoreError(400, f"「{k}」 는 목록")
|
||||
return _dec(doc)
|
||||
|
||||
|
||||
def read(key: str) -> dict:
|
||||
found = docs().get(key)
|
||||
fresh = found is None
|
||||
if fresh:
|
||||
row = store.logic(key)["logic"] # 없는 키 = 404
|
||||
found = md.blank(key, str(row.get("이름") or ""), str(row.get("결과단위") or "원"))
|
||||
out = _run(found)
|
||||
return {"문서": found, **out, "파일": _where(key) or "", "새": fresh}
|
||||
|
||||
|
||||
def preview(doc: dict, given: dict | None = None) -> dict:
|
||||
return _run(_check(doc), given)
|
||||
|
||||
|
||||
def _file_for(key: str) -> str:
|
||||
"""문서 파일 — 있던 파일 › 로직 파일 짝 › 자체."""
|
||||
have = _where(key)
|
||||
if have:
|
||||
return have
|
||||
try:
|
||||
return ml.file_of(store.logic(key)["file"])
|
||||
except store.StoreError:
|
||||
return OWN_FILE
|
||||
|
||||
|
||||
def save(doc: dict) -> dict:
|
||||
doc = _check(doc)
|
||||
key = str(doc["키"]).strip()
|
||||
name = _file_for(key)
|
||||
path = folder() / name
|
||||
data = ml.load_file(path) if path.exists() else {"그룹": "수식문서", "로직파일": "", "줄": []}
|
||||
rows = data.setdefault("줄", [])
|
||||
at = next((i for i, d in enumerate(rows) if str(d.get("키")) == key), None)
|
||||
if at is None:
|
||||
rows.append(doc)
|
||||
else:
|
||||
rows[at] = doc
|
||||
path.write_text(ml.dump(data), encoding="utf-8", newline="\n")
|
||||
_memo.clear()
|
||||
return {"문서": doc, **_run(doc), "파일": name}
|
||||
|
||||
|
||||
def candidates(ask: dict) -> dict:
|
||||
"""연결 후보 — 요소 · 표 = M01 색인 찾기 · 단가산출 = 수식 문서 이름 찾기(읽기만)."""
|
||||
if ask.get("갈래") != "단가산출":
|
||||
return link.candidates(store.loaded()[1], ask)
|
||||
words = str(ask.get("검색") or "").lower().split()
|
||||
out = []
|
||||
for key, d in docs().items():
|
||||
text = f"{key} {d.get('이름') or ''}".lower()
|
||||
if words and all(w in text for w in words):
|
||||
ins = [
|
||||
str(v.get("열쇠"))
|
||||
for v in d.get("변수") or []
|
||||
if (v.get("연결") or {}).get("갈래") in ("선택", "입력", "숫자") and v.get("열쇠")
|
||||
]
|
||||
out.append({"키": key, "이름": d.get("이름"), "단위": d.get("결과단위"), "입력": ins})
|
||||
return {"후보": out[: link.LIMIT], "더있음": len(out) > link.LIMIT}
|
||||
|
||||
|
||||
def table(key: str) -> dict:
|
||||
"""표 하나(조건 · 칸 고르기) — 기록 꼴 그대로."""
|
||||
rec = mr.Rec(ml.Links(store.loaded()[1], {}))
|
||||
rec.see_table(key)
|
||||
return rec.표[key]
|
||||
|
||||
|
||||
# ── 로직 지우기(804-7 · 요구 687-1) — 로직 줄 + 수식 문서를 서버 정본에서 함께 ──
|
||||
def users(key: str) -> list[str]:
|
||||
"""이 로직을 부르는 곳 — 다른 수식 문서(단가산출 연결) · 일위대가 조합 · M02 단가산출(바탕).
|
||||
다른 로직 줄이 부르는 것은 로직 저장 검사(422)가 막음."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게
|
||||
|
||||
out = []
|
||||
for k, d in docs().items():
|
||||
if k != key and any(
|
||||
(v.get("연결") or {}).get("갈래") == "단가산출"
|
||||
and str((v.get("연결") or {}).get("키")) == key
|
||||
for v in d.get("변수") or []
|
||||
):
|
||||
out.append(f"단가산출 {k} {d.get('이름') or ''}".strip())
|
||||
for c in combo_store.combos_of_logic(key)["combos"]:
|
||||
out.append(f"일위대가 조합 {c.get('키')} {c.get('이름') or ''}".strip())
|
||||
for where, path in layers.layer_files("sangeun"):
|
||||
try:
|
||||
sk = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if str((sk.get("바탕") or {}).get("로직") or "") == key:
|
||||
out.append(f"M02 단가산출 {where} {sk.get('이름') or path.stem}")
|
||||
return out
|
||||
|
||||
|
||||
def drop(key: str) -> None:
|
||||
"""수식 문서 줄 하나 지움(없으면 그냥)."""
|
||||
name = _where(key)
|
||||
if not name:
|
||||
return
|
||||
path = folder() / name
|
||||
data = ml.load_file(path)
|
||||
data["줄"] = [d for d in data.get("줄") or [] if str(d.get("키")) != key]
|
||||
path.write_text(ml.dump(data), encoding="utf-8", newline="\n")
|
||||
_memo.clear()
|
||||
|
||||
|
||||
def remove(key: str, file: str, version: str, confirmed: bool) -> dict:
|
||||
"""로직 지우기 — 부르는 곳이 있으면 409 {쓰는곳} · 로직 저장 길(개정 확인 · 판 · 검사) 뒤 문서도."""
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
|
||||
used = users(key)
|
||||
if used:
|
||||
raise store.StoreError(409, {"쓰는곳": used})
|
||||
got = make.save(
|
||||
[{"file": file, "version": version, "changes": [{"op": "delete", "key": key}]}],
|
||||
confirmed,
|
||||
)
|
||||
drop(key)
|
||||
return got
|
||||
@@ -0,0 +1,103 @@
|
||||
"""M01 수식 탭 서버 몫(PLAN 796-1 · 요구 655 · 657) — 수식 나무 읽기 · 고침 미리 셈 · 저장.
|
||||
|
||||
모델 = `scripts/master_equation.py`(나무 · 변수 표) · `master_equation_edit.py`(고침 · 검사) ·
|
||||
키보드 글 번역 · 상한 = M02 단가산출 식 칸과 한 벌(`M02_Sangeun_Formula` 불러 씀 · 고치지 않음) ·
|
||||
셈 = 엔진 한 벌(③ 서버 단독 · 브라우저 값 받아 적지 않음) · 저장 = 기존 M01 `/save`(개정확인) ·
|
||||
M02 사본(795-1)은 `export_row` · `edit_row` 를 사본 줄로 부름 · 시험 `test_796_1_formula_model.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
|
||||
_ERR = (store.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
|
||||
|
||||
import master_equation as meq # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤
|
||||
import master_equation_link as link # noqa: E402
|
||||
import master_equation_edit as me # noqa: E402
|
||||
|
||||
|
||||
def _fx() -> Any:
|
||||
from M02_MasterTemplete import M02_Sangeun_Formula as fx # M02 가 M01 을 부름 — 늦게
|
||||
|
||||
return fx
|
||||
|
||||
|
||||
def words(text: str, row: dict) -> tuple[str, list[str]]:
|
||||
"""층 번역 — 상한(길이 · 괄호) · `[노임 …]` · `[별칭]` → 엔진 식."""
|
||||
fx = _fx()
|
||||
if bad := fx.limits(text):
|
||||
return text, bad
|
||||
return fx.to_engine(text, row)
|
||||
|
||||
|
||||
def _given(inputs: dict | None) -> dict:
|
||||
return {k: store._dec(v) for k, v in (inputs or {}).items()}
|
||||
|
||||
|
||||
def _typed(eq: dict, row: dict) -> dict:
|
||||
"""줄마다 `입력글` = 키보드 한 줄 식(이름 꼴 · 변수 글) — 식 칸 처음 글."""
|
||||
fx = _fx()
|
||||
for line in eq.get("줄") or []:
|
||||
if line.get("식"):
|
||||
line["입력글"] = me.keyboard_of(fx.to_name(str(line["식"]), row), row)
|
||||
return eq
|
||||
|
||||
|
||||
def export_row(whole, row: dict, inputs: dict | None = None) -> dict:
|
||||
"""로직 줄(정본 · 사본) → 수식 — 마스터에 그 줄을 얹어 셈."""
|
||||
eq = meq.export(me.swap(whole, row), str(row.get("키")), _given(inputs))
|
||||
return _typed(eq, row)
|
||||
|
||||
|
||||
def edit_row(whole, row: dict, edits: list) -> tuple[dict, list[str]]:
|
||||
"""고침 → (새 줄, 까닭) — 까닭 있으면 저장 안 됨(M01 · M02 사본 같은 함수)."""
|
||||
new, why, _ = me.apply(whole, row, edits, words)
|
||||
return new, why
|
||||
|
||||
|
||||
def read(key: str, inputs: dict | None = None) -> dict:
|
||||
whole = store.loaded()[1]
|
||||
try:
|
||||
return {"ok": True, "수식": export_row(whole, whole.logic(key), inputs)}
|
||||
except _ERR as e:
|
||||
return {"ok": False, "reason": str(e) or type(e).__name__}
|
||||
|
||||
|
||||
def preview(key: str, edits: list, inputs: dict | None = None) -> dict:
|
||||
"""고침 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전, 후}."""
|
||||
whole = store.loaded()[1]
|
||||
row = whole.logic(key)
|
||||
new, why = edit_row(whole, row, edits)
|
||||
if why:
|
||||
return {"되나": False, "까닭": why}
|
||||
try:
|
||||
before = meq.export(whole, key, _given(inputs))["합계"]
|
||||
eq = export_row(whole, new, inputs)
|
||||
except _ERR as e:
|
||||
return {"되나": False, "까닭": [f"셈 막힘 — {e}"]}
|
||||
return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]}
|
||||
|
||||
|
||||
def links(ask: dict) -> dict:
|
||||
"""새 기호 연결 후보(800-3) — {갈래: 표 | 요소 | 로직, 검색, 분류?} → {후보, 더있음} · 읽기만."""
|
||||
return link.candidates(store.loaded()[1], ask)
|
||||
|
||||
|
||||
def save(
|
||||
key: str, edits: list, inputs: dict | None = None, version: str = "", confirmed: bool = False
|
||||
) -> dict:
|
||||
"""고침 저장 — 검사 통과 · 기존 M01 `/save`(정본 = 개정확인 · 판 다르면 409)."""
|
||||
got = store.logic(key)
|
||||
if version and version != got["version"]:
|
||||
raise store.StoreError(409, {"stale": [got["file"]]})
|
||||
new, why = edit_row(store.loaded()[1], got["logic"], edits)
|
||||
if why:
|
||||
raise store.StoreError(400, {"까닭": why})
|
||||
change = {"op": "edit", "key": key, "row": new}
|
||||
part = {"file": got["file"], "version": got["version"], "changes": [change]}
|
||||
out = make.save([part], confirmed)
|
||||
return {**out, **read(key, inputs)}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""M01 요율 화면 — 조달청 제비율 엑셀 책 읽기 (798-1 · 읽기만).
|
||||
|
||||
책 JSON = `resources/master_data/제비율엑셀/조달청_제비율_책.json`
|
||||
(빌더 `scripts/build_제비율책.py` 가 판마다 한 번 만듦 · RT 정본 값과 `--check` 대조).
|
||||
요율 셈(`B09_Estimation_Rates.py`)은 이 책을 읽지 않음 — 화면 표현만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
BOOK = "제비율엑셀/조달청_제비율_책.json"
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
path = store.FOLDER / BOOK
|
||||
if not path.is_file():
|
||||
raise store.StoreError(404, "제비율 엑셀 책이 없습니다 — build_제비율책.py 를 돌려 주세요")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -12,11 +12,13 @@ from typing import Any, Literal
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from M01_MasterData import M01_MasterData_Doc as doc_screen
|
||||
from M01_MasterData import M01_MasterData_Equation as equation
|
||||
from M01_MasterData import M01_MasterData_Sheet as line_sheet
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M01_MasterData import M01_MasterData_Store_Flow as flow
|
||||
from M01_MasterData import M01_MasterData_Store_List as store_list
|
||||
from M01_MasterData import M01_MasterData_RateSheet as rate_sheet
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
from M01_MasterData import M01_MasterData_Store_Sheet as sheet
|
||||
@@ -134,6 +136,12 @@ def get_files(group: str) -> dict:
|
||||
return {"files": [{**f, "판정규": mbs.edition(f["edition"])} for f in files]}
|
||||
|
||||
|
||||
@router.get("/rate-sheet")
|
||||
def get_rate_sheet() -> dict:
|
||||
"""요율 화면 위쪽 내장 엑셀(조달청 제비율) — 읽기만(798-1)."""
|
||||
return _call(rate_sheet.load)
|
||||
|
||||
|
||||
@router.get("/subs")
|
||||
def get_subs(file: str = "", group: str = "") -> dict:
|
||||
return _call(store.subs, file, group)
|
||||
@@ -215,9 +223,8 @@ def get_logic_source(src: str) -> dict:
|
||||
|
||||
@router.get("/logic/auto")
|
||||
def get_logic_auto(key: str) -> dict:
|
||||
"""견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값."""
|
||||
got = _call(flow.auto, key)
|
||||
return got if got is not None else _call(store.auto, key)
|
||||
"""견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님)."""
|
||||
return _call(store.auto, key)
|
||||
|
||||
|
||||
@router.get("/elements")
|
||||
@@ -246,10 +253,6 @@ def get_materials(
|
||||
|
||||
@router.post("/calc")
|
||||
def post_calc(body: CalcBody) -> dict:
|
||||
"""줄에 「흐름」 이 있으면 흐름 셈(입력 = 설계 때 정할 것) · 없으면 옛 셈(정함 4)."""
|
||||
got = _call(flow.calc, body.key, body.inputs, body.row)
|
||||
if got is not None:
|
||||
return got
|
||||
with store.cm.mf.mp.choosing(body.고름):
|
||||
got = _call(store.calc, body.key, body.inputs, body.row, body.file)
|
||||
return _pct(got, body)
|
||||
@@ -272,36 +275,15 @@ def _pct(got: dict, body: CalcBody) -> dict:
|
||||
|
||||
@router.post("/text")
|
||||
def post_text(body: CalcBody) -> dict:
|
||||
"""읽는 식 줄 — 몸은 `/calc` 와 같음 · 흐름 로직은 출력 상자 줄."""
|
||||
got = _call(flow.text, body.key, body.inputs, body.row)
|
||||
if got is not None:
|
||||
return got
|
||||
"""읽는 식 줄 — 몸은 `/calc` 와 같음."""
|
||||
with store.cm.mf.mp.choosing(body.고름):
|
||||
return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1)
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
def post_save(body: SaveBody) -> dict:
|
||||
"""「흐름」 줄은 검사 · 서버 다시 셈 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}."""
|
||||
return _call(flow.save, [f.model_dump() for f in body.files], body.개정확인)
|
||||
|
||||
|
||||
@router.get("/logic/flow")
|
||||
def get_logic_flow(key: str, file: str = "") -> dict:
|
||||
"""흐름 받기 — {flow, converted, notes} · 흐름이 있으면 그대로 · 없으면 변환(파일 안 바뀜)."""
|
||||
return _call(flow.get_flow, file, key)
|
||||
|
||||
|
||||
class FlowDraft(BaseModel):
|
||||
file: str = ""
|
||||
key: str = ""
|
||||
row: dict[str, Any]
|
||||
|
||||
|
||||
@router.post("/logic/flow")
|
||||
def post_logic_flow(body: FlowDraft) -> dict:
|
||||
"""저장 안 한 고친 줄(옛 편집기) → 흐름 변환 — {flow, converted, notes} · 쓰기 없음."""
|
||||
return _call(flow.get_flow, body.file, body.key, body.row)
|
||||
"""로직 정본 줄 고침 = 개정 확인 · 새 줄(키 없음)은 자체 GX — {files, added, warnings}."""
|
||||
return _call(make.save, [f.model_dump() for f in body.files], body.개정확인)
|
||||
|
||||
|
||||
@router.post("/logic/new")
|
||||
@@ -371,6 +353,99 @@ def post_logic_sheet_check(body: SheetCheck) -> dict:
|
||||
return _call(line_sheet.check, body.row, body.갈래, body.차례, body.식)
|
||||
|
||||
|
||||
class EquationBody(BaseModel):
|
||||
key: str
|
||||
inputs: dict[str, Any] = {}
|
||||
고침: list[dict[str, Any]] = []
|
||||
version: str = ""
|
||||
개정확인: bool = False
|
||||
|
||||
|
||||
@router.post("/logic/equation")
|
||||
def post_logic_equation(body: EquationBody) -> dict:
|
||||
"""수식 탭(796-1) — 줄마다 이름식 · 값식 나무 · 풀이 · 변수표 · 합계(셈 · 쓰기 없음)."""
|
||||
return _call(equation.read, body.key, body.inputs)
|
||||
|
||||
|
||||
@router.post("/logic/equation/preview")
|
||||
def post_logic_equation_preview(body: EquationBody) -> dict:
|
||||
"""수식 고침 미리 셈 — {되나, 까닭, 줄, 수식, 전, 후} · 저장 안 함."""
|
||||
return _call(equation.preview, body.key, body.고침, body.inputs)
|
||||
|
||||
|
||||
class LinksBody(BaseModel):
|
||||
갈래: str
|
||||
검색: str = ""
|
||||
분류: str = "인력"
|
||||
|
||||
|
||||
@router.post("/logic/equation/links")
|
||||
def post_logic_equation_links(body: LinksBody) -> dict:
|
||||
"""새 기호 연결 후보(800-3) — 표 · 요소 · 로직 이름 찾기(읽기만)."""
|
||||
return _call(equation.links, body.model_dump())
|
||||
|
||||
|
||||
@router.post("/logic/equation/save")
|
||||
def post_logic_equation_save(body: EquationBody) -> dict:
|
||||
"""수식 고침 저장 — 검사 통과 뒤 `/save` 길(정본 = 개정확인 · 판 다르면 409)."""
|
||||
return _call(equation.save, body.key, body.고침, body.inputs, body.version, body.개정확인)
|
||||
|
||||
|
||||
# ── 수식 문서(802-3) — 단가산출 기초 정본 · 화면은 같은 계산기로 즉시 셈 ──
|
||||
class DocBody(BaseModel):
|
||||
문서: dict[str, Any]
|
||||
입력: dict[str, Any] = {}
|
||||
|
||||
|
||||
@router.get("/logic/doc")
|
||||
def get_logic_doc(key: str) -> dict:
|
||||
"""수식 문서 {문서, 셈, 연결, 파일, 새} — 없으면 빈 문서(쓰기 없음)."""
|
||||
return _call(doc_screen.read, key)
|
||||
|
||||
|
||||
@router.post("/logic/doc/preview")
|
||||
def post_logic_doc_preview(body: DocBody) -> dict:
|
||||
"""미리 셈 {셈, 연결} — 화면 캐시에 없는 연결을 채움 · 쓰기 없음."""
|
||||
return _call(doc_screen.preview, body.문서, body.입력)
|
||||
|
||||
|
||||
@router.post("/logic/doc/save")
|
||||
def post_logic_doc_save(body: DocBody) -> dict:
|
||||
"""저장 — 서버가 정본으로 다시 셈 · 문서 파일에 씀 → {문서, 셈, 연결, 파일}."""
|
||||
return _call(doc_screen.save, body.문서)
|
||||
|
||||
|
||||
class RemoveBody(BaseModel):
|
||||
key: str
|
||||
file: str
|
||||
version: str
|
||||
개정확인: bool = False
|
||||
|
||||
|
||||
@router.get("/logic/users")
|
||||
def get_logic_users(key: str) -> dict:
|
||||
"""로직을 부르는 곳(지우기 전 확인 · 읽기만) — {쓰는곳}."""
|
||||
return {"쓰는곳": doc_screen.users(key)}
|
||||
|
||||
|
||||
@router.post("/logic/remove")
|
||||
def post_logic_remove(body: RemoveBody) -> dict:
|
||||
"""로직 지우기(804-7) — 로직 줄 + 수식 문서 · 부르는 곳 있으면 409 {쓰는곳}."""
|
||||
return _call(doc_screen.remove, body.key, body.file, body.version, body.개정확인)
|
||||
|
||||
|
||||
@router.post("/logic/doc/links")
|
||||
def post_logic_doc_links(body: LinksBody) -> dict:
|
||||
"""연결 후보 — 요소 · 표 · 단가산출(수식 문서) 이름 찾기(읽기만)."""
|
||||
return _call(doc_screen.candidates, body.model_dump())
|
||||
|
||||
|
||||
@router.get("/logic/doc/table")
|
||||
def get_logic_doc_table(key: str) -> dict:
|
||||
"""표 하나 — 조건 · 칸 고르기(읽기만)."""
|
||||
return _call(doc_screen.table, key)
|
||||
|
||||
|
||||
# ── 일위대가 조합 — 조합 길은 M02 층 길(`/api/m02/layers/{layer}/combos` · PLAN 27-2a) ──
|
||||
@router.get("/logic/combos")
|
||||
def get_logic_combos(key: str) -> dict:
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
"""M01 로직 「흐름」 칸(PLAN 36-4 · 정함 1 · 2 · 4 · 10 · 11 · 12).
|
||||
|
||||
정함 = `docs/raw/M01_쉬운단가/흐름도_적용_정함.md`.
|
||||
|
||||
· 흐름 = DataFlow Model `CfLogic` 꼴 그대로 · 로직 줄 칸 「흐름」 · 있으면 흐름이 정본
|
||||
· 쓰는 때 = `/save` 줄에 실어 온 것뿐(자동 변환 결과는 안 씀) — 꼴 검사(상자 · 선 · 돌고 도는 선) →
|
||||
서버 셈(`M02_DF_Compute.run`)으로 다시 셈 → 받는 칸에 값만 되돌려 넣음 · 빈 받는 자리는 경고
|
||||
· 새 줄(키 없음) + 흐름 = 자체 파일(`로직_자체.json`)에 더함 → 키 = 대장 `GX` 다음 · 구분 자체
|
||||
· 계산 = 흐름이 있으면 흐름 셈 · 없으면 옛 셈(`Store.calc`) · 입력 이름 = 「설계 때 정할 것」
|
||||
· 다른 로직 입력 = 마스터 키도 — 흐름 로직은 흐름 셈 · 옛 로직은 옛 셈(노무비 · 재료비 · 경비 · 계)
|
||||
· 흐름 받기 = 줄의 흐름 그대로 · 없으면 sub7 변환(`M02_DF_Convert.convert`) · 아직 없으면 빈 흐름
|
||||
권한 = `/api/m01` 전체가 시스템 관리자(`main.py`) — 공용 로직 흐름도 관리자가 고침.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from contextvars import ContextVar
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from common_util import common_util_money as mfm
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
|
||||
|
||||
FLOW = "흐름"
|
||||
KINDS = ("input", "function", "output")
|
||||
CONVERT = "M02_MasterTemplete.DataFlow.M02_DF_Convert"
|
||||
NOT_READY = "변환 준비 중"
|
||||
_ACTIVE: ContextVar[tuple] = ContextVar("m01_flow_active", default=()) # 셈 중인 흐름 키
|
||||
|
||||
|
||||
# ── 검사(Model `link` · `ruleWhy` 와 같은 뜻) ──────────────────────────────
|
||||
def _outs(box: dict) -> set:
|
||||
"""내보내는 알약 — Model `portsOut`."""
|
||||
if box.get("kind") == "function":
|
||||
return {((box.get("func") or {}).get("output") or {}).get("name")}
|
||||
inp = box.get("input") or {}
|
||||
if box.get("kind") != "input":
|
||||
return set()
|
||||
if inp.get("mode") == "logic":
|
||||
return {p.get("name") for p in (inp.get("logic") or {}).get("outputs") or []}
|
||||
out = {dc._port(e) for e in inp.get("elements") or []} if inp.get("mode") == "group" else set()
|
||||
if inp.get("mode") == "group" and inp.get("table"):
|
||||
out.add(dc.TABLE_PORT)
|
||||
return out | {v.get("name") for v in inp.get("variables") or []}
|
||||
|
||||
|
||||
def _ins(box: dict) -> set:
|
||||
"""받는 알약 — Model `portsIn`."""
|
||||
if box.get("kind") == "function":
|
||||
return {p.get("port") for p in (box.get("func") or {}).get("inputs") or []}
|
||||
if box.get("kind") == "output":
|
||||
return {dc.OUTPUT_PORT}
|
||||
inp = box.get("input") or {}
|
||||
return {p.get("name") for p in (inp.get("logic") or {}).get("designs") or []}
|
||||
|
||||
|
||||
def check(flow: Any) -> list[str]:
|
||||
"""흐름 꼴 검사 — 틀리면 422 · 돌려받는 것 = 경고(빈 받는 자리)."""
|
||||
if not isinstance(flow, dict) or not isinstance(flow.get("boxes"), list):
|
||||
raise store.StoreError(422, {"errors": ["흐름 꼴이 아님 — 상자(boxes) 목록이 없음"]})
|
||||
if not isinstance(flow.get("links"), list):
|
||||
raise store.StoreError(422, {"errors": ["흐름 꼴이 아님 — 선(links) 목록이 없음"]})
|
||||
errors, boxes = [], {}
|
||||
for b in flow["boxes"]:
|
||||
if not isinstance(b, dict) or not b.get("id"):
|
||||
errors.append("번호(id) 없는 상자가 있음")
|
||||
continue
|
||||
name = b.get("name") or b["id"]
|
||||
if b["id"] in boxes:
|
||||
errors.append(f"상자 번호가 겹침 「{b['id']}」")
|
||||
boxes[b["id"]] = b
|
||||
if b.get("kind") not in KINDS:
|
||||
errors.append(f"「{name}」 모르는 상자 종류 「{b.get('kind')}」")
|
||||
elif b["kind"] == "function" and not ((b.get("func") or {}).get("output") or {}).get(
|
||||
"name"
|
||||
):
|
||||
errors.append(f"「{name}」 함수 상자에 결과 알약이 없음")
|
||||
taken = set()
|
||||
for ln in flow["links"]:
|
||||
src, to = (ln or {}).get("from") or {}, (ln or {}).get("to") or {}
|
||||
a, b = boxes.get(src.get("box")), boxes.get(to.get("box"))
|
||||
if (
|
||||
a is None
|
||||
or b is None
|
||||
or src.get("name") not in _outs(a)
|
||||
or to.get("name") not in _ins(b)
|
||||
):
|
||||
errors.append(f"선 「{(ln or {}).get('id')}」 이 없는 알약을 이음")
|
||||
continue
|
||||
if (to["box"], to["name"]) in taken:
|
||||
errors.append(f"「{b.get('name')} · {to['name']}」 에 선이 둘 이상")
|
||||
taken.add((to["box"], to["name"]))
|
||||
for p in dc._sources(ln.get("rule")):
|
||||
if p.get("name") not in _outs(boxes.get(p.get("box")) or {}):
|
||||
errors.append(f"선 「{ln.get('id')}」 규칙의 조건 알약이 없음")
|
||||
if not errors:
|
||||
errors += [
|
||||
f"「{b.get('name')}」 상자가 서로를 기다림(돌고 도는 선)" for b in dc._waves(flow)[1]
|
||||
]
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
return [
|
||||
f"「{b.get('name')} · {port}」 받는 자리에 들어오는 선이 없음"
|
||||
for b in boxes.values()
|
||||
if b.get("kind") != "input"
|
||||
for port in sorted(_ins(b) - {n for bx, n in taken if bx == b["id"]}, key=str)
|
||||
]
|
||||
|
||||
|
||||
# ── 다른 로직 · 계산 ───────────────────────────────────────────────────
|
||||
def _old(key: str, designs: dict) -> dict:
|
||||
"""옛 로직 값 — 옛 셈 · 내보내는 알약 = 노무비 · 재료비 · 경비 · 계(돈 아닌 로직은 결과)."""
|
||||
given = {k: v for k, v in designs.items() if v is not None}
|
||||
whole, mf = dv.SOURCE.get(), store.cm.mf
|
||||
if whole is not None: # 단가 자리(프로젝트 사본)의 옛 로직
|
||||
try:
|
||||
given = mf.fill_empty(whole.logic(key), {k: _value(v) for k, v in given.items()})
|
||||
got = mf.run(whole, key, given)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"outputs": {}, "errors": [str(e) or type(e).__name__]}
|
||||
if "결과" in got:
|
||||
return {"outputs": {"결과": got["결과"]}, "errors": []}
|
||||
return {"outputs": {k: got[k] for k in mf.BUNDLE}, "errors": []}
|
||||
got = store.calc(key, given)
|
||||
if not got.get("ok"):
|
||||
return {"outputs": {}, "errors": [got.get("reason") or "옛 셈 오류"]}
|
||||
if "result" in got:
|
||||
return {"outputs": {"결과": got["result"]}, "errors": []}
|
||||
return {"outputs": dict(got["sums"]), "errors": []}
|
||||
|
||||
|
||||
def load(key: str) -> dict:
|
||||
"""다른 로직 입력이 부르는 마스터 로직 — 흐름이면 흐름 · 옛 로직이면 옛 셈 함수(`calc`)."""
|
||||
whole = dv.SOURCE.get()
|
||||
row = whole.logic(key) if whole is not None else store.logic(key)["logic"]
|
||||
if isinstance(row.get(FLOW), dict):
|
||||
return {**row[FLOW], "key": key}
|
||||
return {"key": key, "calc": lambda designs: _old(key, designs)}
|
||||
|
||||
|
||||
def old_designs(key: str) -> list[dict]:
|
||||
"""옛 로직의 받는 알약 = 로직 입력 — Model `CfDesign` 꼴."""
|
||||
out = []
|
||||
for one in store.logic(key)["logic"].get("입력") or []:
|
||||
choices = one.get("고르기")
|
||||
variable = {"name": one.get("이름"), "type": "choice" if choices else "number",
|
||||
"unit": one.get("단위") or "-", "example": None} # fmt: skip
|
||||
if choices:
|
||||
variable["choices"] = choices
|
||||
out.append({"box": "", "port": one.get("이름"), "kind": "변수",
|
||||
"unit": one.get("단위") or "-", "variable": variable}) # fmt: skip
|
||||
return out
|
||||
|
||||
|
||||
def _value(v: Any, words: set | frozenset = frozenset()) -> Any:
|
||||
"""화면이 보낸 값 — 수 글은 수로(「3」 → 3) · 고르기 글 값(「080」 · 「1048」)은 글 그대로."""
|
||||
if isinstance(v, str) and v in words:
|
||||
return v
|
||||
if isinstance(v, str) and v.strip():
|
||||
try:
|
||||
return Decimal(v.strip())
|
||||
except InvalidOperation:
|
||||
return v
|
||||
return store._dec(v)
|
||||
|
||||
|
||||
def run(flow: dict, key: str, inputs: dict, strict: bool = False) -> dict:
|
||||
"""흐름 셈 — 입력 = 「설계 때 정할 것」 이름 → 값 · strict(조합 · 프로젝트) = 안 준 입력은
|
||||
예시값
|
||||
대신 빈 값 + 알림 「빈 입력」(옛 엔진처럼 그 값을 쓰는 곳만 비움)."""
|
||||
active = _ACTIVE.get()
|
||||
if key and key in active: # 옛 셈을 건너 다시 돌아온 흐름(흐름 → 옛 로직 → 흐름)
|
||||
raise store.cm.mf.FormulaError(f"로직이 서로를 부름 — {' → '.join([*active, key])}")
|
||||
token = _ACTIVE.set((*active, key))
|
||||
try:
|
||||
words = { # 고르기에 글로 든 값 — 옛 셈처럼 글 그대로(수로 바꾸면 고르기 밖)
|
||||
c
|
||||
for d in dc.design_inputs(flow)
|
||||
for c in (d.get("variable") or {}).get("choices") or []
|
||||
if isinstance(c, str)
|
||||
}
|
||||
given = {k: _value(v, words) for k, v in (inputs or {}).items()}
|
||||
flow = {**flow, "key": key}
|
||||
values = dc._design_values(flow, given)
|
||||
designs = dc.design_inputs(flow) if strict else []
|
||||
lack = [d["port"] for d in designs if d["port"] not in given]
|
||||
if lack: # 안 준 입력 — 엔진 `_inputs` 와 같이 멈춤(빈 값으로 준 입력은 아래 「빈 입력」)
|
||||
raise store.cm.mf.FormulaError(f"「{key}」 입력 「{lack[0]}」 없음")
|
||||
blank = [d for d in designs if given.get(d["port"]) is None]
|
||||
for d in blank:
|
||||
values.setdefault(d["box"], {})[d["port"]] = None
|
||||
got = dc.run(flow, values, load, flow.get("userElements") or [], [key])
|
||||
got["notes"] = [*(f"입력 「{d['port']}」 빈 입력" for d in blank), *got.get("notes", [])]
|
||||
return got
|
||||
finally:
|
||||
_ACTIVE.reset(token)
|
||||
|
||||
|
||||
def _engine(master, ref: str, row: dict, given: dict) -> dict:
|
||||
"""옛 엔진 `mf.run` 갈래(조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…)) — `run` 과 같은 꼴.
|
||||
|
||||
단가 · 값 · 표 = 부른 쪽이 넘긴 master(프로젝트 사본이면 그 단가 · `M02_DF_Values.SOURCE`).
|
||||
"""
|
||||
picks = (
|
||||
store.cm.mf.mp.CHOSEN.get() or {}
|
||||
) # 고른 자재 — 흐름은 재료 자리 이름으로(호표 차례 아님)
|
||||
slots = {d["port"] for d in dc.design_inputs(row[FLOW]) if d.get("kind") == "재료 자리"}
|
||||
given = {**given, **{k: v for k, v in picks.items() if k in slots}}
|
||||
token = dv.SOURCE.set(master)
|
||||
try:
|
||||
got = run(row[FLOW], ref, given, strict=True)
|
||||
finally:
|
||||
dv.SOURCE.reset(token)
|
||||
# 빈 값(빈 입력 · 단가 없는 품목 · 표에 없는 조건 → #N/A)을 쓴 칸은 엔진처럼 그 값만 비움 — 알림
|
||||
empty = [e for e in got["errors"] if "#N/A" in e or "단가 없는 품목" in e]
|
||||
stop = [e for e in got["errors"] if e not in empty]
|
||||
if stop:
|
||||
raise store.cm.mf.FormulaError(f"「{ref}」 흐름 셈 — {' · '.join(stop)}")
|
||||
cost, notes = got["cost"], [*(got.get("notes") or []), *empty]
|
||||
outs = [b for b in row[FLOW].get("boxes") or [] if b.get("kind") == "output"]
|
||||
extra = {"중간": {}, "값들": cost["값들"], **({"알림": notes} if notes else {})}
|
||||
if outs and all((b.get("output") or {}).get("cost") in (None, "값") for b in outs):
|
||||
first = got["outputs"].get(outs[0]["id"]) # 돈 아닌 로직 = 첫 출력이 결과 · 나머지는 값들
|
||||
return {"결과": first, **extra}
|
||||
lines = [] # 옛 「줄」 꼴 — 돈 출력 상자마다 한 줄(수량 1 · 단가 = 금액)
|
||||
for b in outs:
|
||||
kind, value = (b.get("output") or {}).get("cost"), got["outputs"].get(b["id"])
|
||||
value = None if value == dc.NA_VALUE else value
|
||||
if kind in dc.COSTS:
|
||||
unit = (b.get("output") or {}).get("unit") or ""
|
||||
lines.append({"이름": b.get("name") or "", "단위": unit,
|
||||
"수량": Decimal(1), "단가": value, "금액": value, "비목": {kind: value},
|
||||
**({} if value is not None else {"까닭": "값 없음"})}) # fmt: skip
|
||||
sums = {k: cost[k] for k in dc.COSTS}
|
||||
return {"줄": lines, **sums, "계": cost["계"], **extra}
|
||||
|
||||
|
||||
def _inputs(row: dict) -> list[dict]:
|
||||
"""흐름 로직 입력 = 「설계 때 정할 것」 → 옛 입력 꼴(이름 · 단위 · 고르기 · 범위 · 예시)."""
|
||||
out = []
|
||||
for d in dc.design_inputs(row[FLOW]):
|
||||
v = d.get("variable") or {}
|
||||
one = {"이름": d["port"], "단위": None if d.get("unit") in (None, "-") else d["unit"]}
|
||||
one |= {"고르기": v["choices"]} if v.get("choices") else {}
|
||||
one |= {"범위": v["range"]} if v.get("range") else {}
|
||||
out.append(one | {"예시": v.get("example")})
|
||||
return out
|
||||
|
||||
|
||||
store.cm.mf.FLOW_RUN = _engine # 옛 엔진을 부르는 곳이 모두 흐름 로직을 흐름 셈으로(한 곳 갈래)
|
||||
store.cm.mf.FLOW_INPUTS = (
|
||||
_inputs # 입력을 읽는 곳(시험입력 · 자동값 · 프로젝트 입력 · 연결)도 흐름에서
|
||||
)
|
||||
|
||||
|
||||
def text(key: str, inputs: dict, row: dict | None = None) -> dict | None:
|
||||
"""읽는 식 줄(`/text` 꼴) — 흐름 로직이면 출력 상자를 비목 묶음 줄로 · 아니면 None(옛 글)."""
|
||||
got = calc(key, inputs, row)
|
||||
if got is None or not got.get("flow") or "outputs" not in got:
|
||||
return got
|
||||
groups: dict[str, list] = {}
|
||||
for o in got["outputs"]:
|
||||
value = o["value"]
|
||||
# 비목 소계 = 정수 · 그 밖 = 수량 꼴
|
||||
shown = (mfm.fmt_money if o["cost"] else mfm.fmt)(value)
|
||||
line = {"이름": o["name"], "글": shown, "이름글": o["name"], "금액": value, "조각": []}
|
||||
groups.setdefault(o["cost"] or "값", []).append(line)
|
||||
out = [{"비목": k, "줄": v} for k, v in groups.items()]
|
||||
return {
|
||||
"ok": got["ok"],
|
||||
"flow": True,
|
||||
"groups": out,
|
||||
**({"reason": got["reason"]} if "reason" in got else {}),
|
||||
}
|
||||
|
||||
|
||||
def auto(key: str) -> dict | None:
|
||||
"""견본 입력 — 흐름 로직이면 「설계 때 정할 것」 예시값 · 아니면 None(옛 자동값)."""
|
||||
flow = store.logic(key)["logic"].get(FLOW)
|
||||
if not isinstance(flow, dict):
|
||||
return None
|
||||
return {
|
||||
"값": {d["port"]: (d.get("variable") or {}).get("example") for d in dc.design_inputs(flow)}
|
||||
}
|
||||
|
||||
|
||||
def calc(key: str, inputs: dict, row: dict | None = None) -> dict | None:
|
||||
"""계산 갈래 — 줄에 흐름이 없으면 None(옛 셈 그대로) · 있으면 흐름 셈을 `/calc` 꼴로."""
|
||||
if row is not None:
|
||||
flow, key = row.get(FLOW), str(row.get("키") or key)
|
||||
else:
|
||||
try:
|
||||
flow = store.logic(key)["logic"].get(FLOW)
|
||||
except store.StoreError:
|
||||
return None
|
||||
if not isinstance(flow, dict):
|
||||
return None
|
||||
try:
|
||||
got = run(flow, key, inputs)
|
||||
except store.cm.mf.FormulaError as e:
|
||||
return {"ok": False, "flow": True, "reason": str(e)}
|
||||
names = {b["id"]: b for b in flow.get("boxes") or []}
|
||||
outputs = [
|
||||
{"box": i, "name": names[i].get("name"), "cost": (names[i].get("output") or {}).get("cost"),
|
||||
"unit": (names[i].get("output") or {}).get("unit") or names[i].get("unit"), "value": v}
|
||||
for i, v in got["outputs"].items() if i in names
|
||||
] # fmt: skip
|
||||
cost = got["cost"]
|
||||
out = {"ok": not got["errors"], "flow": True, "lines": [], "middle": {},
|
||||
"sums": {k: cost[k] for k in (*dc.COSTS, "계")}, "values": cost["값들"],
|
||||
"outputs": outputs, "errors": got["errors"], "breaks": got["breaks"]} # fmt: skip
|
||||
if got["errors"]:
|
||||
out["reason"] = " · ".join(got["errors"])
|
||||
return out
|
||||
|
||||
|
||||
# ── 흐름 받기 ──────────────────────────────────────────────────────────
|
||||
def _row(file: str, key: str) -> dict:
|
||||
if not file:
|
||||
return store.logic(key)["logic"]
|
||||
data = store.read(file)[0]
|
||||
hit = next((r for r in data.get("줄") or [] if str(r.get("키")) == key), None)
|
||||
if hit is None:
|
||||
raise store.StoreError(404, f"{file} · 없는 키 「{key}」")
|
||||
return hit
|
||||
|
||||
|
||||
def get_flow(file: str, key: str, draft: dict | None = None) -> dict:
|
||||
"""{flow, converted, notes} — 흐름이 있으면 그대로 · 없으면 변환(파일은 안 바뀜)
|
||||
· draft = 저장 안 한 고친 줄(옛 편집기) — 늘 그 줄 그대로 변환(쓰기 없음)."""
|
||||
row = _row(file, key) if draft is None else store._dec({**draft, "키": draft.get("키") or key})
|
||||
if draft is not None:
|
||||
row.pop(FLOW, None)
|
||||
elif isinstance(row.get(FLOW), dict):
|
||||
return {"flow": {**row[FLOW], "key": key}, "converted": False, "notes": []}
|
||||
try:
|
||||
convert = importlib.import_module(CONVERT).convert
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name != CONVERT:
|
||||
raise
|
||||
empty = {"key": key, "name": row.get("이름") or "", "resultUnit": row.get("결과단위") or "",
|
||||
"boxes": [], "links": [], "userElements": []} # fmt: skip
|
||||
return {"flow": empty, "converted": True, "notes": [NOT_READY]}
|
||||
got = convert(row, {"key": key, "file": file, "master": store.loaded()[1]})
|
||||
return {"flow": {**got["flow"], "key": key}, "converted": True, "notes": got.get("notes") or []}
|
||||
|
||||
|
||||
# ── 저장 ──────────────────────────────────────────────────────────────
|
||||
def _redo(row: dict, key: str) -> list[str]:
|
||||
"""흐름 검사 → 서버 셈으로 다시 셈 → 받는 칸 값만 되돌려 넣음 · 돌려받는 것 = 경고."""
|
||||
warns = check(row[FLOW])
|
||||
got = run(row[FLOW], key, {})
|
||||
filled = got["filled"]
|
||||
filled.pop("key", None) # 키는 줄의 「키」 하나 — 흐름 안에 두지 않음
|
||||
row[FLOW] = filled
|
||||
return warns + got["errors"]
|
||||
|
||||
|
||||
def _bare(row: dict) -> dict:
|
||||
"""흐름 로직 줄 — 계산 칸(중간 · 호표 · 결과 · 덧줄)은 지움(흐름이 정본) · 「입력」 은 흐름의
|
||||
「설계 때 정할 것」 으로 같은 꼴로 다시 씀(로직 입력 약속 하나 · 설명은 이름이 같으면 살림)
|
||||
· 끝수 · 시험입력 · 메타 칸은 둠 · 출처 없으면 「자체」."""
|
||||
kept = {k: v for k, v in row.items() if k not in store.cm.mf.CALC_FIELDS}
|
||||
said = {s.get("이름"): s.get("설명") for s in row.get("입력") or [] if s.get("설명")}
|
||||
inputs = []
|
||||
for spec in store.cm.mf.inputs_of(row):
|
||||
one = {
|
||||
"이름": spec["이름"],
|
||||
**({"설명": said[spec["이름"]]} if spec["이름"] in said else {}),
|
||||
}
|
||||
one |= {k: spec[k] for k in ("단위", "고르기", "범위") if spec.get(k) is not None}
|
||||
inputs.append(one)
|
||||
return {"끝수": None, **kept, "입력": inputs, "출처": row.get("출처") or "자체"}
|
||||
|
||||
|
||||
def save(batch: list[dict], confirmed: bool = False) -> dict:
|
||||
"""`/save` — 흐름 줄은 검사 · 다시 셈 · 새 줄은 자체 GX · 그 밖은 `Store.save` 그대로 ·
|
||||
로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise` · 713-1)."""
|
||||
revise.guard(batch, confirmed)
|
||||
warns, fresh = [], False
|
||||
for part in batch:
|
||||
for ch in part.get("changes") or []:
|
||||
row = ch.get("row")
|
||||
if not isinstance(row, dict) or FLOW not in row:
|
||||
continue
|
||||
check(row[FLOW]) # 꼴부터 — 입력 약속을 흐름에서 다시 쓰기 전에
|
||||
new = ch.get("op") == "add" or (ch.get("op") == "edit" and not ch.get("key"))
|
||||
if new:
|
||||
if part.get("file") != make.OWN:
|
||||
raise store.StoreError(400, f"새 흐름 로직은 「{make.OWN}」 에만 더함")
|
||||
row = make._clean(_bare(row), str(row.get("소유") or make.OWNERS[0]))
|
||||
ch.update(op="add", key=None)
|
||||
row = _bare(row) if not new else row
|
||||
ch["row"] = row
|
||||
name = row.get("이름") or ch.get("key") or "새 로직"
|
||||
warns += [f"「{name}」 {w}" for w in _redo(row, str(ch.get("key") or ""))]
|
||||
if part.get("file") == make.OWN and not make._own_path().is_file():
|
||||
make._make_file()
|
||||
fresh = True
|
||||
part["version"] = part.get("version") or store.read(make.OWN)[1]
|
||||
before = {p["file"]: _keys(p["file"]) for p in batch}
|
||||
try:
|
||||
files = store.save(batch)
|
||||
except store.StoreError:
|
||||
if fresh:
|
||||
make._own_path().unlink(missing_ok=True)
|
||||
raise
|
||||
added = [{"file": f, "key": k} for f, old in before.items() for k in _keys(f) if k not in old]
|
||||
return {"files": files, "added": added, "warnings": warns}
|
||||
|
||||
|
||||
def _keys(file: str) -> list[str]:
|
||||
try:
|
||||
data = store.read(file)[0]
|
||||
except store.StoreError:
|
||||
return []
|
||||
return [str(r.get("키")) for r in data.get(store.items_key(data)) or []]
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
|
||||
from common_util.common_util_time import kst_today
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Revise as revise
|
||||
|
||||
cm, mk, mcp = store.cm, store.mk, store.mcp
|
||||
|
||||
@@ -278,6 +279,43 @@ def _make_file() -> None:
|
||||
path.write_text(store.dump(head), encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def save(batch: list[dict], confirmed: bool = False) -> dict:
|
||||
"""`/save` — 로직 정본 줄 고침은 `confirmed`(「원문 개정 반영」) 없으면 409(`Store_Revise`) ·
|
||||
자체 파일 새 줄(키 없음)은 서버가 칸을 잡고 GX 다음 키 — {files, added, warnings}."""
|
||||
revise.guard(batch, confirmed)
|
||||
fresh = False
|
||||
for part in batch:
|
||||
if part.get("file") != OWN:
|
||||
continue
|
||||
for ch in part.get("changes") or []:
|
||||
row = ch.get("row")
|
||||
if isinstance(row, dict) and (ch.get("op") == "add" or not ch.get("key")):
|
||||
ch.update(op="add", key=None, row=_clean(row, str(row.get("소유") or OWNERS[0])))
|
||||
if not _own_path().is_file():
|
||||
_make_file()
|
||||
fresh = True
|
||||
part["version"] = part.get("version") or store.read(OWN)[1]
|
||||
before = {p["file"]: _file_keys(p["file"]) for p in batch}
|
||||
try:
|
||||
files = store.save(batch)
|
||||
except store.StoreError:
|
||||
if fresh:
|
||||
_own_path().unlink(missing_ok=True)
|
||||
raise
|
||||
added = [
|
||||
{"file": f, "key": k} for f, old in before.items() for k in _file_keys(f) if k not in old
|
||||
]
|
||||
return {"files": files, "added": added, "warnings": []}
|
||||
|
||||
|
||||
def _file_keys(file: str) -> list[str]:
|
||||
try:
|
||||
data = store.read(file)[0]
|
||||
except store.StoreError:
|
||||
return []
|
||||
return [str(r.get("키")) for r in data.get(store.items_key(data)) or []]
|
||||
|
||||
|
||||
def _find(key: str) -> tuple[str, dict]:
|
||||
got = store.logic(key)
|
||||
return got["file"], got["logic"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 제목 크기 · 자리 · 단추 차례 · 공용 단추 크기 · 찾기 칸 꼴이 한 벌로 같음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fitButtonRow } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import "./M01_MasterData_UI_Head.css";
|
||||
|
||||
@@ -19,7 +20,7 @@ export interface HeadOptions {
|
||||
buttons: HTMLElement[];
|
||||
}
|
||||
|
||||
/** 제목 줄 — 제목은 18px · 왼쪽 · 남는 자리를 차지해 단추를 오른쪽으로 밂 */
|
||||
/** 제목 줄 — 제목은 18px · 왼쪽 · 남는 자리를 차지해 단추를 오른쪽으로 밂 · 넘치면 아이콘 단추는 아이콘만(804-8) */
|
||||
export function buildHeadBar(opts: HeadOptions): { root: HTMLElement; title: HTMLElement } {
|
||||
const title = typeof opts.title === "string" ? el("h2", { text: opts.title }) : opts.title;
|
||||
title.classList.add("m01-head__title");
|
||||
@@ -27,6 +28,7 @@ export function buildHeadBar(opts: HeadOptions): { root: HTMLElement; title: HTM
|
||||
className: "m01-head",
|
||||
children: [...(opts.lead ?? []), title, ...(opts.status ?? []), ...opts.buttons],
|
||||
});
|
||||
fitButtonRow(root);
|
||||
return { root, title };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab.ts
|
||||
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 시트(794-1 · 식 고침 = 관리자) · 단가산출 상세 · 시험 계산 / 오른쪽 흐름도
|
||||
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) · 단가산출 상세 / 오른쪽 원문
|
||||
* 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run`
|
||||
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
|
||||
*
|
||||
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
|
||||
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
|
||||
* 머리 단추(804-7 · 요구 687-2) = [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장]
|
||||
* [저장] = 기본정보 · 별칭(로직 줄 `/save`) + 수식(줄 쌓기 `/logic/doc/save`) 한 번에 · [되돌림] = 둘 다 저장 뒤 상태로
|
||||
* [로직 삭제] = 확인 → 서버 정본에서 바로(`/logic/remove` — 로직 줄 + 수식 문서 · 부르는 곳 있으면 막고 까닭) → 로직 목록
|
||||
* ========================================================================== */
|
||||
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
@@ -22,6 +26,8 @@ import {
|
||||
fetchLogicFiles,
|
||||
fetchLogics,
|
||||
fetchLogicSubs,
|
||||
fetchLogicUsers,
|
||||
removeLogic,
|
||||
saveFiles,
|
||||
type CalcLine,
|
||||
type TextAnswer,
|
||||
@@ -30,7 +36,7 @@ import {
|
||||
type LogicSummary,
|
||||
type SaveFile,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
|
||||
import { createDrive } from "./M01_MasterData_UI_Logic_Drive";
|
||||
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
|
||||
import type { SideHandle } from "./M01_MasterData_UI_Side";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
@@ -38,26 +44,13 @@ import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_
|
||||
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import {
|
||||
emptyFlow,
|
||||
designFields,
|
||||
examplesOf,
|
||||
fetchFlow,
|
||||
fetchFlowFor,
|
||||
sameShape,
|
||||
withExamples,
|
||||
mountLogicFlow,
|
||||
tf,
|
||||
type LogicFlow,
|
||||
} from "./M01_MasterData_UI_LogicLab_Flow";
|
||||
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
|
||||
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
|
||||
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
|
||||
import { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet";
|
||||
import { createStackScreen } from "../A00_Common/line_stack_screen";
|
||||
import { buildHeadBar } from "./M01_MasterData_UI_Head";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
import "./M01_MasterData_UI_Logic_Style.css";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
|
||||
@@ -83,20 +76,11 @@ interface Opened extends Draft {
|
||||
lastText?: TextAnswer;
|
||||
ownHost: HTMLElement;
|
||||
values: Record<string, string>;
|
||||
/** 흐름 로직의 왼쪽 「지금 내용」 자리 — 흐름도 왼쪽 보기가 그림 */
|
||||
flowHost: HTMLElement;
|
||||
/** 오른쪽 흐름도의 기준(예시값만 다른지 견줄 때) */
|
||||
flowBase?: CfLogic;
|
||||
}
|
||||
|
||||
/** 줄의 흐름(있으면 흐름도로 셈) */
|
||||
const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null;
|
||||
const OWN_FILE = "로직_자체.json";
|
||||
/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) · 그 밖은 시험 고침(722-2) 덮은 사본 */
|
||||
const calcRow = (row: LogicRow | null): LogicRow | null => {
|
||||
const f = flowOf(row);
|
||||
return f && row ? { ...row, 입력: designFields(f) } : trial.apply(row);
|
||||
};
|
||||
/** 시험 계산에 넘길 줄 — 시험 고침(722-2) 덮은 사본 */
|
||||
const calcRow = (row: LogicRow | null): LogicRow | null => trial.apply(row);
|
||||
|
||||
const cacheKey = "m01_logic_drafts_lab";
|
||||
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
|
||||
@@ -123,27 +107,30 @@ export async function mountM01LogicLab(
|
||||
let drafts = loadDrafts();
|
||||
let opened: Opened | null = null;
|
||||
let calcInputs = "";
|
||||
/** 오른쪽 흐름도 패널 — 로직을 열 때마다 새로 */
|
||||
let flowNow: LogicFlow | null = null;
|
||||
const admin = await fetchSessionUser()
|
||||
.then((u) => u?.role === "SYSTEM_ADMIN")
|
||||
.catch(() => false);
|
||||
let recalc: number | undefined;
|
||||
const sheet = createLabSheet({
|
||||
const drive = createDrive();
|
||||
// 수식 = 줄 쌓기(804-2 · 804-6 옛 수식 문서 탭 걷음) — 수식 문서 · 캐시 m01_doc_drafts
|
||||
const stack = createStackScreen({
|
||||
base: "/m01/logic/doc",
|
||||
admin,
|
||||
onInput: (name, v) => opened && syncLeft(opened, { [name]: v }, true),
|
||||
edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길)
|
||||
run: () => calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click(),
|
||||
cache: "m01_doc_drafts",
|
||||
page: () => persist(),
|
||||
});
|
||||
const leave = (): Promise<boolean> => stack.canLeave();
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유)
|
||||
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
|
||||
const panes = buildLogicSide(editor);
|
||||
const views = createViewPicker(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽
|
||||
// 보기 고르기 — 머리줄 [신규 로직] 왼쪽
|
||||
const views = createViewPicker(editor, (v) => {
|
||||
if (v === "stack") stack.draw(opened?.origKey ?? "", true);
|
||||
});
|
||||
const errors = el("div", {
|
||||
className: "ui-banner ui-banner--danger m01-logic__reasons",
|
||||
attrs: { hidden: "" },
|
||||
});
|
||||
const calc = el("div", { className: "m01lab__calc" });
|
||||
const filterHost = el("div");
|
||||
const waiting = el("p", { className: "ui-text ui-text--muted", text: tx("Loading") });
|
||||
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
|
||||
@@ -152,7 +139,11 @@ export async function mountM01LogicLab(
|
||||
(item) => void open(item.id, item.sub, item.key),
|
||||
(item, alias) => void onAlias(item, alias),
|
||||
);
|
||||
const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) });
|
||||
const back = createButton({
|
||||
label: tx("Bar_Back"),
|
||||
variant: "ghost",
|
||||
onClick: () => void leave().then((ok) => ok && show(null)),
|
||||
});
|
||||
|
||||
const persist = (): void => {
|
||||
try {
|
||||
@@ -185,8 +176,7 @@ export async function mountM01LogicLab(
|
||||
list.setMarks(marks, extra, aliases);
|
||||
const n = Object.keys(drafts).length;
|
||||
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
|
||||
saveButton.disabled = discardButton.disabled = n === 0;
|
||||
saveButton.hidden = discardButton.hidden = n === 0; // 저장 안 한 고친 것이 없으면 숨김
|
||||
saveButton.disabled = discardButton.disabled = n === 0 && !stack.dirty(); // 로직 줄 · 수식 둘 다 봄
|
||||
};
|
||||
|
||||
const touch = (): void => {
|
||||
@@ -195,13 +185,12 @@ export async function mountM01LogicLab(
|
||||
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
|
||||
else drafts[opened.id] = pick(opened, clone(opened.row));
|
||||
persist();
|
||||
if (!flowOf(opened.row)) scheduleFlow(opened); // 왼쪽 편집기로 고침 — 오른쪽 흐름도도 따라감
|
||||
const inputs = JSON.stringify(calcRow(opened.row)?.입력 ?? []);
|
||||
if (inputs !== calcInputs) drawCalc();
|
||||
if (isOwnLogic(opened.row)) {
|
||||
// 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림
|
||||
window.clearTimeout(recalc);
|
||||
recalc = window.setTimeout(() => calc.querySelector("button")?.click(), 700);
|
||||
recalc = window.setTimeout(() => void drive.run(), 700);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -213,18 +202,18 @@ export async function mountM01LogicLab(
|
||||
row,
|
||||
});
|
||||
|
||||
/** 구동을 지금 로직으로 (다시) 시작 — 읽는 식 미리보기 · 첫 셈 */
|
||||
const drawCalc = (): void => {
|
||||
if (!opened?.row) {
|
||||
calc.replaceChildren();
|
||||
drive.stop();
|
||||
return;
|
||||
}
|
||||
const current = opened;
|
||||
calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []);
|
||||
buildCalc(calc, {
|
||||
drive.start({
|
||||
savedKey: current.origKey,
|
||||
file: current.file,
|
||||
row: calcRow(current.row) as LogicRow,
|
||||
prices: current.prices,
|
||||
dirty: () => current.id in drafts || trial.has(current.row),
|
||||
values: current.values,
|
||||
onText: (text) => {
|
||||
@@ -232,7 +221,6 @@ export async function mountM01LogicLab(
|
||||
if (text.ok) current.lastText = text;
|
||||
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
|
||||
},
|
||||
onAnswer: sheet.answer,
|
||||
onLines: (lines) => {
|
||||
current.lines = lines;
|
||||
drawEditor();
|
||||
@@ -263,120 +251,30 @@ export async function mountM01LogicLab(
|
||||
values: current.values,
|
||||
ownHost: current.ownHost,
|
||||
onChange: touch,
|
||||
onInput: (name, v) => syncLeft(current, { [name]: v }, true),
|
||||
onInput: (name, v) => syncLeft(current, { [name]: v }),
|
||||
onTrial: drawCalc,
|
||||
calcHost: calc,
|
||||
flowHost: flowOf(current.row) ? current.flowHost : undefined,
|
||||
rerun: () => void drive.run(),
|
||||
admin,
|
||||
sheet: sheet.root,
|
||||
stack: stack.root,
|
||||
});
|
||||
sheet.draw(current.row as LogicRow, current.original, current.values);
|
||||
flowNow?.redrawLeft();
|
||||
stack.draw(current.origKey ?? "");
|
||||
};
|
||||
|
||||
/** 왼쪽 고침 뒤 잠시 쉬면 고친 줄을 서버가 흐름도로 바꿔 줌 → 오른쪽에 넣음 · 마지막 물음만 받음 */
|
||||
let flowTimer: number | undefined;
|
||||
let flowSeq = 0;
|
||||
const scheduleFlow = (o: Opened): void => {
|
||||
window.clearTimeout(flowTimer);
|
||||
flowTimer = window.setTimeout(() => {
|
||||
const seq = ++flowSeq;
|
||||
if (!o.row) return;
|
||||
void fetchFlowFor(o.row, o.origKey ?? "", o.file).then((got) => {
|
||||
if (!got || seq !== flowSeq || opened !== o || !flowNow || flowOf(o.row)) return;
|
||||
o.flowBase = withExamples(got.flow, o.values);
|
||||
flowNow.set(clone(o.flowBase));
|
||||
});
|
||||
}, 700);
|
||||
};
|
||||
|
||||
/** 오른쪽 흐름도 — 줄의 흐름 · 없으면 서버 변환(흐름 받기) · 그것도 없으면 빈 흐름 */
|
||||
const startFlow = async (o: Opened): Promise<void> => {
|
||||
const row = o.row;
|
||||
if (!row) return;
|
||||
panes.flowSplit.right.replaceChildren(
|
||||
el("p", { className: "m01lab-flow__wait", text: tf("Waiting") }),
|
||||
);
|
||||
const own = flowOf(row);
|
||||
const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file);
|
||||
// 시험 입력 한 벌 — 열 때 왼쪽 시험 계산 입력을 흐름 변수 예시값으로
|
||||
o.flowBase = withExamples(
|
||||
own ?? got?.flow ?? (await emptyFlow(row.이름, row.결과단위)),
|
||||
o.values,
|
||||
);
|
||||
if (opened !== o) return;
|
||||
try {
|
||||
const made = await mountLogicFlow({
|
||||
split: panes.flowSplit,
|
||||
folded: panes.flowFolded(),
|
||||
onFold: (on) => panes.pick(on ? "none" : "flow"),
|
||||
flow: clone(o.flowBase),
|
||||
title: row.이름 || row.키,
|
||||
readOnly: !admin,
|
||||
notes: got?.notes ?? [],
|
||||
converted: !!got?.converted,
|
||||
leftHost: () => (flowOf(o.row) ? o.flowHost : null),
|
||||
onChange: (lg) => {
|
||||
if (!o.row || opened !== o) return;
|
||||
const was = flowOf(o.row);
|
||||
if (sameShape(lg, was ?? o.flowBase ?? lg)) {
|
||||
// 예시값만 바뀜 — 고친 것 아님 · 왼쪽 시험 계산 입력에 같은 이름으로 넣고 다시 셈
|
||||
o.flowBase = lg;
|
||||
syncLeft(o, examplesOf(lg));
|
||||
return;
|
||||
}
|
||||
o.row.흐름 = lg;
|
||||
touch();
|
||||
if (!was) drawEditor(); // 이제 흐름 로직 — 왼쪽을 흐름 보기로
|
||||
},
|
||||
});
|
||||
if (opened !== o) return made.destroy();
|
||||
flowNow = made;
|
||||
panes.bindFlow(made.fold);
|
||||
made.setExamples(o.values); // 띄우는 사이에 온 자동값까지
|
||||
} catch (e) {
|
||||
panes.flowSplit.right.replaceChildren(
|
||||
el("p", { className: "ui-text ui-text--muted", text: tf("Failed", String(e)) }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */
|
||||
const syncLeft = (o: Opened, ex: Record<string, string>, loud = false): void => {
|
||||
/** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */
|
||||
const syncLeft = (o: Opened, ex: Record<string, string>): void => {
|
||||
const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름));
|
||||
const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v);
|
||||
if (!changed.length) return;
|
||||
quiet = !loud; // 텍스트 수식 고침(loud)은 흐름 예시값에도 — 시험 계산 칸 고침과 같은 길
|
||||
for (const [k, v] of changed) o.values[k] = v;
|
||||
quiet = false;
|
||||
drawCalc();
|
||||
calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
};
|
||||
/** 왼쪽 시험 계산 입력 — 칸이 바뀌면 흐름 예시값으로(이 로직이 열려 있을 때만) */
|
||||
let quiet = false;
|
||||
const watchValues = (): Record<string, string> => {
|
||||
const self: Record<string, string> = new Proxy({} as Record<string, string>, {
|
||||
set(t, k, v) {
|
||||
const was = t[k as string];
|
||||
t[k as string] = v as string;
|
||||
if (!quiet && was !== v && opened?.values === self)
|
||||
flowNow?.setExamples({ [k as string]: v as string });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return self;
|
||||
void drive.run();
|
||||
};
|
||||
|
||||
const show = (next: Opened | null): void => {
|
||||
flowNow?.destroy();
|
||||
flowNow = null;
|
||||
panes.bindFlow(null);
|
||||
opened = next;
|
||||
panes.showSource(next?.row?.출처 ?? "");
|
||||
errors.hidden = true;
|
||||
drawEditor();
|
||||
drawCalc();
|
||||
if (next?.row) void startFlow(next);
|
||||
};
|
||||
|
||||
const open = async (id: string, sub: string, key: string): Promise<void> => {
|
||||
@@ -391,8 +289,7 @@ export async function mountM01LogicLab(
|
||||
lines: null,
|
||||
text: null,
|
||||
ownHost: ownHost(),
|
||||
values: watchValues(),
|
||||
flowHost: el("div"),
|
||||
values: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -411,8 +308,7 @@ export async function mountM01LogicLab(
|
||||
lines: null,
|
||||
text: null,
|
||||
ownHost: ownHost(),
|
||||
values: watchValues(),
|
||||
flowHost: el("div"),
|
||||
values: {},
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
@@ -420,6 +316,7 @@ export async function mountM01LogicLab(
|
||||
};
|
||||
|
||||
let allLogics: LogicSummary[] = [];
|
||||
let saving = false; // [저장] 뒤 목록 다시 읽는 중 — 거름 되살림이 연 로직을 떠나지 않게(저장 뒤 다시 엶)
|
||||
|
||||
const reload = async (): Promise<void> => {
|
||||
const [logics, subs] = await Promise.all([fetchLogics(), fetchLogicSubs()]);
|
||||
@@ -437,7 +334,7 @@ export async function mountM01LogicLab(
|
||||
restore: true,
|
||||
onPick: (sub, detail) => {
|
||||
list.setPick(sub, detail);
|
||||
if (opened) show(null);
|
||||
if (opened && !saving) void leave().then((ok) => ok && show(null));
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -464,8 +361,21 @@ export async function mountM01LogicLab(
|
||||
}
|
||||
};
|
||||
|
||||
/** 부르는 곳 까닭 띠 */
|
||||
const usedBy = (used: string[]): void => {
|
||||
errors.replaceChildren(
|
||||
el("strong", { text: tx("Delete_Used") }),
|
||||
...used.map((x) => el("div", { text: x })),
|
||||
);
|
||||
errors.hidden = false;
|
||||
};
|
||||
|
||||
const onDelete = async (): Promise<void> => {
|
||||
if (!opened?.row) return;
|
||||
if (opened.origKey) {
|
||||
const used = (await fetchLogicUsers(opened.origKey).catch(() => null))?.쓰는곳 ?? [];
|
||||
if (used.length) return usedBy(used); // 확인 창 전에 막음
|
||||
}
|
||||
if (
|
||||
!(await showConfirmDialog(
|
||||
tx("Confirm_Delete", { v: opened.row.원문번호 || opened.row.키 }),
|
||||
@@ -473,21 +383,38 @@ export async function mountM01LogicLab(
|
||||
))
|
||||
)
|
||||
return;
|
||||
if (opened.origKey === null) {
|
||||
delete drafts[opened.id];
|
||||
const was = opened;
|
||||
if (was.origKey === null) {
|
||||
delete drafts[was.id];
|
||||
persist();
|
||||
show(null);
|
||||
return;
|
||||
}
|
||||
drafts[opened.id] = pick(opened, null);
|
||||
opened.row = null;
|
||||
persist();
|
||||
show(opened);
|
||||
const key = was.origKey;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const one = await fetchLogic(key); // 지금 판(캐시에 옛 판이 남았어도)
|
||||
const done = await saveRevised((ok) => removeLogic(key, one.file, one.version, ok));
|
||||
if (!done) return; // 개정 확인 취소
|
||||
delete drafts[was.id];
|
||||
await stack.discard(); // 그 로직 수식 캐시도 비움
|
||||
await reload();
|
||||
show(null); // 로직 목록
|
||||
showToast(tx("Delete_Done", { v: was.row?.원문번호 || key }), "success");
|
||||
} catch (error) {
|
||||
const used =
|
||||
error instanceof ApiError ? (error.detail as { 쓰는곳?: string[] })?.쓰는곳 : null;
|
||||
if (used?.length) usedBy(used);
|
||||
else failed(error);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
const onDiscard = async (): Promise<void> => {
|
||||
if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return;
|
||||
drafts = {};
|
||||
await stack.discard();
|
||||
persist();
|
||||
const was = opened;
|
||||
if (was && was.origKey !== null)
|
||||
@@ -506,7 +433,10 @@ export async function mountM01LogicLab(
|
||||
byFile.set(d.file, part);
|
||||
}
|
||||
if (!byFile.size) {
|
||||
showToast(tx("Save_Nothing"), "info");
|
||||
// 수식만 고침 — 줄 쌓기 정본 저장
|
||||
if (!stack.dirty()) return void showToast(tx("Save_Nothing"), "info");
|
||||
if (await stack.save()) showToast(tx("Save_Done"), "success");
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
@@ -516,13 +446,16 @@ export async function mountM01LogicLab(
|
||||
if (!saved) return; // 개정 확인 취소 = 캐시 그대로
|
||||
const was = opened;
|
||||
drafts = {};
|
||||
await reload();
|
||||
saving = true;
|
||||
await reload().finally(() => (saving = false));
|
||||
showToast(tx("Save_Done"), "success");
|
||||
const newKey = was?.row && !was.row.키 ? saved.added?.at(-1)?.key : undefined;
|
||||
const made = newKey ? allLogics.find((x) => x.키 === newKey) : undefined;
|
||||
if (made) await open(logicId(made.구분, made.키), made.구분, made.키);
|
||||
else if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키);
|
||||
else show(null);
|
||||
if (opened) await stack.save(); // 수식 고침도 같은 [저장]에(여는 중이면 다 읽고 · 새 로직 = 새 키로 이어 받은 뒤 · 고침 없으면 그냥)
|
||||
persist();
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
} finally {
|
||||
@@ -557,7 +490,8 @@ export async function mountM01LogicLab(
|
||||
...info,
|
||||
입력: [],
|
||||
중간: [],
|
||||
흐름: await emptyFlow(info.이름, info.결과단위),
|
||||
호표: [], // 빈 시트 줄 — 자체 로직 편집기 · 시트로 채움
|
||||
끝수: null,
|
||||
};
|
||||
const id = logicId("자체", `새${Date.now()}`);
|
||||
drafts[id] = { sub: "자체", file: OWN_FILE, version: own?.version ?? "", origKey: null, row };
|
||||
@@ -568,15 +502,21 @@ export async function mountM01LogicLab(
|
||||
}
|
||||
};
|
||||
|
||||
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
|
||||
const saveButton = createButton({
|
||||
label: tx("Bar_Save"),
|
||||
iconStart: "check",
|
||||
onClick: () => void onSave(),
|
||||
});
|
||||
const discardButton = createButton({
|
||||
label: tx("Bar_Discard"),
|
||||
variant: "ghost",
|
||||
iconStart: "undo",
|
||||
onClick: () => void onDiscard(),
|
||||
});
|
||||
const copyButton = createButton({
|
||||
label: tl("Copy_Open"),
|
||||
variant: "ghost",
|
||||
iconStart: "copy",
|
||||
onClick: () =>
|
||||
opened?.origKey
|
||||
? void openCopyScreen({
|
||||
@@ -595,9 +535,19 @@ export async function mountM01LogicLab(
|
||||
status: [pending],
|
||||
buttons: [
|
||||
views.root,
|
||||
createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }),
|
||||
createButton({
|
||||
label: tl("New"),
|
||||
variant: "filled",
|
||||
iconStart: "plus",
|
||||
onClick: () => void onNew(),
|
||||
}),
|
||||
copyButton,
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
createButton({
|
||||
label: tx("Bar_Delete"),
|
||||
variant: "danger",
|
||||
iconStart: "trash",
|
||||
onClick: () => void onDelete(),
|
||||
}),
|
||||
discardButton,
|
||||
saveButton,
|
||||
],
|
||||
@@ -617,6 +567,7 @@ export async function mountM01LogicLab(
|
||||
show(null);
|
||||
showLoadingOverlay();
|
||||
const openKeyOn = async (key: string): Promise<void> => {
|
||||
if (!(await leave())) return;
|
||||
const found = allLogics.find((x) => x.키 === key);
|
||||
if (found) await open(logicId(found.구분, found.키), found.구분, found.키);
|
||||
else showToast(tx("Load_Failed"), "error");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Basic.ts
|
||||
* 「신규 로직」 기본정보 칸 — 이름 · 원문번호 · 결과 단위 · 출처(구분 = 자체). 확인하면 값을 돌려줌 · 취소면 null
|
||||
* 저장은 하지 않음 — 부른 쪽이 빈 흐름도와 함께 캐시(drafts)에 새 줄로 넣고 [저장] 때 서버가 GX 새 키를 줌.
|
||||
* 저장은 하지 않음 — 부른 쪽이 빈 시트 줄로 캐시(drafts)에 넣고 [저장] 때 서버가 GX 새 키를 줌.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Detail.ts
|
||||
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
|
||||
* ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`)
|
||||
* 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(줄 쌓기 804-2 · 804-6) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침)
|
||||
* 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 보기는 799-2 에 걷음 — 셈은 화면 칸 없이 `_Logic_Drive.ts` 구동
|
||||
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -11,14 +11,16 @@ import type { LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { buildEst } from "./M01_MasterData_UI_LogicLab_Est";
|
||||
import { buildHo, lineOpener, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho";
|
||||
import { trial } from "./M01_MasterData_UI_LogicLab_Cover";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
|
||||
import { block } from "./M01_MasterData_UI_LogicLab_View";
|
||||
|
||||
/** 원문 절 머리 개정 연도 글 — [2008, 2020, 2025] → 「'08 · '20 · '25」(없으면 빈 글) */
|
||||
const revisionText = (row: LogicRow): string =>
|
||||
(row.개정 ?? []).map((y) => `'${String(y).slice(-2)}`).join(" · ");
|
||||
|
||||
export { trial };
|
||||
|
||||
@@ -28,19 +30,14 @@ export interface DetailContext extends HoContext {
|
||||
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
|
||||
ownHost: HTMLElement;
|
||||
onChange: () => void;
|
||||
/** 시험 계산 칸(`buildCalc` 가 채움) — 둘째 컨테이너 안에 놓음 */
|
||||
calcHost: HTMLElement;
|
||||
/** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 ·
|
||||
* 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */
|
||||
flowHost?: HTMLElement;
|
||||
/** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */
|
||||
/** 시스템 관리자 — 기본 로직 안내 글이 다름(시트에서 고침 / 보기만) */
|
||||
admin?: boolean;
|
||||
/** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */
|
||||
/** 722-2 시험 고침 뒤 — 구동을 고친 줄로 다시 시작(있으면 그 자리 고침 켜짐 · 셈은 `rerun`) */
|
||||
onTrial?: () => void;
|
||||
/** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
|
||||
cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType<typeof lineOpener> };
|
||||
/** 794-1 M01 줄 식 시트(`_LogicLab_Sheet`) 그릇 — 있으면 「시트」 덩이(M02 는 안 넘김) */
|
||||
sheet?: HTMLElement;
|
||||
/** 717-2b M02 단가산출 — 단가산출 상세 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
|
||||
cover?: { ho: HTMLElement; open?: ReturnType<typeof lineOpener> };
|
||||
/** 수식 탭 = 줄 쌓기 그릇(`A00_Common/line_stack_screen.ts` · 804-2 · 804-6) */
|
||||
stack?: HTMLElement;
|
||||
}
|
||||
|
||||
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
|
||||
@@ -114,6 +111,7 @@ function infoBox(ctx: DetailContext): HTMLElement[] {
|
||||
field(tx("Head_Name"), "이름"),
|
||||
field(tx("Head_Unit"), "결과단위"),
|
||||
field(tx("Head_Source"), "출처"),
|
||||
cell(tx("Head_Revision"), revisionText(row)),
|
||||
cell(tx("Head_Owner"), row.소유 ?? ""),
|
||||
el("label", {
|
||||
className: "m01lab__cell m01lab__cell--wide",
|
||||
@@ -188,7 +186,7 @@ function trialEdit(ctx: DetailContext): HoEdit | undefined {
|
||||
if (cover) covers[idx] = cover;
|
||||
else delete covers[idx];
|
||||
redraw();
|
||||
ctx.calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
ctx.rerun?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -219,37 +217,19 @@ function hoBox(ctx: DetailContext): HTMLElement {
|
||||
return block(section(tl("Ho_Title"), blocks, hint), "ho");
|
||||
}
|
||||
|
||||
/** 상세 화면 넷을 `host` 에 쌓음 */
|
||||
/** 상세 화면(기본정보 · 수식 · 단가산출 상세)을 `host` 에 쌓음 */
|
||||
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
const formula = el("div", { className: "m01lab__formula" });
|
||||
const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput };
|
||||
// 748-2 텍스트 수식 변수 · 값 누름 = 상세 줄과 같은 모달(M02 = 그 상세가 준 열기 · M01 = 시험 고침 고리)
|
||||
const open = ctx.cover ? ctx.cover.open : lineOpener({ ...ctx, edit: trialEdit(ctx) });
|
||||
buildFormula(formula, { text: ctx.text, ...fx, open });
|
||||
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
|
||||
const active = document.activeElement;
|
||||
const kept =
|
||||
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
|
||||
? { node: active, from: active.selectionStart, to: active.selectionEnd }
|
||||
: null;
|
||||
// 737-3 단가산출 상세 · 텍스트 수식 두 보기 상자 제목 같음 · 「지금 입력」 꼬리표 없음
|
||||
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
|
||||
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임
|
||||
host.replaceChildren(
|
||||
...infoBox(ctx),
|
||||
...(ctx.sheet ? [block(ctx.sheet, "sheet")] : []),
|
||||
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
||||
block(
|
||||
section(tl("View_Est"), [
|
||||
ctx.cover?.est ?? buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx }),
|
||||
]),
|
||||
"est",
|
||||
),
|
||||
...(ctx.flowHost && !ctx.cover
|
||||
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
|
||||
: [
|
||||
ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx),
|
||||
block(section(tl("Ho_Title"), [formula]), "formula"),
|
||||
]),
|
||||
...(ctx.stack ? [block(ctx.stack, "stack")] : []),
|
||||
ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx),
|
||||
);
|
||||
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
||||
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Est.ts
|
||||
* 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로.
|
||||
* 인력 · 재료 · 경비(장비) 묶음(714-1 줄 덧칸 `묶음`) · 묶음 머리 변수 칸 = 줄들의 `쓰는입력` · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import type { LogicInput } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { safeReason } from "./M01_MasterData_UI_Logic_Text";
|
||||
import type { PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export interface EstContext {
|
||||
text: PreviewTextAnswer | null;
|
||||
unit: string;
|
||||
inputs: LogicInput[];
|
||||
values: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function buildEst(ctx: EstContext): HTMLElement {
|
||||
const host = el("div", { className: "m01lab-est", attrs: { "data-est": "" } });
|
||||
const text = ctx.text;
|
||||
if (!text || (text.ok && text.계산됨 === false)) {
|
||||
host.append(createText(tl("Formula_Slot"), "muted"));
|
||||
return host;
|
||||
}
|
||||
if (!text.ok) {
|
||||
host.append(createText(safeReason(text.reason), "error"));
|
||||
return host;
|
||||
}
|
||||
host.append(
|
||||
...calcSheetRows({
|
||||
묶음: text.groups,
|
||||
계: text.계,
|
||||
sumLabel: `${tl("Total")} (${ctx.unit || "원"})`,
|
||||
bundle: true,
|
||||
inputs: ctx.inputs,
|
||||
values: ctx.values,
|
||||
onInput: ctx.onInput,
|
||||
}),
|
||||
);
|
||||
return host;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.m01lab-flow__wait {
|
||||
margin: 0;
|
||||
padding: var(--spacing-16);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m01lab-flow__note {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01lab-flow__note[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Flow.ts
|
||||
* 로직 상세 오른쪽 흐름도 — 칸 · 폭은 오른쪽 접는 패널(LogicLab_Side) 몫
|
||||
* 잇기(mountLogicFlow) — 패널(M02_DF_Panel) · 왼쪽 보기(LogicLab_FlowView) · 입력 값 · 다른 로직 흐름 받기
|
||||
* DataFlow 파일은 import() 로 늦게(엔진이 M01 첫 묶음에 안 들어가게). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 2 · 5 · 6 · 7
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
import type { CfValues } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
|
||||
import "./M01_MasterData_UI_LogicLab_Flow.css";
|
||||
|
||||
const TEXT = {
|
||||
New: ["신규 로직", "New logic"],
|
||||
Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"],
|
||||
Converted: [
|
||||
"이 로직의 계산 방법을 흐름도로 보여 줌",
|
||||
"Shows how this logic is calculated as a flow diagram",
|
||||
],
|
||||
Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"],
|
||||
} as const;
|
||||
|
||||
export const tf = (key: keyof typeof TEXT, v = ""): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
|
||||
|
||||
export interface SplitHandle {
|
||||
/** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */
|
||||
right: HTMLElement;
|
||||
/** 패널이 알린 너비(px) · null = 패널 내림 */
|
||||
setRight(px: number | null): void;
|
||||
}
|
||||
|
||||
/* ── 흐름 받기 · 잇기 ─────────────────────────────────────────────────── */
|
||||
|
||||
export interface FlowAnswer {
|
||||
flow: CfLogic;
|
||||
converted: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 계산 방법을 흐름도로 바꿔 줌 · 못 받으면 null */
|
||||
export async function fetchFlow(key: string, file = ""): Promise<FlowAnswer | null> {
|
||||
try {
|
||||
const q = new URLSearchParams({ key, ...(file ? { file } : {}) });
|
||||
const r = await fetch(`${API_BASE_URL}/m01/logic/flow?${q}`, { credentials: "include" });
|
||||
if (!r.ok) return null;
|
||||
const got = (await r.json()) as Partial<FlowAnswer>;
|
||||
return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 고치는 중인 줄의 흐름 — 왼쪽 편집기로 고친 줄(저장 전)을 서버가 흐름도로 바꿔 줌 · 못 받으면 null */
|
||||
export async function fetchFlowFor(
|
||||
row: unknown,
|
||||
key: string,
|
||||
file: string,
|
||||
): Promise<FlowAnswer | null> {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE_URL}/m01/logic/flow`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ file, key, row }),
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const got = (await r.json()) as Partial<FlowAnswer>;
|
||||
return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const loadFlowMods = () =>
|
||||
Promise.all([
|
||||
import("../M02_MasterTemplete/DataFlow/M02_DF_Panel"),
|
||||
import("./M01_MasterData_UI_LogicLab_FlowView"),
|
||||
import("../M02_MasterTemplete/DataFlow/M02_DF_Model"),
|
||||
import("../M02_MasterTemplete/DataFlow/M02_DF_Eval"),
|
||||
import("../M02_MasterTemplete/DataFlow/M02_DF_Api"),
|
||||
import("../M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail"),
|
||||
]).then(([panel, view, model, ev, api, detail]) => ({ panel, view, model, ev, api, detail }));
|
||||
|
||||
/** 빈 흐름 — 신규 로직 · 흐름을 못 받은 로직 */
|
||||
export async function emptyFlow(name: string, unit: string): Promise<CfLogic> {
|
||||
return (await import("../M02_MasterTemplete/DataFlow/M02_DF_Model")).newLogic(name, unit);
|
||||
}
|
||||
|
||||
/* ── 시험 입력 한 벌 — 왼쪽 시험 계산 입력 ↔ 흐름 변수 예시값(이름이 같은 변수끼리) ── */
|
||||
|
||||
const varsOf = (lg: CfLogic) =>
|
||||
lg.boxes.flatMap((b) => [...(b.input?.variables ?? []), ...(b.func?.variables ?? [])]);
|
||||
|
||||
/** 흐름 변수 예시값 — 이름 → 글(빈 값은 뺌) */
|
||||
export function examplesOf(lg: CfLogic): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const v of varsOf(lg))
|
||||
if (v.example !== null && v.example !== undefined && v.example !== "")
|
||||
out[v.name] = String(v.example);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 예시값을 넣은 새 흐름 — 수처럼 생긴 글은 수로 · 빈 값은 그대로 둠 */
|
||||
export function withExamples(lg: CfLogic, vals: Record<string, string>): CfLogic {
|
||||
const next = JSON.parse(JSON.stringify(lg)) as CfLogic;
|
||||
for (const v of varsOf(next)) {
|
||||
const raw = (vals[v.name] ?? "").trim();
|
||||
if (raw) v.example = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 흐름 로직의 시험 계산 입력 칸 — 「설계 때 정할 것」(변수 · 고르기 · 범위 · 단위) · 이름이 겹치면 하나 */
|
||||
export function designFields(
|
||||
lg: CfLogic,
|
||||
): { 이름: string; 단위?: string; 고르기?: (string | number)[]; 범위?: [number, number] }[] {
|
||||
const seen = new Set<string>();
|
||||
return varsOf(lg).flatMap((v) => {
|
||||
if (!v.name || seen.has(v.name)) return [];
|
||||
seen.add(v.name);
|
||||
return [
|
||||
{
|
||||
이름: v.name,
|
||||
...(v.unit && v.unit !== "-" ? { 단위: v.unit } : {}),
|
||||
...(v.type === "choice" && v.choices?.length ? { 고르기: v.choices } : {}),
|
||||
...(v.range ? { 범위: v.range } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 예시값만 다른가 — 그 밖이 같으면 고친 것으로 안 셈 */
|
||||
export function sameShape(a: CfLogic, b: CfLogic): boolean {
|
||||
const strip = (lg: CfLogic): string => {
|
||||
const c = JSON.parse(JSON.stringify(lg)) as CfLogic;
|
||||
for (const v of varsOf(c)) v.example = null;
|
||||
return JSON.stringify(c);
|
||||
};
|
||||
return strip(a) === strip(b);
|
||||
}
|
||||
|
||||
export interface LogicFlowOpts {
|
||||
split: SplitHandle;
|
||||
flow: CfLogic;
|
||||
title: string;
|
||||
readOnly: boolean;
|
||||
/** 변환 알림 — 패널 위 한 줄 */
|
||||
notes: string[];
|
||||
converted: boolean;
|
||||
/** 흐름 로직이면 왼쪽 「지금 내용」 자리(LogicLab_Detail 의 flowHost) */
|
||||
leftHost(): HTMLElement | null;
|
||||
onChange(lg: CfLogic): void;
|
||||
/** 패널 접힘 — 부르는 쪽(오른쪽 두 패널)이 정함 */
|
||||
folded?: boolean;
|
||||
onFold?(on: boolean): void;
|
||||
}
|
||||
|
||||
export interface LogicFlow {
|
||||
/** 밖에서 바꿈(고친 것 버리기) — 패널 · 왼쪽 함께 */
|
||||
set(lg: CfLogic): void;
|
||||
/** 왼쪽만 다시(왼쪽 화면을 새로 그린 뒤) */
|
||||
redrawLeft(): void;
|
||||
/** 왼쪽 시험 계산 입력이 바뀜 — 같은 이름 변수의 예시값으로(고친 것 아님) */
|
||||
setExamples(vals: Record<string, string>): void;
|
||||
/** 패널 접기 · 펴기 */
|
||||
fold(on: boolean): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */
|
||||
export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
|
||||
const M = await loadFlowMods();
|
||||
const host = el("div", { className: "m01lab-flow__panel" });
|
||||
const note = el("p", { className: "m01lab-flow__note" });
|
||||
const notes = [...(opts.converted ? [tf("Converted")] : []), ...opts.notes];
|
||||
note.textContent = notes.join(" · ");
|
||||
note.hidden = !notes.length;
|
||||
opts.split.right.replaceChildren(note, host);
|
||||
let lg = opts.flow;
|
||||
let values: CfValues = {};
|
||||
let valueSig = "";
|
||||
let logics: Record<string, CfLogic> = {};
|
||||
let logicSig = "";
|
||||
let gone = false;
|
||||
|
||||
/** 다른 로직 — 마스터 로직(흐름 받기) */
|
||||
const getLogic = async (key: string): Promise<CfLogic | null> =>
|
||||
(await fetchFlow(key))?.flow ?? null;
|
||||
|
||||
const left = (): void => {
|
||||
const at = opts.leftHost();
|
||||
if (!at || gone) return;
|
||||
M.view.buildFlowView(at, lg, values, { logics });
|
||||
};
|
||||
/** 입력 값 · 다른 로직 — 물음이 바뀐 때만 다시 받음 · 받으면 왼쪽 다시 */
|
||||
const refresh = (): void => {
|
||||
let ports: Record<string, Record<string, unknown>> = {};
|
||||
try {
|
||||
ports = M.ev.evalLogic(lg, values, { logics }).ports;
|
||||
} catch {
|
||||
/* 셈이 막혀도 물음은 보냄 */
|
||||
}
|
||||
const pill = (p: { box: string; name: string }): unknown => ports[p.box]?.[p.name] ?? null;
|
||||
const asks = M.detail.queriesOf(lg, pill, M.model.elementPort);
|
||||
const sig = JSON.stringify(asks.map((a) => a.q));
|
||||
if (sig !== valueSig) {
|
||||
valueSig = sig;
|
||||
void M.detail.fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then(
|
||||
(got) => {
|
||||
if (sig !== valueSig || gone) return;
|
||||
values = got.values;
|
||||
left();
|
||||
refresh(); // 받은 값으로 조건 찾기 값이 바뀌면 한 번 더
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
const keys = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
|
||||
if (keys !== logicSig) {
|
||||
logicSig = keys;
|
||||
void M.detail.loadLogics(lg, getLogic).then((got) => {
|
||||
if (keys !== logicSig || gone) return;
|
||||
logics = got.logics;
|
||||
left();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const panel = M.panel.mountFlowPanel(host, {
|
||||
logic: lg,
|
||||
title: opts.title,
|
||||
readOnly: opts.readOnly,
|
||||
logics: getLogic,
|
||||
onResize: (px) => opts.split.setRight(px),
|
||||
folded: opts.folded,
|
||||
onFold: opts.onFold,
|
||||
onChange: (next) => {
|
||||
lg = next;
|
||||
opts.onChange(next);
|
||||
left();
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
left();
|
||||
refresh();
|
||||
return {
|
||||
set: (next) => {
|
||||
lg = next;
|
||||
panel.set(next);
|
||||
left();
|
||||
refresh();
|
||||
},
|
||||
redrawLeft: left,
|
||||
fold: (on) => panel.fold(on),
|
||||
setExamples: (vals) => {
|
||||
const next = withExamples(lg, vals);
|
||||
if (JSON.stringify(next) === JSON.stringify(lg)) return;
|
||||
lg = next;
|
||||
panel.set(next);
|
||||
left();
|
||||
refresh();
|
||||
},
|
||||
destroy: () => {
|
||||
gone = true;
|
||||
panel.destroy();
|
||||
opts.split.setRight(null);
|
||||
opts.split.right.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_FlowRows.ts
|
||||
* 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어
|
||||
* 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함.
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
|
||||
import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round";
|
||||
|
||||
export type FlowGroupKey = "labor" | "material" | "cost" | "plain";
|
||||
|
||||
/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */
|
||||
export const GROUP_OF: Record<CfCost, FlowGroupKey> = {
|
||||
노무비: "labor",
|
||||
재료비: "material",
|
||||
경비: "cost",
|
||||
값: "plain",
|
||||
};
|
||||
export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"];
|
||||
const COST_OF: Record<Exclude<FlowGroupKey, "plain">, "노무비" | "재료비" | "경비"> = {
|
||||
labor: "노무비",
|
||||
material: "재료비",
|
||||
cost: "경비",
|
||||
};
|
||||
|
||||
export interface FlowLine {
|
||||
box: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
value: number | string | boolean | null;
|
||||
/** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FlowGroup {
|
||||
key: FlowGroupKey;
|
||||
lines: FlowLine[];
|
||||
/** 비용 묶음만 — 합계 끝수를 적용한 소계 */
|
||||
sub: number | null;
|
||||
subText: string;
|
||||
}
|
||||
|
||||
export interface FlowFunc {
|
||||
box: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
value: unknown;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FlowRows {
|
||||
groups: FlowGroup[];
|
||||
/** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */
|
||||
total: number;
|
||||
totalText: string;
|
||||
funcs: FlowFunc[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const plain = (v: unknown): string =>
|
||||
v === null || v === undefined || Array.isArray(v)
|
||||
? "—"
|
||||
: typeof v === "number"
|
||||
? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 })
|
||||
: String(v);
|
||||
|
||||
/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */
|
||||
export function flowRows(
|
||||
lg: CfLogic,
|
||||
ev: CfEvalResult,
|
||||
round: { item: CfRound; total: CfRound | null },
|
||||
): FlowRows {
|
||||
const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output");
|
||||
const groups = GROUP_ORDER.map((key): FlowGroup => {
|
||||
const mine = outs.filter((b) => GROUP_OF[b.output?.cost ?? "값"] === key);
|
||||
const lines = mine.map((b): FlowLine => {
|
||||
const v = ev.outputs[b.id] ?? null;
|
||||
const r = b.output?.round ?? (key === "plain" ? undefined : round.item);
|
||||
return {
|
||||
box: b.id,
|
||||
name: b.name,
|
||||
unit: b.output?.unit ?? b.unit,
|
||||
value: v,
|
||||
text: typeof v === "number" ? showRounded(v, r) : plain(v),
|
||||
};
|
||||
});
|
||||
const raw = key === "plain" ? null : ev.cost[COST_OF[key]];
|
||||
const sub = raw === null ? null : roundBy(round.total, raw);
|
||||
return {
|
||||
key,
|
||||
lines,
|
||||
sub,
|
||||
subText: sub === null ? "" : showRounded(sub, round.total ?? undefined),
|
||||
};
|
||||
}).filter((g) => g.lines.length);
|
||||
const total = roundBy(round.total, ev.cost.계);
|
||||
const funcs = lg.boxes
|
||||
.filter((b) => b.kind === "function" && b.func)
|
||||
.map((b): FlowFunc => {
|
||||
const out = b.func!.output;
|
||||
const v = ev.ports[b.id]?.[out.name];
|
||||
return {
|
||||
box: b.id,
|
||||
name: b.name,
|
||||
unit: out.unit || b.unit,
|
||||
value: v,
|
||||
text:
|
||||
typeof v === "number"
|
||||
? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 })
|
||||
: plain(v),
|
||||
};
|
||||
});
|
||||
return {
|
||||
groups,
|
||||
total,
|
||||
totalText: showRounded(total, round.total ?? undefined),
|
||||
funcs,
|
||||
errors: ev.errors,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.m01flow__group {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01flow__block {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01flow__func {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01flow__branch {
|
||||
margin: 0 0 0 var(--spacing-16);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01flow__branch--now {
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_FlowView.ts
|
||||
* 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다
|
||||
* 식 두 줄 — sub2 `formulaLines`). 기본정보 · 시험 계산은 Detail 이 둠.
|
||||
* 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름.
|
||||
* 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름.
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
evalLogic,
|
||||
type CfEvalOptions,
|
||||
type CfEvalResult,
|
||||
type CfValues,
|
||||
} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
|
||||
import {
|
||||
cellValuesOf,
|
||||
formulaLines,
|
||||
type FormulaLine,
|
||||
} from "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView";
|
||||
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
import {
|
||||
itemRoundNow,
|
||||
roundText,
|
||||
totalRoundNow,
|
||||
totalRoundOf,
|
||||
} from "../M02_MasterTemplete/DataFlow/M02_DF_Round";
|
||||
import {
|
||||
flowRows,
|
||||
type FlowFunc,
|
||||
type FlowGroup,
|
||||
type FlowGroupKey,
|
||||
} from "./M01_MasterData_UI_LogicLab_FlowRows";
|
||||
import "./M01_MasterData_UI_LogicLab_FlowView.css";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
|
||||
const TEXT = {
|
||||
Detail: ["단가산출 상세", "Unit-price detail"],
|
||||
Formula: ["텍스트 수식", "Text formulas"],
|
||||
Labor: ["인력 · 노무비", "Labor · labor cost"],
|
||||
Material: ["재료 · 재료비", "Material · material cost"],
|
||||
Cost: ["경비", "Expense"],
|
||||
Plain: ["값(비용 아님)", "Values (not costs)"],
|
||||
HName: ["이름", "Name"],
|
||||
HUnit: ["단위", "Unit"],
|
||||
HValue: ["값", "Value"],
|
||||
Sub: ["소계", "Subtotal"],
|
||||
Total: ["계", "Total"],
|
||||
TotalNote: ["합계 끝수", "Total rounding"],
|
||||
None: ["출력 상자가 없음", "No output box"],
|
||||
NoFunc: ["함수 상자가 없음", "No function box"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
const GROUP_TITLE: Record<FlowGroupKey, TextKey> = {
|
||||
labor: "Labor",
|
||||
material: "Material",
|
||||
cost: "Cost",
|
||||
plain: "Plain",
|
||||
};
|
||||
|
||||
const section = (title: string, body: HTMLElement[]): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m01-logic__section m01lab__box",
|
||||
children: [
|
||||
el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })] }),
|
||||
...body,
|
||||
],
|
||||
});
|
||||
|
||||
const groupTable = (g: FlowGroup): HTMLElement => {
|
||||
const sub =
|
||||
g.sub === null
|
||||
? []
|
||||
: [
|
||||
el("tr", {
|
||||
className: "m01lab__sub",
|
||||
children: [
|
||||
el("td", { text: tf("Sub"), attrs: { colspan: "2" } }),
|
||||
el("td", { className: "m01-logic__money", text: g.subText }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
return el("div", {
|
||||
className: "m01flow__group",
|
||||
children: [
|
||||
el("h4", { text: tf(GROUP_TITLE[g.key]) }),
|
||||
el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table ui-table--data m01-logic__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [tf("HName"), tf("HUnit"), tf("HValue")].map((h) =>
|
||||
el("th", { text: h }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", {
|
||||
children: [
|
||||
...g.lines.map((l) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
el("td", { text: l.name }),
|
||||
el("td", { text: l.unit }),
|
||||
el("td", { className: "m01-logic__money", text: l.text }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
...sub,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/** 함수 상자 하나의 글 줄 — 식 보기가 있으면 두 줄(+ 갈래) · 없으면 이름 = 값 */
|
||||
function funcBlock(f: FlowFunc, lines: FormulaLine[] | null): HTMLElement {
|
||||
const unit = f.unit && f.unit !== "-" ? ` ${f.unit}` : "";
|
||||
const head = el("p", { className: "m01flow__func", text: `${f.name} = ${f.text}${unit}` });
|
||||
if (!lines?.length) return el("div", { className: "m01flow__block", children: [head] });
|
||||
return el("div", {
|
||||
className: "m01flow__block",
|
||||
children: [
|
||||
head,
|
||||
...lines.flatMap((l) => [
|
||||
el("p", { className: "m01lab__expr", text: `${l.name} = ${l.named}` }),
|
||||
el("p", { className: "m01lab__expr", text: `${l.name} = ${l.valued}` }),
|
||||
...(l.branches ?? []).map((b) =>
|
||||
el("p", {
|
||||
className: `m01flow__branch${b.now ? " m01flow__branch--now" : ""}`,
|
||||
text: `${b.now ? "▶ " : ""}${b.when} → ${b.then}`,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 왼쪽 「지금 내용」 — 단가산출 상세 + 텍스트 수식을 `host` 에 그림(`values` = 입력 값 · 다시 부르면 다시 그림) */
|
||||
export function buildFlowView(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
values: CfValues,
|
||||
opts: { logics?: CfEvalOptions["logics"]; result?: CfEvalResult } = {},
|
||||
): void {
|
||||
let ev: CfEvalResult;
|
||||
try {
|
||||
ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics });
|
||||
} catch (e) {
|
||||
const failed = section(tf("Detail"), [
|
||||
el("p", { className: "ui-text ui-text--muted", text: String(e) }),
|
||||
]);
|
||||
failed.dataset.block = "ho";
|
||||
host.replaceChildren(failed);
|
||||
return;
|
||||
}
|
||||
const totalRound = totalRoundOf(lg, totalRoundNow());
|
||||
const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRound });
|
||||
const detail = section(tf("Detail"), [
|
||||
...(rows.groups.length
|
||||
? rows.groups.map(groupTable)
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: tf("None") })]),
|
||||
el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": String(rows.total) },
|
||||
text: `${tf("Total")} ${rows.totalText}`,
|
||||
}),
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: `${tf("TotalNote")}: ${roundText(totalRound)}`,
|
||||
}),
|
||||
...rows.errors.slice(0, 3).map((t) => createText(t, "error", 14)),
|
||||
]);
|
||||
/** 함수 상자마다 식 두 줄(sub2 식 보기 · 받는 알약 값을 칸에 넣고 셈) — 못 그리면 이름 = 값만 */
|
||||
const linesOf = (id: string): FormulaLine[] | null => {
|
||||
const box = lg.boxes.find((b) => b.id === id);
|
||||
if (!box?.func) return null;
|
||||
try {
|
||||
return formulaLines(box, cellValuesOf(box, ev.arrived?.[id]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const bodies = rows.funcs.map((f) => funcBlock(f, linesOf(f.box)));
|
||||
const text = section(
|
||||
tf("Formula"),
|
||||
bodies.length ? bodies : [el("p", { className: "ui-text ui-text--muted", text: tf("NoFunc") })],
|
||||
);
|
||||
// 보기 고르기 이름표(`LogicLab_View.ts`) — 고른 덩이만 보임
|
||||
detail.dataset.block = "ho";
|
||||
text.dataset.block = "formula";
|
||||
host.replaceChildren(detail, text);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/* M01 로직 개선 시험 — 텍스트 수식 두 칸(왼쪽 이름 식 · 오른쪽 값 식) */
|
||||
.m01lab-fx {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
column-gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab-fx__head,
|
||||
.m01lab-fx__row {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.m01lab-fx__head {
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01lab-fx__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* 748-2 한 줄 = 변수 이름 · 값을 하나하나 누름(행 고르기 없음) */
|
||||
.m01lab-fx__toks,
|
||||
.m01lab-fx__val {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01lab-fx__cell {
|
||||
min-width: 0;
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 714-3 줄이 쓰는 입력 — 누르면 그 자리 고르기/글 칸 */
|
||||
.m01lab-fx__vars {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01lab-fx__var .ui-field {
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.m01lab-fx,
|
||||
.m01lab-fx__head,
|
||||
.m01lab-fx__row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Formula.ts
|
||||
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
|
||||
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
|
||||
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
|
||||
* 737-2 — 비목(노무비 · 재료비 · 경비)마다 구분 상자 · 맨 위 옵션 고르기(그 상자 줄이 쓰는 입력 · 서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각 + 어느 줄도 안 쓰는 공통 입력 · 6 → 4 → 3 → 2 → 1 열) →
|
||||
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { createCard } from "@ui/ui_template_card";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { optionGrid, varRow } from "../A00_Common/calc_sheet";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import { buildFoot, FOOT_KEYS, FOOT_OF, type FootKey } from "./M01_MasterData_UI_LogicLab_Foot";
|
||||
import { nameTokens, tokenRow, valueTokens } from "./M01_MasterData_UI_LogicLab_FormulaTokens";
|
||||
import "./M01_MasterData_UI_LogicLab_Formula.css";
|
||||
|
||||
/** 로직 열자마자 값 없이 부른 미리보기 응답은 `계산됨` 을 false 로 표시 — [계산] 을 거친 응답은 안 붙어 true 취급 */
|
||||
export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean };
|
||||
|
||||
export interface FormulaContext {
|
||||
text: PreviewTextAnswer | null;
|
||||
/** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */
|
||||
inputs?: LogicInput[];
|
||||
values?: Record<string, string>;
|
||||
onInput?: (name: string, value: string) => void;
|
||||
/** 748-2 줄 → 그 줄 자료 모달 열기(변수 · 값을 누르면) — 없으면 글자만 */
|
||||
open?: (line: TextLine) => (() => void) | undefined;
|
||||
}
|
||||
|
||||
/* ── 순수 셈: 줄이 쓰는 입력 ── */
|
||||
/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */
|
||||
export function usedInputs(
|
||||
line: Pick<TextLine, "조각" | "요소조각">,
|
||||
names: string[],
|
||||
): [string, string][] {
|
||||
const out = new Map<string, string>();
|
||||
for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) {
|
||||
const row = p.kind === "표" ? p.참조?.행 : null;
|
||||
if (row && typeof row === "object")
|
||||
for (const [k, v] of Object.entries(row))
|
||||
if (names.includes(k) && !out.has(k)) out.set(k, String(v));
|
||||
if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글))
|
||||
out.set(p.글, String(p.값));
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` · 722-1 계산식 칸도 씀 */
|
||||
export function inputChip(
|
||||
spec: LogicInput,
|
||||
shown: string,
|
||||
onInput: (v: string) => void,
|
||||
): HTMLElement {
|
||||
const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } });
|
||||
const chip = createButton({
|
||||
label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`,
|
||||
variant: "ghost",
|
||||
size: 20,
|
||||
tone: "option",
|
||||
onClick: (ev) => {
|
||||
ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게
|
||||
let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게
|
||||
const done = (v: string | null): void => {
|
||||
if (over) return;
|
||||
over = true;
|
||||
if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim());
|
||||
else wrap.replaceChildren(chip);
|
||||
};
|
||||
let field: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
const pick = createSelectField({
|
||||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
value: shown,
|
||||
size: 24,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => done(v),
|
||||
});
|
||||
pick.select.addEventListener("blur", () => done(null));
|
||||
field = pick.root;
|
||||
} else {
|
||||
const box = createInputField({ value: shown, size: 24 });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
box.input.addEventListener("keydown", (k) => {
|
||||
if (k.isComposing) return;
|
||||
if (k.key === "Enter") done(box.input.value);
|
||||
if (k.key === "Escape") (k.preventDefault(), done(null));
|
||||
});
|
||||
box.input.addEventListener("blur", () => done(box.input.value));
|
||||
field = box.root;
|
||||
}
|
||||
field.addEventListener("click", (e) => e.stopPropagation());
|
||||
wrap.replaceChildren(field);
|
||||
field.querySelector<HTMLElement>("select, input")?.focus();
|
||||
},
|
||||
});
|
||||
chip.title = `${tl("Var_Tone")} · ${tl("Formula_Var")}`;
|
||||
wrap.append(chip);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text });
|
||||
|
||||
export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
|
||||
const text = ctx.text;
|
||||
if (!text) {
|
||||
host.replaceChildren(muted(tl("Formula_Slot")));
|
||||
return;
|
||||
}
|
||||
if (!text.ok) {
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-banner ui-banner--danger m01-logic__reasons",
|
||||
children: [
|
||||
el("strong", { text: tx("Calc_Stopped") }),
|
||||
el("div", { text: safeReason(text.reason) }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const heads = (): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01lab-fx__head",
|
||||
children: [
|
||||
el("strong", { text: tl("Formula_Left") }),
|
||||
el("strong", { text: tl("Formula_Right") }),
|
||||
],
|
||||
});
|
||||
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
|
||||
// 737-2 옵션 칸 = 비목 상자 맨 위 — 그 상자 줄이 쓰는 입력 + 어느 줄도 안 쓰는 입력(공통 · 737-3)
|
||||
const names = specs.map((x) => x.이름);
|
||||
const usedBy = (line: TextLine): string[] =>
|
||||
line.쓰는입력
|
||||
? line.쓰는입력.filter((n) => names.includes(n))
|
||||
: usedInputs(line, names).map(([n]) => n);
|
||||
const everyUsed = new Set(text.groups.flatMap((g) => g.줄.flatMap(usedBy)));
|
||||
const common = names.filter((n) => !everyUsed.has(n));
|
||||
const options = (g: (typeof text.groups)[number]): HTMLElement[] => {
|
||||
const onInput = ctx.onInput;
|
||||
if (!onInput) return [];
|
||||
const mine = new Set([...g.줄.flatMap(usedBy), ...common]);
|
||||
return optionGrid(
|
||||
specs
|
||||
.filter((x) => mine.has(x.이름))
|
||||
.map((x) => varRow(x, ctx.values?.[x.이름] ?? "", (v) => onInput(x.이름, v))),
|
||||
);
|
||||
};
|
||||
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
|
||||
const onInput = ctx.onInput;
|
||||
/** 변수 이름이 시험 입력이면 옵션 고르기 · 아니면 그 줄 모달 */
|
||||
const spec = (name: string): LogicInput | undefined =>
|
||||
onInput ? specs.find((x) => x.이름 === name) : undefined;
|
||||
// 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계
|
||||
const boxes = text.groups.map((g) => {
|
||||
const visible = g.줄.filter((l) => !l.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림
|
||||
const lines = visible.map((line, i) => {
|
||||
const pair = String(line.짝 ?? `${g.비목}${i}`);
|
||||
const open = ctx.open?.(line);
|
||||
return el("div", {
|
||||
className: `m01lab-fx__row m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`,
|
||||
attrs: { "data-text-line": line.이름, "data-pair": pair },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01lab-fx__cell",
|
||||
children: [
|
||||
tokenRow(nameTokens(line.이름글 ?? line.이름), open, (name) => {
|
||||
const s = spec(name);
|
||||
return s && onInput
|
||||
? inputChip(s, ctx.values?.[name] ?? "", (v) => onInput(name, v))
|
||||
: null;
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01lab-fx__cell",
|
||||
children: calculated
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01lab-fx__val",
|
||||
children: [
|
||||
el("span", { text: `${line.이름} :` }),
|
||||
tokenRow(valueTokens(line.글), open),
|
||||
],
|
||||
}),
|
||||
...(line.까닭
|
||||
? [
|
||||
el("div", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: safeReason(line.까닭),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [el("div", { className: "ui-text ui-text--muted", text: tl("Formula_Slot") })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const card = createCard({
|
||||
title: g.비목,
|
||||
pad: 12,
|
||||
body: [
|
||||
...options(g),
|
||||
el("div", {
|
||||
className: "m01lab-fx",
|
||||
children: [
|
||||
heads(),
|
||||
...lines,
|
||||
el("p", {
|
||||
className: "m01lab__subtotal-text m01lab-fx__wide",
|
||||
attrs: { "data-text-sub": String(g.소계) },
|
||||
text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
card.classList.add("m01lab__ho-box");
|
||||
card.dataset.fxBox = g.비목;
|
||||
return card;
|
||||
});
|
||||
// 748-3 맨 아래 비목 소계 고정 줄(노무비 · 재료비 · 경비 — 계 칸 없음 · 상자 소계는 그대로)
|
||||
const sums = Object.fromEntries(
|
||||
FOOT_KEYS.map((k) => [k, text.groups.find((g) => FOOT_OF[g.비목] === k)?.소계 ?? 0]),
|
||||
) as Record<FootKey, number>;
|
||||
const divide =
|
||||
text.나눔 && text.나눔 !== 1
|
||||
? [muted(`${tl("Formula_Divide")} ÷ ${text.나눔}`)] // M02 750-1 단위 환산 — 식은 바탕 단위 그대로
|
||||
: [];
|
||||
host.replaceChildren(...divide, ...boxes, buildFoot(sums, calculated));
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_FormulaTokens.ts
|
||||
* 텍스트 수식 한 줄 글 → 누를 수 있는 조각(PLAN 748-2) —
|
||||
* 왼쪽 변수 이름 식(`이름글`) = 변수 이름 하나하나 · 오른쪽 값 든 식(`글`) = 값(수) 하나하나
|
||||
* 누르면 그 줄 자료 모달(748-1) · 변수가 시험 입력(옵션)이면 옵션 고르기(`inputChip`)
|
||||
* 조각 나누기는 순수 셈(화면 · fetch · sessionStorage 없음) — 칸 그리기는 `tokenRow` 만 화면.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
|
||||
/* ── 순수 셈 ── */
|
||||
export interface Tok {
|
||||
text: string;
|
||||
/** 눌러서 모달을 열 조각 */
|
||||
hot: boolean;
|
||||
}
|
||||
|
||||
const NAME_RE = /\s+|[*+/=()×÷,%:]|[^\s*+/=()×÷,%:]+/g;
|
||||
|
||||
/** 변수 이름 식 → 조각 — 글자(한글 · 영문)가 든 덩이 = 변수 이름(함수 이름 「이름(」 · 수 · 기호는 아님) · 빈칸은 버림 */
|
||||
export function nameTokens(text: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
for (const m of text.matchAll(NAME_RE)) {
|
||||
const t = m[0];
|
||||
if (/^\s+$/.test(t)) continue;
|
||||
const next = text[(m.index ?? 0) + t.length];
|
||||
out.push({ text: t, hot: /[가-힣A-Za-z]/.test(t) && next !== "(" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 값 든 식 → 조각 — 수(콤마 · 소수점 든 것까지) 하나하나 = 값 · 나머지(기호 · 글)는 그대로 */
|
||||
export function valueTokens(text: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
let last = 0;
|
||||
const plain = (s: string): void => {
|
||||
const t = s.trim();
|
||||
if (t) out.push({ text: t, hot: false });
|
||||
};
|
||||
for (const m of text.matchAll(/\d[\d,]*(?:\.\d+)?/g)) {
|
||||
plain(text.slice(last, m.index));
|
||||
out.push({ text: m[0], hot: true });
|
||||
last = (m.index ?? 0) + m[0].length;
|
||||
}
|
||||
plain(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
/** 조각 한 줄 — `hot` 은 낮은 단추(요소 연결 갈래 색) · `make` 가 단추를 주면 그걸로(옵션 칩) · 아니면 글자 */
|
||||
export function tokenRow(
|
||||
toks: Tok[],
|
||||
open: (() => void) | undefined,
|
||||
make?: (text: string) => HTMLElement | null,
|
||||
): HTMLElement {
|
||||
return el("div", {
|
||||
className: "m01lab-fx__toks",
|
||||
children: toks.map((t) => {
|
||||
if (!t.hot) return el("span", { text: t.text });
|
||||
const own = make?.(t.text);
|
||||
if (own) return own;
|
||||
if (!open) return el("span", { text: t.text });
|
||||
const b = createButton({ label: t.text, variant: "ghost", size: 20, tone: "link" });
|
||||
b.classList.add("m01lab-fx__tok");
|
||||
b.dataset.fxTok = t.text;
|
||||
b.addEventListener("click", open);
|
||||
return b;
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export interface HoRowLike {
|
||||
bundle: string;
|
||||
merge: boolean;
|
||||
amount: number | null;
|
||||
/** 합계 밖 줄(엔진 `합밖`) — 보이되 소계에 안 더함 */
|
||||
out?: boolean;
|
||||
}
|
||||
export interface HoMerged<T> {
|
||||
idx: number;
|
||||
@@ -80,7 +82,7 @@ export function boxesOf<T extends HoRowLike>(list: T[], always: string[] = []):
|
||||
const merged = new Map<string, HoMerged<T>>();
|
||||
for (const e of list) {
|
||||
const b = take(e.bundle);
|
||||
b.소계 += e.amount ?? 0;
|
||||
if (!e.out) b.소계 += e.amount ?? 0;
|
||||
if (!e.merge) {
|
||||
b.줄.push(e);
|
||||
continue;
|
||||
@@ -112,6 +114,7 @@ const TEXT = {
|
||||
Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"],
|
||||
Edited: ["시험 고침", "Trial edit"],
|
||||
EditedM02: ["변경 항목", "Override"],
|
||||
Out: ["합 밖", "Outside total"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b);
|
||||
@@ -141,8 +144,8 @@ export interface HoContext {
|
||||
values: Record<string, string>;
|
||||
/** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */
|
||||
onInput?: (name: string, value: string) => void;
|
||||
/** 시험 계산 칸 — 재료 후보 고르기가 씀(없으면 후보만 보임) */
|
||||
calcHost?: HTMLElement;
|
||||
/** 지금 값으로 다시 셈(구동) — 재료 후보 고르기가 씀(없으면 후보만 보임) */
|
||||
rerun?: () => void;
|
||||
/** 그 자리 고침 고리(없으면 모달은 보기만) */
|
||||
edit?: HoEdit;
|
||||
/** (M02 750-2) Ho 줄 차례 → SK 줄 cid — 읽는 식 줄을 `cid` 로 이음(이름 차례 추측 안 함) */
|
||||
@@ -278,8 +281,8 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi
|
||||
usedSlot: line?.품목?.값칸,
|
||||
...(item?.종류 === "재료" && ctx.pickMat
|
||||
? { onPick: ctx.pickMat(e.idx) }
|
||||
: item?.종류 === "재료" && ctx.calcHost
|
||||
? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) }
|
||||
: item?.종류 === "재료" && ctx.rerun
|
||||
? { onPick: (it) => pickMaterial(ctx.values, e.idx, it, ctx.rerun!) }
|
||||
: {}),
|
||||
// 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만)
|
||||
...(edit && (e.extra ? edit.scope === "m02" : item)
|
||||
@@ -333,6 +336,7 @@ function entries(ctx: HoContext): Entry[] {
|
||||
price: shown,
|
||||
linePrice: shown,
|
||||
amount: line ? line.금액 : null,
|
||||
out: !!line?.합밖,
|
||||
warn: line?.단위경고,
|
||||
extra: false,
|
||||
line,
|
||||
@@ -360,6 +364,7 @@ function entries(ctx: HoContext): Entry[] {
|
||||
price: "",
|
||||
linePrice: "",
|
||||
amount: line ? line.금액 : null,
|
||||
out: !!line?.합밖,
|
||||
extra: true,
|
||||
line,
|
||||
logic: false,
|
||||
@@ -463,6 +468,7 @@ function nameCell(e: Entry, label: string, tag: string): HTMLElement {
|
||||
: el("div", { text: label }),
|
||||
...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []),
|
||||
...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []),
|
||||
...(e.out ? [createBadge(th("Out"), "warning")] : []),
|
||||
...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []),
|
||||
...(tag ? [createBadge(tag, "accent")] : []),
|
||||
],
|
||||
@@ -671,7 +677,9 @@ export function buildHo(ctx: HoContext): HTMLElement[] {
|
||||
const sums = Object.fromEntries(
|
||||
FOOT_KEYS.map((g) => [
|
||||
g,
|
||||
rounddown(all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0)),
|
||||
rounddown(
|
||||
all.filter((e) => e.group === g && !e.out).reduce((s, e) => s + (e.amount ?? 0), 0),
|
||||
),
|
||||
]),
|
||||
) as Record<FootKey, number>; // 751-3 비목 소계 = 줄 합 1 원 버림 · 계 = 셋 합(서버와 같은 값)
|
||||
const total = FOOT_KEYS.reduce((s, g) => s + sums[g], 0);
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Sheet.ts
|
||||
* M01 단가산출 로직 상세 「시트」 보기(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴.
|
||||
* 한 줄 「이름 | 식 | 값 | 비목」(줄 꼴 = `logic/M01_Logic_Sheet_Model`) · 상태줄 = 지금 · 고치기 전 + [계산]
|
||||
* 식 글 = 서버 이름 꼴(`POST /api/m01/logic/sheet` · 줄이 바뀔 때만 · 옛 요청 끊음)
|
||||
* 고침 = 시스템 관리자만 — [식 고침] → 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/logic/sheet/check`)
|
||||
* → 되면 엔진 식을 줄 사본에(`onEdit`) → 상세 캐시(drafts) → 기존 M01 [저장](서버 검사 · 개정 확인)
|
||||
* 옵션 칸 = 시험 값(저장 안 함 · 고치면 시험 계산) · 자동저장 없음 · 부품 = calc_row · 입력 · 고르기 · 단추 · 배지
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell";
|
||||
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
|
||||
import type { CalcAnswer, LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
formulaAt,
|
||||
labItems,
|
||||
setLabFormula,
|
||||
spotKey,
|
||||
type LabItem,
|
||||
type LabKind,
|
||||
type LabNames,
|
||||
type LabPart,
|
||||
} from "./logic/M01_Logic_Sheet_Model";
|
||||
import "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css";
|
||||
|
||||
const TEXT = {
|
||||
Sheet: ["시트", "Sheet"],
|
||||
Now: ["지금", "Now"],
|
||||
Before: ["고치기 전", "Before edit"],
|
||||
Run: ["계산", "Calculate"],
|
||||
근거: ["근거", "Basis"],
|
||||
옵션: ["옵션 · 시험 값", "Options · trial values"],
|
||||
계산: ["변수 · 계산", "Variables"],
|
||||
자원: ["자원", "Resources"],
|
||||
율: ["율 · 덧줄", "Rates"],
|
||||
단계: ["단계", "Stages"],
|
||||
소계: ["소계 · 합계", "Subtotals"],
|
||||
Edit: ["식 고침", "Edit formula"],
|
||||
Close: ["식 닫기", "Close formula"],
|
||||
Back: ["원래로", "Revert"],
|
||||
Wait: ["[계산] 을 누르면 값이 채워짐", "Press Calculate to fill values"],
|
||||
Flow: ["흐름 로직 — 시트로 못 봄", "Flow logic — no sheet"],
|
||||
Address: ["번지", "Address"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
|
||||
const SECTION: Record<LabKind, keyof typeof TEXT> = {
|
||||
근거: "근거",
|
||||
옵션: "옵션",
|
||||
계산: "계산",
|
||||
자원: "자원",
|
||||
율: "율",
|
||||
단계: "단계",
|
||||
소계: "소계",
|
||||
합계: "소계",
|
||||
};
|
||||
const ORDER: (keyof typeof TEXT)[] = ["근거", "옵션", "계산", "자원", "율", "단계", "소계"];
|
||||
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
|
||||
|
||||
export interface LabSheetDraw {
|
||||
row: LogicRow;
|
||||
/** 저장본 줄(새 로직 = null) */
|
||||
original: LogicRow | null;
|
||||
values: Record<string, string>;
|
||||
answer: CalcAnswer | null;
|
||||
}
|
||||
|
||||
export interface LabSheetOptions {
|
||||
/** 시스템 관리자 — 식 칸 · [식 고침] · [원래로] */
|
||||
admin: boolean;
|
||||
onInput: (name: string, value: string) => void;
|
||||
/** 줄 식 칸을 고친 뒤(줄 객체는 여기서 고침) — 상세가 캐시(touch) · 다시 그림 */
|
||||
edited: () => void;
|
||||
/** 시험 계산 [계산] 누름 */
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const r = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
|
||||
if (!r.ok) throw new Error(typeof data.detail === "string" ? data.detail : r.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function createLabSheet(opt: LabSheetOptions) {
|
||||
const status = el("div", { className: "m02-sks__status", attrs: { "data-lab-status": "" } });
|
||||
const body = el("div", { className: "m02-sks__body" });
|
||||
const run = createButton({ label: t("Run"), variant: "ghost", size: 28, onClick: opt.run });
|
||||
const root = el("div", {
|
||||
className: "m02-sks",
|
||||
attrs: { "data-lab-sheet": "" },
|
||||
children: [createToolbar({ items: [run, "divider", status], label: t("Sheet") }), body],
|
||||
});
|
||||
let last: LabSheetDraw | null = null;
|
||||
let names: LabNames | null = null;
|
||||
let asked = "";
|
||||
let ctl: AbortController | null = null;
|
||||
let before: number | null = null;
|
||||
let beforeAt = ""; // 고치기 전 값을 잰 시험 값(옵션) — 옵션이 바뀌면 고친 줄의 「고치기 전」 은 모름
|
||||
const open = new Set<string>();
|
||||
const drafts = new Map<string, string>();
|
||||
|
||||
/** 줄이 바뀌면 이름 꼴 식 글을 다시 받음(옛 요청 끊음) */
|
||||
const askNames = (row: LogicRow): void => {
|
||||
const text = JSON.stringify(row);
|
||||
if (text === asked) return;
|
||||
asked = text;
|
||||
ctl?.abort();
|
||||
const mine = (ctl = new AbortController());
|
||||
post<LabNames>("/logic/sheet", { row }, mine.signal).then(
|
||||
(got) => {
|
||||
if (mine !== ctl) return;
|
||||
names = got;
|
||||
if (last) paint(last);
|
||||
},
|
||||
() => undefined, // 못 받으면 엔진 식 그대로 보임
|
||||
);
|
||||
};
|
||||
|
||||
const check =
|
||||
(row: LogicRow, part: LabPart, at: number) =>
|
||||
(text: string, signal: AbortSignal): Promise<FormulaVerdict> =>
|
||||
post<FormulaVerdict>("/logic/sheet/check", { row, 갈래: part, 차례: at, 식: text }, signal);
|
||||
|
||||
const money = (it: LabItem): string => {
|
||||
const v = it.값;
|
||||
if (typeof v !== "number") return it.kind === "옵션" ? (it.input?.단위 ?? "") : String(v ?? "");
|
||||
if (it.kind === "자원" || it.kind === "율") return formatLineMoney(v);
|
||||
return it.kind === "소계" || it.kind === "합계" ? formatMoney(v) : NUM.format(v);
|
||||
};
|
||||
|
||||
function optionCell(d: LabSheetDraw, it: LabItem): HTMLElement {
|
||||
const spec = it.input!;
|
||||
const value = d.values[spec.이름] ?? "";
|
||||
if (spec.고르기?.length) {
|
||||
const sel = createSelectField({
|
||||
options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })),
|
||||
value,
|
||||
compact: true,
|
||||
size: 24,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => opt.onInput(spec.이름, v),
|
||||
});
|
||||
sel.select.dataset.sheetCell = `opt|${spec.이름}`;
|
||||
return sel.root;
|
||||
}
|
||||
const box = createInputField({ value, compact: true, size: 24 });
|
||||
box.input.setAttribute("aria-label", spec.이름);
|
||||
box.input.dataset.sheetCell = `opt|${spec.이름}`;
|
||||
box.input.addEventListener("change", () => opt.onInput(spec.이름, box.input.value.trim()));
|
||||
return box.root;
|
||||
}
|
||||
|
||||
function tail(d: LabSheetDraw, it: LabItem): HTMLElement {
|
||||
const kids: HTMLElement[] = it.비목 ? [createBadge(it.비목, "neutral")] : [];
|
||||
if (it.번지) kids.push(createBadge(`${t("Address")} ${it.번지}`, "info"));
|
||||
if (opt.admin && it.part && it.at !== undefined) {
|
||||
const key = spotKey(it.part, it.at);
|
||||
const on = open.has(key);
|
||||
const toggle = createButton({
|
||||
label: on ? t("Close") : t("Edit"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => {
|
||||
if (on) open.delete(key);
|
||||
else open.add(key);
|
||||
drafts.delete(key);
|
||||
paint(d);
|
||||
},
|
||||
});
|
||||
toggle.dataset.sheetFxToggle = key;
|
||||
kids.push(toggle);
|
||||
if (it.changed && d.original) {
|
||||
const { part, at } = it;
|
||||
const back = createButton({
|
||||
label: t("Back"),
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
onClick: () => edit(d, part, at, null),
|
||||
});
|
||||
back.dataset.sheetRevert = key;
|
||||
kids.push(back);
|
||||
}
|
||||
}
|
||||
return el("span", { children: kids });
|
||||
}
|
||||
|
||||
function rowOf(d: LabSheetDraw, it: LabItem): HTMLElement {
|
||||
const key = it.part && it.at !== undefined ? spotKey(it.part, it.at) : "";
|
||||
const editing = opt.admin && key && open.has(key) && it.part && it.at !== undefined;
|
||||
const middle =
|
||||
it.kind === "옵션"
|
||||
? optionCell(d, it)
|
||||
: el("div", {
|
||||
className: "m02-sks__mid",
|
||||
children: [
|
||||
createCalcFormula(it.식 || (["근거", "소계", "합계"].includes(it.kind) ? "" : "—")),
|
||||
...(editing
|
||||
? [
|
||||
createFormulaCell({
|
||||
key,
|
||||
spot: key,
|
||||
shown: it.식,
|
||||
origin: it.식,
|
||||
drafts,
|
||||
check: check(d.row, it.part!, it.at!),
|
||||
put: (engine) => {
|
||||
if (engine === null) return;
|
||||
open.delete(key);
|
||||
edit(d, it.part!, it.at!, engine);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
const value = el("span", {
|
||||
className: `ui-calc-row__out${it.kind === "소계" || it.kind === "합계" ? " is-key" : ""}`,
|
||||
text: money(it),
|
||||
});
|
||||
const row = createCalcSheetRow(it.이름, middle, value, tail(d, it));
|
||||
row.dataset.sheetKind = it.kind;
|
||||
if (key) row.dataset.sheetSpot = key;
|
||||
if (it.changed) row.classList.add("is-changed");
|
||||
return row;
|
||||
}
|
||||
|
||||
function drawStatus(d: LabSheetDraw): void {
|
||||
const ok = d.answer?.ok ? d.answer : null;
|
||||
const now = ok?.sums?.계 ?? null;
|
||||
const at = JSON.stringify(d.values);
|
||||
const same = !d.original || JSON.stringify(d.row) === JSON.stringify(d.original);
|
||||
if (same && now !== null)
|
||||
[before, beforeAt] = [now, at]; // 저장본 그대로 = 지금이 고치기 전
|
||||
else if (!same && at !== beforeAt) before = null;
|
||||
const part = (k: string, label: string, v: number | null): HTMLElement =>
|
||||
el("span", {
|
||||
className: "m02-sks__amount",
|
||||
attrs: { "data-lab-amount": k },
|
||||
children: [createText(`${label} ${v == null ? "—" : formatMoney(v)}`, "muted", 14)],
|
||||
});
|
||||
const diff = now !== null && before !== null && now !== before ? now - before : 0;
|
||||
status.replaceChildren(
|
||||
part("now", t("Now"), now),
|
||||
part("before", t("Before"), before),
|
||||
...(diff
|
||||
? [createBadge(`${diff > 0 ? "+" : "−"}${NUM.format(Math.abs(diff))}`, "warning")]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
|
||||
function paint(d: LabSheetDraw): void {
|
||||
last = d;
|
||||
drawStatus(d);
|
||||
if (d.row.흐름) return void body.replaceChildren(createText(t("Flow"), "muted"));
|
||||
const items = labItems({ ...d, names });
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const keep = active && body.contains(active) ? active.dataset.sheetCell : "";
|
||||
const typed = active instanceof HTMLInputElement && keep ? active.value : null; // 치던 글 지킴
|
||||
const out: HTMLElement[] = [];
|
||||
for (const sec of ORDER) {
|
||||
const mine = items.filter((it) => SECTION[it.kind] === sec);
|
||||
if (!mine.length) continue;
|
||||
out.push(
|
||||
el("div", {
|
||||
className: "m02-sks__head",
|
||||
attrs: { "data-sheet-section": sec },
|
||||
children: [createText(t(sec), "muted", 14)],
|
||||
}),
|
||||
...mine.map((it) => rowOf(d, it)),
|
||||
);
|
||||
}
|
||||
if (!d.answer) out.push(createText(t("Wait"), "muted"));
|
||||
body.replaceChildren(...out);
|
||||
const back = keep
|
||||
? body.querySelector<HTMLElement>(`[data-sheet-cell="${CSS.escape(keep)}"]`)
|
||||
: null;
|
||||
if (back instanceof HTMLInputElement && typed !== null) back.value = typed;
|
||||
back?.focus();
|
||||
}
|
||||
|
||||
/** 식 칸 고침(엔진 식) · null = 저장본 식 → 상세 캐시 · 시험 계산 다시 */
|
||||
function edit(d: LabSheetDraw, part: LabPart, at: number, engine: string | null): void {
|
||||
const text = engine ?? (d.original ? formulaAt(d.original, part, at) : null);
|
||||
if (text === null || !setLabFormula(d.row, part, at, text)) return;
|
||||
opt.edited();
|
||||
opt.run();
|
||||
}
|
||||
|
||||
let answer: CalcAnswer | null = null;
|
||||
let answerOf: Record<string, string> | null = null; // 답을 받은 로직(시험 값 그릇 = 연 로직마다 하나)
|
||||
let base: { src: string | null; row: LogicRow | null } = { src: null, row: null };
|
||||
return {
|
||||
root,
|
||||
/** 시험 계산 답 — 상세 `buildCalc` onAnswer */
|
||||
answer: (a: CalcAnswer) => void (answer = a),
|
||||
/** 상세가 그릴 때마다 — 줄 · 저장본 글(`original`) · 시험 값 그릇 */
|
||||
draw: (row: LogicRow, original: string | null, values: Record<string, string>) => {
|
||||
if (answerOf !== values) [answer, answerOf] = [null, values]; // 다른 로직 · 저장 뒤
|
||||
if (base.src !== original) {
|
||||
base = { src: original, row: original ? (JSON.parse(original) as LogicRow) : null };
|
||||
open.clear(); // 연 식 칸 · 치던 글 버림
|
||||
drafts.clear();
|
||||
}
|
||||
askNames(row);
|
||||
paint({ row, original: base.row, values, answer });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,3 @@
|
||||
/* 로직 상세 오른쪽 두 패널(흐름도 · 원문) — 공용 오른쪽 접는 패널 안 */
|
||||
.m01lab-side__slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 접기 · 폭은 공용 패널 몫 — 흐름도 패널 자체 접기 단추 · 끌기 경계 · 테두리는 뺌 */
|
||||
.m01lab-side .m02-dfp__toggle,
|
||||
.m01lab-side .m02-dfp__grip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01lab-side .m02-dfp {
|
||||
border-left: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* 원문 꼴 한 벌(M01 · M02 같음) — 둘레 글자 크기(M01 14)를 안 따르고 본문 크기 */
|
||||
.m01lab-src {
|
||||
font-size: var(--text-body);
|
||||
@@ -23,6 +7,22 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 원문 머리 — 제목 · [원문 다운로드] */
|
||||
.m01lab-src__top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab-src__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01lab-src__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -61,3 +61,8 @@
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
box-shadow: -3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* 원문 손잡이 = 브라우저 오른쪽 끝(요구 687-17) — 칸을 페이지 오른쪽 여백(로직 칸 12 + 패널 24)만큼 밀어 붙임 · 본문이 그 자리까지 */
|
||||
html:not([data-size="compact"], [data-size="medium"]) .m01-master__panel .m01-logic .m01lab-side {
|
||||
margin-inline-end: calc(-1 * (var(--spacing-12) + var(--spacing-24)));
|
||||
}
|
||||
|
||||
@@ -1,77 +1,45 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Side.ts
|
||||
* 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘 「흐름도」(M02_DF_Panel) · 「원문」(품셈 본문 절)
|
||||
* 공용 오른쪽 접는 패널(ui_template_side_panels) — 하나만 열림 · 모두 접힘 됨 · 폭 공유 · 접힌 패널 = 세로 글 띠
|
||||
* 열린 패널 · 폭은 취향 키(`m01-logic-side`) · 원문 = 공용 원문 보기(`LogicLab_Source`)
|
||||
* 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」(품셈 본문 절)
|
||||
* 공용 오른쪽 접는 패널(ui_template_side_panels) — 접힘 됨 · 접힌 패널 = 세로 글 띠
|
||||
* 열림 · 폭은 취향 키(`m01-logic-side`) · 원문 = 공용 원문 보기(`LogicLab_Source`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow";
|
||||
import { createSourceView } from "./M01_MasterData_UI_LogicLab_Source";
|
||||
import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Side.css";
|
||||
|
||||
const TEXT = {
|
||||
Flow: ["흐름도", "Flow"],
|
||||
Source: ["원문", "Source"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
export type PanelPick = "flow" | "source" | "none";
|
||||
|
||||
export interface LogicSide {
|
||||
/** [왼쪽 지금 내용, 오른쪽 패널] 칸 */
|
||||
root: HTMLElement;
|
||||
/** 흐름도 패널에 넘길 칸 — right = 흐름도 자리 · 폭은 오른쪽 패널 몫(setRight 안 씀) */
|
||||
flowSplit: SplitHandle;
|
||||
flowFolded(): boolean;
|
||||
pick(next: PanelPick): void;
|
||||
/** 흐름도 패널이 뜨면 접기 함수 · 내리면 null */
|
||||
bindFlow(fold: ((on: boolean) => void) | null): void;
|
||||
/** 연 로직의 출처 글 — 원문 패널이 열려 있으면 찾음 */
|
||||
showSource(src: string): void;
|
||||
}
|
||||
|
||||
export function buildLogicSide(left: HTMLElement): LogicSide {
|
||||
const flowSlot = el("div", { className: "m01lab-side__slot" });
|
||||
const source = createSourceView();
|
||||
const sp = createSidePanels({
|
||||
prefKey: "m01-logic-side",
|
||||
defaultWidth: 520,
|
||||
minWidth: 320,
|
||||
panels: [
|
||||
{ id: "flow", title: ts("Flow"), body: flowSlot },
|
||||
{ id: "source", title: ts("Source"), body: source.root },
|
||||
],
|
||||
onChange: () => {
|
||||
flowFold?.(sp.current() !== "flow"); // 다시 펴면 흐름도 판을 폭에 맞춤
|
||||
load();
|
||||
},
|
||||
});
|
||||
const root = el("div", {
|
||||
className: "m01lab-side ui-side-panels-row",
|
||||
children: [left, sp.root],
|
||||
});
|
||||
|
||||
let flowFold: ((on: boolean) => void) | null = null;
|
||||
let want = "";
|
||||
|
||||
/** 원문 패널이 열려 있을 때만 찾음 */
|
||||
const load = (): void => {
|
||||
if (sp.current() === "source") source.show(want);
|
||||
};
|
||||
|
||||
const sp = createSidePanels({
|
||||
prefKey: "m01-logic-side",
|
||||
defaultWidth: 520,
|
||||
minWidth: 320,
|
||||
panels: [{ id: "source", title: ts("Source"), body: source.root }],
|
||||
onChange: load,
|
||||
});
|
||||
return {
|
||||
root,
|
||||
flowSplit: { right: flowSlot, setRight: () => undefined },
|
||||
flowFolded: () => sp.current() !== "flow",
|
||||
pick: (next) => sp.open(next === "none" ? null : next),
|
||||
bindFlow: (f) => {
|
||||
flowFold = f;
|
||||
},
|
||||
root: el("div", { className: "m01lab-side ui-side-panels-row", children: [left, sp.root] }),
|
||||
showSource: (src) => {
|
||||
want = src;
|
||||
load();
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Side.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -85,7 +84,7 @@ export interface SourceViewOptions {
|
||||
|
||||
export function createSourceView(opts: SourceViewOptions = {}): SourceView {
|
||||
const body = el("div", { className: "m01lab-src__body" });
|
||||
const title = el("strong", { className: "m02-dfp__title" });
|
||||
const title = el("strong", { className: "m01lab-src__title" });
|
||||
const download = opts.pdf
|
||||
? (el("a", {
|
||||
className: "ui-btn ui-btn--ghost ui-btn--sm",
|
||||
@@ -149,7 +148,7 @@ export function createSourceView(opts: SourceViewOptions = {}): SourceView {
|
||||
};
|
||||
|
||||
const head = download
|
||||
? el("div", { className: "m02-dfp__head", children: [title, download] })
|
||||
? el("div", { className: "m01lab-src__top", children: [title, download] })
|
||||
: title;
|
||||
return { root: el("div", { className: "m01lab-src", children: [head, body] }), show };
|
||||
}
|
||||
|
||||
@@ -35,10 +35,6 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.m01lab__group {
|
||||
margin: var(--spacing-8) 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 칸 폭 고정(`table-layout: fixed`) — 인력·재료·경비 세 표가 저마다 딴 `<table>` 이라
|
||||
내용 기준으로 폭이 갈리던 것을 막고 셋이 같은 폭으로 맞춰짐(일감35 이어 — 수량 칸 알약화). */
|
||||
.m01lab__ho {
|
||||
@@ -143,23 +139,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01lab__text {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--text-body-sm);
|
||||
word-break: break-all;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.m01lab__text--off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.m01lab__subtotal-text {
|
||||
margin: var(--spacing-4) 0;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 복사해서 만들기(3-4) — 별칭 꼴 식 시트 */
|
||||
.ui-modal.m01copy__sheet {
|
||||
width: min(1040px, 94vw);
|
||||
@@ -198,12 +177,9 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
|
||||
.m01-logic__editor[data-view="sheet"] [data-block]:not([data-block="sheet"]),
|
||||
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]),
|
||||
.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]),
|
||||
.m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]),
|
||||
.m01-logic__editor[data-view="formula"] [data-block]:not([data-block="formula"]) {
|
||||
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김 */
|
||||
.m01-logic__editor[data-view="stack"] [data-block]:not([data-block="stack"]),
|
||||
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -219,11 +195,6 @@
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 상세 머리 「지금 입력」 한 줄 */
|
||||
.m01lab__now {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 재료 모달(711-2) — 위 물가 넷 가로 · 시험 계산이 쓴 값만 굵게 · 후보 목록은 모달 안 스크롤 */
|
||||
.m01lab__slots .ui-stat:not(.is-used) .ui-stat__value {
|
||||
font-weight: 400;
|
||||
@@ -234,14 +205,6 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 714-2 「인력·재료·경비」 — 공용 계산서 줄을 세로로 */
|
||||
.m01lab-est {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 722-1 공용 단가산출 상세 — 하위 박스(카드) 안 · 옵션 줄 · 표 사이 간격 · 맨 아래 비목 소계 */
|
||||
.m01lab__ho-box > .ui-card__body {
|
||||
display: flex;
|
||||
|
||||
@@ -7,17 +7,18 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Info_Title: ["기본정보", "Basic info"],
|
||||
New: ["신규 로직", "New logic"],
|
||||
Info_Base: [
|
||||
"기본 로직 — 식은 오른쪽 흐름도에서 고침(시스템 관리자) · 여기서는 비고만",
|
||||
"Base logic — edit formulas in the flow diagram on the right (system admin) · only the note here",
|
||||
"기본 로직 — 식은 수식 탭에서 고침(시스템 관리자) · 여기서는 비고만",
|
||||
"Base logic — edit formulas in the Equation tab (system admin) · only the note here",
|
||||
],
|
||||
Info_ReadOnly: [
|
||||
"기본 로직 — 보기만 · 비고만 고칠 수 있음",
|
||||
"Base logic — view only · only the note can be edited",
|
||||
],
|
||||
Info_Own: [
|
||||
"자체 로직 — 칸을 고치면 잠시 뒤 호표 소계와 텍스트 수식이 다시 계산됨",
|
||||
"Own logic — edits recalculate the subtotals and formula shortly",
|
||||
"자체 로직 — 칸을 고치면 잠시 뒤 호표 소계가 다시 계산됨",
|
||||
"Own logic — edits recalculate the subtotals shortly",
|
||||
],
|
||||
Edit_Title: [
|
||||
"고치기 (호표 · 중간 값 · 덧줄 · 끝수)",
|
||||
@@ -25,19 +26,10 @@ const TEXT = {
|
||||
],
|
||||
Ho_Fold: ["묶음 소계 표 (읽기용 — 고치기는 위 편집기)", "Group subtotals (read-only)"],
|
||||
Ho_Title: ["단가산출 상세", "Unit-price detail"],
|
||||
Formula_Title: ["텍스트 수식", "Text formula"],
|
||||
Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"],
|
||||
Formula_Divide: [
|
||||
"식은 바탕 단위 기준 · 금액 · 소계 = 식 값",
|
||||
"Formulas use the base unit · amounts and subtotals = formula value",
|
||||
],
|
||||
Calc_Title: ["시험 계산", "Trial calculation"],
|
||||
View_Est: ["인력·재료·경비", "Labor·material·expense"],
|
||||
View_Sheet: ["시트", "Sheet"],
|
||||
Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"],
|
||||
View_Equation: ["수식", "Equation"],
|
||||
Edit_SheetHint: ["식은 「수식」 보기에서 고침", "Edit formulas in the Equation view"],
|
||||
View_Label: ["보기", "View"],
|
||||
Now_Inputs: ["지금 입력", "Current inputs"],
|
||||
Now_None: ["없음", "none"],
|
||||
G_labor: ["인력", "Labor"],
|
||||
G_material: ["재료", "Material"],
|
||||
G_cost: ["경비", "Expense"],
|
||||
@@ -55,20 +47,10 @@ const TEXT = {
|
||||
Col_Amount: ["금액", "Amount"],
|
||||
Empty_Group: ["줄 없음", "No lines"],
|
||||
Not_Money: ["호표가 없는 로직 — 결과 식", "No unit-cost lines — result formula"],
|
||||
Calc_First: [
|
||||
"금액은 아래 「시험 계산」 을 누르면 채워짐",
|
||||
"Amounts fill after the trial calculation",
|
||||
],
|
||||
Calc_First: ["금액은 셈이 끝나면 채워짐", "Amounts fill when the calculation finishes"],
|
||||
Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"],
|
||||
Modal_Close: ["닫기", "Close"],
|
||||
Formula_Left: ["변수 이름 식", "Formula with names"],
|
||||
Formula_Right: ["값이 들어간 식", "Formula with values"],
|
||||
Formula_Var: [
|
||||
"누르면 시험 계산 입력을 고침 · 다시 셈(정본 안 바뀜)",
|
||||
"Click to change the trial input and recalculate (source unchanged)",
|
||||
],
|
||||
Pill_Tone: ["요소 연결", "Linked element"],
|
||||
Var_Tone: ["옵션", "Option"],
|
||||
Modal_Sum: ["한 줄 풀이", "In one line"],
|
||||
Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"],
|
||||
Modal_UnitPrice: ["단가", "Unit price"],
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_View.ts
|
||||
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
|
||||
* 보기 다섯 = 시트(기본 · 794-1 줄 식 시트) · 단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
|
||||
* 보기 둘 = 수식(기본 · 줄 쌓기 화면 `A00_Common/line_stack_screen.ts` · 804-6 옛 수식 문서 탭 걷음) · 단가산출 상세 — 고른 덩이만 보임
|
||||
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
|
||||
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
|
||||
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 시트)
|
||||
* 셈 구동은 화면 칸 없이 `_Logic_Drive.ts` · 기본정보는 이름표 없음(늘 맨 위)
|
||||
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 수식)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export type LabView = "sheet" | "ho" | "est" | "calc" | "formula";
|
||||
export type LabView = "stack" | "ho";
|
||||
|
||||
/** 덩이에 보기 이름표를 붙여 돌려줌 */
|
||||
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
|
||||
@@ -21,31 +20,23 @@ export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
|
||||
};
|
||||
|
||||
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
|
||||
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
|
||||
host.dataset.view = "sheet";
|
||||
export function createViewPicker(
|
||||
host: HTMLElement,
|
||||
onChange?: (v: LabView) => void,
|
||||
): { root: HTMLElement } {
|
||||
host.dataset.view = "stack";
|
||||
const segment = createSegment({
|
||||
options: [
|
||||
{ value: "sheet", label: tl("View_Sheet") },
|
||||
{ value: "stack", label: tl("View_Equation") },
|
||||
{ value: "ho", label: tl("Ho_Title") },
|
||||
{ value: "est", label: tl("View_Est") },
|
||||
{ value: "calc", label: tl("Calc_Title") },
|
||||
{ value: "formula", label: tl("Formula_Title") },
|
||||
],
|
||||
value: "sheet",
|
||||
value: "stack",
|
||||
size: 32,
|
||||
ariaLabel: tl("View_Label"),
|
||||
onChange: (v) => (host.dataset.view = v),
|
||||
onChange: (v) => {
|
||||
host.dataset.view = v;
|
||||
onChange?.(v as LabView);
|
||||
},
|
||||
});
|
||||
return { root: createToolbar({ items: [segment.root, "divider"], label: tl("View_Label") }) };
|
||||
}
|
||||
|
||||
/** 상세 머리 「지금 입력」 한 줄(읽기만) — 숨은 시험 계산 칸에 든 값 */
|
||||
export function nowInputs(values: Record<string, string>): HTMLElement {
|
||||
const filled = Object.entries(values).filter(([, v]) => String(v ?? "").trim() !== "");
|
||||
const text = filled.length ? filled.map(([k, v]) => `${k} ${v}`).join(" · ") : tl("Now_None");
|
||||
return el("p", {
|
||||
className: "ui-text ui-text--muted m01lab__now",
|
||||
attrs: { "data-now": "" },
|
||||
text: `${tl("Now_Inputs")} · ${text}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface LogicRow {
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
출처: string;
|
||||
/** 원문 절 머리 개정 연도(빌더가 채움 · 읽기만 · 794-2b) */
|
||||
개정?: number[];
|
||||
소유?: string;
|
||||
입력: LogicInput[];
|
||||
중간: NamedFormula[];
|
||||
@@ -107,6 +109,8 @@ export interface CalcLine {
|
||||
원문?: { 비목?: Record<string, number | null> };
|
||||
/** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */
|
||||
옵션꺼짐?: boolean;
|
||||
/** (796-2d) 엔진이 합계에서 빼는 줄(합밖 · 제외 단계) — 보이되 소계 · 합계에 안 더함 */
|
||||
합밖?: boolean;
|
||||
}
|
||||
|
||||
/** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */
|
||||
@@ -303,6 +307,18 @@ export const saveFiles = (
|
||||
개정확인 = false,
|
||||
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files, 개정확인 });
|
||||
|
||||
/** 로직을 부르는 곳(지우기 전 · 804-7) — 다른 수식 문서 · 일위대가 조합 · M02 단가산출 */
|
||||
export const fetchLogicUsers = (key: string): Promise<{ 쓰는곳: string[] }> =>
|
||||
request(`/logic/users?key=${encodeURIComponent(key)}`);
|
||||
|
||||
/** 로직 지우기(804-7) — 로직 줄 + 수식 문서 · 409 {쓰는곳} = 부르는 곳 있어 막음 · 409 {개정} = 정본 개정 확인 */
|
||||
export const removeLogic = (
|
||||
key: string,
|
||||
file: string,
|
||||
version: string,
|
||||
개정확인 = false,
|
||||
): Promise<unknown> => request("/logic/remove", { key, file, version, 개정확인 });
|
||||
|
||||
/** 본떠 만들기 — 정본·자체 둘 다 본뜸 · 새 키(`GX…`)는 서버가 줌(계약 5장) */
|
||||
export const copyLogic = (
|
||||
key: string,
|
||||
|
||||
@@ -2,28 +2,25 @@
|
||||
* M01_MasterData_UI_Logic_Calc.ts
|
||||
* 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로)
|
||||
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
|
||||
* 셈 구동(입력 → 서버 → 답) = `_Logic_Drive.ts` — 로직 상세는 이 칸 없이 그 구동만 씀(799-2) · 이 칸은 로직 복사 화면이 씀
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchAuto,
|
||||
pctView,
|
||||
runCalc,
|
||||
runText,
|
||||
searchElements,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type ElementBrief,
|
||||
type TextAnswer,
|
||||
type LogicRow,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
isMaterialKeyInput,
|
||||
machineControl,
|
||||
materialRows,
|
||||
pickBody,
|
||||
showItems,
|
||||
} from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { autoFill, driveRun } from "./M01_MasterData_UI_Logic_Drive";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatLineMoney, formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
@@ -52,12 +49,6 @@ async function keyName(key: string): Promise<string | null> {
|
||||
return name;
|
||||
}
|
||||
|
||||
/** (M02 750-2) SK 셈 답으로 그리는 시험 계산 — 답 + 호표 차례 줄(재료 칸 잡힌 품목 글용) */
|
||||
export interface CalcShown {
|
||||
answer: CalcAnswer;
|
||||
items?: (CalcLine | undefined)[];
|
||||
}
|
||||
|
||||
export interface CalcContext {
|
||||
/** 저장된 키 — 새 로직은 null */
|
||||
savedKey: string | null;
|
||||
@@ -70,16 +61,6 @@ export interface CalcContext {
|
||||
/** 로직마다 넣은 값 — 다시 그려도 남음 */
|
||||
values: Record<string, string>;
|
||||
onLines?: (lines: CalcLine[] | null) => void;
|
||||
/** 794-1 셈 답 통째(중간 값 · 소계) — M01 줄 식 시트 값 */
|
||||
onAnswer?: (answer: CalcAnswer) => void;
|
||||
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
|
||||
onText?: (answer: TextAnswer) => void;
|
||||
/** (M02 750-2) 있으면 [계산] = 이 셈(SK 셈 답 하나) · `/calc` · `/text` · 미리보기 안 부름 */
|
||||
answer?: () => Promise<CalcShown | null>;
|
||||
/** (M02 750-2) 이미 받은 SK 셈 답 — 그릴 때 바로 결과로 */
|
||||
shown?: CalcShown | null;
|
||||
/** (M02 750-2) 재료 고름 = SK 문서 `고름` 에도 */
|
||||
held?: (i: number, it: { ref: string; label: string }) => void;
|
||||
}
|
||||
|
||||
const grid = (children: HTMLElement[]): HTMLElement =>
|
||||
@@ -87,12 +68,9 @@ const grid = (children: HTMLElement[]): HTMLElement =>
|
||||
|
||||
const SUMS = ["노무비", "재료비", "경비", "계"];
|
||||
|
||||
/** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) */
|
||||
/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */
|
||||
const autoAsked = new WeakSet<Record<string, string>>();
|
||||
|
||||
/** 로직을 열 때마다 새로 부르는 미리보기 — 늦게 온 응답이 새 로직을 덮지 않게 순번으로 거름 */
|
||||
let previewSeq = 0;
|
||||
|
||||
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
// 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 —
|
||||
// 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂
|
||||
@@ -147,78 +125,23 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
children: [el("span", { text: label }), control],
|
||||
});
|
||||
});
|
||||
const materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again, ctx.held);
|
||||
const materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again);
|
||||
const out = el("div", { className: "m01-logic__calc-out" });
|
||||
const mySeqAtOpen = ctx.onText && !ctx.answer ? previewSeq + 1 : previewSeq; // 이 그리기가 받을 순번
|
||||
let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음
|
||||
if (ctx.onText && !ctx.answer) {
|
||||
const mySeq = ++previewSeq;
|
||||
const draft = ctx.dirty() || ctx.savedKey === null;
|
||||
const body = {
|
||||
key: ctx.savedKey ?? ctx.row.키,
|
||||
inputs: {},
|
||||
...(draft ? { row: ctx.row, file: ctx.file } : {}),
|
||||
};
|
||||
void runText(body).then((answer) => {
|
||||
if (manualRun || mySeq !== previewSeq) return; // [계산] 을 눌렀거나 다른 로직이 이미 열림
|
||||
(answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false;
|
||||
ctx.onText?.(answer);
|
||||
});
|
||||
}
|
||||
if (!fields.length && !autoAsked.has(ctx.values)) {
|
||||
autoAsked.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 계산
|
||||
queueMicrotask(() => {
|
||||
if (previewSeq === mySeqAtOpen) again();
|
||||
});
|
||||
queueMicrotask(again);
|
||||
} else if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) {
|
||||
autoAsked.add(ctx.values);
|
||||
void fetchAuto(ctx.savedKey).then((auto) => {
|
||||
const names = (ctx.row.입력 ?? [])
|
||||
.map((spec) => spec.이름)
|
||||
.filter((n) => auto[n] !== undefined && auto[n] !== null && !(ctx.values[n] ?? "").trim());
|
||||
if (!names.length || previewSeq !== mySeqAtOpen) return; // 값이 없거나 다른 로직이 이미 열림
|
||||
for (const n of names) ctx.values[n] = String(auto[n]);
|
||||
buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산(결과 · 텍스트 수식까지)
|
||||
void autoFill(ctx).then((filled) => {
|
||||
if (!filled) return;
|
||||
buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산
|
||||
again();
|
||||
});
|
||||
}
|
||||
const money = (ctx.row.결과단위 ?? "").startsWith("원");
|
||||
const paint = (s: CalcShown): void => {
|
||||
out.replaceChildren(...answerView(s.answer, false, money));
|
||||
if (s.answer.ok) showItems(host, ctx.values, s.items ?? s.answer.lines ?? []);
|
||||
};
|
||||
const run = async (): Promise<void> => {
|
||||
manualRun = true;
|
||||
if (ctx.answer) {
|
||||
try {
|
||||
const got = await ctx.answer();
|
||||
if (got) paint(got);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const inputs: Record<string, unknown> = {};
|
||||
for (const spec of ctx.row.입력 ?? []) {
|
||||
const raw = (ctx.values[spec.이름] ?? "").trim();
|
||||
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
|
||||
// 고르기는 원래 값(수면 수) 그대로 — 글 "35" 로 보내면 「고르기 밖」
|
||||
const option = spec.고르기?.find((o) => String(o) === raw);
|
||||
inputs[spec.이름] =
|
||||
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
|
||||
}
|
||||
const draft = ctx.dirty() || ctx.savedKey === null;
|
||||
try {
|
||||
const body = {
|
||||
key: ctx.savedKey ?? ctx.row.키,
|
||||
inputs,
|
||||
...pickBody(ctx.values),
|
||||
...(draft ? { row: ctx.row, file: ctx.file } : {}),
|
||||
};
|
||||
const answer = await runCalc(body);
|
||||
if (ctx.onText) ctx.onText(await runText(body));
|
||||
ctx.onAnswer?.(answer);
|
||||
ctx.onLines?.(answer.ok ? (answer.lines ?? null) : null);
|
||||
const { answer, draft } = await driveRun(ctx);
|
||||
if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines);
|
||||
out.replaceChildren(...answerView(answer, draft, money));
|
||||
} catch (error) {
|
||||
@@ -240,7 +163,6 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
runButton,
|
||||
out,
|
||||
);
|
||||
if (ctx.shown) paint(ctx.shown);
|
||||
}
|
||||
|
||||
/** 중간 값 이름이 돈 꼴(경비 · 노무비 · 공구손료 · 금액 · 단가 · 노임) — 1,000 미만이어도 돈 꼴 */
|
||||
|
||||
@@ -25,17 +25,15 @@ const picksOf = (values: Record<string, string>): Map<number, Chosen> => {
|
||||
return mine;
|
||||
};
|
||||
|
||||
/** 재료 고름(시험 입력 · 저장 안 함) — 시험 계산 칸(`host`)의 자재 글을 바꾸고 다시 셈 · 상세 표 줄 모달이 씀 */
|
||||
/** 재료 고름(시험 입력 · 저장 안 함) — 고른 뒤 `again`(다시 셈) · 상세 표 줄 모달이 씀 */
|
||||
export function pickMaterial(
|
||||
host: HTMLElement,
|
||||
values: Record<string, string>,
|
||||
i: number,
|
||||
it: Chosen,
|
||||
again: () => void,
|
||||
): void {
|
||||
picksOf(values).set(i, it);
|
||||
const link = host.querySelector<HTMLElement>(`[data-mat-idx="${i}"]`);
|
||||
if (link) link.textContent = it.label;
|
||||
host.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
again();
|
||||
}
|
||||
|
||||
/** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */
|
||||
@@ -76,8 +74,6 @@ export function materialRows(
|
||||
prices: Record<string, ElementBrief | null>,
|
||||
values: Record<string, string>,
|
||||
again: () => void,
|
||||
/** (M02 750-2) 고르면 SK 문서 `고름`에 — 줄 차례 → 고른 재료 · 없으면 시험 입력(M01) */
|
||||
sink?: (i: number, it: Chosen) => void,
|
||||
): HTMLElement[] {
|
||||
const mine = picksOf(values);
|
||||
return (row.호표 ?? [])
|
||||
@@ -119,7 +115,6 @@ export function materialRows(
|
||||
onPick: (it) => {
|
||||
mine.set(i, it);
|
||||
current.textContent = it.label;
|
||||
sink?.(i, it);
|
||||
again();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Drive.ts
|
||||
* 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)과 로직 상세(`LogicLab`)가 같이 씀.
|
||||
* `driveRun` = 한 번 셈(`/calc` + `/text`) · `previewText` = 열 때 읽는 식 미리보기 · `autoFill` = 자동값 채움
|
||||
* `createDrive` = 로직 상세용 묶음 — 열 때 미리보기 + 첫 셈(입력 없으면 바로 · 있으면 자동값 채운 뒤) · `run` 은 칸 없이 다시 셈
|
||||
* 셈 자리 = 서버(CLAUDE.md 5 장 ③) · 화면 · sessionStorage 없음 · 고른 재료(`CalcPick`) 는 값 객체에 딸림
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchAuto,
|
||||
runCalc,
|
||||
runText,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type LogicRow,
|
||||
type TextAnswer,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { isMaterialKeyInput, pickBody } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
|
||||
export interface DriveContext {
|
||||
/** 저장된 키 — 새 로직은 null */
|
||||
savedKey: string | null;
|
||||
/** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */
|
||||
file?: string;
|
||||
row: LogicRow;
|
||||
dirty: () => boolean;
|
||||
/** 로직마다 넣은 값 — 다시 그려도 남음 */
|
||||
values: Record<string, string>;
|
||||
onLines?: (lines: CalcLine[] | null) => void;
|
||||
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
|
||||
onText?: (answer: TextAnswer) => void;
|
||||
}
|
||||
|
||||
/** 칸에 든 값 → 엔진 입력 — 빈 칸은 안 보냄(엔진이 「입력 없음」 으로 멈춤) · 고르기는 원래 값 그대로 */
|
||||
function inputsOf(row: LogicRow, values: Record<string, string>): Record<string, unknown> {
|
||||
const inputs: Record<string, unknown> = {};
|
||||
for (const spec of row.입력 ?? []) {
|
||||
const raw = (values[spec.이름] ?? "").trim();
|
||||
if (raw === "") continue;
|
||||
const option = spec.고르기?.find((o) => String(o) === raw); // 글 "35" 로 보내면 「고르기 밖」
|
||||
inputs[spec.이름] =
|
||||
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
const draftBody = (ctx: DriveContext): { row?: LogicRow; file?: string } =>
|
||||
ctx.dirty() || ctx.savedKey === null ? { row: ctx.row, file: ctx.file } : {};
|
||||
|
||||
/** 한 번 셈 — 답을 `onText` → `onLines` 차례로 알리고 돌려줌(`draft` = 저장 전 고침으로 셈) */
|
||||
export async function driveRun(
|
||||
ctx: DriveContext,
|
||||
inputs = inputsOf(ctx.row, ctx.values),
|
||||
): Promise<{ answer: CalcAnswer; draft: boolean }> {
|
||||
const draft = ctx.dirty() || ctx.savedKey === null;
|
||||
const body = {
|
||||
key: ctx.savedKey ?? ctx.row.키,
|
||||
inputs,
|
||||
...pickBody(ctx.values),
|
||||
...draftBody(ctx),
|
||||
};
|
||||
const answer = await runCalc(body);
|
||||
if (ctx.onText) ctx.onText(await runText(body));
|
||||
ctx.onLines?.(answer.ok ? (answer.lines ?? null) : null);
|
||||
return { answer, draft };
|
||||
}
|
||||
|
||||
/** 열 때 읽는 식 미리보기 — [계산] 전에도 이름 식 칸이 비치게 · 늦게 온 응답은 `live()` 로 거름 */
|
||||
export function previewText(ctx: DriveContext, live: () => boolean): void {
|
||||
if (!ctx.onText) return;
|
||||
const body = { key: ctx.savedKey ?? ctx.row.키, inputs: {}, ...draftBody(ctx) };
|
||||
void runText(body).then((answer) => {
|
||||
if (!live()) return;
|
||||
(answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false;
|
||||
ctx.onText?.(answer);
|
||||
});
|
||||
}
|
||||
|
||||
/** 자동값으로 빈 칸 채움 — 채웠으면 true */
|
||||
export async function autoFill(ctx: DriveContext): Promise<boolean> {
|
||||
if (ctx.savedKey === null) return false;
|
||||
const auto = await fetchAuto(ctx.savedKey);
|
||||
const names = (ctx.row.입력 ?? [])
|
||||
.map((spec) => spec.이름)
|
||||
.filter((n) => auto[n] !== undefined && auto[n] !== null && !(ctx.values[n] ?? "").trim());
|
||||
for (const n of names) ctx.values[n] = String(auto[n]);
|
||||
return names.length > 0;
|
||||
}
|
||||
|
||||
/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */
|
||||
const first = new WeakSet<Record<string, string>>();
|
||||
|
||||
/** 로직 상세용 구동 — `start` = 로직을 열거나 입력 칸이 바뀔 때 · `run` = 지금 값으로 다시 셈 */
|
||||
export function createDrive(): {
|
||||
start: (ctx: DriveContext) => void;
|
||||
run: () => Promise<void>;
|
||||
stop: () => void;
|
||||
} {
|
||||
let seq = 0; // 로직을 바꾸면 늦게 온 응답을 거름
|
||||
let now: DriveContext | null = null;
|
||||
let ran = false; // 셈 답이 이미 왔으면 뒤늦은 미리보기로 안 덮음
|
||||
const run = async (): Promise<void> => {
|
||||
const ctx = now;
|
||||
if (!ctx) return;
|
||||
ran = true;
|
||||
try {
|
||||
await driveRun(ctx);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
const start = (ctx: DriveContext): void => {
|
||||
now = ctx;
|
||||
ran = false;
|
||||
const mine = ++seq;
|
||||
previewText(ctx, () => !ran && mine === seq);
|
||||
if (first.has(ctx.values)) return;
|
||||
if (!(ctx.row.입력 ?? []).some((spec) => !isMaterialKeyInput(spec))) {
|
||||
first.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 셈
|
||||
queueMicrotask(() => mine === seq && void run());
|
||||
} else if (ctx.savedKey !== null) {
|
||||
first.add(ctx.values);
|
||||
void autoFill(ctx).then((filled) => filled && mine === seq && void run());
|
||||
}
|
||||
};
|
||||
return {
|
||||
start,
|
||||
run,
|
||||
stop: () => {
|
||||
now = null;
|
||||
seq++;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const TEXT = {
|
||||
Bar_Back: ["← 로직 목록", "← Logics"],
|
||||
Bar_Delete: ["로직 삭제", "Delete logic"],
|
||||
Bar_Save: ["저장", "Save"],
|
||||
Bar_Discard: ["캐시 삭제", "Clear cache"],
|
||||
Bar_Discard: ["되돌림", "Revert"],
|
||||
Bar_Pending: ["저장 안 한 로직 {n} 개", "{n} unsaved logics"],
|
||||
Head_File: ["파일", "File"],
|
||||
Head_Key: ["키", "Key"],
|
||||
@@ -35,6 +35,7 @@ const TEXT = {
|
||||
Head_Name: ["이름", "Name"],
|
||||
Head_Unit: ["결과 단위", "Result unit"],
|
||||
Head_Source: ["출처", "Source"],
|
||||
Head_Revision: ["개정", "Revised"],
|
||||
Head_Owner: ["소유", "Owner"],
|
||||
Head_Note: ["비고", "Note"],
|
||||
Head_Blocked: ["막힌 까닭", "Why blocked"],
|
||||
@@ -100,12 +101,14 @@ const TEXT = {
|
||||
Lab_Title: ["단가산출 기초", "Unit-price logic"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
Confirm_Delete: [
|
||||
"로직 「{v}」 을 지울까요? [저장] 때 파일에서 빠짐",
|
||||
"Delete logic “{v}”? Removed on save",
|
||||
"로직 「{v}」 을 지울까요? 로직 · 수식이 서버에서 바로 지워짐",
|
||||
"Delete logic “{v}”? Logic and formula are removed on the server now",
|
||||
],
|
||||
Delete_Done: ["로직 「{v}」 지움", "Deleted logic “{v}”"],
|
||||
Delete_Used: ["부르는 곳이 있어 못 지움", "In use — not deleted"],
|
||||
Confirm_Discard: [
|
||||
"저장 안 한 고친 것(캐시)을 모두 지울까요?",
|
||||
"Clear all unsaved edits (cache)?",
|
||||
"저장 안 한 고친 것(기본정보 · 수식)을 버리고 저장 뒤 상태로 되돌릴까요?",
|
||||
"Discard unsaved edits (info · formula) and revert to the saved state?",
|
||||
],
|
||||
Confirm_Leave: [
|
||||
"저장 안 한 로직이 있음 — 다른 로직을 열어도 고친 것은 남음",
|
||||
|
||||
@@ -33,6 +33,7 @@ import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
|
||||
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
|
||||
import { buildHeadBar, buildHeadFilters } from "./M01_MasterData_UI_Head";
|
||||
import { renderRows } from "./M01_MasterData_UI_Rows";
|
||||
import { renderRateRows } from "./M01_MasterData_UI_RateSheet";
|
||||
import {
|
||||
buildSide,
|
||||
fileLabel,
|
||||
@@ -169,7 +170,9 @@ function buildPage(): HTMLElement {
|
||||
title.textContent = groupTitle.join(" · ");
|
||||
dispose = TABLE_GROUPS.includes(pick.group)
|
||||
? renderTables(body, pick, query, openLogicTab)
|
||||
: renderRows(body, pick, query);
|
||||
: pick.group === "요율"
|
||||
? renderRateRows(body, pick, query)
|
||||
: renderRows(body, pick, query);
|
||||
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* 요율 화면 — 위 조달청 제비율 엑셀(읽기만) · 아래 RT 표 (798-1) */
|
||||
.m01-rate__bar {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.m01-rate__title {
|
||||
margin-right: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-rate__sheet {
|
||||
flex: none;
|
||||
height: clamp(260px, 44vh, 480px);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m01-rate__rows .m01-master__grid tr[data-key] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 어두운 화면 — 엑셀 원본 채움(연분홍 · 연파랑 · 연초록)은 밝은 색 그대로라 그 칸 글자만 어둡게 */
|
||||
.m01-rate__sheet .aislo-cell[style*="background-color"] {
|
||||
color: #1f1f1f;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_RateSheet.ts
|
||||
* 요율 화면 — 위 = 조달청 제비율 엑셀(내장 엑셀 · 읽기만 · 확대 · 축소) / 아래 = 기존 RT 표(Rows).
|
||||
* RT 줄을 누르면 엑셀의 대응 칸으로 가서 칠함. 요율 셈 로직(B09)은 이 책을 읽지 않음 — 표현만.
|
||||
* 책 = 서버 `/api/m01/rate-sheet`(빌더 `build_제비율책.py` 가 만든 파일) · 798-1
|
||||
* ⚠ 내장 엑셀은 `import()` 로만 불러옴(요율 화면을 열 때만 받음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||
import { fetchRateSheet, type RateSheetData } from "./M01_MasterData_Api_Fetch";
|
||||
import { renderRows } from "./M01_MasterData_UI_Rows";
|
||||
import type { Pick } from "./M01_MasterData_UI_Side";
|
||||
import "./M01_MasterData_UI_RateSheet.css";
|
||||
|
||||
interface Handle {
|
||||
select(range: string, sheet?: string): boolean;
|
||||
repaint(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/** 요율 파일이면 엑셀 + 표 · 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
|
||||
export function renderRateRows(host: HTMLElement, pick: Pick, q: string): () => void {
|
||||
const sheetHost = el("div", { className: "m01-rate__sheet" });
|
||||
const rowsHost = el("div", { className: "m01-rate__rows" });
|
||||
const zoomIn = createButton({ label: L("M01_RateZoomIn"), variant: "ghost", size: "sm" });
|
||||
const zoomOut = createButton({ label: L("M01_RateZoomOut"), variant: "ghost", size: "sm" });
|
||||
const bar = el("div", {
|
||||
className: "ui-toolbar m01-rate__bar",
|
||||
children: [
|
||||
el("span", { className: "m01-rate__title", text: L("M01_RateSheetTitle") }),
|
||||
zoomOut,
|
||||
zoomIn,
|
||||
],
|
||||
});
|
||||
host.replaceChildren(bar, sheetHost, rowsHost);
|
||||
const offRows = renderRows(rowsHost, pick, q);
|
||||
|
||||
let closed = false;
|
||||
let book: RateSheetData | null = null;
|
||||
let sheet: Handle | null = null;
|
||||
let mark: { sheet: string; r0: number; c0: number; r1: number; c1: number } | null = null;
|
||||
let parse: ((a1: string) => { r0: number; c0: number; r1: number; c1: number } | null) | null =
|
||||
null;
|
||||
|
||||
const zoom = (dir: 1 | -1): void => {
|
||||
const grid = sheetHost.querySelector<HTMLElement>(".ss-grid-host");
|
||||
if (!grid) return;
|
||||
const probe = new WheelEvent("wheel", { ctrlKey: true, deltaY: -120 });
|
||||
const sign = wheelZoomsIn(probe) ? -1 : 1; // ☰ [설정] 휠 방향을 따라감
|
||||
grid.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
ctrlKey: true,
|
||||
deltaY: sign * dir * 120,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
zoomIn.addEventListener("click", () => zoom(1));
|
||||
zoomOut.addEventListener("click", () => zoom(-1));
|
||||
|
||||
/** RT 줄 눌림 → 대응 칸으로 가서 칠함 */
|
||||
rowsHost.addEventListener("click", (ev) => {
|
||||
const key = (ev.target as HTMLElement).closest<HTMLElement>("tr[data-key]")?.dataset.key;
|
||||
if (!key || !book || !sheet || !parse) return;
|
||||
const spot = book.대응[key];
|
||||
const area = spot ? parse(spot.범위) : null;
|
||||
if (!spot || !area) return showToast(L("M01_RateNoCell"), "info");
|
||||
mark = { sheet: spot.시트, ...area };
|
||||
sheet.select(spot.칸, spot.시트);
|
||||
sheet.repaint();
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [data, sheetMod, addr] = await Promise.all([
|
||||
fetchRateSheet(),
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
import("../A00_Common/spreadsheet/spreadsheet_address"),
|
||||
]);
|
||||
if (closed) return;
|
||||
book = data;
|
||||
parse = addr.parseRange;
|
||||
sheet = sheetMod.createSpreadsheet(sheetHost, data.책, {
|
||||
readOnly: true,
|
||||
칠: (id, r, c) =>
|
||||
mark && mark.sheet === id && r >= mark.r0 && r <= mark.r1 && c >= mark.c0 && c <= mark.c1
|
||||
? "danger mine"
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!closed) showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
offRows();
|
||||
sheet?.destroy();
|
||||
};
|
||||
}
|
||||
@@ -154,6 +154,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
|
||||
const now = flatten(cur);
|
||||
const tr = el("tr");
|
||||
tr.classList.toggle("is-deleted", deleted);
|
||||
tr.dataset.key = key; // 요율 화면 — 줄을 누르면 엑셀 대응 칸으로(798-1)
|
||||
tr.append(
|
||||
actionCell(deleted ? L("M01_RowRestore") : L("M01_RowDelete"), () =>
|
||||
toggleDelete(file, d.version, key),
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData/logic/M01_Logic_Sheet_Model.ts
|
||||
* M01 로직 줄 식 시트 꼴(PLAN 794-1 · 요구 650~652 · 요구서_652 1 장 6) — M02 단가산출 시트와 같은 꼴
|
||||
* 「이름 | 식 | 값 | 비목」 · 줄 갈래 = 근거(출처 · 비고 · 끝수) · 옵션(입력) · 계산(중간) · 자원(호표) ·
|
||||
* 율(덧줄) · 단계 · 소계 · 합계 · 식 글 = 서버 이름 꼴(`/logic/sheet` · 없으면 엔진 식 「*」 → 「×」)
|
||||
* 값 = 시험 계산 답(줄 금액 = 호표 → 덧줄 차례 · 중간 값 · 비목 소계)
|
||||
* 고침 = 줄 사본 식 칸 하나(`setLabFormula`) — 저장은 화면이 기존 M01 [저장] 길로
|
||||
* 짝 = 서버 `M01_MasterData_Sheet.py`(자리 = 갈래 · 차례 꼴만 같음) · 화면 · fetch · sessionStorage 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
CalcAnswer,
|
||||
CalcLine,
|
||||
LogicInput,
|
||||
LogicRow,
|
||||
NamedFormula,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
|
||||
export type LabPart = "중간" | "호표" | "덧줄";
|
||||
export type LabKind = "근거" | "옵션" | "계산" | "자원" | "율" | "단계" | "소계" | "합계";
|
||||
|
||||
/** 줄마다 식 칸 이름(갈래 → 칸) — 서버 `FIELD` 와 같음 */
|
||||
export const FIELD: Record<LabPart, "식" | "수량"> = { 중간: "식", 호표: "수량", 덧줄: "식" };
|
||||
|
||||
export interface LabNames {
|
||||
중간: string[];
|
||||
호표: string[];
|
||||
덧줄: string[];
|
||||
이름표?: { 글: string; 갈래: string }[];
|
||||
}
|
||||
|
||||
export interface LabItem {
|
||||
kind: LabKind;
|
||||
이름: string;
|
||||
/** 이름 꼴 식 글 · 빈 글 = 식 없음 */
|
||||
식: string;
|
||||
값: number | string | null;
|
||||
/** 비목 줄임(노 · 재 · 경) */
|
||||
비목: string;
|
||||
/** 식 칸 자리(중간 · 호표 · 덧줄) */
|
||||
part?: LabPart;
|
||||
at?: number;
|
||||
input?: LogicInput;
|
||||
/** 금액번지(791-1) */
|
||||
번지?: string;
|
||||
/** 저장본과 식이 다름 */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
const SHORT: Record<string, string> = { 노무비: "노", 재료비: "재", 경비: "경" };
|
||||
const short = (v: unknown): string => (typeof v === "string" ? (SHORT[v] ?? v) : "");
|
||||
|
||||
/** 엔진 식 → 보기 글(이름 꼴이 아직 없을 때) */
|
||||
export const plain = (s: string): string => s.replace(/\s*\*\s*/g, " × ");
|
||||
|
||||
export const spotKey = (part: LabPart, at: number): string => `${part}#${at}`;
|
||||
|
||||
/** 줄 식 칸 값(엔진 식) */
|
||||
export function formulaAt(row: LogicRow, part: LabPart, at: number): string {
|
||||
const list = (row[part] ?? []) as unknown as Record<string, unknown>[];
|
||||
return String(list[at]?.[FIELD[part]] ?? "");
|
||||
}
|
||||
|
||||
/** 줄 식 칸 하나를 엔진 식으로 바꿈(줄 객체 그대로 고침) · 없는 자리 = false */
|
||||
export function setLabFormula(row: LogicRow, part: LabPart, at: number, engine: string): boolean {
|
||||
const list = row[part] as unknown as Record<string, unknown>[] | undefined;
|
||||
if (!list || at < 0 || at >= list.length) return false;
|
||||
list[at][FIELD[part]] = engine;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 끝수 글 — 글자면 그대로 · 묶음이면 「대상 방법 자리」 */
|
||||
export function cutText(rule: unknown): string {
|
||||
if (rule == null || rule === "") return "";
|
||||
if (typeof rule === "string") return rule;
|
||||
const r = rule as Record<string, unknown>;
|
||||
return [r.대상, r.방법, r.자리 != null ? `${String(r.자리)} 자리` : ""]
|
||||
.filter((x) => x !== undefined && x !== "")
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
const valueOf = (v: unknown): number | string | null =>
|
||||
typeof v === "number" || typeof v === "string" ? v : null;
|
||||
|
||||
/** 시트 줄 — 근거 · 옵션 · 계산 · 자원 · 율 · 단계 · 소계 · 합계 */
|
||||
export function labItems(o: {
|
||||
row: LogicRow;
|
||||
/** 저장본 줄(새 로직 = null) — 식이 다르면 고친 줄 */
|
||||
original: LogicRow | null;
|
||||
values: Record<string, string>;
|
||||
names: LabNames | null;
|
||||
answer: CalcAnswer | null;
|
||||
}): LabItem[] {
|
||||
const { row, original } = o;
|
||||
const out: LabItem[] = [];
|
||||
const item = (x: Partial<LabItem> & { kind: LabKind; 이름: string }): void =>
|
||||
void out.push({ 식: "", 값: null, 비목: "", changed: false, ...x });
|
||||
if (row.출처) item({ kind: "근거", 이름: row.원문번호 || "", 식: row.출처 });
|
||||
for (const line of (row.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
|
||||
item({ kind: "근거", 이름: "", 식: line.trim() });
|
||||
for (const input of row.입력 ?? [])
|
||||
item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input });
|
||||
const ok = o.answer?.ok ? o.answer : null;
|
||||
const mid = ok?.middle ?? {};
|
||||
const shown = (part: LabPart, at: number): string =>
|
||||
o.names?.[part]?.[at] ?? plain(formulaAt(row, part, at));
|
||||
const changed = (part: LabPart, at: number): boolean =>
|
||||
!!original && formulaAt(original, part, at) !== formulaAt(row, part, at);
|
||||
const spot = (part: LabPart, at: number) => ({
|
||||
part,
|
||||
at,
|
||||
식: shown(part, at),
|
||||
changed: changed(part, at),
|
||||
});
|
||||
(row.중간 ?? []).forEach((f: NamedFormula, at) =>
|
||||
item({ kind: "계산", 이름: f.이름, 값: valueOf(mid[f.이름]), ...spot("중간", at) }),
|
||||
);
|
||||
const ho = row.호표 ?? [];
|
||||
const extra = row.덧줄 ?? [];
|
||||
const lines: (CalcLine | undefined)[] =
|
||||
ok?.lines && ok.lines.length === ho.length + extra.length ? ok.lines : [];
|
||||
ho.forEach((h, at) => {
|
||||
const b = (h as { 번지?: string }).번지;
|
||||
item({
|
||||
kind: "자원",
|
||||
이름: h.단위 ? `${h.이름 ?? ""} (${h.단위})` : (h.이름 ?? ""),
|
||||
값: lines[at]?.금액 ?? null,
|
||||
비목: short(h.비목),
|
||||
...(b ? { 번지: b } : {}),
|
||||
...spot("호표", at),
|
||||
});
|
||||
});
|
||||
extra.forEach((x, at) => {
|
||||
const b = (x as { 번지?: string }).번지;
|
||||
item({
|
||||
kind: "율",
|
||||
이름: x.이름,
|
||||
값: lines[ho.length + at]?.금액 ?? null,
|
||||
비목: short(x.비목),
|
||||
...(b ? { 번지: b } : {}),
|
||||
...spot("덧줄", at),
|
||||
});
|
||||
});
|
||||
const steps = (row.단계 ?? []) as { 이름?: string; 줄?: unknown }[];
|
||||
for (const s of Array.isArray(steps) ? steps : [])
|
||||
item({
|
||||
kind: "단계",
|
||||
이름: String(s.이름 ?? ""),
|
||||
식: Array.isArray(s.줄) ? s.줄.map(String).join(" + ") : "",
|
||||
});
|
||||
const cut = cutText(row.끝수);
|
||||
if (cut) item({ kind: "근거", 이름: "끝수", 식: cut });
|
||||
const sums = ok?.sums ?? null;
|
||||
for (const k of Object.keys(SHORT))
|
||||
if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] });
|
||||
if (sums && "계" in sums) item({ kind: "합계", 이름: "계", 값: sums.계 });
|
||||
return out;
|
||||
}
|
||||
@@ -1,195 +1,20 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Api.ts
|
||||
* 단가산출 흐름도 Api — 입력 값 한 번에 받기 · 자재 찾기 · 다른 로직 값 · 설계 때 정할 것 ·
|
||||
* 단위 · 금액 표준 표(흐름 저장은 로직 줄 「흐름」 칸 · M01 `/save`). 모양 = sub7 Model · 아래는 Api 몫만.
|
||||
* 서버 = `M02_DF_Router.py`(`/api/m02/df/*`) · 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md`
|
||||
* 단위 · 금액 표준 표 읽기 — 서버 `GET /api/m02/df/standards`(`M02_DF_Router.py`)
|
||||
* 부르는 곳 = 단위 고르기(`M02_DF_UnitPick`) · 끝수 표준(`M02_DF_Round`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { CfDesign, CfElementRef, CfGroup, CfLogic, CfUserElement } from "./M02_DF_Model";
|
||||
|
||||
/** 돈 로직 결과에서 꺼낼 칸(기계 시간당 경비) */
|
||||
export type CfField = "노무비" | "재료비" | "경비" | "계";
|
||||
|
||||
/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소 `UE-…` 는 다이어그램 안 · `_data`)에서
|
||||
* · 인력 · 재료 ref = 키 → 그 줄 단가(기존 계산과 같은 셈) · 재료 ref = 검색어 묶음 JSON 글도 됨(unit 을 주면
|
||||
* 기존 로직 재료 줄과 같은 단가)
|
||||
* · 유가전력 · 환율 = 그 줄 값 · 요율 = 값 묶음의 field(기본 rate_percent · %)
|
||||
* · 기계 ref = EQ 키 + field(값 칸 — 가격 · 손료계수 …) 또는 `로직(GC…, 기계='…', 지역=지역)` + inputs → 한 시간 값
|
||||
* · 소요량 · 계수 table = {key, row?} → 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「·아래」 「·위」)
|
||||
* · 재료 자리 slot = 요소 그대로(mode fixed · choice · rule · scope · default) + field · op(조건 찾기 규칙) ·
|
||||
* value = 이어진 알약 값(조건 찾기) · pick = 고른 품목 키(선택) → 단가 · 여럿이면 candidates */
|
||||
export interface CfQuery {
|
||||
id: string;
|
||||
group: CfGroup;
|
||||
ref?: string;
|
||||
table?: { key: string; row?: number };
|
||||
unit?: string;
|
||||
field?: string;
|
||||
inputs?: Record<string, unknown>;
|
||||
slot?: Partial<CfElementRef> & {
|
||||
field?: "규격" | "이름" | "키" | "검색어";
|
||||
op?: "포함" | "같음";
|
||||
};
|
||||
value?: unknown;
|
||||
pick?: string;
|
||||
/** 다른 로직 입력 상자 — 서버가 그 로직을 받은 설계 값으로 셈(/logic-value · 화면 queriesOf 가 만듦 · /values 로는 안 감) */
|
||||
logic?: { key: string; designs: Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** 입력 값 답 — 표는 `rows` · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
|
||||
export interface CfAnswer {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | string | null;
|
||||
rows?: (string | number | null)[][];
|
||||
name?: string;
|
||||
unit?: string;
|
||||
reason?: string;
|
||||
/** 빈 단가 · 맞는 재료 없음 — 옛 셈처럼 그 값만 비우고 셈은 계속(흐름은 「#N/A」) */
|
||||
blank?: boolean;
|
||||
/** 재료 조건 찾기가 여럿 맞을 때 후보 다섯 */
|
||||
candidates?: CfMaterial[];
|
||||
}
|
||||
|
||||
/** 자재 찾기 한 줄 */
|
||||
export interface CfMaterial {
|
||||
ref: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
구분: string;
|
||||
상세구분: string;
|
||||
}
|
||||
|
||||
/** 단위 · 금액 표준 표 — 고친 것(`_data`)이 있으면 saved */
|
||||
/** 단위 · 금액 표준 표 */
|
||||
export interface CfStandards {
|
||||
unit: { 이름: string; 줄: Record<string, unknown>[]; [k: string]: unknown };
|
||||
money: { 이름: string; 줄: Record<string, unknown>[]; [k: string]: unknown };
|
||||
saved: boolean;
|
||||
}
|
||||
|
||||
/** 서버 계산 결과 — 저장 답(화면 Eval 과 같은 차례 · 같은 규칙 · arrived = 상자마다 받은 값 · breaks = 끊긴 곳) */
|
||||
export interface CfComputed {
|
||||
arrived?: Record<string, Record<string, unknown>>;
|
||||
breaks?: { kind: string; box: string; port?: string; link?: string; text: string }[];
|
||||
/** 상자 id → 내보내는 알약 이름 → 값(표는 칸 덩어리) */
|
||||
ports: Record<string, Record<string, unknown>>;
|
||||
/** 출력 상자 id → 값 */
|
||||
outputs: Record<string, number | string | boolean | null>;
|
||||
cost: {
|
||||
노무비: number;
|
||||
재료비: number;
|
||||
경비: number;
|
||||
값들: Record<string, number | string | boolean | null>;
|
||||
계: number;
|
||||
};
|
||||
/** 쉬운 말 */
|
||||
errors: string[];
|
||||
export async function cfStandards(): Promise<CfStandards> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02/df/standards`, { credentials: "include" });
|
||||
if (!response.ok) throw new Error(`표준 표 읽기 실패 (${response.status})`);
|
||||
return (await response.json()) as CfStandards;
|
||||
}
|
||||
|
||||
/** 서버가 준 몸과 상태 번호 — 0 = 서버에 못 닿음 */
|
||||
export class CfError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public detail: unknown,
|
||||
) {
|
||||
super(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
}
|
||||
|
||||
async function send<T>(path: string, body?: unknown, method?: string): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/m02/df${path}`, {
|
||||
method: method ?? (body === undefined ? "GET" : "POST"),
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new CfError(0, "");
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 ·
|
||||
* userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */
|
||||
export async function cfValues(
|
||||
items: CfQuery[],
|
||||
userElements: CfUserElement[] = [],
|
||||
): Promise<CfAnswer[]> {
|
||||
return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values;
|
||||
}
|
||||
|
||||
const PLAIN: Record<number, string> = {
|
||||
0: "서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요",
|
||||
400: "넣은 값의 모양이 맞지 않음",
|
||||
403: "견본은 바꾸거나 지울 수 없음 — 저장하면 새 다이어그램으로 남음",
|
||||
404: "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요",
|
||||
503: "엑셀 계산을 서버에서 못 함 — 잠시 뒤 다시 해 주세요",
|
||||
};
|
||||
|
||||
/** 실패 → 쉬운 말(키 · 날 글이 섞인 서버 글은 뭉뚱그림) */
|
||||
export function cfPlain(e: unknown): string {
|
||||
if (!(e instanceof CfError)) return e instanceof Error ? e.message : PLAIN[0];
|
||||
const text = typeof e.detail === "string" ? e.detail : "";
|
||||
if (e.status === 400 && text && !/[A-Z]{2}\d{6}|[{}]/.test(text)) return text;
|
||||
return PLAIN[e.status] ?? "저장하지 못함 — 잠시 뒤 다시 해 주세요";
|
||||
}
|
||||
|
||||
/** 자재 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 규격 · 키 · 원문번호) · 쪽 넘기기 */
|
||||
export async function cfMaterials(q: {
|
||||
q?: string;
|
||||
sub?: string;
|
||||
detail?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<{ total: number; page: number; size: number; items: CfMaterial[] }> {
|
||||
const qs = new URLSearchParams(
|
||||
Object.entries(q).flatMap(([k, v]) => (v === undefined || v === "" ? [] : [[k, String(v)]])),
|
||||
);
|
||||
return send(`/materials?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/** 다른 로직 값 — 저장 로직 · 견본을 받은 설계 값(없으면 예시값)으로 서버가 셈 · 출력 이름 → 값 */
|
||||
export async function cfLogicValue(
|
||||
key: string,
|
||||
designs: Record<string, unknown> = {},
|
||||
): Promise<{ outputs: Record<string, unknown>; cost: CfComputed["cost"]; errors: string[] }> {
|
||||
return send("/logic-value", { key, designs });
|
||||
}
|
||||
|
||||
/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true ·
|
||||
* notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */
|
||||
export async function cfLogicFlow(
|
||||
file: string,
|
||||
key: string,
|
||||
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
throw new CfError(0, "");
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
|
||||
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
|
||||
return data as { flow: CfLogic; converted: boolean; notes: string[] };
|
||||
}
|
||||
|
||||
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */
|
||||
export async function cfDesignInputs(key: string): Promise<CfDesign[]> {
|
||||
const qs = new URLSearchParams({ key }).toString();
|
||||
return (await send<{ designs: CfDesign[] }>(`/design-inputs?${qs}`)).designs;
|
||||
}
|
||||
|
||||
/** 단위 · 금액 표준 표 읽기 · 고치기(시스템 관리자) · 처음 값으로 */
|
||||
export const cfStandards = (): Promise<CfStandards> => send("/standards");
|
||||
export const cfStandardsSave = (body: Omit<CfStandards, "saved">): Promise<CfStandards> =>
|
||||
send("/standards", body, "PUT");
|
||||
export const cfStandardsReset = (): Promise<CfStandards> => send("/standards", undefined, "DELETE");
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_CellTable.ts
|
||||
* 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기
|
||||
* 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfBox, CfLogic } from "./M02_DF_Model";
|
||||
import { portText } from "./M02_DF_Detail";
|
||||
import "@ui/ui_template_badge.css";
|
||||
import "@ui/ui_template_table.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["칸 연결 표", "Cell connections"],
|
||||
Cell: ["칸", "Cell"],
|
||||
Pill: ["연결 알약", "Pill"],
|
||||
Kind: ["종류", "Kind"],
|
||||
Line: ["이어진 선", "Line"],
|
||||
In: ["입력", "Input"],
|
||||
Var: ["변수", "Variable"],
|
||||
Out: ["결과", "Result"],
|
||||
None: ["아직 연결된 칸이 없음", "No cell is connected yet"],
|
||||
Unplaced: ["칸 없음", "no cell"],
|
||||
NoLine: ["-", "-"],
|
||||
Release: ["풀기", "Release"],
|
||||
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
|
||||
} as const;
|
||||
|
||||
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
export type CellKind = "in" | "var" | "out";
|
||||
|
||||
export interface CellTableHandlers {
|
||||
/** 표에서 칸을 바꿈 — key = 알약 이름 */
|
||||
move(kind: CellKind, key: string, cell: string): void;
|
||||
/** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */
|
||||
release(kind: CellKind, key: string): void;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
kind: CellKind;
|
||||
key: string;
|
||||
cell: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
function rowsOf(lg: CfLogic, box: CfBox): Row[] {
|
||||
const f = box.func;
|
||||
if (!f) return [];
|
||||
const into = (port: string): string => {
|
||||
const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port);
|
||||
return l ? portText(lg, l.from) : tt("NoLine");
|
||||
};
|
||||
const out = (): string => {
|
||||
const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name);
|
||||
return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine");
|
||||
};
|
||||
return [
|
||||
...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })),
|
||||
...f.variables.map((v): Row => ({
|
||||
kind: "var",
|
||||
key: v.name,
|
||||
cell: v.cell ?? "",
|
||||
line: tt("NoLine"),
|
||||
})),
|
||||
{ kind: "out", key: f.output.name, cell: f.output.cell, line: out() },
|
||||
];
|
||||
}
|
||||
|
||||
/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */
|
||||
export function renderCellTable(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
box: CfBox,
|
||||
readOnly: boolean,
|
||||
handlers: CellTableHandlers,
|
||||
): void {
|
||||
host.classList.add("m02-dfcells");
|
||||
const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key);
|
||||
const head = el("tr", {
|
||||
children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) =>
|
||||
el("th", { text: t }),
|
||||
),
|
||||
});
|
||||
const body = rows.map((r) => {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm m02-dfcells__cell",
|
||||
attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") },
|
||||
});
|
||||
input.value = r.cell;
|
||||
input.readOnly = readOnly;
|
||||
input.addEventListener("change", () => {
|
||||
const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
input.title = m ? "" : tt("BadCell");
|
||||
if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`);
|
||||
});
|
||||
return el("tr", {
|
||||
className: `m02-dfcells__row is-${r.kind}`,
|
||||
children: [
|
||||
el("td", { children: [input] }),
|
||||
el("td", { text: r.key }),
|
||||
el("td", {
|
||||
children: [
|
||||
el("span", {
|
||||
className: `ui-badge ui-badge--pill ui-badge--${r.kind === "in" ? "info" : r.kind === "var" ? "warning" : "success"} m02-dfcells__kind`,
|
||||
attrs: { "data-size": "20" },
|
||||
text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("td", { text: r.line }),
|
||||
el("td", {
|
||||
children:
|
||||
readOnly || (r.kind !== "var" && !r.cell)
|
||||
? []
|
||||
: [
|
||||
createButton({
|
||||
label: tt("Release"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => handlers.release(r.kind, r.key),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-dfcells__title", text: tt("Title") }),
|
||||
el("table", {
|
||||
className: "ui-table ui-table--data m02-dfcells__table",
|
||||
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,624 +0,0 @@
|
||||
"""단가산출 흐름도 서버 계산(PLAN 35-9 · 정함 4 · 5 · 6 · 7 · 12) — 저장 다시 셈 · 다른 로직 값.
|
||||
|
||||
모양 = sub7 Model `M02_DF_Model.ts`(CfLogic · CfLink.rule · CfElementRef.mode · CfInput.logic ·
|
||||
CfRound). 화면 계산 `M02_DF_Eval.evalLogic` 과 같은 차례 · 같은 뜻으로 셈(짝 ① — 화면은 TS · 서버는
|
||||
이 파일 · 함수 시트는 같은 엔진(Node · `common_util_spreadsheet`)):
|
||||
· 상자 차례 = 선 · 선 규칙 조건 알약 · 재료 조건 찾기 알약에 기댐(Model `depends`) — 서로 안 기대는
|
||||
함수 상자들은 한 번에 Node 로 셈(시트마다 통합문서 하나)
|
||||
· 입력 상자 알약 = 요소(재료는 고정 · 선택 · 조건 찾기 — 알약 이름 = `elementPort`) · 표 · 변수 ·
|
||||
다른 로직 출력 — 단가는 마스터에서 새로(`M02_DF_Values`) · 설계 값 values = {상자: {알약: 값}}
|
||||
· 선 규칙 = `M02_DF_Rule.applyRule` 과 같은 뜻(값 기준) — 막히면(통과 안 됨 · 표에 없는 조건 ·
|
||||
여럿 ·
|
||||
오류) 받는 함수는 셈하지 않고 빈 값 · 끝수 = 엔진 ROUND · ROUNDUP · ROUNDDOWN 과 같음
|
||||
· 다른 로직 = 저장 로직 · 견본을 받은 설계 값(선 · 검증 칸 · 없으면 그 로직 예시값)으로 셈 ·
|
||||
서로 부르면 막음
|
||||
마스터 파일은 읽기만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
|
||||
from typing import Any, Callable
|
||||
|
||||
from common_util import common_util_spreadsheet as spreadsheet
|
||||
from M01_MasterData import (
|
||||
M01_MasterData_Router as m01,
|
||||
) # _num — M01 Router 가 이 파일을 부름(서로 부름)
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
|
||||
from M02_MasterTemplete.DataFlow.M02_DF_Lookup import ( # noqa: F401 — 시험이 cp._lookup 을 부름
|
||||
RANGE_RULE,
|
||||
_cond_of,
|
||||
_exact,
|
||||
_is_err,
|
||||
_lookup,
|
||||
_num_of,
|
||||
_same,
|
||||
)
|
||||
|
||||
_CELL = re.compile(r"^([A-Z]+)(\d+)$")
|
||||
TABLE_PORT, OUTPUT_PORT = "표", "값"
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
GATE = {"=": "=", "≠": "<>", "≥": ">=", "≤": "<=", ">": ">", "<": "<"}
|
||||
ROUND = {"반올림": "ROUND", "올림": "ROUNDUP", "버림": "ROUNDDOWN"}
|
||||
HELP = "_df_" # 보조 시트 이름 머리(표 블록 · 끝수)
|
||||
FAR = Decimal("1E+300") # 범위 칸 열린 끝 — 엑셀 비교가 늘 참이 되게
|
||||
Loader = Callable[[str], dict]
|
||||
|
||||
|
||||
def _col(letters: str) -> int:
|
||||
n = 0
|
||||
for ch in letters:
|
||||
n = n * 26 + ord(ch) - 64
|
||||
return n
|
||||
|
||||
|
||||
def _letters(n: int) -> str:
|
||||
out = ""
|
||||
while n:
|
||||
n, rest = divmod(n - 1, 26)
|
||||
out = chr(65 + rest) + out
|
||||
return out
|
||||
|
||||
|
||||
def _at(cell: str) -> tuple[int, int, str]:
|
||||
m = _CELL.match(str(cell or "").strip().upper())
|
||||
if not m:
|
||||
raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」")
|
||||
return _col(m.group(1)), int(m.group(2)), f"{m.group(1)}{m.group(2)}"
|
||||
|
||||
|
||||
def _style(cells: dict, cell: str) -> dict:
|
||||
return {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {}
|
||||
|
||||
|
||||
def _put(cells: dict, cell: str, value: Any) -> None:
|
||||
"""칸 하나 — 값 · 수식 `{"식"}` · 빈 값은 값만 뺌(서식은 둠)."""
|
||||
keep = _style(cells, cell)
|
||||
if isinstance(value, dict) and "식" in value:
|
||||
cells[cell] = {"식": value["식"], **keep}
|
||||
elif value is None or value == "":
|
||||
cells.pop(cell, None) if not keep else cells.__setitem__(cell, keep)
|
||||
else:
|
||||
cells[cell] = {"값": value, **keep}
|
||||
|
||||
|
||||
def _place(cells: dict, cell: str, value: Any) -> None:
|
||||
"""받는 칸에 값 · 수식 · 표 블록(cell 이 왼위)."""
|
||||
col, row, at = _at(cell)
|
||||
if isinstance(value, list):
|
||||
for dy, line in enumerate(value):
|
||||
for dx, one in enumerate(line):
|
||||
_put(cells, f"{_letters(col + dx)}{row + dy}", one)
|
||||
else:
|
||||
_put(cells, at, value)
|
||||
|
||||
|
||||
def _plain(v: Any) -> Any:
|
||||
"""엔진 계산값 한 칸 → 수 · 글 · 참거짓 · 빈 값(오류는 None)."""
|
||||
if not isinstance(v, dict):
|
||||
return v
|
||||
if "수" in v:
|
||||
return Decimal(str(v["수"]))
|
||||
return v.get("글", v.get("참"))
|
||||
|
||||
|
||||
def _literal(v: Any) -> str:
|
||||
"""수식 안 값 글 — 수는 그대로 · 글은 따옴표 · 빈 값은 빈 글."""
|
||||
if v is None:
|
||||
return '""'
|
||||
if isinstance(v, bool):
|
||||
return "TRUE" if v else "FALSE"
|
||||
if isinstance(v, (int, float, Decimal)):
|
||||
return str(v)
|
||||
return '"' + str(v).replace('"', '""') + '"'
|
||||
|
||||
|
||||
# ── 입력 상자 ──────────────────────────────────────────────────────────
|
||||
def _element_value(
|
||||
group: str, el: dict, given: Any, ports: dict, errors: list, box: dict, ctx: dict
|
||||
) -> Any:
|
||||
"""요소 알약 값 — 재료는 방식 셋 · 그 밖은 그룹 값 · 설계 값이 이김(선택 자리 = 키 · 수)."""
|
||||
mode = el.get("mode") or "fixed"
|
||||
env = ctx.get("env") or {} # 로직 입력(자재지역 · 계약종별 …) — 옛 셈 재료 찾기가 읽음
|
||||
if group == "재료" and mode == "choice":
|
||||
if isinstance(given, (int, float, Decimal)) and not isinstance(given, bool):
|
||||
return given
|
||||
slot = {"mode": "choice", "default": el.get("default"), "inputs": env}
|
||||
got = dv.slot_price(slot, pick=given or None)
|
||||
elif group == "재료" and mode == "rule":
|
||||
rule = el.get("rule") or {}
|
||||
src = rule.get("from") or {}
|
||||
slot = {
|
||||
"mode": "rule",
|
||||
"scope": el.get("scope"),
|
||||
"field": rule.get("field"),
|
||||
"op": rule.get("op"),
|
||||
"ref": el.get("ref"),
|
||||
"unit": el.get("unit"),
|
||||
"inputs": env,
|
||||
}
|
||||
value = ports.get(src.get("box"), {}).get(src.get("name"))
|
||||
if rule.get("field") == "검색어": # 틀의 자리마다 알약 값 → 채운 검색어 글(빈 자리면 None)
|
||||
value, slot["unit"] = _fill_template(rule, ports), el.get("unit")
|
||||
got = dv.slot_price(slot, value)
|
||||
if not got["ok"] and not got.get("blank") and el.get("default"):
|
||||
errors.append(
|
||||
f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본 품목으로 셈"
|
||||
)
|
||||
got = dv.slot_price({"mode": "fixed", "ref": el["default"], "inputs": env})
|
||||
elif group in dv.GROUP_RULE and mode == "rule": # 인력 · 기계 · 유가전력 조건 찾기
|
||||
rule = el.get("rule") or {}
|
||||
src = rule.get("from") or {}
|
||||
slot = {"field": rule.get("field"), "op": rule.get("op"), "scope": el.get("scope"),
|
||||
"ref": el.get("ref"), "unit": el.get("unit")} # fmt: skip
|
||||
got = dv.group_find(group, slot, ports.get(src.get("box"), {}).get(src.get("name")))
|
||||
if not got["ok"] and not got.get("blank") and el.get("default"):
|
||||
errors.append(f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본으로 셈")
|
||||
got = dv.one_value({"group": group, "ref": el["default"], "unit": el.get("unit")})
|
||||
elif given is not None:
|
||||
return given
|
||||
else:
|
||||
item = {"group": group, "ref": el.get("ref"), "unit": el.get("unit"), "inputs": env}
|
||||
got = dv.one_value(item, ctx["local"])
|
||||
if not got["ok"] and got.get("blank"): # 옛 셈처럼 그 값만 비움(알림) — 셈은 계속
|
||||
ctx["notes"].append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}")
|
||||
return NA_VALUE # 그 값을 쓰는 식은 빈 값(옛 엔진) — 비목 합은 0
|
||||
if not got["ok"]:
|
||||
errors.append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}")
|
||||
return None
|
||||
return got.get("value")
|
||||
|
||||
|
||||
def _fill_template(rule: dict, ports: dict) -> str | None:
|
||||
"""재료 검색어 틀의 자리에 slots 알약 값 — 옛 엔진 fill 그대로(빈 자리 = None)."""
|
||||
env = {}
|
||||
for x in rule.get("slots") or []:
|
||||
src = x.get("from") or {}
|
||||
env[x.get("name")] = ports.get(src.get("box"), {}).get(src.get("name"))
|
||||
if any(v is None for v in env.values()):
|
||||
return None
|
||||
try:
|
||||
return store.cm.mf.fill(str(rule.get("template") or ""), env)
|
||||
except store.cm.mf.FormulaError:
|
||||
return None
|
||||
|
||||
|
||||
def _port(el: dict) -> str:
|
||||
"""요소 알약 이름 — Model `elementPort`(고정 아니면 자리 이름 slot · 없으면 name)."""
|
||||
slot = str(el.get("slot") or "").strip()
|
||||
return slot if el.get("mode") not in (None, "fixed") and slot else el.get("name")
|
||||
|
||||
|
||||
def _input_ports(lg: dict, box: dict, values: dict, ports: dict, errors: list, ctx: dict) -> dict:
|
||||
inp, given, out = box.get("input") or {}, values.get(box["id"]) or {}, {}
|
||||
mode = inp.get("mode")
|
||||
if mode == "logic":
|
||||
return _logic_ports(lg, box, given, ports, errors, ctx)
|
||||
if mode == "group":
|
||||
group = inp.get("group")
|
||||
for el in inp.get("elements") or []:
|
||||
out[_port(el)] = _element_value(
|
||||
group, el, given.get(_port(el)), ports, errors, box, ctx
|
||||
)
|
||||
if inp.get("table"):
|
||||
got = dv.one_value({"group": group, "table": inp["table"]}, ctx["local"])
|
||||
out[TABLE_PORT] = got.get("rows") if got["ok"] else None
|
||||
if not got["ok"]:
|
||||
errors.append(f"「{box.get('name')} · 표」 {got.get('reason')}")
|
||||
for v in inp.get("variables") or []:
|
||||
out[v.get("name")] = given.get(v.get("name"), v.get("example"))
|
||||
return out
|
||||
|
||||
|
||||
def _logic_ports(lg: dict, box: dict, given: dict, ports: dict, errors: list, ctx: dict) -> dict:
|
||||
"""다른 로직 — 받는 알약(설계 값) = 선 · 검증 칸 값 · 없으면 그 로직 예시값 → 그 로직 출력."""
|
||||
ref = (box.get("input") or {}).get("logic") or {}
|
||||
key = str(ref.get("key") or "")
|
||||
if key in ctx["stack"]:
|
||||
errors.append(
|
||||
f"「{box.get('name')}」 로직이 서로를 부름 — {' → '.join([*ctx['stack'], key])}"
|
||||
)
|
||||
return {}
|
||||
try:
|
||||
other = ctx["load"](key)
|
||||
except store.StoreError as e:
|
||||
errors.append(f"「{box.get('name')}」 {e.detail}")
|
||||
return {}
|
||||
designs = {**given}
|
||||
for link in lg.get("links") or []:
|
||||
if link.get("to", {}).get("box") == box["id"]:
|
||||
src = link.get("from") or {}
|
||||
designs[link["to"].get("name")] = ports.get(src.get("box"), {}).get(src.get("name"))
|
||||
if callable(
|
||||
other.get("calc")
|
||||
): # 옛 마스터 로직(흐름 없음) — 옛 셈 · 노무비 · 재료비 · 경비 · 계
|
||||
result = other["calc"](designs)
|
||||
errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]]
|
||||
return result["outputs"]
|
||||
stack = [*ctx["stack"], key]
|
||||
result = run(other, _design_values(other, designs), ctx["load"], ctx["local"], stack)
|
||||
errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]]
|
||||
names = {b["id"]: b.get("name") for b in other.get("boxes") or []}
|
||||
return {names[i]: v for i, v in result["outputs"].items() if i in names}
|
||||
|
||||
|
||||
def _design_values(lg: dict, designs: dict) -> dict:
|
||||
"""다른 로직의 설계 값 {알약 이름: 값} → 그 로직의 values {상자 id: {알약: 값}}."""
|
||||
out: dict[str, dict] = {}
|
||||
for d in design_inputs(lg):
|
||||
if d["port"] in designs and designs[d["port"]] is not None:
|
||||
out.setdefault(d["box"], {})[d["port"]] = designs[d["port"]]
|
||||
return out
|
||||
|
||||
|
||||
# ── 설계 때 정할 것 ────────────────────────────────────────────────────
|
||||
def design_inputs(lg: dict) -> list[dict]:
|
||||
"""변수 · 재료 자리(선택) · 다른 로직의 받는 알약(선이 없을 때) — Model `CfDesign` 꼴."""
|
||||
links = lg.get("links") or []
|
||||
fed = {
|
||||
(ln.get("to") or {}).get("box", "") + "\0" + (ln.get("to") or {}).get("name", "")
|
||||
for ln in links
|
||||
}
|
||||
out = []
|
||||
for box in lg.get("boxes") or []:
|
||||
inp = box.get("input") or {}
|
||||
if box.get("kind") != "input":
|
||||
for v in (box.get("func") or {}).get("variables") or []:
|
||||
out.append(
|
||||
{
|
||||
"box": box["id"],
|
||||
"port": v.get("name"),
|
||||
"kind": "변수",
|
||||
"unit": v.get("unit") or "-",
|
||||
"variable": v,
|
||||
}
|
||||
)
|
||||
continue
|
||||
for v in inp.get("variables") or []:
|
||||
out.append(
|
||||
{
|
||||
"box": box["id"],
|
||||
"port": v.get("name"),
|
||||
"kind": "변수",
|
||||
"unit": v.get("unit") or "-",
|
||||
"variable": v,
|
||||
}
|
||||
)
|
||||
for el in inp.get("elements") or [] if inp.get("group") == "재료" else []:
|
||||
if el.get("mode") == "choice":
|
||||
out.append(
|
||||
{
|
||||
"box": box["id"],
|
||||
"port": _port(el),
|
||||
"kind": "재료 자리",
|
||||
"unit": el.get("unit") or "-",
|
||||
"element": el,
|
||||
}
|
||||
)
|
||||
for pill in (
|
||||
((inp.get("logic") or {}).get("designs") or []) if inp.get("mode") == "logic" else []
|
||||
):
|
||||
if box["id"] + "\0" + pill.get("name", "") not in fed:
|
||||
out.append(
|
||||
{
|
||||
"box": box["id"],
|
||||
"port": pill.get("name"),
|
||||
"kind": "다른 로직",
|
||||
"unit": pill.get("unit") or "-",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── 선 규칙(`M02_DF_Rule.applyRule` 와 같은 뜻 · 값 기준) ────────────────────
|
||||
def _compare(a: Any, op: str, b: Any) -> bool:
|
||||
if op in ("=", "≠"):
|
||||
return _same(a, b) == (op == "=")
|
||||
na, nb = _num_of(a if a is not None else 0), _num_of(b)
|
||||
if na is None or nb is None:
|
||||
sa, sb = str(a or "").lower(), str(b or "").lower()
|
||||
na, nb = (sa, sb) if na is None and nb is None else (None, None)
|
||||
if na is None:
|
||||
return False
|
||||
return {"≥": na >= nb, "≤": na <= nb, ">": na > nb, "<": na < nb}.get(op, False)
|
||||
|
||||
|
||||
def _rounded(value: Any, r: dict) -> Any:
|
||||
"""끝수 — 엔진 ROUND(반올림 · 0 에서 먼 쪽) · ROUNDUP · ROUNDDOWN · 자리는 음수도."""
|
||||
n = _num_of(value)
|
||||
if n is None:
|
||||
return value
|
||||
mode = {"반올림": ROUND_HALF_UP, "올림": ROUND_UP, "버림": ROUND_DOWN}.get(
|
||||
r.get("method"), ROUND_HALF_UP
|
||||
)
|
||||
places = int(r.get("places") or 0)
|
||||
return (
|
||||
n.quantize(Decimal(1).scaleb(-places), rounding=mode)
|
||||
if places >= 0
|
||||
else ((n.scaleb(places).quantize(Decimal(1), rounding=mode)).scaleb(-places))
|
||||
)
|
||||
|
||||
|
||||
def apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]:
|
||||
"""(값, 까닭) — 까닭 = 통과 안 됨 · 표에 없는 조건 · 표에 여럿 맞음 · 오류 · None."""
|
||||
kind = rule.get("kind")
|
||||
if kind == "lookup":
|
||||
return _lookup(rule, value, conds)
|
||||
if kind == "gate":
|
||||
rows, join = _gate_conds(rule)
|
||||
hits = [
|
||||
_compare(_cond_of(conds, c.get("from") or {}), c.get("op"), c.get("value"))
|
||||
for c in rows
|
||||
]
|
||||
ok = any(hits) if join == "또는" else all(hits)
|
||||
return (value, None) if ok else (None, "통과 안 됨")
|
||||
n = _num_of(value)
|
||||
if n is None:
|
||||
return (None, None) if value is None else (None, "오류")
|
||||
if kind == "round":
|
||||
return _rounded(n, rule), None
|
||||
v = Decimal(str(rule.get("value")))
|
||||
op = rule.get("op")
|
||||
if op == "÷" and v == 0:
|
||||
return None, "오류"
|
||||
return {"×": n * v, "÷": n / v, "+%": n * (1 + v / 100), "-%": n * (1 - v / 100)}.get(
|
||||
op, n
|
||||
), None
|
||||
|
||||
|
||||
def _sources(rule: dict | None) -> list[dict]:
|
||||
if not rule:
|
||||
return []
|
||||
if rule.get("kind") == "lookup":
|
||||
return [c.get("from") or {} for c in rule.get("conds") or []]
|
||||
if rule.get("kind") == "gate":
|
||||
return [c.get("from") or {} for c in _gate_conds(rule)[0]]
|
||||
return []
|
||||
|
||||
|
||||
def _gate_conds(rule: dict) -> tuple[list[dict], str]:
|
||||
"""조건 통과 줄 · 잇는 말 — conds · join · 옛 한 줄 꼴은 줄 하나(Model gateConds)."""
|
||||
rows = rule.get("conds") or [
|
||||
{"from": rule.get("from") or {}, "op": rule.get("op"), "value": rule.get("value")}
|
||||
]
|
||||
return rows, rule.get("join") or "그리고"
|
||||
|
||||
|
||||
# ── 계산(`M02_DF_Eval.evalLogic` 와 같은 차례) ─────────────────────────────
|
||||
def _depends(lg: dict) -> list[tuple[str, str]]:
|
||||
"""상자 사이 기댐 — 선 + 선 규칙 조건 알약 + 재료 조건 찾기 알약(Model `depends`)."""
|
||||
out = []
|
||||
for ln in lg.get("links") or []:
|
||||
out.append((ln["from"].get("box"), ln["to"].get("box")))
|
||||
out += [(p.get("box"), ln["to"].get("box")) for p in _sources(ln.get("rule"))]
|
||||
for b in lg.get("boxes") or []:
|
||||
for el in (b.get("input") or {}).get("elements") or []:
|
||||
if el.get("mode") == "rule" and el.get("rule"):
|
||||
rule = el["rule"]
|
||||
srcs = [
|
||||
rule.get("from") or {},
|
||||
*[x.get("from") or {} for x in rule.get("slots") or []],
|
||||
]
|
||||
out += [(p.get("box"), b["id"]) for p in srcs]
|
||||
return out
|
||||
|
||||
|
||||
def _waves(lg: dict) -> tuple[list[list[dict]], list[dict]]:
|
||||
"""차례 물결 — 한 물결 안 상자는 서로 안 기댐(함수 시트를 한 번에 셈) · 돌고 도는 것은 따로."""
|
||||
deps, boxes = _depends(lg), lg.get("boxes") or []
|
||||
left, waves = {b["id"] for b in boxes}, []
|
||||
while left:
|
||||
ready = [
|
||||
b
|
||||
for b in boxes
|
||||
if b["id"] in left and not any(t == b["id"] and f != t and f in left for f, t in deps)
|
||||
]
|
||||
if not ready:
|
||||
break
|
||||
waves.append(ready)
|
||||
left -= {b["id"] for b in ready}
|
||||
return waves, [b for b in boxes if b["id"] in left]
|
||||
|
||||
|
||||
def _sheet_of(box: dict, arrived: dict, given: dict) -> dict:
|
||||
"""함수 상자 시트 — 받는 칸 · 변수 칸에 값(Eval `runFunc` 와 같음)."""
|
||||
func = box["func"]
|
||||
sheet = copy.deepcopy(func.get("sheet") or {})
|
||||
cells = sheet.setdefault("칸", {})
|
||||
for p in func.get("inputs") or []:
|
||||
if str(p.get("cell") or "").strip():
|
||||
port = p.get("port")
|
||||
v = arrived[port] if arrived.get(port) is not None else given.get(port)
|
||||
_place(cells, p["cell"], {"식": "NA()"} if _is_err(v) else v) # 오류 값 · 빈 단가
|
||||
for v in func.get("variables") or []:
|
||||
if v.get("cell"):
|
||||
_place(cells, v["cell"], given.get(v.get("name"), v.get("example")))
|
||||
return sheet
|
||||
|
||||
|
||||
REST = "앞 조건이 안 맞아 쉬는 갈래"
|
||||
NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음)
|
||||
|
||||
|
||||
def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | None = None) -> dict:
|
||||
"""셈 — {ports, outputs, cost, errors, breaks, filled}(filled = 받는 칸에 값만 되돌려 넣음)."""
|
||||
ctx = {"load": load, "local": local, "stack": stack or [lg.get("key") or ""], "notes": [],
|
||||
"env": _env(lg, values)} # fmt: skip
|
||||
boxes = {b["id"]: b for b in lg.get("boxes") or []}
|
||||
links = lg.get("links") or []
|
||||
name = lambda i: boxes.get(i, {}).get("name") or i # noqa: E731
|
||||
errors, breaks, ports, arrived, outputs = [], [], {}, {}, {}
|
||||
blocked: set[str] = set()
|
||||
resting: set[str] = set() # 조건이 안 맞아 쉬는 상자 — Eval 과 같은 뜻
|
||||
late: dict[str, list[dict]] = {} # 함수로 가는 표 찾기 실패 — 결과가 오류일 때만 끊긴 곳
|
||||
cost: dict[str, Any] = {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}}
|
||||
|
||||
def through(ln: dict) -> Any:
|
||||
src = ln.get("from") or {}
|
||||
v = ports.get(src.get("box"), {}).get(src.get("name"))
|
||||
rule = ln.get("rule")
|
||||
if (
|
||||
v == NA_VALUE
|
||||
and (boxes.get(src.get("box"), {}).get("input") or {}).get("mode") == "group"
|
||||
):
|
||||
to = ln["to"] # 빈 단가 — 결과가 오류일 때만 끊긴 곳(Eval NoPrice 와 같음)
|
||||
text = f"「{name(to.get('box'))}」 — 단가 없는 품목을 씀(「{src.get('name')}」)"
|
||||
brk = {"kind": "단가 없는 품목", "level": "문제", "box": to.get("box")}
|
||||
brk |= {"port": to.get("name"), "link": ln.get("id"), "text": text}
|
||||
late.setdefault(to.get("box"), []).append(brk)
|
||||
if not rule:
|
||||
return v
|
||||
if v is None and rule.get("kind") in ("scale", "round"):
|
||||
return None
|
||||
conds = {}
|
||||
for p in _sources(rule):
|
||||
got_v = ports.get(p.get("box"), {}).get(p.get("name"))
|
||||
conds[p.get("name")] = got_v
|
||||
conds[f"{p.get('box')}|{p.get('name')}"] = got_v
|
||||
got, why = apply_rule(rule, v, conds)
|
||||
if why:
|
||||
text = f"「{name(src.get('box'))}」 → 「{name(ln['to'].get('box'))}」 {why}"
|
||||
kind = "오류" if why in ("표에 여럿 맞음", "오류") else why
|
||||
to = ln["to"]
|
||||
brk = {
|
||||
"kind": kind,
|
||||
# 무게 — 통과 안 됨 = 쉼 · 나머지 = 문제(Eval CfBreak.level 과 같음)
|
||||
"level": "쉼" if kind == "통과 안 됨" else "문제",
|
||||
"box": to.get("box"),
|
||||
"port": to.get("name"),
|
||||
"link": ln.get("id"),
|
||||
"text": text,
|
||||
}
|
||||
if (
|
||||
rule.get("kind") == "lookup"
|
||||
and boxes.get(to.get("box"), {}).get("kind") == "function"
|
||||
):
|
||||
late.setdefault(to.get("box"), []).append(brk)
|
||||
return NA_VALUE
|
||||
blocked.add(to.get("box"))
|
||||
if why == "통과 안 됨":
|
||||
resting.add(to.get("box"))
|
||||
breaks.append(brk)
|
||||
if why != "통과 안 됨":
|
||||
errors.append(text)
|
||||
return None if why else got
|
||||
|
||||
def arrive(box: dict) -> dict:
|
||||
"""받는 값 — 쉬는 갈래 빈 값은 「쉼」 줄만 · 선이 모두 쉬면 상자도 쉼(Eval 과 같음)."""
|
||||
ins = [ln for ln in links if ln["to"].get("box") == box["id"]]
|
||||
got, rest = {}, 0
|
||||
for ln in ins:
|
||||
src, to = ln.get("from") or {}, ln["to"]
|
||||
if (
|
||||
src.get("box") in resting
|
||||
and ports.get(src.get("box"), {}).get(src.get("name")) is None
|
||||
):
|
||||
rest += 1
|
||||
got[to.get("name")] = None
|
||||
breaks.append(
|
||||
{
|
||||
"kind": "통과 안 됨",
|
||||
"level": "쉼",
|
||||
"box": to.get("box"),
|
||||
"port": to.get("name"),
|
||||
"link": ln.get("id"),
|
||||
"text": f"「{name(src.get('box'))}」 → 「{name(to.get('box'))}」 — {REST}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
got[to.get("name")] = through(ln)
|
||||
if ins and rest == len(ins):
|
||||
resting.add(box["id"])
|
||||
blocked.add(box["id"])
|
||||
arrived[box["id"]] = got
|
||||
return got
|
||||
|
||||
waves, loop = _waves(lg)
|
||||
errors += [f"「{b.get('name')}」 상자가 서로를 기다림(돌고 도는 선)" for b in loop]
|
||||
sheets: dict[str, dict] = {}
|
||||
for wave in waves:
|
||||
funcs = []
|
||||
for b in wave:
|
||||
given = values.get(b["id"]) or {}
|
||||
if b.get("kind") == "input" and (b.get("input") or {}).get("mode") == "logic":
|
||||
ports[b["id"]] = _logic_ports(lg, b, {**given, **arrive(b)}, ports, errors, ctx)
|
||||
elif b.get("kind") == "input":
|
||||
ports[b["id"]] = _input_ports(lg, b, values, ports, errors, ctx)
|
||||
elif b.get("kind") == "function" and b.get("func"):
|
||||
sheet = _sheet_of(b, arrive(b), given)
|
||||
sheets[b["id"]] = sheet
|
||||
if b["id"] in blocked:
|
||||
ports[b["id"]] = {b["func"]["output"].get("name"): None}
|
||||
else:
|
||||
funcs.append(b)
|
||||
elif b.get("kind") == "output":
|
||||
outputs[b["id"]] = arrive(b).get(OUTPUT_PORT)
|
||||
if funcs: # 이 물결의 함수 시트를 한 번에(시트마다 통합문서 하나 — Eval 과 같음)
|
||||
books = [
|
||||
m01._num(
|
||||
{"종류": "통합문서", "판": 1, "서식": [{}] * 64, "시트": [sheets[f["id"]]]}
|
||||
)
|
||||
for f in funcs
|
||||
]
|
||||
try:
|
||||
calcs = spreadsheet.recalc_workbooks(books)
|
||||
except spreadsheet.RecalcError as e:
|
||||
raise store.StoreError(503, str(e)) from e
|
||||
for f, book, calc in zip(funcs, books, calcs):
|
||||
sheet = book["시트"][0]
|
||||
for cell, v in (calc.get(sheet.get("id")) or {}).items():
|
||||
if "오류" in v and (sheet.get("칸") or {}).get(cell, {}).get("식") != "NA()":
|
||||
errors.append(f"「{f.get('name')}」 {cell} 칸 계산 오류 {v['오류']}")
|
||||
out = f["func"].get("output") or {}
|
||||
at = _at(out.get("cell") or "A1")[2]
|
||||
hit = (calc.get(sheet.get("id")) or {}).get(at)
|
||||
value = (
|
||||
hit["오류"] # 오류 값을 넘김(Eval fromCalc 와 같음 · 받는 칸은 그 글/NA())
|
||||
if isinstance(hit, dict) and "오류" in hit
|
||||
else _plain(hit)
|
||||
if hit is not None
|
||||
else ((sheet.get("칸") or {}).get(at) or {}).get("값")
|
||||
)
|
||||
if out.get("round") and _num_of(value) is not None:
|
||||
value = _rounded(value, out["round"])
|
||||
ports[f["id"]] = {out.get("name"): value}
|
||||
if value is None or (isinstance(hit, dict) and "오류" in hit):
|
||||
for brk in late.get(f["id"], []):
|
||||
breaks.append(brk)
|
||||
errors.append(brk["text"])
|
||||
for b in boxes.values(): # 출력 상자 · 원가(Eval 과 같음)
|
||||
if b.get("kind") != "output":
|
||||
continue
|
||||
value = outputs.get(b["id"])
|
||||
value = None if isinstance(value, list) else value
|
||||
if (b.get("output") or {}).get("round") and _num_of(value) is not None:
|
||||
value = _rounded(value, b["output"]["round"])
|
||||
outputs[b["id"]] = value
|
||||
kind = (b.get("output") or {}).get("cost") or "값"
|
||||
if kind == "값":
|
||||
cost["값들"][b.get("name")] = value
|
||||
elif _num_of(value) is not None:
|
||||
cost[kind] += _num_of(value)
|
||||
elif value is not None:
|
||||
errors.append(f"「{b.get('name')}」 출력 값이 수가 아님")
|
||||
cost["계"] = sum(cost[k] for k in COSTS)
|
||||
filled = copy.deepcopy(lg) # 받는 칸 · 변수 칸에 값만 되돌려 넣음
|
||||
for b in filled.get("boxes") or []:
|
||||
if b["id"] in sheets:
|
||||
b["func"]["sheet"] = sheets[b["id"]]
|
||||
return {"ports": ports, "arrived": arrived, "outputs": outputs, "cost": cost, "errors": errors,
|
||||
"breaks": breaks, "notes": ctx["notes"], "filled": filled} # fmt: skip
|
||||
|
||||
|
||||
def _env(lg: dict, values: dict) -> dict:
|
||||
"""입력 상자 변수 값(설계 값 · 없으면 예시값) — 재료 찾기가 읽는 로직 입력."""
|
||||
return {
|
||||
v.get("name"): (values.get(b["id"]) or {}).get(v.get("name"), v.get("example"))
|
||||
for b in lg.get("boxes") or []
|
||||
if b.get("kind") == "input"
|
||||
for v in (b.get("input") or {}).get("variables") or []
|
||||
if v.get("name")
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
"""M02 단가산출 흐름도 — 옛 로직 줄 → 흐름도(CfLogic) 변환 (PLAN 36-2 · 흐름도_적용_정함 2 · 3).
|
||||
|
||||
옛 식은 지금 서버 파서(`master_formula.parse` · `store.cm.mf`)로 나무를 읽어 함수 상자 시트로
|
||||
· 이름마다 받는 알약 → 칸(B2 · B3 …) · 식은 칸 주소 · 함수 상자 하나 = 결과 하나.
|
||||
|
||||
입력 → 입력 상자 「설계 값」(변수 · 고르기 = choice · 범위)
|
||||
중간 · 호표 수량 × 단가 · 덧줄 · 결과 → 함수 상자 · 비목 합 → 출력 상자(비목 셋 · 결과 = 값)
|
||||
호표 단가 · 식 속 요소 키 → 그룹 입력 상자(인력 · 재료 · 기계 · 유가전력 · 환율 · 요율) 요소 알약
|
||||
· 키에 {입력} 이 끼면 조건 찾기(field 「키」 · 같음 · from = 입력 알약 · 자리 여럿 = 글 잇는 함수)
|
||||
찾기(표, 조건=값).칸 → 표 입력 상자 「표」 알약 + 선 규칙 「표에서 찾기」(lookup)
|
||||
로직(키, 넘길 값=…) → 다른 로직 입력 상자(받는 알약 = 넘길 값 · 내보내는 알약 = 비목 · 결과)
|
||||
만약 → IF · 그리고 / 또는 → AND / OR · 최소 / 최대 → MIN / MAX
|
||||
· 올림 / 버림 / 반올림 → ROUNDUP / ROUNDDOWN / ROUND
|
||||
자동 배치 — 입력 왼쪽 → 함수 가운데(기댐 깊이마다 한 칸) → 출력 오른쪽 · 칸마다 위에서 아래로
|
||||
|
||||
부르는 꼴 — convert(row, ctx={key, file, master}) → {flow, notes} · row = 옛 로직 줄 · 파일 안 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
mf = store.cm.mf
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
OUTPUT_PORT = "값"
|
||||
TABLE_PORT = "표"
|
||||
COLORS = {"input": "#2f80ed", "function": "#7b4fd6", "output": "#27ae60"}
|
||||
GROUP_OF = {"LB": "인력", "MT": "재료", "MP": "재료", "MO": "유가전력", "EQ": "기계"}
|
||||
GROUP_OF |= {"FX": "환율", "RT": "요율"}
|
||||
FUNCS = {"만약": "IF", "최소": "MIN", "최대": "MAX", "올림": "ROUNDUP", "버림": "ROUNDDOWN"}
|
||||
FUNCS["반올림"] = "ROUND"
|
||||
OPS = {"==": "=", "!=": "<>"}
|
||||
JOINS = {"그리고": "AND", "또는": "OR"}
|
||||
_HOLE = re.compile(r"\{([^{}]+)\}")
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
"""옮기지 못하는 꼴 — 쉬운 말 까닭."""
|
||||
|
||||
|
||||
def plain(v: Any) -> Any:
|
||||
"""JSON 값 — Decimal → int · float."""
|
||||
if isinstance(v, Decimal):
|
||||
return int(v) if v == v.to_integral_value() else float(v)
|
||||
if isinstance(v, (list, tuple)):
|
||||
return [plain(x) for x in v]
|
||||
if isinstance(v, dict):
|
||||
return {k: plain(x) for k, x in v.items()}
|
||||
return v
|
||||
|
||||
|
||||
def literal(v: Any) -> str:
|
||||
if isinstance(v, str):
|
||||
return '"' + v.replace('"', '""') + '"'
|
||||
if isinstance(v, Decimal):
|
||||
return format(v.normalize(), "f")
|
||||
return str(plain(v))
|
||||
|
||||
|
||||
class _Flow:
|
||||
"""흐름 한 벌 — 상자 · 선 · 못 옮긴 곳."""
|
||||
|
||||
def __init__(self, row: dict, master: Any):
|
||||
self.row = row
|
||||
self.master = master
|
||||
self.specs = {s["이름"]: s for s in row.get("입력") or []}
|
||||
self.boxes: dict[str, dict] = {}
|
||||
self.links: list[dict] = []
|
||||
self.notes: list[str] = []
|
||||
self.named: dict[str, dict] = {} # 중간 · 덧줄 이름 → 내보내는 알약
|
||||
self.cache: dict[str, dict] = {} # 같은 요소 · 표 · 로직 · 값 → 알약(하나만)
|
||||
|
||||
# ── 상자 · 선 ─────────────────────────────────────────────────────
|
||||
def box(self, kind: str, name: str, **body: Any) -> dict:
|
||||
bid = f"{kind[:2]}-{len(self.boxes) + 1}"
|
||||
self.boxes[bid] = {"id": bid, "kind": kind, "name": name, "color": COLORS[kind]}
|
||||
self.boxes[bid] |= {"unit": "-", "x": 0, "y": 0, **body}
|
||||
return self.boxes[bid]
|
||||
|
||||
def link(self, src: dict, to: dict, rule: dict | None = None) -> None:
|
||||
one = {"id": f"l{len(self.links) + 1}", "from": dict(src), "to": dict(to)}
|
||||
self.links.append(one | ({"rule": rule} if rule else {}))
|
||||
|
||||
def design(self) -> None:
|
||||
"""설계 값 상자 — 예시값 = 줄의 시험입력 · 없으면 왼쪽 시험 계산 기본값(master_auto)."""
|
||||
given = store.mau.values(self.master, self.row)
|
||||
given.update(self.row.get("시험입력") or {})
|
||||
for (
|
||||
k,
|
||||
v,
|
||||
) in given.items(): # 글로 적힌 수 → 수(고르기 값은 그대로 · 옛 엔진 _inputs 와 같음)
|
||||
spec = self.specs.get(k) or {}
|
||||
if isinstance(v, str) and "고르기" not in spec:
|
||||
try:
|
||||
given[k] = Decimal(v.strip())
|
||||
except ArithmeticError:
|
||||
pass
|
||||
variables = []
|
||||
for s in self.row.get("입력") or []:
|
||||
pick = s.get("고르기")
|
||||
one = {"name": s["이름"], "type": "choice" if pick else "number"}
|
||||
one |= {"unit": str(s.get("단위") or "-"), "example": plain(given.get(s["이름"]))}
|
||||
if pick:
|
||||
one["choices"] = plain(pick)
|
||||
if "범위" in s:
|
||||
one["range"] = plain(s["범위"])
|
||||
variables.append(one)
|
||||
box = {"id": "in-design", "kind": "input", "name": "설계 값", "color": COLORS["input"]}
|
||||
inp = {"mode": "variable", "elements": [], "variables": variables}
|
||||
self.boxes["in-design"] = box | {"unit": "-", "x": 0, "y": 0, "input": inp}
|
||||
|
||||
# ── 바깥 값 알약 ──────────────────────────────────────────────────
|
||||
def element(
|
||||
self, group: str, ref: str, unit: str, label: str, rule: dict | None = None, **more: Any
|
||||
) -> dict:
|
||||
"""그룹 입력 상자의 요소 알약 — 그룹마다 상자 하나 · 같은 ref · 단위 · 조건이면 알약 하나
|
||||
· rule = 조건 찾기(CfMaterialRule · 알약 값은 Model depends 로 · 선 없음) · more = scope."""
|
||||
key = f"el|{group}|{ref}|{unit}|{json.dumps(rule, ensure_ascii=False, sort_keys=True)}"
|
||||
if key in self.cache:
|
||||
return self.cache[key]
|
||||
gkey = f"group|{group}"
|
||||
if gkey not in self.cache:
|
||||
inp = {"mode": "group", "group": group, "elements": [], "variables": []}
|
||||
self.cache[gkey] = self.box("input", group, input=inp)
|
||||
els = self.cache[gkey]["input"]["elements"]
|
||||
name = label
|
||||
while any(e["name"] == name or e.get("slot") == name for e in els):
|
||||
name += "·"
|
||||
one = {"ref": ref, "name": name, "spec": "", "unit": unit or "-", "mode": "fixed"}
|
||||
if rule:
|
||||
one |= {"mode": "rule", "rule": rule, "slot": name, **more}
|
||||
els.append(one)
|
||||
self.cache[key] = {"box": self.cache[gkey]["id"], "name": name}
|
||||
return self.cache[key]
|
||||
|
||||
def priced(self, group: str, ref: Any, unit: str, label: str, field: str | None = None):
|
||||
"""요소 → 식 마디 「@port」 — 키에 {입력} 이 끼면 조건 찾기(field 키 · 같음 · from 알약)
|
||||
· 자리 표시가 여럿이면 함수 상자가 글을 이어 만든 알약 하나로(정함 · 조사 ③)."""
|
||||
if isinstance(ref, dict):
|
||||
texts = {k: v for k, v in ref.items() if isinstance(v, str) and _HOLE.search(v)}
|
||||
else:
|
||||
texts = {None: ref} if _HOLE.search(ref) else {}
|
||||
if not texts:
|
||||
text = ref if isinstance(ref, str) else json.dumps(plain(ref), ensure_ascii=False)
|
||||
return ("@port", self.element(group, self._key(text, field), unit, label))
|
||||
if len(texts) > 1:
|
||||
raise Unsupported(f"자리 표시가 여러 칸에 낀 요소 「{ref}」")
|
||||
((at, text),) = texts.items()
|
||||
if at == "검색어": # 재료 검색어 틀 — 자리마다 알약 · 서버는 옛 검색어 찾기(정함 · sub4)
|
||||
names = list(dict.fromkeys(_HOLE.findall(text)))
|
||||
slots = [{"name": n, "from": self.hole(n)} for n in names]
|
||||
rule = {"field": "검색어", "op": "같음", "from": slots[0]["from"]}
|
||||
rule |= {"template": text, "slots": slots}
|
||||
scope = {k: str(v) for k, v in ref.items() if k in ("구분", "상세구분") and v}
|
||||
tpl = json.dumps(plain(ref), ensure_ascii=False)
|
||||
return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule, scope=scope))
|
||||
# 찾는 값 = 원문번호(「EQ:{기계}」 → 기계 · 「MO:경유:{지역}」 → "경유:"&지역)
|
||||
want = text.partition(":")[2] if at is None and ":" in text else text
|
||||
if _HOLE.fullmatch(want):
|
||||
src = self.hole(want[1:-1])
|
||||
else:
|
||||
src = self.func(f"{label} 찾는 말", ("@text", want))
|
||||
tpl = text if at is None else json.dumps(plain(ref), ensure_ascii=False)
|
||||
tpl += f"#{field}" if field else ""
|
||||
rule = {"field": "키", "op": "같음", "from": src}
|
||||
return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule))
|
||||
|
||||
def hole(self, name: str) -> dict:
|
||||
"""자리 표시 {이름} 의 값 알약 — 입력 · 중간."""
|
||||
if name in self.specs:
|
||||
return {"box": "in-design", "name": name}
|
||||
if name in self.named:
|
||||
return self.named[name]
|
||||
raise Unsupported(f"자리 표시 {{{name}}} 의 값이 없음")
|
||||
|
||||
def _key(self, ref: str, field: str | None) -> str:
|
||||
"""「ID:원문번호」 → 키 · 기계 칸은 「EQ키#칸」(입력 판 꼴)."""
|
||||
if ":" in ref and not ref.lstrip().startswith("{"):
|
||||
ref = str(self.master.get(ref).get("키"))
|
||||
if field and ref[:2] != "EQ":
|
||||
raise Unsupported(f"「{ref}」 의 칸 「{field}」 읽기")
|
||||
return f"{ref}#{field}" if field else ref
|
||||
|
||||
def table(self, key: str) -> dict:
|
||||
if f"tb|{key}" not in self.cache:
|
||||
group = "소요량" if key[:2] in ("QF", "QC") else "계수"
|
||||
inp = {"mode": "group", "group": group, "elements": [], "variables": []}
|
||||
box = self.box("input", f"표 {key}", input=inp | {"table": {"key": key}})
|
||||
self.cache[f"tb|{key}"] = {"box": box["id"], "name": TABLE_PORT}
|
||||
return self.cache[f"tb|{key}"]
|
||||
|
||||
def logic(self, node: tuple) -> str:
|
||||
"""로직(키, 넘길 값) → 다른 로직 입력 상자 id(같은 부르기는 하나)."""
|
||||
key = "lg|" + repr(node)
|
||||
if key not in self.cache:
|
||||
head = node[1]
|
||||
if "{" in head:
|
||||
raise Unsupported(f"입력 따라 바뀌는 로직 「{head}」")
|
||||
ref = str(self.master.logic(head).get("키"))
|
||||
outs = [{"name": n, "unit": "원"} for n in (*COSTS, "결과")]
|
||||
designs = [{"name": k, "unit": "-"} for k, _ in node[2]]
|
||||
logic = {"key": ref, "outputs": outs, "designs": designs}
|
||||
box = self.box("input", f"로직 {ref}", input={"mode": "logic", "elements": []})
|
||||
box["input"] |= {"variables": [], "logic": logic}
|
||||
for k, v in node[2]:
|
||||
self.link(self.value(v), {"box": box["id"], "name": k})
|
||||
self.cache[key] = {"box": box["id"]}
|
||||
return self.cache[key]["box"]
|
||||
|
||||
def value(self, node: tuple) -> dict:
|
||||
"""조건 · 넘길 값 → 내보내는 알약(입력 · 중간 · 아니면 함수 상자 · 같은 식은 하나)."""
|
||||
if node[0] == "name" and node[1] in self.specs:
|
||||
return {"box": "in-design", "name": node[1]}
|
||||
if node[0] == "name" and node[1] in self.named:
|
||||
return self.named[node[1]]
|
||||
key = "val|" + repr(node)
|
||||
if key not in self.cache:
|
||||
label = literal(node[1]).strip('"') if node[0] in ("num", "str") else "값"
|
||||
self.cache[key] = self.func(label, node)
|
||||
return self.cache[key]
|
||||
|
||||
# ── 함수 상자 ─────────────────────────────────────────────────────
|
||||
def func(self, name: str, node: tuple, scope: dict | None = None) -> dict:
|
||||
"""식 하나 → 함수 상자(결과 하나) · 받는 알약마다 B2 · B3 … · 결과 칸은 그 아래."""
|
||||
bid = f"fn-{len(self.boxes) + 1}"
|
||||
self.boxes[bid] = {} # 자리 먼저 — 안에서 만드는 상자와 id 가 겹치지 않게
|
||||
inputs: list[dict] = []
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def take(src: dict, label: str, rule: dict | None = None) -> str:
|
||||
k = json.dumps([src, rule], ensure_ascii=False, sort_keys=True, default=str)
|
||||
if k not in seen:
|
||||
port = label
|
||||
while any(i["port"] == port for i in inputs):
|
||||
port += "·"
|
||||
seen[k] = f"B{len(inputs) + 2}"
|
||||
inputs.append({"port": port, "cell": seen[k]})
|
||||
self.link(src, {"box": bid, "name": port}, rule)
|
||||
return seen[k]
|
||||
|
||||
formula = self.excel(node, take, scope or {})
|
||||
out = f"B{len(inputs) + 2}"
|
||||
cells: dict[str, Any] = {"A1": {"값": "알약"}, "B1": {"값": "값"}}
|
||||
for i in inputs:
|
||||
cells["A" + i["cell"][1:]] = {"값": i["port"]}
|
||||
cells["A" + out[1:]] = {"값": name}
|
||||
cells[out] = {"식": formula}
|
||||
sheet = {"id": f"s-{bid}", "이름": name[:31] or bid, "칸": cells}
|
||||
func = {"sheet": sheet, "inputs": inputs, "variables": []}
|
||||
func["output"] = {"cell": out, "name": name, "unit": "-"}
|
||||
body = {"id": bid, "kind": "function", "name": name, "color": COLORS["function"]}
|
||||
self.boxes[bid] = body | {"unit": "-", "x": 0, "y": 0, "func": func}
|
||||
return {"box": bid, "name": name}
|
||||
|
||||
def excel(self, node: tuple, take, scope: dict) -> str: # noqa: C901 — 마디 갈래 그대로
|
||||
kind = node[0]
|
||||
if kind in ("num", "str"):
|
||||
return literal(node[1])
|
||||
if kind == "neg":
|
||||
return f"-({self.excel(node[1], take, scope)})"
|
||||
if kind == "bin":
|
||||
a, b = (self.excel(x, take, scope) for x in node[2:4])
|
||||
if node[1] in JOINS:
|
||||
return f"{JOINS[node[1]]}({a},{b})"
|
||||
return f"({a}{OPS.get(node[1], node[1])}{b})"
|
||||
if kind == "call":
|
||||
items = [self.excel(x, take, scope) for x in node[2]]
|
||||
if node[1] in ("올림", "버림", "반올림") and len(items) == 1:
|
||||
items.append("0")
|
||||
return f"{FUNCS[node[1]]}({','.join(items)})"
|
||||
if kind == "name":
|
||||
return self.name(node[1], take, scope)
|
||||
if kind == "ref":
|
||||
group = GROUP_OF.get(node[1][:2], "기계")
|
||||
return self.excel(self.priced(group, node[1], "", node[1]), take, scope)
|
||||
if kind == "field":
|
||||
return self.field(node, take, scope)
|
||||
if kind == "logic":
|
||||
return take({"box": self.logic(node), "name": "결과"}, f"{node[1]} 결과")
|
||||
if kind == "@port":
|
||||
return take(node[1], node[1]["name"])
|
||||
if kind == "@sum0": # 비목 합 — 빈 줄(빈 단가 · 못 셈 덧줄)은 0(옛 엔진 sums += 0)
|
||||
cells = [f"IFERROR({take(p, p['name'])},0)" for p in node[1]]
|
||||
return f"SUM({','.join(cells)})" if cells else "0"
|
||||
if kind == "@line": # 호표 줄 수량 × 단가 — 수량 0 이면 0(옛 엔진 · 빈 단가여도)
|
||||
q, p = self.excel(node[1], take, scope), self.excel(node[2], take, scope)
|
||||
return f"IF({q}=0,0,{q}*{p})"
|
||||
if kind == "@sum":
|
||||
cells = [take(p, p["name"]) for p in node[1]]
|
||||
return f"SUM({','.join(cells)})" if cells else "0"
|
||||
if kind == "@text": # 자리 표시 글 「레미콘 {a}-{b}」 → "레미콘 "&B2&"-"&B3
|
||||
bits = _HOLE.split(node[1])
|
||||
out = [literal(b) if i % 2 == 0 else take(self.hole(b), b) for i, b in enumerate(bits)]
|
||||
return "&".join(x for x in out if x != '""') or '""'
|
||||
raise Unsupported(f"모르는 식 마디 「{kind}」")
|
||||
|
||||
def name(self, name: str, take, scope: dict) -> str:
|
||||
if name in scope: # 덧줄의 비목 합(빈 줄 = 0) · 줄 금액(빈 줄이면 빈 값 — 옛 env 「줄」)
|
||||
return self.excel(("@sum0" if name in COSTS else "@sum", scope[name]), take, scope)
|
||||
if name in self.named:
|
||||
return take(self.named[name], name)
|
||||
if name in self.specs:
|
||||
return take({"box": "in-design", "name": name}, name)
|
||||
raise Unsupported(f"모르는 이름 「{name}」")
|
||||
|
||||
def field(self, node: tuple, take, scope: dict) -> str:
|
||||
base, col = node[1], node[2]
|
||||
if base[0] == "find":
|
||||
if "{" in base[1]:
|
||||
raise Unsupported(f"입력 따라 바뀌는 표 「{base[1]}」")
|
||||
key = str(self.master.get(base[1]).get("키"))
|
||||
conds = [{"col": k, "from": self.value(v)} for k, v in base[2]]
|
||||
return take(self.table(key), col, {"kind": "lookup", "conds": conds, "col": col})
|
||||
if base[0] == "ref":
|
||||
group = GROUP_OF.get(base[1][:2], "기계")
|
||||
node2 = self.priced(group, base[1], "", f"{base[1]} {col}", col)
|
||||
return self.excel(node2, take, scope)
|
||||
if base[0] == "logic" and col == "계": # 다른 로직 출력은 비목 셋 — 계 = 그 합
|
||||
bid = self.logic(base)
|
||||
return self.excel(("@sum", [{"box": bid, "name": k} for k in COSTS]), take, scope)
|
||||
if base[0] == "logic":
|
||||
return take({"box": self.logic(base), "name": col}, f"{base[1]} {col}")
|
||||
if base[0] == "name" and base[1] == "줄" and f"줄.{col}" in scope:
|
||||
return self.name(f"줄.{col}", take, scope)
|
||||
raise Unsupported(f"칸 읽기 「{col}」")
|
||||
|
||||
|
||||
# ── 한 로직 ──────────────────────────────────────────────────────────
|
||||
def convert(row: dict, ctx: dict | None = None) -> dict:
|
||||
"""옛 로직 줄 → {flow: CfLogic, notes: 못 옮긴 곳} · ctx = {key, file, master}(M01 저장소
|
||||
/api/m01/logic/flow) · master 가 없으면 저장소가 읽어 둔 것."""
|
||||
master = (ctx or {}).get("master") or store.loaded()[1]
|
||||
f = _Flow(row, master)
|
||||
f.design()
|
||||
try:
|
||||
_body(f, row)
|
||||
except Unsupported as e:
|
||||
f.notes.append(f"못 옮김 — {e}")
|
||||
except mf.FormulaError as e:
|
||||
f.notes.append(f"못 옮김 — {e}")
|
||||
f.boxes = {k: b for k, b in f.boxes.items() if b} # 못 옮겨 비운 자리
|
||||
_layout(f)
|
||||
flow = {"key": row.get("키"), "name": row.get("이름") or ""}
|
||||
flow |= {"resultUnit": row.get("결과단위") or "", "boxes": list(f.boxes.values())}
|
||||
flow |= {"links": f.links, "userElements": []}
|
||||
flow["totalRound"] = None # 옛 계는 끝수 없음(줄.끝수 묶음은 출력 상자 끝수로 실음)
|
||||
return {"flow": flow, "notes": list(dict.fromkeys(f.notes))}
|
||||
|
||||
|
||||
def _output(f: _Flow, name: str, cost: str, src: dict, rule: dict | None, on: str) -> None:
|
||||
out = {"cost": cost, "unit": "원" if cost != "값" else "-"}
|
||||
if rule and rule.get("대상") == on:
|
||||
out["round"] = {"places": int(rule.get("자리") or 0), "method": str(rule.get("방법"))}
|
||||
box = f.box("output", name, output=out)
|
||||
f.link(src, {"box": box["id"], "name": OUTPUT_PORT})
|
||||
|
||||
|
||||
def _body(f: _Flow, row: dict) -> None:
|
||||
rule = row.get("끝수") if isinstance(row.get("끝수"), dict) else None
|
||||
for step in row.get("중간") or []:
|
||||
f.named[step["이름"]] = f.func(step["이름"], mf.parse(step["식"]))
|
||||
if "결과" in row:
|
||||
_output(f, "결과", "값", f.func("결과", mf.parse(row["결과"]["식"])), rule, "결과")
|
||||
return
|
||||
parts: dict[str, list[dict]] = {c: [] for c in COSTS}
|
||||
lines: dict[str, list[dict]] = {}
|
||||
for i, item in enumerate(row.get("호표") or []):
|
||||
name = str(item.get("이름") or f"줄 {i + 1}")
|
||||
qty = mf.parse(str(item["수량"]))
|
||||
if item.get("종류") == "로직":
|
||||
call = mf.parse(str(item["요소"]))
|
||||
if call[0] != "logic":
|
||||
raise Unsupported(f"「{name}」 단가가 로직 부르기 꼴 아님")
|
||||
bid = f.logic(call)
|
||||
whole = ("@sum", [{"box": bid, "name": k} for k in COSTS])
|
||||
for c in [item["비목"]] if item.get("비목") else COSTS:
|
||||
price = whole if item.get("비목") else ("@port", {"box": bid, "name": c})
|
||||
part = f.func(f"{name} {c}", ("bin", "*", qty, price))
|
||||
parts[c].append(part)
|
||||
lines.setdefault(f"줄.{name}", []).append(part)
|
||||
continue
|
||||
unit = str(item.get("단위") or "")
|
||||
ref = item["요소"] # 그룹 = 키 앞 두 글자(「MO:경유:{지역}」 = 유가전력) · 없으면 줄 종류
|
||||
group = GROUP_OF.get(ref[:2], str(item.get("종류"))) if isinstance(ref, str) else "재료"
|
||||
price = f.priced(group, ref, unit, f"{name} 단가")
|
||||
part = f.func(f"{name} 금액", ("@line", qty, price))
|
||||
parts[item["비목"]].append(part)
|
||||
lines.setdefault(f"줄.{name}", []).append(part)
|
||||
for extra in row.get("덧줄") or []:
|
||||
scope = {c: list(parts[c]) for c in COSTS} | lines
|
||||
part = f.func(extra["이름"], mf.parse(extra["식"]), scope)
|
||||
f.named[extra["이름"]] = part
|
||||
parts[extra["비목"]].append(part)
|
||||
for c in COSTS:
|
||||
if parts[c]:
|
||||
_output(f, c, c, f.func(f"{c} 합", ("@sum0", parts[c])), rule, "계")
|
||||
|
||||
|
||||
# ── 자동 배치 ────────────────────────────────────────────────────────
|
||||
def _layout(f: _Flow) -> None:
|
||||
"""입력 왼쪽 → 함수 가운데(기댐 깊이마다 한 칸) → 출력 오른쪽 · 칸마다 위→아래(겹침 없음)."""
|
||||
feeds: dict[str, set[str]] = {}
|
||||
for ln in f.links:
|
||||
srcs = [ln["from"], *[c["from"] for c in (ln.get("rule") or {}).get("conds", [])]]
|
||||
feeds.setdefault(ln["to"]["box"], set()).update(s["box"] for s in srcs)
|
||||
for bid, b in f.boxes.items(): # 조건 찾기 요소 — 값을 주는 알약 상자 뒤
|
||||
for e in (b.get("input") or {}).get("elements") or []:
|
||||
rule = e.get("rule") or {}
|
||||
srcs = [rule.get("from"), *[x["from"] for x in rule.get("slots") or []]]
|
||||
feeds.setdefault(bid, set()).update(p["box"] for p in srcs if p)
|
||||
depth: dict[str, int] = {}
|
||||
|
||||
def level(bid: str, path: frozenset = frozenset()) -> int:
|
||||
if bid not in depth:
|
||||
ins = [x for x in feeds.get(bid, ()) if x not in path and x in f.boxes]
|
||||
depth[bid] = 1 + max((level(x, path | {bid}) for x in ins), default=-1)
|
||||
return depth[bid]
|
||||
|
||||
for bid in f.boxes:
|
||||
level(bid)
|
||||
last = max(depth.values(), default=0) + 1
|
||||
rows: dict[int, int] = {}
|
||||
for bid, box in f.boxes.items():
|
||||
col = last if box["kind"] == "output" else depth[bid]
|
||||
box["x"], box["y"] = 40 + col * 300, 40 + rows.get(col, 0) * 170
|
||||
rows[col] = rows.get(col, 0) + 1
|
||||
@@ -1,140 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Detail.ts
|
||||
* 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 끊김 글 · 흐름 줄 · DOM 없음
|
||||
* 셈은 모델 것 그대로(Eval 의 evalLogic · breaks · Model 의 traceBack · traceForward · Rule 의 ruleLabel)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
breaks,
|
||||
evalLogic,
|
||||
type CfBreak,
|
||||
type CfEvalOptions,
|
||||
type CfEvalResult,
|
||||
type CfValue,
|
||||
type CfValues,
|
||||
} from "./M02_DF_Eval";
|
||||
import {
|
||||
portDir,
|
||||
portsOut,
|
||||
traceBack,
|
||||
traceForward,
|
||||
type CfBox,
|
||||
type CfGateOp,
|
||||
type CfLink,
|
||||
type CfLogic,
|
||||
type CfPort,
|
||||
type CfRule,
|
||||
type CfStep,
|
||||
} from "./M02_DF_Model";
|
||||
import { ruleLabel } from "./M02_DF_Rule";
|
||||
|
||||
const TEXT = {
|
||||
Table: ["표 {n}줄", "table, {n} rows"],
|
||||
Empty: ["값 없음", "no value"],
|
||||
NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"],
|
||||
} as const;
|
||||
|
||||
export const td = (key: keyof typeof TEXT, fill: Record<string, string | number> = {}): string =>
|
||||
Object.entries(fill).reduce(
|
||||
(s, [k, v]) => s.replace(`{${k}}`, String(v)),
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
|
||||
);
|
||||
|
||||
export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"];
|
||||
export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"];
|
||||
|
||||
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
|
||||
export interface DetailOptions {
|
||||
values?: CfValues;
|
||||
logics?: CfEvalOptions["logics"];
|
||||
}
|
||||
|
||||
export const boxOf = (lg: CfLogic, id: string): CfBox | undefined =>
|
||||
lg.boxes.find((b) => b.id === id);
|
||||
|
||||
export const portText = (lg: CfLogic, p: CfPort): string =>
|
||||
`${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`;
|
||||
|
||||
/** 계산 한 번 — 한창 만드는 중 막히면 null */
|
||||
export function evalSafe(lg: CfLogic, o: DetailOptions = {}): CfEvalResult | null {
|
||||
try {
|
||||
return evalLogic(lg, o.values, { logics: o.logics });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined =>
|
||||
ev?.ports[p.box]?.[p.name];
|
||||
|
||||
/** 숫자 글 — 천 단위 쉼표 · 군더더기 소수는 뗌 */
|
||||
export const commas = (n: number): string =>
|
||||
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 화면에 보일 값 글 — 표는 줄 수 */
|
||||
export function showValue(v: CfValue | undefined): string {
|
||||
if (v === undefined || v === null) return td("Empty");
|
||||
if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) });
|
||||
return typeof v === "number" ? commas(v) : String(v);
|
||||
}
|
||||
|
||||
export function unitOf(lg: CfLogic, p: CfPort): string {
|
||||
const box = boxOf(lg, p.box);
|
||||
return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-";
|
||||
}
|
||||
|
||||
/** 알약이 나가는(out) 쪽인가 */
|
||||
export const isOut = (box: CfBox, name: string): boolean => portDir(box, name) === "out";
|
||||
|
||||
/** 선 위 규칙 글 */
|
||||
export const labelOf = (rule: CfRule | undefined): string =>
|
||||
rule ? ruleLabel(rule) : td("NoRule");
|
||||
|
||||
/** 지나가기 전 값 → 지난 뒤 값 — 지난 뒤 값은 셈이 받는 알약에 실제로 들여보낸 것 · 못 들어갔으면 끊긴 까닭 */
|
||||
export function preview(link: CfLink, ev: CfEvalResult | null): { before: string; after: string } {
|
||||
const before = showValue(valueOf(ev, link.from));
|
||||
if (!link.rule) return { before, after: before };
|
||||
const arrived = ev?.arrived?.[link.to.box]?.[link.to.name];
|
||||
if (arrived !== undefined && arrived !== null) return { before, after: showValue(arrived) };
|
||||
const why = ev?.breaks?.find((b) => b.link === link.id)?.text;
|
||||
return { before, after: why ?? td("Empty") };
|
||||
}
|
||||
|
||||
/** 이 선에서 알릴 것 — 이 선이 막힌 까닭 · 같은 알약에서 나간 조건 선의 겹침 · 안 맞음 */
|
||||
export function linkNotes(link: CfLink, ev: CfEvalResult | null): string[] {
|
||||
return (ev?.breaks ?? [])
|
||||
.filter(
|
||||
(b) =>
|
||||
b.link === link.id ||
|
||||
((b.kind === "조건 겹침" || b.kind === "조건 안 맞음") &&
|
||||
b.box === link.from.box &&
|
||||
b.port === link.from.name),
|
||||
)
|
||||
.map((b) => b.text);
|
||||
}
|
||||
|
||||
/** 온 길(dir "in") · 가는 길(dir "out") — 모델 것에서 자기 자신 줄은 뺌 */
|
||||
export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] {
|
||||
const steps = dir === "in" ? traceBack(lg, p) : traceForward(lg, p);
|
||||
return steps.filter((s) => !(s.box === p.box && s.port === p.name));
|
||||
}
|
||||
|
||||
/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */
|
||||
export function breakLines(
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
name?: string,
|
||||
o: DetailOptions = {},
|
||||
): string[] {
|
||||
let list: CfBreak[] = [];
|
||||
try {
|
||||
list = breaks(lg, o.values, { logics: o.logics });
|
||||
} catch {
|
||||
// 만드는 중 셈이 막히면 끊긴 곳 없음으로 둠
|
||||
}
|
||||
return list
|
||||
.filter((b) => b.box === boxId && (!name || !b.port || b.port === name))
|
||||
.map((b) => b.text);
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
/* 단가산출 다이어그램 — 자유 배치 */
|
||||
.m02-dfdg {
|
||||
--cfdg-in: var(--color-accent);
|
||||
--cfdg-out: #c2610c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .m02-dfdg {
|
||||
--cfdg-out: #e5a866;
|
||||
--cfdg-lift: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .m02-dfdg {
|
||||
--cfdg-out: #e5a866;
|
||||
--cfdg-lift: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.m02-dfdg__bar,
|
||||
.m02-dfdg__tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dfdg__tools {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.m02-dfdg__title {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-dfdg__gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.m02-dfdg__zoom {
|
||||
min-width: 3rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 판 — 화면 위쪽 큰 영역 */
|
||||
.m02-dfdg__view {
|
||||
position: relative;
|
||||
height: max(360px, 55vh);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-canvas);
|
||||
background-image: radial-gradient(
|
||||
color-mix(in srgb, var(--color-text-muted) 35%, transparent) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 20px 20px;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
/* 왼쪽 끌기로 글자가 잡히지 않게 */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.m02-dfdg__view.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.m02-dfdg__world {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.m02-dfdg__lines {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 상자 — 종류별 기본 색 · 상자마다 바꾼 색(--cf-color) */
|
||||
.m02-dfdg__box {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 11rem;
|
||||
max-width: 16rem;
|
||||
padding: 0 0 var(--spacing-8);
|
||||
border: 1px solid color-mix(in srgb, var(--cf-color) 55%, var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-dfdg__box:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-selected {
|
||||
border: 2px solid var(--cf-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cf-color) 22%, transparent);
|
||||
}
|
||||
|
||||
.m02-dfdg__head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: baseline;
|
||||
gap: 0 var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: color-mix(in srgb, var(--cf-color) 16%, var(--color-surface));
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.m02-dfdg__kind {
|
||||
grid-column: 1;
|
||||
/* 어두운 화면은 종류 색을 밝게 섞음 */
|
||||
color: color-mix(in srgb, var(--cf-color) 55%, var(--cfdg-lift, var(--cf-color)));
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-dfdg__unit {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-dfdg__name {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-dfdg__pills {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dfdg__ins,
|
||||
.m02-dfdg__outs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-dfdg__outs {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 알약 — 받는 파랑 · 내보내는 주황 · 빈 자리 회색 */
|
||||
.m02-dfdg__pill {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 9rem;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--cfdg-in);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--cfdg-in) 12%, var(--color-surface));
|
||||
color: var(--cfdg-in);
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-out {
|
||||
border-color: var(--cfdg-out);
|
||||
background: color-mix(in srgb, var(--cfdg-out) 14%, var(--color-surface));
|
||||
color: var(--cfdg-out);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-empty {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-chosen {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
|
||||
.m02-dfdg.is-linking .m02-dfdg__pill.is-target {
|
||||
box-shadow: 0 0 0 2px var(--cfdg-in);
|
||||
}
|
||||
|
||||
.m02-dfdg__pill-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill-value {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill-value:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 선 — 굽은 선 */
|
||||
.m02-dfdg__link {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-dfdg__hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 12;
|
||||
}
|
||||
|
||||
.m02-dfdg__wire {
|
||||
fill: none;
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__link.is-strong .m02-dfdg__wire {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.m02-dfdg__link.is-dim .m02-dfdg__wire {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.m02-dfdg__drag {
|
||||
fill: none;
|
||||
stroke: var(--cfdg-out);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
/* 함수 상자 흐린 「+」 받는 자리 */
|
||||
.m02-dfdg__pill.is-plus {
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
border-style: dashed;
|
||||
border-color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-plus:hover,
|
||||
.m02-dfdg.is-linking .m02-dfdg__pill.is-plus.is-target {
|
||||
opacity: 1;
|
||||
border-color: var(--cfdg-in);
|
||||
color: var(--cfdg-in);
|
||||
}
|
||||
|
||||
/* ── 흐름도 더함: 선 규칙 이름표 · 흐름 길 · 끊긴 곳 · 알약 고름 ── */
|
||||
.m02-dfdg {
|
||||
--dfdg-back: var(--color-accent);
|
||||
--dfdg-fwd: #c2410c;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .m02-dfdg {
|
||||
--dfdg-fwd: #f0a35e;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .m02-dfdg {
|
||||
--dfdg-fwd: #f0a35e;
|
||||
}
|
||||
}
|
||||
|
||||
.m02-dfdg__rule rect {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-border);
|
||||
}
|
||||
|
||||
.m02-dfdg__rule text {
|
||||
fill: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.m02-dfdg__link.is-strong .m02-dfdg__rule rect {
|
||||
stroke: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 흐름 길 — 색 + 굵기 + 점선(색만으로 뜻을 안 전함) */
|
||||
.m02-dfdg__link.is-back .m02-dfdg__wire {
|
||||
stroke: var(--dfdg-back);
|
||||
stroke-width: 3.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__link.is-fwd .m02-dfdg__wire {
|
||||
stroke: var(--dfdg-fwd);
|
||||
stroke-width: 3.5;
|
||||
stroke-dasharray: 8 5;
|
||||
}
|
||||
|
||||
.m02-dfdg__link.is-dim .m02-dfdg__wire,
|
||||
.m02-dfdg__link.is-dim .m02-dfdg__rule {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-back {
|
||||
outline: 3px solid var(--dfdg-back);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-fwd {
|
||||
outline: 3px dashed var(--dfdg-fwd);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-dim {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-back {
|
||||
box-shadow: 0 0 0 2px var(--dfdg-back);
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-fwd {
|
||||
outline: 2px dashed var(--dfdg-fwd);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.m02-dfdg__legend {
|
||||
padding: 0 var(--spacing-8);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-dfdg__legend.is-back {
|
||||
border: 2px solid var(--dfdg-back);
|
||||
color: var(--dfdg-back);
|
||||
}
|
||||
|
||||
.m02-dfdg__legend.is-fwd {
|
||||
border: 2px dashed var(--dfdg-fwd);
|
||||
color: var(--dfdg-fwd);
|
||||
}
|
||||
|
||||
/* 고른 알약 테두리 */
|
||||
.m02-dfdg__pill.is-chosen {
|
||||
box-shadow: 0 0 0 2px var(--color-text);
|
||||
}
|
||||
|
||||
/* 끊긴 곳 */
|
||||
.m02-dfdg__warn {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
right: -9px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-broken {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m02-dfdg__pill-warn {
|
||||
color: var(--color-danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m02-dfdg__linkwarn circle {
|
||||
fill: var(--color-danger);
|
||||
}
|
||||
|
||||
.m02-dfdg__linkwarn text {
|
||||
fill: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 + 화살 */
|
||||
.m02-dfdg__dep {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-dfdg__depwire {
|
||||
fill: none;
|
||||
stroke: color-mix(in srgb, var(--color-text-muted) 70%, transparent);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 3 4;
|
||||
}
|
||||
|
||||
.m02-dfdg__arrow {
|
||||
fill: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-dfdg__deptext rect {
|
||||
fill: var(--color-canvas);
|
||||
stroke: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.m02-dfdg__deptext text {
|
||||
fill: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.m02-dfdg__dep.is-strong .m02-dfdg__depwire {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__dep.is-back .m02-dfdg__depwire {
|
||||
stroke: var(--dfdg-back);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__dep.is-fwd .m02-dfdg__depwire {
|
||||
stroke: var(--dfdg-fwd);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__dep.is-dim {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
/* 쉼 — 조건이 안 맞아 이번엔 안 지나감(문제 아님) */
|
||||
.m02-dfdg__link.is-rest .m02-dfdg__wire {
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 2 5;
|
||||
}
|
||||
|
||||
.m02-dfdg__restmark rect {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-dasharray: 2 2;
|
||||
}
|
||||
|
||||
.m02-dfdg__restmark text {
|
||||
fill: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-rest {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 빈 판 안내 — 가운데 세 줄 */
|
||||
.m02-dfdg__empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
list-style: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 쉼 — 받는 알약만(상자 흐림은 들어오는 선이 모두 쉼일 때) */
|
||||
.m02-dfdg__pill.is-in.is-rest {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -1,697 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Diagram.ts
|
||||
* 단가산출 흐름도 — 다이어그램(흐름도 정함 7 · 8 · 13 · 1판 「상자 · 선 약속」)
|
||||
* 자유 배치 상자(입력 · 함수 · 출력) · 굽은 선 + 가운데 선 규칙 이름표 · 흐름 길(온 길 · 가는 길) 칠하기 ·
|
||||
* 끊긴 곳 표시 · 알약 · 선 · 상자 고르기. 마우스 — 판 옮기기 = 가운데 단추 끌기(또는 Space + 왼쪽 끌기) ·
|
||||
* 고르기 = 왼쪽 누름 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 휠 = 확대 · 글자 선택 막음.
|
||||
* 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { wheelZoomsIn } from "../../A00_Common/touch_bridge";
|
||||
import { guardMenuAfterDrag, isAuxPanButton } from "../../A00_Common/input_prefs";
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model";
|
||||
import {
|
||||
arrange,
|
||||
clampZoom,
|
||||
fitView,
|
||||
KIND_COLOR,
|
||||
depEnd,
|
||||
depInTrace,
|
||||
depPath,
|
||||
follow,
|
||||
depLines,
|
||||
isRest,
|
||||
restParts,
|
||||
linkLabel,
|
||||
midOf,
|
||||
pillInfo,
|
||||
svgAdd as add,
|
||||
svgLabel as label,
|
||||
pillsOf,
|
||||
resolveDrop,
|
||||
showValue,
|
||||
traceOf,
|
||||
tt,
|
||||
type DfPillSel,
|
||||
type DiagramOn,
|
||||
type DiagramOpts,
|
||||
type TEXT,
|
||||
} from "./M02_DF_Diagram_Parts";
|
||||
import "./M02_DF_Diagram.css";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
|
||||
export * from "./M02_DF_Diagram_Parts";
|
||||
|
||||
interface HostState {
|
||||
selected: string | null;
|
||||
linking: CfPort | null;
|
||||
pill: DfPillSel | null;
|
||||
note: string;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
fitted: boolean;
|
||||
fitKey?: number;
|
||||
watch?: ResizeObserver;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, HostState>();
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
/** Space 를 누르고 있음 — 터치패드용 판 옮기기 */
|
||||
let spaceDown = false;
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space" && !(e.target as HTMLElement)?.closest?.("input, textarea, select"))
|
||||
spaceDown = true;
|
||||
});
|
||||
window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false));
|
||||
window.addEventListener("blur", () => (spaceDown = false));
|
||||
}
|
||||
|
||||
export function renderDiagram(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
on: DiagramOn,
|
||||
opts: DiagramOpts = {},
|
||||
): void {
|
||||
const st: HostState = states.get(host) ?? {
|
||||
selected: null,
|
||||
linking: null,
|
||||
pill: null,
|
||||
note: "",
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
fitted: false,
|
||||
};
|
||||
if (opts.selected !== undefined) st.selected = opts.selected;
|
||||
if (opts.fitKey !== undefined && opts.fitKey !== st.fitKey)
|
||||
[st.fitKey, st.fitted] = [opts.fitKey, false];
|
||||
const ro = !!opts.readOnly; // 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음(고르기 · 흐름 따라가기는 됨)
|
||||
if (opts.selectedPill !== undefined) st.pill = opts.selectedPill;
|
||||
states.set(host, st);
|
||||
const values = opts.values ?? {};
|
||||
const labels = opts.labels ?? {};
|
||||
const allBreaks = opts.breaks ?? [];
|
||||
/** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */
|
||||
const breaks = allBreaks.filter((k) => !isRest(k));
|
||||
const rests = allBreaks.filter(isRest);
|
||||
const {
|
||||
links: restLinks,
|
||||
pills: restPills,
|
||||
boxes: restBoxes,
|
||||
} = restParts(lg, rests, opts.values ?? {});
|
||||
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
|
||||
const redraw = (): void =>
|
||||
renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill });
|
||||
const select = (id: string | null, pill?: DfPillSel, note = ""): void => {
|
||||
st.selected = pill ? null : id;
|
||||
st.pill = pill ?? null;
|
||||
st.note = note;
|
||||
on.select(id, pill);
|
||||
redraw();
|
||||
};
|
||||
const tryLink = (from: CfPort, target: Parameters<typeof resolveDrop>[2]): void => {
|
||||
const got = resolveDrop(lg, from, target);
|
||||
st.linking = null;
|
||||
st.note = "why" in got ? got.why : on.link(from, got.to) || "";
|
||||
redraw();
|
||||
};
|
||||
const markOf = (pick: Parameters<typeof traceOf>[1]): string => {
|
||||
const m = traceOf(opts.trace, pick);
|
||||
return m ? ` is-${m}` : "";
|
||||
};
|
||||
const warn = (why: string, onClick: () => void): HTMLElement => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfdg__warn",
|
||||
text: "!",
|
||||
attrs: { type: "button", title: why, "aria-label": `${tt("Break")} · ${why}` },
|
||||
});
|
||||
b.addEventListener("pointerdown", (ev) => ev.stopPropagation());
|
||||
b.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return b;
|
||||
};
|
||||
|
||||
/* 판 · 세상(확대 · 옮김을 한 번에) */
|
||||
const world = el("div", { className: "m02-dfdg__world" });
|
||||
const svg = document.createElementNS(SVG, "svg");
|
||||
svg.classList.add("m02-dfdg__lines");
|
||||
world.append(svg);
|
||||
const view = el("div", {
|
||||
className: "m02-dfdg__view",
|
||||
attrs: { "aria-label": tt("Title") },
|
||||
children: [world],
|
||||
});
|
||||
const zoomLabel = el("span", { className: "m02-dfdg__zoom" });
|
||||
const applyView = (): void => {
|
||||
world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`;
|
||||
zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`;
|
||||
};
|
||||
/** 화면 점 → 세상 자리 */
|
||||
const toWorld = (cx: number, cy: number): { x: number; y: number } => {
|
||||
const r = view.getBoundingClientRect();
|
||||
return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom };
|
||||
};
|
||||
|
||||
/* 상자 · 알약 */
|
||||
const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => {
|
||||
const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name);
|
||||
const chosen = st.pill?.box === b.id && st.pill.port === p.name && st.pill.dir === dir;
|
||||
const broken = breaks.find((k) => !k.link && k.box === b.id && k.port === p.name);
|
||||
const sel: DfPillSel = { box: b.id, port: p.name, dir };
|
||||
const btn = el("button", {
|
||||
className:
|
||||
[
|
||||
"m02-dfdg__pill",
|
||||
`is-${dir}`,
|
||||
wired ? "" : "is-empty",
|
||||
chosen ? "is-chosen" : "",
|
||||
broken ? "is-broken" : "",
|
||||
dir === "in" && restPills.has(`${b.id}|${p.name}`) ? "is-rest" : "",
|
||||
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
|
||||
].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)),
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-box": b.id,
|
||||
"data-name": p.name,
|
||||
"data-dir": dir,
|
||||
title: `${p.name} (${p.unit || "-"})`,
|
||||
},
|
||||
children: [
|
||||
...(broken ? [el("span", { className: "m02-dfdg__pill-warn", text: "!" })] : []),
|
||||
el("span", { className: "m02-dfdg__pill-name", text: wired ? p.name : `? ${p.name}` }),
|
||||
el("span", { className: "m02-dfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }),
|
||||
],
|
||||
});
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir });
|
||||
st.linking = null;
|
||||
if (chosen) select(null);
|
||||
else select(b.id, sel, broken?.text ?? "");
|
||||
});
|
||||
if (dir === "out")
|
||||
btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name }));
|
||||
return btn;
|
||||
};
|
||||
|
||||
/** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */
|
||||
const plusSlot = (b: CfBox): HTMLElement => {
|
||||
const slot = el("button", {
|
||||
className: `m02-dfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`,
|
||||
text: "+",
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-box": b.id,
|
||||
"data-dir": "in",
|
||||
"data-plus": "1",
|
||||
title: tt("PlusIn"),
|
||||
},
|
||||
});
|
||||
slot.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" });
|
||||
st.note = tt("PlusIn");
|
||||
redraw();
|
||||
});
|
||||
return slot;
|
||||
};
|
||||
|
||||
const boxEl = (b: CfBox): HTMLElement => {
|
||||
const { ins, outs } = pillsOf(b);
|
||||
const kind = tt(`K_${b.kind}` as keyof typeof TEXT);
|
||||
const broken = breaks.filter((k) => !k.link && !k.port && k.box === b.id);
|
||||
const head = el("div", {
|
||||
className: "m02-dfdg__head",
|
||||
children: [
|
||||
el("span", { className: "m02-dfdg__kind", text: kind }),
|
||||
el("strong", { className: "m02-dfdg__name", text: b.name }),
|
||||
el("span", { className: "m02-dfdg__unit", text: b.unit || "-" }),
|
||||
],
|
||||
});
|
||||
const node = el("div", {
|
||||
className:
|
||||
`m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` +
|
||||
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))) +
|
||||
(restBoxes.has(b.id) ? " is-rest" : ""),
|
||||
attrs: {
|
||||
"data-box": b.id,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"aria-label": `${kind} · ${b.name}`,
|
||||
},
|
||||
children: [
|
||||
head,
|
||||
el("div", {
|
||||
className: "m02-dfdg__pills",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-dfdg__ins",
|
||||
children: [
|
||||
...ins.map((p) => pillEl(b, p, "in")),
|
||||
...(b.kind === "function" && !ro ? [plusSlot(b)] : []),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m02-dfdg__outs",
|
||||
children: outs.map((p) => pillEl(b, p, "out")),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...(broken.length
|
||||
? [
|
||||
warn(broken.map((k) => k.text).join(" · "), () =>
|
||||
select(b.id, undefined, broken.map((k) => k.text).join(" · ")),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
node.style.left = `${b.x}px`;
|
||||
node.style.top = `${b.y}px`;
|
||||
node.style.setProperty("--cf-color", b.color || KIND_COLOR[b.kind]);
|
||||
node.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: null });
|
||||
select(st.selected === b.id ? null : b.id);
|
||||
});
|
||||
node.addEventListener("keydown", (ev) => {
|
||||
if (ev.target !== node) return;
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
node.click();
|
||||
}
|
||||
const step = ev.shiftKey ? 50 : 10;
|
||||
const d = {
|
||||
ArrowLeft: [-step, 0],
|
||||
ArrowRight: [step, 0],
|
||||
ArrowUp: [0, -step],
|
||||
ArrowDown: [0, step],
|
||||
}[ev.key];
|
||||
if (d && st.selected === b.id && !ro) {
|
||||
ev.preventDefault();
|
||||
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
|
||||
}
|
||||
});
|
||||
head.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node);
|
||||
});
|
||||
return node;
|
||||
};
|
||||
|
||||
world.append(...lg.boxes.map(boxEl));
|
||||
|
||||
/* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */
|
||||
const setZoom = (z: number, cx?: number, cy?: number): void => {
|
||||
const r = view.getBoundingClientRect();
|
||||
const px = cx ?? r.left + r.width / 2;
|
||||
const py = cy ?? r.top + r.height / 2;
|
||||
const at = toWorld(px, py);
|
||||
st.zoom = clampZoom(z);
|
||||
st.panX = px - r.left - at.x * st.zoom;
|
||||
st.panY = py - r.top - at.y * st.zoom;
|
||||
applyView();
|
||||
drawLines();
|
||||
};
|
||||
/** 맞추기 — 저절로(처음 · 다시 맞춤)는 fitMin · 「한눈에」 단추는 fitAllMin(전체가 보이게) */
|
||||
const fit = (button = false): void => {
|
||||
const rects = lg.boxes.map((b) => {
|
||||
const n = world.querySelector<HTMLElement>(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`);
|
||||
return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 };
|
||||
});
|
||||
const r = view.getBoundingClientRect();
|
||||
Object.assign(
|
||||
st,
|
||||
fitView(
|
||||
rects,
|
||||
{ w: r.width, h: r.height },
|
||||
40,
|
||||
button ? (opts.fitAllMin ?? opts.fitMin) : opts.fitMin,
|
||||
),
|
||||
);
|
||||
applyView();
|
||||
drawLines();
|
||||
};
|
||||
const freeSpot = (): { x: number; y: number } => {
|
||||
const r = view.getBoundingClientRect();
|
||||
const c = toWorld(r.left + r.width / 3, r.top + r.height / 3);
|
||||
let y = c.y;
|
||||
while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100;
|
||||
return { x: Math.round(c.x), y: Math.round(y) };
|
||||
};
|
||||
/** 공용 작은 단추 */
|
||||
const sm = (
|
||||
label: keyof typeof TEXT,
|
||||
onClick: () => void,
|
||||
variant: "ghost" | "filled" | "danger" = "ghost",
|
||||
) => createButton({ label: tt(label), size: "sm", variant, onClick });
|
||||
const addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement =>
|
||||
sm(label, () => {
|
||||
const at = freeSpot();
|
||||
on.add(kind, at.x, at.y);
|
||||
});
|
||||
const endName = (e: CfPort): string => `${boxName(e.box)} · ${e.name}`;
|
||||
|
||||
const selLink = lg.links.find((l) => l.id === st.selected) ?? null;
|
||||
const selBox = lg.boxes.find((b) => b.id === st.selected) ?? null;
|
||||
const askRemove = async (b: CfBox): Promise<void> => {
|
||||
if (await showConfirmDialog(tt("AskRemove", { v: b.name }), tt("Remove"))) on.remove(b.id);
|
||||
};
|
||||
const tools: HTMLElement[] = [];
|
||||
if (st.linking) {
|
||||
tools.push(
|
||||
el("span", { text: tt("PickTarget", { v: st.linking.name }) }),
|
||||
sm("Cancel", () => ((st.linking = null), redraw())),
|
||||
);
|
||||
} else if (st.pill) {
|
||||
const p = st.pill;
|
||||
tools.push(el("span", { text: pillInfo(lg, p, values) }));
|
||||
if (p.dir === "out" && !ro)
|
||||
tools.push(
|
||||
sm(
|
||||
"Connect",
|
||||
() => ((st.linking = { box: p.box, name: p.port }), (st.pill = null), redraw()),
|
||||
"filled",
|
||||
),
|
||||
);
|
||||
} else if (selLink) {
|
||||
const rule = labels[selLink.id] ?? linkLabel(selLink);
|
||||
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
|
||||
tools.push(
|
||||
el("span", { text: rule ? `${line} · ${rule}` : line }),
|
||||
...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]),
|
||||
);
|
||||
} else if (selBox && !ro) {
|
||||
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
|
||||
}
|
||||
const legend =
|
||||
opts.trace && (opts.trace.back || opts.trace.forward)
|
||||
? [
|
||||
el("span", { className: "m02-dfdg__legend is-back", text: tt("Back") }),
|
||||
el("span", { className: "m02-dfdg__legend is-fwd", text: tt("Forward") }),
|
||||
]
|
||||
: [];
|
||||
|
||||
const bar = el("div", {
|
||||
className: "ui-toolbar m02-dfdg__bar",
|
||||
children: [
|
||||
el("strong", { className: "m02-dfdg__title", text: tt("Title") }),
|
||||
...(ro
|
||||
? []
|
||||
: [
|
||||
addBtn("input", "AddInput"),
|
||||
addBtn("function", "AddFunc"),
|
||||
addBtn("output", "AddOutput"),
|
||||
]),
|
||||
el("span", { className: "m02-dfdg__gap" }),
|
||||
...legend,
|
||||
sm("ZoomOut", () => setZoom(st.zoom / 1.2)),
|
||||
zoomLabel,
|
||||
sm("ZoomIn", () => setZoom(st.zoom * 1.2)),
|
||||
sm("Fit", () => fit(true)),
|
||||
...(ro
|
||||
? []
|
||||
: [
|
||||
sm("Arrange", () =>
|
||||
Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)),
|
||||
),
|
||||
]),
|
||||
],
|
||||
});
|
||||
const root = el("div", {
|
||||
className: `m02-dfdg${st.linking ? " is-linking" : ""}`,
|
||||
children: [
|
||||
bar,
|
||||
el("div", {
|
||||
className: "ui-toolbar m02-dfdg__tools",
|
||||
children: [
|
||||
...tools,
|
||||
el("span", { className: "ui-text ui-text--error", text: st.note }),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: tools.length || st.note ? "" : tt("Hint"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
view,
|
||||
],
|
||||
});
|
||||
root.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape" && (st.linking || st.pill)) {
|
||||
st.linking = null;
|
||||
st.pill = null;
|
||||
redraw();
|
||||
}
|
||||
if (ro || ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
|
||||
if (selLink) on.unlink(selLink.id);
|
||||
else if (selBox) void askRemove(selBox);
|
||||
});
|
||||
view.addEventListener("click", (ev) => {
|
||||
if (ev.target === view || ev.target === world) select(null);
|
||||
});
|
||||
view.addEventListener(
|
||||
"wheel",
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setZoom(st.zoom * (wheelZoomsIn(ev) ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); // ☰ [설정] 방향(332)
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
// 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함(마우스)
|
||||
view.addEventListener(
|
||||
"pointerdown",
|
||||
(ev) => {
|
||||
// 손가락 빈 곳 끌기도 판 옮기기(기기대응 3-3)
|
||||
const touchBlank = ev.pointerType === "touch" && (ev.target === view || ev.target === world);
|
||||
if (isAuxPanButton(ev.button) || (ev.button === 0 && spaceDown) || touchBlank) {
|
||||
guardMenuAfterDrag(ev);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragPan(ev);
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault());
|
||||
view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault());
|
||||
if (!lg.boxes.length)
|
||||
view.append(
|
||||
el("ol", {
|
||||
className: "m02-dfdg__empty",
|
||||
children: (["Empty1", "Empty2", "Empty3"] as const).map((k) => el("li", { text: tt(k) })),
|
||||
}),
|
||||
);
|
||||
host.replaceChildren(root);
|
||||
applyView();
|
||||
|
||||
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 · 쉼 · 기댐 선 */
|
||||
function drawLines(): void {
|
||||
svg.replaceChildren();
|
||||
const at = (end: CfPort, dir: "in" | "out", side: "near" | "far" = "near") => {
|
||||
const pill = world.querySelector<HTMLElement>(
|
||||
`.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`,
|
||||
);
|
||||
if (!pill) return null;
|
||||
const r = pill.getBoundingClientRect();
|
||||
const right = (dir === "out") === (side === "near");
|
||||
return toWorld(right ? r.right : r.left, r.top + r.height / 2);
|
||||
};
|
||||
const defs = add(svg, "defs", {});
|
||||
const arrow = add(defs, "marker", {
|
||||
id: "m02-dfdg-arrow",
|
||||
viewBox: "0 0 8 8",
|
||||
refX: "7",
|
||||
refY: "4",
|
||||
markerWidth: "7",
|
||||
markerHeight: "7",
|
||||
orient: "auto",
|
||||
});
|
||||
add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", class: "m02-dfdg__arrow" });
|
||||
const dot = add(defs, "marker", {
|
||||
id: "m02-dfdg-dot",
|
||||
viewBox: "0 0 8 8",
|
||||
refX: "4",
|
||||
refY: "4",
|
||||
markerWidth: "6",
|
||||
markerHeight: "6",
|
||||
});
|
||||
add(dot, "circle", { cx: "4", cy: "4", r: "3", class: "m02-dfdg__arrow" });
|
||||
const mids = new Map<string, { x: number; y: number; w: number }>();
|
||||
for (const l of lg.links) {
|
||||
const a = at(l.from, "out");
|
||||
const b = at(l.to, "in");
|
||||
if (!a || !b) continue;
|
||||
const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
|
||||
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
|
||||
const strong =
|
||||
st.selected === l.id ||
|
||||
(selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id));
|
||||
const traced = markOf((s) => s.some((x) => x.link === l.id));
|
||||
const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음
|
||||
const dim = !mark && st.selected !== null && !strong ? " is-dim" : "";
|
||||
const rest = restLinks.has(l.id) ? " is-rest" : "";
|
||||
const g = add(svg, "g", {
|
||||
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}${rest}`,
|
||||
"data-link": l.id,
|
||||
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
|
||||
});
|
||||
add(g, "path", { d, class: "m02-dfdg__hit" });
|
||||
add(g, "path", { d, class: "m02-dfdg__wire" });
|
||||
const mid = midOf(a, b);
|
||||
const rule = labels[l.id] ?? linkLabel(l);
|
||||
mids.set(l.id, { ...mid, w: rule ? label(g, mid.x, mid.y, rule, "m02-dfdg__rule") : 0 });
|
||||
if (rest) label(g, mid.x, mid.y + (rule ? 18 : 0), tt("Rest"), "m02-dfdg__restmark");
|
||||
const broken = breaks.filter((k) => k.link === l.id);
|
||||
const why = [...broken, ...rests.filter((k) => k.link === l.id)]
|
||||
.map((k) => k.text)
|
||||
.join(" · ");
|
||||
if (broken.length) {
|
||||
const cy = (mid.y - (rule ? 18 : 0)).toFixed(1);
|
||||
const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why });
|
||||
add(badge, "circle", { cx: mid.x.toFixed(1), cy, r: "8" });
|
||||
const t = add(badge, "text", {
|
||||
x: mid.x.toFixed(1),
|
||||
y: cy,
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
t.textContent = "!";
|
||||
add(badge, "title", {}).textContent = why;
|
||||
}
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why);
|
||||
});
|
||||
}
|
||||
// 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살
|
||||
for (const dep of depLines(lg)) {
|
||||
// 시작 = 조건 알약 오른쪽 · 끝 = 그 규칙 선의 이름표 가장자리 또는 받는 재료 알약 오른쪽
|
||||
const a = at(dep.from, "out");
|
||||
const tag = "link" in dep.to ? mids.get(dep.to.link) : null;
|
||||
const pill = "box" in dep.to ? at({ box: dep.to.box, name: dep.to.port }, "out") : null;
|
||||
const end = tag ? depEnd(a, tag) : pill && { ...pill, side: "right" as const };
|
||||
if (!a || !end) continue;
|
||||
const b = { x: end.x, y: end.y };
|
||||
const d = depPath(a, b, end.side);
|
||||
const where = "link" in dep.to ? dep.to.link : dep.to.box;
|
||||
const steps = opts.trace;
|
||||
const traced =
|
||||
steps?.back && depInTrace(dep, steps.back)
|
||||
? " is-back"
|
||||
: steps?.forward && depInTrace(dep, steps.forward)
|
||||
? " is-fwd"
|
||||
: steps && (steps.back?.length || steps.forward?.length)
|
||||
? " is-dim"
|
||||
: "";
|
||||
const g = add(svg, "g", {
|
||||
class: `m02-dfdg__dep${traced}${st.selected === where ? " is-strong" : ""}`,
|
||||
"data-dep": dep.id,
|
||||
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
|
||||
});
|
||||
add(g, "path", { d, class: "m02-dfdg__hit" });
|
||||
add(g, "path", {
|
||||
d,
|
||||
class: "m02-dfdg__depwire",
|
||||
"marker-start": "url(#m02-dfdg-dot)",
|
||||
"marker-end": "url(#m02-dfdg-arrow)",
|
||||
});
|
||||
const m = midOf(a, b);
|
||||
label(g, m.x, m.y, dep.text, "m02-dfdg__deptext");
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
select(where);
|
||||
});
|
||||
}
|
||||
}
|
||||
const firstFit = (): void => {
|
||||
if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines();
|
||||
st.fitted = true;
|
||||
fit();
|
||||
};
|
||||
st.watch?.disconnect();
|
||||
st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(firstFit);
|
||||
st.watch?.observe(view);
|
||||
if (view.isConnected) firstFit();
|
||||
|
||||
/* 끌기 셋 — 상자 · 판 · 선 */
|
||||
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
|
||||
ev.preventDefault(); // 끌면서 글자가 잡히지 않게
|
||||
const start = toWorld(ev.clientX, ev.clientY);
|
||||
let x = b.x;
|
||||
let y = b.y;
|
||||
follow(
|
||||
ev,
|
||||
(m) => {
|
||||
const now = toWorld(m.clientX, m.clientY);
|
||||
x = Math.round(b.x + now.x - start.x);
|
||||
y = Math.round(b.y + now.y - start.y);
|
||||
node.style.left = `${x}px`;
|
||||
node.style.top = `${y}px`;
|
||||
drawLines();
|
||||
},
|
||||
(_u, moved) => {
|
||||
if (moved) on.move(b.id, x, y);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function dragPan(ev: PointerEvent): void {
|
||||
const p0 = { x: st.panX, y: st.panY };
|
||||
view.classList.add("is-panning");
|
||||
follow(
|
||||
ev,
|
||||
(m) => {
|
||||
st.panX = p0.x + m.clientX - ev.clientX;
|
||||
st.panY = p0.y + m.clientY - ev.clientY;
|
||||
applyView();
|
||||
},
|
||||
() => view.classList.remove("is-panning"),
|
||||
);
|
||||
}
|
||||
|
||||
function dragLink(ev: PointerEvent, from: CfPort): void {
|
||||
if (ev.button !== 0 || spaceDown || ro) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const pill = ev.currentTarget as HTMLElement;
|
||||
const r = pill.getBoundingClientRect();
|
||||
const a = toWorld(r.right, r.top + r.height / 2);
|
||||
let line: SVGPathElement | null = null;
|
||||
follow(
|
||||
ev,
|
||||
(m) => {
|
||||
const b = toWorld(m.clientX, m.clientY);
|
||||
if (!line) {
|
||||
line = document.createElementNS(SVG, "path");
|
||||
line.setAttribute("class", "m02-dfdg__drag");
|
||||
svg.append(line);
|
||||
}
|
||||
line.setAttribute("d", `M${a.x},${a.y} L${b.x},${b.y}`);
|
||||
},
|
||||
(u, moved) => {
|
||||
if (!moved) return;
|
||||
line?.remove();
|
||||
const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null;
|
||||
const p = hit?.closest<HTMLElement>(".m02-dfdg__pill");
|
||||
const bx = hit?.closest<HTMLElement>(".m02-dfdg__box");
|
||||
tryLink(
|
||||
from,
|
||||
p
|
||||
? {
|
||||
box: p.dataset.box ?? "",
|
||||
name: p.dataset.name ?? null,
|
||||
dir: (p.dataset.dir as "in" | "out") ?? null,
|
||||
}
|
||||
: bx
|
||||
? { box: bx.dataset.box ?? "", name: null, dir: null }
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Diagram_Parts.ts
|
||||
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
|
||||
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
portsIn,
|
||||
portsOut,
|
||||
type CfBox,
|
||||
type CfKind,
|
||||
type CfLogic,
|
||||
type CfPill,
|
||||
type CfPort,
|
||||
type CfLink,
|
||||
type CfStep,
|
||||
elementPort,
|
||||
ruleSources,
|
||||
} from "./M02_DF_Model";
|
||||
import type { CfBreak } from "./M02_DF_Eval";
|
||||
import { ruleLabel } from "./M02_DF_Rule";
|
||||
|
||||
export const TEXT = {
|
||||
Title: ["다이어그램", "Diagram"],
|
||||
K_input: ["입력", "Input"],
|
||||
K_function: ["함수", "Function"],
|
||||
K_output: ["출력", "Output"],
|
||||
AddInput: ["+ 입력", "+ Input"],
|
||||
AddFunc: ["+ 함수", "+ Function"],
|
||||
AddOutput: ["+ 출력", "+ Output"],
|
||||
ZoomIn: ["확대", "Zoom in"],
|
||||
ZoomOut: ["축소", "Zoom out"],
|
||||
Fit: ["한눈에", "Fit all"],
|
||||
Arrange: ["줄 맞추기", "Line up"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Connect: ["잇기", "Connect"],
|
||||
Disconnect: ["끊기", "Disconnect"],
|
||||
Remove: ["상자 지우기", "Remove box"],
|
||||
AskRemove: [
|
||||
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
|
||||
"Remove box “{v}”? Its lines go too",
|
||||
],
|
||||
PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"],
|
||||
Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"],
|
||||
NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"],
|
||||
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
|
||||
NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"],
|
||||
NoMatch: [
|
||||
"같은 이름 받는 알약 없음 — 알약에 바로 놓으세요",
|
||||
"No pill with that name — drop on a pill",
|
||||
],
|
||||
Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"],
|
||||
From: ["어디서", "From"],
|
||||
To: ["어디로", "To"],
|
||||
Value: ["지금 값", "Now"],
|
||||
PlusIn: [
|
||||
"받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김",
|
||||
"Drop an output pill here to add an input",
|
||||
],
|
||||
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
||||
Hint: [
|
||||
"상자 머리를 끌어 옮김 · 가운데 버튼(또는 Space + 끌기)로 판 옮김 · 휠로 확대",
|
||||
"Drag a head to move · middle button (or Space + drag) to pan · wheel to zoom",
|
||||
],
|
||||
Break: ["끊긴 곳", "Break"],
|
||||
Back: ["온 길", "Came from"],
|
||||
DepLookup: ["표 조건", "table key"],
|
||||
DepGate: ["조건", "condition"],
|
||||
DepMaterial: ["{f}에 {o}", "{f} {o}"],
|
||||
Rest: ["쉼", "Resting"],
|
||||
Empty1: ["① 「+ 입력」 으로 상자를 놓음", "① Place a box with “+ Input”"],
|
||||
Empty2: ["② 알약을 끌어 다른 상자에 놓으면 선", "② Drag a pill onto another box to draw a line"],
|
||||
Empty3: ["③ 선을 누르면 규칙", "③ Tap a line to set its rule"],
|
||||
Forward: ["가는 길", "Goes to"],
|
||||
} as const;
|
||||
|
||||
export const tt = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
|
||||
|
||||
/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */
|
||||
export const KIND_COLOR: Record<CfKind, string> = {
|
||||
input: "#2a6fb0",
|
||||
function: "#7a4fb5",
|
||||
output: "#c2610c",
|
||||
};
|
||||
|
||||
/* ── 맞출 모양 ──────────────────────────────────────────────────────── */
|
||||
|
||||
/** 고른 알약 */
|
||||
export interface DfPillSel {
|
||||
box: string;
|
||||
port: string;
|
||||
dir: "in" | "out";
|
||||
}
|
||||
|
||||
export interface DiagramOn {
|
||||
/** 상자 · 선 id · 없음 — 알약을 고르면 상자 id 와 알약 */
|
||||
select(id: string | null, pill?: DfPillSel): void;
|
||||
move(boxId: string, x: number, y: number): void;
|
||||
/** 잇기 — 막히면 까닭(쉬운 말) */
|
||||
link(from: CfPort, to: CfPort): string | null | void;
|
||||
unlink(linkId: string): void;
|
||||
add(kind: CfKind, x: number, y: number): void;
|
||||
remove(boxId: string): void;
|
||||
}
|
||||
|
||||
export interface DiagramOpts {
|
||||
/** 상자 id → 알약 이름 → 지금 값 */
|
||||
values?: Record<string, Record<string, unknown>>;
|
||||
/** 고른 상자 id · 선 id */
|
||||
selected?: string | null;
|
||||
/** 고른 알약 */
|
||||
selectedPill?: DfPillSel | null;
|
||||
/** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */
|
||||
labels?: Record<string, string>;
|
||||
/** 흐름 길 — Model traceBack · traceForward 걸음 */
|
||||
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
|
||||
/** 끊긴 곳 — Eval breaks */
|
||||
breaks?: CfBreak[];
|
||||
/** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */
|
||||
readOnly?: boolean;
|
||||
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
|
||||
fitKey?: number;
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */
|
||||
fitMin?: number;
|
||||
/** 「한눈에」 단추로 맞출 때 가장 작은 확대 — 없으면 fitMin */
|
||||
fitAllMin?: number;
|
||||
}
|
||||
|
||||
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
|
||||
|
||||
/** 알약 — Model 의 받는 · 내보내는 알약 */
|
||||
export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } {
|
||||
return { ins: portsIn(box), outs: portsOut(box) };
|
||||
}
|
||||
|
||||
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
|
||||
export function resolveDrop(
|
||||
lg: CfLogic,
|
||||
from: CfPort,
|
||||
target: { box: string; name: string | null; dir: "in" | "out" | null } | null,
|
||||
): { to: CfPort } | { why: string } {
|
||||
if (!target) return { why: tt("NotIn") };
|
||||
if (target.box === from.box) return { why: tt("SameBox") };
|
||||
if (target.dir === "out") return { why: tt("NotIn") };
|
||||
const box = lg.boxes.find((b) => b.id === target.box);
|
||||
// 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」)
|
||||
if (box?.kind === "function" && target.name === null)
|
||||
return { to: { box: box.id, name: from.name } };
|
||||
const ins = box ? pillsOf(box).ins : [];
|
||||
if (!box || !ins.length) return { why: tt("NoIn") };
|
||||
const name =
|
||||
target.name ??
|
||||
ins.find((p) => p.name === from.name)?.name ??
|
||||
(ins.length === 1 ? ins[0].name : null);
|
||||
if (!name) return { why: tt("NoMatch") };
|
||||
if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") };
|
||||
return { to: { box: box.id, name } };
|
||||
}
|
||||
|
||||
/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */
|
||||
export function arrange(
|
||||
lg: CfLogic,
|
||||
gap = { x: 260, y: 130 },
|
||||
): Record<string, { x: number; y: number }> {
|
||||
const depth = new Map<string, number>(lg.boxes.map((b) => [b.id, 0]));
|
||||
for (let round = 0; round < lg.boxes.length; round++) {
|
||||
let moved = false;
|
||||
for (const l of lg.links) {
|
||||
const d = (depth.get(l.from.box) ?? 0) + 1;
|
||||
if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) {
|
||||
depth.set(l.to.box, d);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
const rows = new Map<number, number>();
|
||||
const out: Record<string, { x: number; y: number }> = {};
|
||||
for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) {
|
||||
const d = depth.get(b.id) ?? 0;
|
||||
const r = rows.get(d) ?? 0;
|
||||
rows.set(d, r + 1);
|
||||
out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */
|
||||
export const FIT_MIN = 0.9;
|
||||
|
||||
export const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
|
||||
|
||||
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
|
||||
export function fitView(
|
||||
rects: { x: number; y: number; w: number; h: number }[],
|
||||
view: { w: number; h: number },
|
||||
pad = 40,
|
||||
min = FIT_MIN,
|
||||
): { zoom: number; panX: number; panY: number } {
|
||||
if (!rects.length) return { zoom: 1, panX: 0, panY: 0 };
|
||||
const x0 = Math.min(...rects.map((r) => r.x)) - pad;
|
||||
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
|
||||
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
|
||||
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
|
||||
const zoom = clampZoom(Math.max(min, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
|
||||
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
|
||||
const place = (size: number, from: number, to: number): number =>
|
||||
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
|
||||
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
|
||||
}
|
||||
|
||||
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
|
||||
export const showValue = (v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
if (typeof v === "number")
|
||||
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
|
||||
if (Array.isArray(v)) {
|
||||
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
|
||||
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
|
||||
}
|
||||
return String(v).slice(0, 12);
|
||||
};
|
||||
|
||||
/* ── 흐름 길 · 선 규칙 글 ───────────────────────────────────────────── */
|
||||
|
||||
/** 흐름 길 자리 — 온 길 · 가는 길 · 흐림(길이 있는데 안 듦) · 「」(길 없음) */
|
||||
export type TraceMark = "back" | "fwd" | "dim" | "";
|
||||
|
||||
export function traceOf(
|
||||
trace: DiagramOpts["trace"],
|
||||
pick: (steps: CfStep[]) => boolean,
|
||||
): TraceMark {
|
||||
if (!trace || (!trace.back?.length && !trace.forward?.length)) return "";
|
||||
if (trace.back && pick(trace.back)) return "back";
|
||||
if (trace.forward && pick(trace.forward)) return "fwd";
|
||||
return "dim";
|
||||
}
|
||||
|
||||
/** 선 위 규칙 글 — Rule 의 ruleLabel · 규칙 없으면 「」 */
|
||||
export const linkLabel = (link: CfLink): string => (link.rule ? ruleLabel(link.rule) : "");
|
||||
|
||||
/** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 t = 0.5 가 두 끝의 가운데 */
|
||||
export const midOf = (
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): { x: number; y: number } => ({
|
||||
x: (a.x + b.x) / 2,
|
||||
y: (a.y + b.y) / 2,
|
||||
});
|
||||
|
||||
/* ── 기댐 선 · 쉼 ──────────────────────────────────────────────────── */
|
||||
|
||||
/** 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳(선이면 그 선 이름표 · 재료 조건 찾기면 그 재료 알약) */
|
||||
export interface DepLine {
|
||||
id: string;
|
||||
from: CfPort;
|
||||
to: { link: string } | { box: string; port: string };
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function depLines(lg: CfLogic): DepLine[] {
|
||||
const out: DepLine[] = [];
|
||||
for (const l of lg.links)
|
||||
ruleSources(l.rule).forEach((from, i) =>
|
||||
out.push({
|
||||
id: `dep:${l.id}:${i}`,
|
||||
from,
|
||||
to: { link: l.id },
|
||||
text: tt(l.rule?.kind === "lookup" ? "DepLookup" : "DepGate"),
|
||||
}),
|
||||
);
|
||||
for (const b of lg.boxes)
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule)
|
||||
out.push({
|
||||
id: `dep:${b.id}:${elementPort(e)}`,
|
||||
from: e.rule.from,
|
||||
to: { box: b.id, port: elementPort(e) },
|
||||
text: tt("DepMaterial", { f: e.rule.field, o: e.rule.op }),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 기댐 선이 흐름 길에 드나 — 기대는 알약과 닿는 곳(선 · 상자)이 같은 길에 있을 때 */
|
||||
export function depInTrace(dep: DepLine, steps: CfStep[]): boolean {
|
||||
const src = steps.some((s) => s.box === dep.from.box && s.port === dep.from.name);
|
||||
const dst =
|
||||
"link" in dep.to
|
||||
? steps.some((s) => s.link === (dep.to as { link: string }).link)
|
||||
: steps.some((s) => s.box === (dep.to as { box: string }).box);
|
||||
return src && dst;
|
||||
}
|
||||
|
||||
type Pt = { x: number; y: number };
|
||||
|
||||
/** 이름표에 닿는 끝 — 시작이 왼쪽이면 이름표 왼쪽 가장자리 · 오른쪽이면 오른쪽 가장자리(이름표가 없으면 선 가운데) */
|
||||
export function depEnd(a: Pt | null, tag: Pt & { w: number }): Pt & { side: "left" | "right" } {
|
||||
const fromLeft = !a || a.x <= tag.x;
|
||||
return {
|
||||
x: tag.x + (fromLeft ? -1 : 1) * (tag.w / 2 + 2),
|
||||
y: tag.y,
|
||||
side: fromLeft ? "left" : "right",
|
||||
};
|
||||
}
|
||||
|
||||
/** 기댐 선 길 — 알약 오른쪽에서 나가 끝에 닿음 · side = 끝으로 들어가는 쪽(재료 알약은 오른쪽에서) */
|
||||
export function depPath(a: Pt, b: Pt, side: "left" | "right"): string {
|
||||
const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
|
||||
const bx = side === "left" ? b.x - dx : b.x + dx;
|
||||
return `M${a.x},${a.y} C${a.x + dx},${a.y} ${bx},${b.y} ${b.x},${b.y}`;
|
||||
}
|
||||
|
||||
/** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — Eval 의 level 그대로 */
|
||||
export const isRest = (k: CfBreak): boolean => k.level === "쉼";
|
||||
|
||||
/** 쉼 모양 자리 — 선 · 받는 알약(「상자|알약」) · 흐릴 상자
|
||||
* 상자 흐림은 들어오는 선이 모두 쉼일 때 · 또는 상자에 걸린 쉼이 있고 그 상자 내보내는 값이 모두 빈 값일 때만 */
|
||||
export function restParts(
|
||||
lg: CfLogic,
|
||||
rests: CfBreak[],
|
||||
values: Record<string, Record<string, unknown>>,
|
||||
): { links: Set<string>; pills: Set<string>; boxes: Set<string> } {
|
||||
const links = new Set(rests.map((k) => k.link).filter((x): x is string => !!x));
|
||||
const pills = new Set(
|
||||
lg.links.filter((l) => links.has(l.id)).map((l) => `${l.to.box}|${l.to.name}`),
|
||||
);
|
||||
const boxes = new Set<string>();
|
||||
const empty = (v: unknown): boolean => v === null || v === undefined || v === "";
|
||||
for (const b of lg.boxes) {
|
||||
const incoming = lg.links.filter((l) => l.to.box === b.id);
|
||||
const allRest = incoming.length > 0 && incoming.every((l) => links.has(l.id));
|
||||
// 내보내는 알약 값만(들어온 값은 빼고) · 쉼이 그 상자에 걸렸나(Eval 쉼 줄은 link 가 늘 있음)
|
||||
const outs = portsOut(b).map((p) => values[b.id]?.[p.name]);
|
||||
const hit = rests.some((k) => k.box === b.id);
|
||||
if (allRest || (hit && outs.every(empty))) boxes.add(b.id);
|
||||
}
|
||||
return { links, pills, boxes };
|
||||
}
|
||||
|
||||
/** 고른 알약 한 줄 — 이름 · 지금 값 · 어디서(받는) / 어디로(내보내는) */
|
||||
export function pillInfo(
|
||||
lg: CfLogic,
|
||||
p: DfPillSel,
|
||||
values: Record<string, Record<string, unknown>>,
|
||||
): string {
|
||||
const name = (e: CfPort): string =>
|
||||
`${lg.boxes.find((b) => b.id === e.box)?.name ?? ""} · ${e.name}`;
|
||||
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port;
|
||||
const where = lg.links
|
||||
.filter((l) => mine(p.dir === "in" ? l.to : l.from))
|
||||
.map((l) => name(p.dir === "in" ? l.from : l.to))
|
||||
.join(", ");
|
||||
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`;
|
||||
return [p.port, now, where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */
|
||||
export function follow(
|
||||
ev: PointerEvent,
|
||||
move: (m: PointerEvent) => void,
|
||||
end: (u: PointerEvent, moved: boolean) => void,
|
||||
): void {
|
||||
const x0 = ev.clientX;
|
||||
const y0 = ev.clientY;
|
||||
let moved = false;
|
||||
const mv = (m: PointerEvent): void => {
|
||||
if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return;
|
||||
moved = true;
|
||||
move(m);
|
||||
};
|
||||
const up = (u: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", mv);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (moved) {
|
||||
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
|
||||
const swallow = (c: MouseEvent): void => c.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
}
|
||||
end(u, moved);
|
||||
};
|
||||
window.addEventListener("pointermove", mv);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
|
||||
/* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** SVG 마디 하나 붙이기 */
|
||||
export function svgAdd<K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
tag: K,
|
||||
attrs: Record<string, string>,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const n = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
||||
parent.append(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */
|
||||
export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number {
|
||||
const tag = svgAdd(parent, "g", { class: cls });
|
||||
const box = svgAdd(tag, "rect", { rx: "4", ry: "4" });
|
||||
const t = svgAdd(tag, "text", {
|
||||
x: x.toFixed(1),
|
||||
y: y.toFixed(1),
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
t.textContent = text;
|
||||
const w = (t.getComputedTextLength?.() || text.length * 7) + 10;
|
||||
Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) =>
|
||||
box.setAttribute(k, v.toFixed(1)),
|
||||
);
|
||||
return w;
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Entry_Detail.ts
|
||||
* 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름)
|
||||
* · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움.
|
||||
* 경고 = 끊긴 곳 가운데 고른 것 몫 · 다른 로직 입력이 부르는 로직 읽기(겹쳐 부름) · 입력 값 물음 · 결과 단위 후보.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { CfAnswer, CfQuery } from "./M02_DF_Api";
|
||||
import { fillTemplate } from "./M02_DF_InputPanel_Build";
|
||||
import type { CfBreak, CfValues } from "./M02_DF_Eval";
|
||||
import type { CfElementRef, CfLogic, CfPort } from "./M02_DF_Model";
|
||||
import "@ui/ui_template_text.css";
|
||||
|
||||
const TEXT = {
|
||||
Detail: ["상세 내용", "Details"],
|
||||
Settings: ["설정", "Settings"],
|
||||
Body: ["본문", "Content"],
|
||||
Warn: ["경고", "Warnings"],
|
||||
WarnNone: ["경고 없음", "No warnings"],
|
||||
PickSome: [
|
||||
"다이어그램에서 상자 · 알약 · 선을 고르면 여기에 그 상세 내용이 보임",
|
||||
"Choose a box, pill or line in the diagram",
|
||||
],
|
||||
Line: ["선", "Line"],
|
||||
In: ["받는 알약", "Incoming pill"],
|
||||
Out: ["내보내는 알약", "Outgoing pill"],
|
||||
} as const;
|
||||
|
||||
const tt = (key: keyof typeof TEXT): string =>
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string;
|
||||
|
||||
/** 고른 것 — 상자 · 선 · 알약 */
|
||||
export type DfPick =
|
||||
| { kind: "box"; id: string }
|
||||
| { kind: "link"; id: string }
|
||||
| { kind: "pill"; box: string; port: string; dir: "in" | "out" }
|
||||
| null;
|
||||
|
||||
export interface DetailParts {
|
||||
root: HTMLElement;
|
||||
title: HTMLElement;
|
||||
pill: HTMLElement;
|
||||
hint: HTMLElement;
|
||||
settings: HTMLElement;
|
||||
body: HTMLElement;
|
||||
warn: HTMLElement;
|
||||
}
|
||||
|
||||
const row = (label: string, host: HTMLElement): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-df__detail-row",
|
||||
children: [el("h4", { text: label }), host],
|
||||
});
|
||||
|
||||
/** 상세 내용 칸 — 머리 + 설정 · 본문 · 경고 */
|
||||
export function detailFrame(): DetailParts {
|
||||
const title = el("strong", { className: "m02-df__detail-title" });
|
||||
const pill = el("span", { className: "m02-df__detail-pill" });
|
||||
const hint = el("p", { className: "m02-df__muted", text: tt("PickSome") });
|
||||
const settings = el("div", { className: "m02-df__detail-settings" });
|
||||
const body = el("div", { className: "m02-df__detail-body" });
|
||||
const warn = el("ul", { className: "m02-df__detail-warn" });
|
||||
const root = el("section", {
|
||||
className: "m02-df__part m02-df__detail",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-df__detail-head",
|
||||
children: [
|
||||
el("h3", { text: tt("Detail") }),
|
||||
el("div", { className: "m02-df__detail-name", children: [title, pill] }),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m02-df__part-body",
|
||||
children: [
|
||||
hint,
|
||||
row(tt("Settings"), settings),
|
||||
row(tt("Body"), body),
|
||||
row(tt("Warn"), warn),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
return { root, title, pill, hint, settings, body, warn };
|
||||
}
|
||||
|
||||
const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
|
||||
|
||||
/** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */
|
||||
export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void {
|
||||
d.hint.hidden = !!pick;
|
||||
for (const r of [d.settings, d.body, d.warn]) (r.parentElement as HTMLElement).hidden = !pick;
|
||||
const link = pick?.kind === "link" ? lg.links.find((l) => l.id === pick.id) : undefined;
|
||||
d.title.textContent =
|
||||
pick?.kind === "box"
|
||||
? boxName(lg, pick.id)
|
||||
: pick?.kind === "pill"
|
||||
? boxName(lg, pick.box)
|
||||
: link
|
||||
? `${tt("Line")} · ${boxName(lg, link.from.box)} → ${boxName(lg, link.to.box)}`
|
||||
: "";
|
||||
d.pill.textContent =
|
||||
pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.port}` : "";
|
||||
d.pill.hidden = pick?.kind !== "pill";
|
||||
}
|
||||
|
||||
/** 경고 — 끊긴 곳(Eval breaks) 가운데 고른 것 몫만 · 상자 = 그 상자 · 알약 = 그 알약 · 선 = 그 선 */
|
||||
export function drawWarnings(d: DetailParts, pick: DfPick, all: CfBreak[]): void {
|
||||
const mine = all.filter((k) =>
|
||||
pick?.kind === "box"
|
||||
? k.box === pick.id
|
||||
: pick?.kind === "pill"
|
||||
? k.box === pick.box && k.port === pick.port && !k.link
|
||||
: pick?.kind === "link"
|
||||
? k.link === pick.id
|
||||
: false,
|
||||
);
|
||||
d.warn.replaceChildren(
|
||||
...(mine.length
|
||||
? [...new Set(mine.map((k) => k.text))].map((w) => el("li", { text: w }))
|
||||
: [el("li", { className: "ui-text ui-text--muted", text: tt("WarnNone") })]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 다른 로직 입력이 부르는 로직들 — 그 로직이 또 부르는 것까지(겹쳐 부름 · 한 번씩) · 못 읽은 것은 빼고 까닭 */
|
||||
export async function loadLogics(
|
||||
lg: CfLogic,
|
||||
get: (key: string) => Promise<CfLogic | null>,
|
||||
): Promise<{ logics: Record<string, CfLogic>; errors: string[] }> {
|
||||
const logics: Record<string, CfLogic> = {};
|
||||
const errors: string[] = [];
|
||||
const keysOf = (x: CfLogic): string[] =>
|
||||
x.boxes.flatMap((b) =>
|
||||
b.input?.mode === "logic" && b.input.logic?.key ? [b.input.logic.key] : [],
|
||||
);
|
||||
const seen = new Set<string>(lg.key ? [lg.key] : []);
|
||||
let todo = keysOf(lg);
|
||||
while (todo.length) {
|
||||
const key = todo.shift()!;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
try {
|
||||
const got = await get(key);
|
||||
if (!got) throw new Error(key);
|
||||
logics[key] = got;
|
||||
todo = [...todo, ...keysOf(got)];
|
||||
} catch (e) {
|
||||
errors.push(`${key} — ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
return { logics, errors };
|
||||
}
|
||||
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
export async function unitChoices(): Promise<string[]> {
|
||||
const count = new Map<string, number>();
|
||||
try {
|
||||
for (const x of await fetchLogics()) {
|
||||
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
|
||||
}
|
||||
} catch {
|
||||
/* 못 받으면 기본 하나 */
|
||||
}
|
||||
const units = [...count.entries()]
|
||||
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([u]) => u);
|
||||
return units.length ? units : ["원/㎡"];
|
||||
}
|
||||
|
||||
/** 조건 찾기를 쓰는 그룹 — 재료 밖은 인력 · 기계 · 유가전력(field 키 · 서버 Values.GROUP_RULE) */
|
||||
const RULE_GROUPS = ["인력", "기계", "유가전력"];
|
||||
|
||||
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 재료 자리(알약 = 자리 이름 · 조건 찾기는 이어진 알약 값을
|
||||
* pill 로 읽어 넘김 · 인력 · 기계 · 유가 조건 찾기도) · 표(알약 「표」) · 다른 로직(서버가 받은 설계 값으로 셈 · port 「*」)
|
||||
* · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
|
||||
export function queriesOf(
|
||||
lg: CfLogic,
|
||||
pill: (p: CfPort) => unknown = () => null,
|
||||
elementPort: (e: CfElementRef) => string = (e) => e.name,
|
||||
): { q: CfQuery; box: string; port: string }[] {
|
||||
return lg.boxes.flatMap((b) => {
|
||||
const inp = b.input;
|
||||
if (b.kind === "input" && inp?.mode === "logic" && inp.logic?.key) {
|
||||
const ref = inp.logic;
|
||||
const designs: Record<string, unknown> = {};
|
||||
for (const d of ref.designs) {
|
||||
const l = lg.links.find((x) => x.to.box === b.id && x.to.name === d.name);
|
||||
const v = l ? pill(l.from) : ref.examples?.[d.name];
|
||||
if (v !== null && v !== undefined) designs[d.name] = v;
|
||||
}
|
||||
const q: CfQuery = { id: `${b.id}*`, group: "기계", logic: { key: ref.key, designs } };
|
||||
return [{ q, box: b.id, port: "*" }];
|
||||
}
|
||||
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
|
||||
const group = inp.group;
|
||||
const slots = inp.elements
|
||||
.filter(
|
||||
(e) =>
|
||||
(group === "재료" && e.mode && e.mode !== "fixed") ||
|
||||
(RULE_GROUPS.includes(group) && e.mode === "rule"),
|
||||
)
|
||||
.map((e) => {
|
||||
const port = elementPort(e);
|
||||
const q: CfQuery = {
|
||||
id: `${b.id}\u0001${port}`,
|
||||
group,
|
||||
unit: e.unit,
|
||||
slot: { ...e, field: e.rule?.field, op: e.rule?.op },
|
||||
};
|
||||
// 조건 찾기 — 「검색어」 면 틀에 알약 값을 채운 글 · 그 밖은 이어진 알약 값
|
||||
if (e.mode === "rule" && e.rule)
|
||||
q.value =
|
||||
e.rule.field === "검색어"
|
||||
? fillTemplate(e.rule.template ?? "", e.rule.slots ?? [], pill).text
|
||||
: pill(e.rule.from);
|
||||
return { q, box: b.id, port };
|
||||
});
|
||||
const els = inp.elements
|
||||
.filter((e) => e.ref && !e.ref.startsWith("UE-") && !slots.some((s) => s.port === e.name))
|
||||
.map((e) => {
|
||||
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
|
||||
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
|
||||
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
|
||||
if (field) q.field = field;
|
||||
return { q, box: b.id, port: e.name };
|
||||
});
|
||||
const table = inp.table
|
||||
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
|
||||
: [];
|
||||
return [...slots, ...els, ...table];
|
||||
});
|
||||
}
|
||||
|
||||
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
|
||||
export async function fetchValues(
|
||||
ask: (qs: CfQuery[], els: never) => Promise<CfAnswer[]>,
|
||||
lg: CfLogic,
|
||||
pill?: (p: CfPort) => unknown,
|
||||
elementPort?: (e: CfElementRef) => string,
|
||||
): Promise<{ values: CfValues; errors: string[] }> {
|
||||
const all = queriesOf(lg, pill, elementPort);
|
||||
const asks = all.filter((a) => !a.q.logic);
|
||||
const values: CfValues = {};
|
||||
const errors: string[] = [];
|
||||
// 다른 로직 — 서버가 받은 설계 값으로 셈(그 안 조건 찾기 · 단가까지) → 출력 이름마다 값
|
||||
const logicAsks = all.filter((a) => a.q.logic);
|
||||
const logicDone = logicAsks.length
|
||||
? import("./M02_DF_Api").then(({ cfLogicValue }) =>
|
||||
Promise.all(
|
||||
logicAsks.map((a) =>
|
||||
cfLogicValue(a.q.logic!.key, a.q.logic!.designs).then(
|
||||
(got) => {
|
||||
values[a.box] = { ...(values[a.box] ?? {}), ...(got.outputs as CfValues[string]) };
|
||||
},
|
||||
(e: unknown) => errors.push(`${a.q.logic!.key} — ${String(e)}`),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Promise.resolve();
|
||||
if (!asks.length) {
|
||||
await logicDone;
|
||||
return { values, errors };
|
||||
}
|
||||
const answers = await ask(
|
||||
asks.map((a) => a.q),
|
||||
lg.userElements as never,
|
||||
);
|
||||
await logicDone;
|
||||
for (const a of asks) {
|
||||
const got = answers.find((x) => x.id === a.q.id);
|
||||
if (got && !got.ok && got.blank) (values[a.box] ??= {})[a.port] = "#N/A"; // 빈 단가 — Eval 이 NA()
|
||||
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
|
||||
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
|
||||
}
|
||||
return { values, errors };
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Eval.ts
|
||||
* 단가산출 흐름도 계산 · 검사 — 상자를 기댐 차례대로(입력 → 선 규칙 → 함수 · 다른 로직 → 출력) 셈
|
||||
* 함수 상자 = 시트를 우리 스프레드시트 엔진으로 셈(받는 칸에 앞 값) · 선 규칙 · 끝수도 같은 엔진(`M02_DF_Rule`)
|
||||
* 출력 상자를 비용 종류별로 모아 원가 · 끊긴 곳(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 ·
|
||||
* 조건 겹침 · 조건 안 맞음 · 오류) · DOM 없음
|
||||
* 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import {
|
||||
OUTPUT_PORT,
|
||||
depends,
|
||||
designInputs,
|
||||
elementPort,
|
||||
gateConds,
|
||||
portsIn,
|
||||
portsOut,
|
||||
ruleSources,
|
||||
type CfBox,
|
||||
type CfGateRule,
|
||||
type CfLink,
|
||||
type CfLogic,
|
||||
} from "./M02_DF_Model";
|
||||
import { applyRound, applyRule, ruleLabel } from "./M02_DF_Rule";
|
||||
|
||||
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
|
||||
export type CfValue = number | string | boolean | null | (number | string | null)[][];
|
||||
|
||||
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값 · 다른 로직 출력) */
|
||||
export type CfValues = Record<string, Record<string, CfValue>>;
|
||||
|
||||
type Scalar = number | string | boolean | null;
|
||||
|
||||
/** 함수 상자 칸으로 가는 표 찾기 실패 값 — 칸에 NA() 를 넣음(엑셀과 같게 · IF 가 안 고른 갈래면 무시) */
|
||||
const NA_VALUE = "#N/A";
|
||||
const isErrorValue = (v: unknown): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v);
|
||||
|
||||
/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */
|
||||
export interface CfBreak {
|
||||
kind:
|
||||
| "빈 받는 자리"
|
||||
| "통과 안 됨"
|
||||
| "표에 없는 조건"
|
||||
| "맞는 재료 없음"
|
||||
| "단가 없는 품목"
|
||||
| "조건 겹침"
|
||||
| "조건 안 맞음"
|
||||
| "오류";
|
||||
/** 무게 — 「쉼」 = 조건이 안 맞아 쉬는 갈래(그 아래로 번진 빈 값 포함) · 「문제」 = 고쳐야 할 끊김 */
|
||||
level: "문제" | "쉼";
|
||||
box: string;
|
||||
port?: string;
|
||||
link?: string;
|
||||
/** 쉬운 말 */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CfEvalResult {
|
||||
/** 상자 id → 내보내는 알약 이름 → 값 */
|
||||
ports: Record<string, Record<string, CfValue>>;
|
||||
/** 상자 id → 받는 알약 이름 → 선 규칙을 지나 들어온 값 */
|
||||
arrived?: Record<string, Record<string, CfValue>>;
|
||||
/** 출력 상자 id → 값 */
|
||||
outputs: Record<string, Scalar>;
|
||||
cost: {
|
||||
노무비: number;
|
||||
재료비: number;
|
||||
경비: number;
|
||||
/** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */
|
||||
값들: Record<string, Scalar>;
|
||||
계: number;
|
||||
};
|
||||
/** 쉬운 말 */
|
||||
errors: string[];
|
||||
/** 끊긴 곳(셈 때 생긴 것) */
|
||||
breaks?: CfBreak[];
|
||||
}
|
||||
|
||||
/** 다른 로직 입력을 셀 때 쓸 로직들(저장 · 견본 key → 로직) */
|
||||
export interface CfEvalOptions {
|
||||
logics?: Record<string, CfLogic>;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
/** {box} {cell} {err} */
|
||||
CellError: [
|
||||
"「{box}」 {cell} 칸 계산이 막힘 — {err}",
|
||||
"「{box}」 cell {cell} can't be worked out — {err}",
|
||||
],
|
||||
Cycle: ["돌고 도는 참조", "circular reference"],
|
||||
/** {box} */
|
||||
Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"],
|
||||
NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"],
|
||||
/** {box} {pill} */
|
||||
EmptyIn: [
|
||||
"「{box}」 의 받는 알약 「{pill}」 에 선이 없음",
|
||||
"Pill 「{pill}」 of 「{box}」 has no line",
|
||||
],
|
||||
NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"],
|
||||
NoOutput: ["출력 상자가 없음", "There is no output box"],
|
||||
NoInCell: [
|
||||
"「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음",
|
||||
"Pill 「{pill}」 of 「{box}」 is not placed in a cell",
|
||||
],
|
||||
NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"],
|
||||
/** {from} {box} {rule} */
|
||||
NotPassed: [
|
||||
"「{from}」 → 「{box}」 선이 조건({rule})을 통과하지 않음",
|
||||
"Line 「{from}」 → 「{box}」 doesn't pass ({rule})",
|
||||
],
|
||||
NoPrice: [
|
||||
"「{box}」 — 단가 없는 품목을 씀(「{pill}」)",
|
||||
"「{box}」 — uses an item with no price (「{pill}」)",
|
||||
],
|
||||
NoRow: [
|
||||
"「{from}」 → 「{box}」 선 — 표에 맞는 줄이 없음",
|
||||
"Line 「{from}」 → 「{box}」 — no matching table row",
|
||||
],
|
||||
ManyRows: [
|
||||
"「{from}」 → 「{box}」 선 — 표에 맞는 줄이 여럿",
|
||||
"Line 「{from}」 → 「{box}」 — several table rows match",
|
||||
],
|
||||
/** {from} {box} */
|
||||
Rest: [
|
||||
"「{from}」 → 「{box}」 — 앞 조건이 안 맞아 쉬는 갈래",
|
||||
"「{from}」 → 「{box}」 — resting branch (an earlier condition doesn't match)",
|
||||
],
|
||||
RuleError: [
|
||||
"「{from}」 → 「{box}」 선 규칙을 셀 수 없음",
|
||||
"Line 「{from}」 → 「{box}」 rule can't be worked out",
|
||||
],
|
||||
/** {box} {pill} */
|
||||
NoMaterial: [
|
||||
"「{box}」 의 「{pill}」 — 조건에 맞는 재료가 없음",
|
||||
"「{pill}」 of 「{box}」 — no material matches",
|
||||
],
|
||||
Overlap: [
|
||||
"「{box}」 의 「{pill}」 에서 나간 조건 선이 둘 이상 통과함",
|
||||
"Several condition lines from 「{pill}」 of 「{box}」 pass",
|
||||
],
|
||||
NoneMatch: [
|
||||
"「{box}」 의 「{pill}」 에서 나간 조건 선이 하나도 통과하지 않음",
|
||||
"No condition line from 「{pill}」 of 「{box}」 passes",
|
||||
],
|
||||
NoLogic: [
|
||||
"「{box}」 이 부르는 로직을 찾을 수 없음",
|
||||
"The logic used by 「{box}」 can't be found",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
|
||||
Object.entries(fill).reduce(
|
||||
(s, [k, v]) => s.replace(`{${k}}`, v),
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
|
||||
);
|
||||
|
||||
/* ── 차례 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 상자 차례 — 기댐(선 · 규칙 조건 · 재료 조건)대로 · 돌고 도는 상자는 loop */
|
||||
function order(lg: CfLogic): { boxes: CfBox[]; loop: CfBox[] } {
|
||||
const deps = depends(lg);
|
||||
const done: CfBox[] = [];
|
||||
const left = new Set(lg.boxes.map((b) => b.id));
|
||||
for (let moved = true; moved && left.size;) {
|
||||
moved = false;
|
||||
for (const b of lg.boxes) {
|
||||
if (!left.has(b.id)) continue;
|
||||
if (deps.some((d) => d.to === b.id && d.from !== b.id && left.has(d.from))) continue;
|
||||
done.push(b);
|
||||
left.delete(b.id);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
return { boxes: done, loop: lg.boxes.filter((b) => left.has(b.id)) };
|
||||
}
|
||||
|
||||
/* ── 칸 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function putValue(칸: Record<string, Cell>, cell: string, value: Scalar): void {
|
||||
const style = 칸[cell]?.서식;
|
||||
if (value === null) {
|
||||
if (style) 칸[cell] = { 서식: style };
|
||||
else delete 칸[cell];
|
||||
} else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) };
|
||||
}
|
||||
|
||||
/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */
|
||||
function put(칸: Record<string, Cell>, cell: string, value: CfValue): void {
|
||||
const at = parseA1(cell);
|
||||
if (!at) return;
|
||||
if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value);
|
||||
value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x)));
|
||||
}
|
||||
|
||||
const fromCalc = (v: CalcValue | undefined): Scalar => {
|
||||
if (!v) return null;
|
||||
if ("수" in v) return Number(v.수);
|
||||
if ("글" in v) return v.글;
|
||||
if ("참" in v) return v.참;
|
||||
return v.오류;
|
||||
};
|
||||
|
||||
/* ── 상자마다 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */
|
||||
function inputPorts(
|
||||
lg: CfLogic,
|
||||
box: CfBox,
|
||||
given: Record<string, CfValue>,
|
||||
): Record<string, CfValue> {
|
||||
const out: Record<string, CfValue> = {};
|
||||
const inp = box.input;
|
||||
if (!inp) return out;
|
||||
const pills = portsOut(box);
|
||||
const sources: CfValue[] = [
|
||||
...(inp.mode === "group"
|
||||
? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null)
|
||||
: []),
|
||||
...(inp.mode === "group" && inp.table ? [null] : []),
|
||||
...inp.variables.map((v) => v.example),
|
||||
];
|
||||
pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null)));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 · 출력 끝수 */
|
||||
function runFunc(
|
||||
box: CfBox,
|
||||
arrive: Record<string, CfValue>,
|
||||
given: Record<string, CfValue>,
|
||||
errors: string[],
|
||||
): Scalar {
|
||||
const func = box.func!;
|
||||
const sheet: Sheet = structuredClone(func.sheet);
|
||||
for (const i of func.inputs) {
|
||||
const v = arrive[i.port] ?? given[i.port] ?? null;
|
||||
if (isErrorValue(v))
|
||||
sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; // 오류 값 · 빈 단가
|
||||
else put(sheet.칸, i.cell, v);
|
||||
}
|
||||
for (const v of func.variables)
|
||||
if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example);
|
||||
const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
|
||||
for (const [cell, v] of Object.entries(values[sheet.id] ?? {}))
|
||||
if ("오류" in v && sheet.칸[cell]?.식 !== "NA()") {
|
||||
const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류;
|
||||
errors.push(tl("CellError", { box: box.name, cell, err }));
|
||||
}
|
||||
const at = func.output.cell.toUpperCase();
|
||||
const c = sheet.칸[at];
|
||||
const got = !c ? null : c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
|
||||
return typeof got === "number" && func.output.round ? applyRound(func.output.round, got) : got;
|
||||
}
|
||||
|
||||
/* ── 계산 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 화면 계산 — opts.logics 가 있으면 다른 로직 입력을 그 로직으로 셈(없으면 values 의 값) */
|
||||
export function evalLogic(
|
||||
lg: CfLogic,
|
||||
values: CfValues = {},
|
||||
opts: CfEvalOptions = {},
|
||||
): CfEvalResult {
|
||||
const errors: string[] = [];
|
||||
const found: CfBreak[] = [];
|
||||
const ports: CfEvalResult["ports"] = {};
|
||||
const arrived: NonNullable<CfEvalResult["arrived"]> = {};
|
||||
const name = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
|
||||
const valueOf = (box: string, pill: string): CfValue => ports[box]?.[pill] ?? null;
|
||||
const passed = new Map<string, boolean>();
|
||||
/** 선 규칙에서 막힌 상자 — 그 함수는 셈하지 않고 빈 값을 넘김(갈래가 끊김) */
|
||||
const blocked = new Set<string>();
|
||||
/** 조건이 안 맞아 쉬는 상자 — 그 아래로 번짐 */
|
||||
const resting = new Set<string>();
|
||||
/** 함수 상자로 가는 표 찾기 실패 — 그 함수 결과가 오류일 때만 끊긴 곳(엑셀과 같게) */
|
||||
const late = new Map<string, CfBreak[]>();
|
||||
/** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */
|
||||
const through = (l: CfLink): CfValue => {
|
||||
const v = valueOf(l.from.box, l.from.name);
|
||||
// 빈 단가(#N/A) — 옛 엔진처럼 그 값을 쓰는 식은 빈 값 · 결과가 오류일 때만 끊긴 곳(late)
|
||||
if (v === NA_VALUE && lg.boxes.find((b) => b.id === l.from.box)?.input?.mode === "group") {
|
||||
const text = tl("NoPrice", { box: name(l.to.box), pill: l.from.name });
|
||||
const brk: CfBreak = {
|
||||
kind: "단가 없는 품목",
|
||||
level: "문제",
|
||||
box: l.to.box,
|
||||
port: l.to.name,
|
||||
link: l.id,
|
||||
text,
|
||||
};
|
||||
late.set(l.to.box, [...(late.get(l.to.box) ?? []), brk]);
|
||||
}
|
||||
if (!l.rule) return v;
|
||||
if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null;
|
||||
const conds = Object.fromEntries(
|
||||
ruleSources(l.rule).flatMap((p) => [
|
||||
[p.name, valueOf(p.box, p.name)],
|
||||
[`${p.box}|${p.name}`, valueOf(p.box, p.name)],
|
||||
]),
|
||||
);
|
||||
const res = applyRule(l.rule, v, conds);
|
||||
if (l.rule.kind === "gate") passed.set(l.id, res.why === null);
|
||||
const fill = { from: name(l.from.box), box: name(l.to.box), rule: ruleLabel(l.rule) };
|
||||
if (res.why === "통과 안 됨") {
|
||||
resting.add(l.to.box);
|
||||
found.push({
|
||||
kind: "통과 안 됨",
|
||||
level: "쉼",
|
||||
box: l.to.box,
|
||||
port: l.to.name,
|
||||
link: l.id,
|
||||
text: tl("NotPassed", fill),
|
||||
});
|
||||
} else if (res.why) {
|
||||
const kind = res.why === "표에 없는 조건" ? "표에 없는 조건" : "오류";
|
||||
const key =
|
||||
res.why === "표에 없는 조건"
|
||||
? "NoRow"
|
||||
: res.why === "표에 여럿 맞음"
|
||||
? "ManyRows"
|
||||
: "RuleError";
|
||||
const text = tl(key, fill);
|
||||
const brk: CfBreak = {
|
||||
kind,
|
||||
level: "문제",
|
||||
box: l.to.box,
|
||||
port: l.to.name,
|
||||
link: l.id,
|
||||
text,
|
||||
};
|
||||
if (
|
||||
l.rule.kind === "lookup" &&
|
||||
lg.boxes.find((b) => b.id === l.to.box)?.kind === "function"
|
||||
) {
|
||||
late.set(l.to.box, [...(late.get(l.to.box) ?? []), brk]);
|
||||
return NA_VALUE;
|
||||
}
|
||||
found.push(brk);
|
||||
errors.push(text);
|
||||
}
|
||||
if (res.why) blocked.add(l.to.box);
|
||||
return res.why ? null : res.value;
|
||||
};
|
||||
/** 받는 값 — 쉬는 갈래에서 온 빈 값은 「쉼」 줄만 남김 · 들어오는 선이 모두 쉬는 갈래면 받는 상자도 쉼
|
||||
* (하나라도 살아 있으면 셈 · 쉬는 갈래 값은 빈 칸) · 조건 선 바로 뒤 상자는 through 가 막음 */
|
||||
const arrive = (box: CfBox): Record<string, CfValue> => {
|
||||
const ins = lg.links.filter((l) => l.to.box === box.id);
|
||||
const got: Record<string, CfValue> = {};
|
||||
let rest = 0;
|
||||
for (const l of ins) {
|
||||
if (resting.has(l.from.box) && valueOf(l.from.box, l.from.name) === null) {
|
||||
rest++;
|
||||
got[l.to.name] = null;
|
||||
const text = tl("Rest", { from: name(l.from.box), box: name(l.to.box) });
|
||||
found.push({
|
||||
kind: "통과 안 됨",
|
||||
level: "쉼",
|
||||
box: l.to.box,
|
||||
port: l.to.name,
|
||||
link: l.id,
|
||||
text,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
got[l.to.name] = through(l);
|
||||
}
|
||||
if (ins.length && rest === ins.length) {
|
||||
resting.add(box.id);
|
||||
blocked.add(box.id);
|
||||
}
|
||||
arrived[box.id] = got;
|
||||
return got;
|
||||
};
|
||||
const { boxes, loop } = order(lg);
|
||||
for (const b of loop) errors.push(tl("Loop", { box: b.name }));
|
||||
const outputs: CfEvalResult["outputs"] = {};
|
||||
const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
|
||||
for (const b of boxes) {
|
||||
const given = values[b.id] ?? {};
|
||||
if (b.kind === "input" && b.input?.mode !== "logic") {
|
||||
ports[b.id] = inputPorts(lg, b, given);
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && ports[b.id][elementPort(e)] === null) {
|
||||
const text = tl("NoMaterial", { box: b.name, pill: elementPort(e) });
|
||||
found.push({
|
||||
kind: "맞는 재료 없음",
|
||||
level: "문제",
|
||||
box: b.id,
|
||||
port: elementPort(e),
|
||||
text,
|
||||
});
|
||||
errors.push(text);
|
||||
}
|
||||
} else if (b.kind === "input") ports[b.id] = otherLogic(b, arrive(b), given, opts, errors);
|
||||
else if (b.kind === "function") {
|
||||
const got = arrive(b);
|
||||
const out = blocked.has(b.id) ? null : runFunc(b, got, given, errors);
|
||||
ports[b.id] = { [b.func!.output.name]: out };
|
||||
if (out === null || isErrorValue(out))
|
||||
for (const brk of late.get(b.id) ?? []) {
|
||||
found.push(brk);
|
||||
errors.push(brk.text);
|
||||
}
|
||||
} else {
|
||||
const v = arrive(b)[OUTPUT_PORT] ?? null;
|
||||
let scalar: Scalar = Array.isArray(v) ? null : v;
|
||||
const round = b.output?.round;
|
||||
if (typeof scalar === "number" && round) scalar = applyRound(round, scalar);
|
||||
outputs[b.id] = scalar;
|
||||
const kind = b.output?.cost ?? "값";
|
||||
if (kind === "값") cost.값들[b.name] = scalar;
|
||||
else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar;
|
||||
else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name }));
|
||||
}
|
||||
}
|
||||
cost.계 = cost.노무비 + cost.재료비 + cost.경비;
|
||||
gates(lg, passed, name, found, errors);
|
||||
return { ports, arrived, outputs, cost, errors, breaks: found };
|
||||
}
|
||||
|
||||
/** 다른 로직 입력 — 그 로직을 받은 값(설계 때 정할 것)으로 셈 · 없으면 넘긴 값 */
|
||||
function otherLogic(
|
||||
box: CfBox,
|
||||
arrive: Record<string, CfValue>,
|
||||
given: Record<string, CfValue>,
|
||||
opts: CfEvalOptions,
|
||||
errors: string[],
|
||||
): Record<string, CfValue> {
|
||||
const ref = box.input?.logic;
|
||||
const out: Record<string, CfValue> = {};
|
||||
// 서버가 받은 설계 값으로 셈한 출력(Entry_Detail 다른 로직 물음)이 있으면 그것 — 그 안 단가 · 조건 찾기까지 정본
|
||||
if (ref?.outputs.some((p) => given[p.name] !== undefined)) {
|
||||
for (const p of ref.outputs) out[p.name] = given[p.name] ?? null;
|
||||
return out;
|
||||
}
|
||||
const other = ref ? opts.logics?.[ref.key] : undefined;
|
||||
if (!other || (opts.depth ?? 0) > 8) {
|
||||
if (ref && opts.logics && !other) errors.push(tl("NoLogic", { box: box.name }));
|
||||
for (const p of ref?.outputs ?? []) out[p.name] = given[p.name] ?? null;
|
||||
return out;
|
||||
}
|
||||
const inner: CfValues = {};
|
||||
for (const d of designInputs(other)) {
|
||||
// 값 차례 = 이어진 선 > 넘긴 값 > 상자에 둔 예시값 > 그 로직 자기 예시값(inner 에 안 넣음)
|
||||
const v = d.port in arrive ? arrive[d.port] : (given[d.port] ?? ref?.examples?.[d.port]);
|
||||
if (v !== undefined && v !== null) (inner[d.box] ??= {})[d.port] = v;
|
||||
}
|
||||
const ev = evalLogic(other, inner, { logics: opts.logics, depth: (opts.depth ?? 0) + 1 });
|
||||
for (const e of ev.errors) errors.push(`「${box.name}」 · ${e}`);
|
||||
for (const p of ref?.outputs ?? []) {
|
||||
const ob = other.boxes.find((b) => b.kind === "output" && b.name === p.name);
|
||||
out[p.name] = ob ? (ev.outputs[ob.id] ?? null) : (given[p.name] ?? null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 한 알약에서 나간 조건 선(둘 이상) — 겹침 · 안 맞음 */
|
||||
function gates(
|
||||
lg: CfLogic,
|
||||
passed: Map<string, boolean>,
|
||||
name: (id: string) => string,
|
||||
found: CfBreak[],
|
||||
errors: string[],
|
||||
): void {
|
||||
const groups = new Map<string, CfLink[]>();
|
||||
for (const l of lg.links)
|
||||
if (l.rule?.kind === "gate") {
|
||||
// 같은 알약에서 나가 같은 조건 알약들로 가르는 선끼리 — 여러 조건이면 조건 알약 묶음이 같을 때
|
||||
const srcs = ruleSources(l.rule)
|
||||
.map((p) => `${p.box}|${p.name}`)
|
||||
.sort()
|
||||
.join("+");
|
||||
const k = `${srcs}|${l.from.box}|${l.from.name}`;
|
||||
groups.set(k, [...(groups.get(k) ?? []), l]);
|
||||
}
|
||||
for (const list of groups.values()) {
|
||||
if (list.length < 2) continue;
|
||||
const n = list.filter((l) => passed.get(l.id)).length;
|
||||
if (n === 1) continue;
|
||||
const g = gateConds(list[0].rule as CfGateRule).conds[0].from;
|
||||
const fill = { box: name(g.box), pill: g.name };
|
||||
const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill);
|
||||
found.push({
|
||||
kind: n > 1 ? "조건 겹침" : "조건 안 맞음",
|
||||
level: "문제",
|
||||
box: g.box,
|
||||
port: g.name,
|
||||
text,
|
||||
});
|
||||
errors.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 검사 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 끊긴 곳 목록 — 빈 받는 자리(다른 로직의 받는 알약은 설계 때 정할 것이라 뺌) + 셈 때 생긴 것 */
|
||||
export function breaks(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): CfBreak[] {
|
||||
const out: CfBreak[] = [];
|
||||
for (const b of lg.boxes) {
|
||||
if (b.kind === "input") continue;
|
||||
for (const p of portsIn(b))
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
|
||||
out.push({
|
||||
kind: "빈 받는 자리",
|
||||
level: "문제",
|
||||
box: b.id,
|
||||
port: p.name,
|
||||
text: tl("EmptyIn", { box: b.name, pill: p.name }),
|
||||
});
|
||||
}
|
||||
return [...out, ...(evalLogic(lg, values, opts).breaks ?? [])];
|
||||
}
|
||||
|
||||
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 칸에 안 놓인 받는 알약 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
|
||||
export function checkLogic(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): string[] {
|
||||
const out: string[] = [];
|
||||
for (const b of lg.boxes) {
|
||||
if (b.kind !== "input")
|
||||
for (const p of portsIn(b))
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
|
||||
out.push(tl("EmptyIn", { box: b.name, pill: p.name }));
|
||||
if (b.kind === "function" && !parseA1(b.func?.output.cell ?? ""))
|
||||
out.push(tl("NoCell", { box: b.name }));
|
||||
for (const i of b.func?.inputs ?? [])
|
||||
if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port }));
|
||||
}
|
||||
if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput"));
|
||||
const ev = evalLogic(lg, values, opts);
|
||||
for (const b of lg.boxes.filter((x) => x.kind === "input" && x.input?.mode !== "logic"))
|
||||
for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {}))
|
||||
if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill))
|
||||
out.push(tl("NoValue", { box: b.name, pill }));
|
||||
return [...new Set([...out, ...ev.errors])];
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FormulaView.ts
|
||||
* 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 ·
|
||||
* IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text";
|
||||
import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { CfBox } from "./M02_DF_Model";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
|
||||
export type CellScalar = number | string | boolean | null;
|
||||
|
||||
export interface FormulaBranch {
|
||||
when: string;
|
||||
then: string;
|
||||
/** 지금 타는 갈래 */
|
||||
now: boolean;
|
||||
}
|
||||
|
||||
export interface FormulaLine {
|
||||
/** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */
|
||||
name: string;
|
||||
/** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */
|
||||
named: string;
|
||||
/** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */
|
||||
valued: string;
|
||||
/** IF 면 만약 / 아니면 줄 */
|
||||
branches?: FormulaBranch[];
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Title: ["식 보기", "Formulas"],
|
||||
Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"],
|
||||
Else: ["아니면", "Otherwise"],
|
||||
If: ["만약", "If"],
|
||||
Now: ["지금", "now"],
|
||||
NoValue: ["값 없음", "no value"],
|
||||
} as const;
|
||||
|
||||
const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const REF = /(?<![A-Za-z0-9_.'!])\$?([A-Z]{1,3})\$?(\d+)(?![A-Za-z0-9_(])/g;
|
||||
|
||||
/** 따옴표 안 글은 그대로 두고 바깥 글만 바꿈 */
|
||||
function outside(text: string, fn: (part: string) => string): string {
|
||||
return text
|
||||
.split(/("(?:[^"]|"")*")/)
|
||||
.map((p, i) => (i % 2 ? p : fn(p)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, "");
|
||||
|
||||
/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */
|
||||
export function cellNames(box: CfBox): Record<string, string> {
|
||||
const f = box.func;
|
||||
const out: Record<string, string> = {};
|
||||
if (!f) return out;
|
||||
for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port;
|
||||
for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name;
|
||||
if (f.output.cell) out[upper(f.output.cell)] = f.output.name;
|
||||
return out;
|
||||
}
|
||||
|
||||
const refsIn = (formula: string): string[] => {
|
||||
const found: string[] = [];
|
||||
outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m)));
|
||||
return found;
|
||||
};
|
||||
|
||||
const OPS: [RegExp, string][] = [
|
||||
[/\*/g, " × "],
|
||||
[/\//g, " ÷ "],
|
||||
[/<>/g, " ≠ "],
|
||||
[/>=/g, " ≥ "],
|
||||
[/<=/g, " ≤ "],
|
||||
[/(?<![<>≠≥≤])=/g, " = "],
|
||||
[/\+/g, " + "],
|
||||
[/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "],
|
||||
];
|
||||
|
||||
/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */
|
||||
function pretty(text: string): string {
|
||||
return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p))
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const fmt = (n: number): string =>
|
||||
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 오류 글(#N/A …)은 값이 없다는 뜻 — 따옴표째 보이지 않음 */
|
||||
const isError = (v: CellScalar | undefined): boolean =>
|
||||
typeof v === "string" && /^#[A-Z/0!?]+$/.test(v);
|
||||
|
||||
function showScalar(v: CellScalar | undefined): string {
|
||||
if (isError(v)) return tv("NoValue");
|
||||
if (v === undefined || v === null) return "0";
|
||||
if (typeof v === "number") return fmt(v);
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return `"${v}"`;
|
||||
}
|
||||
|
||||
/** 이름으로 쓴 식 */
|
||||
export function namedText(formula: string, names: Record<string, string>): string {
|
||||
return pretty(
|
||||
outside(formula, (p) =>
|
||||
p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 값 넣은 식(값이 문자면 따옴표) */
|
||||
export function valuedText(formula: string, values: Record<string, CellScalar>): string {
|
||||
return pretty(
|
||||
outside(formula, (p) =>
|
||||
p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 갈래 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const num = (v: CellScalar | undefined): number | null => {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */
|
||||
function holds(cond: string, values: Record<string, CellScalar>): boolean | null {
|
||||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond);
|
||||
if (!m) return null;
|
||||
const read = (t: string): CellScalar | undefined => {
|
||||
const x = t.trim();
|
||||
const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x);
|
||||
if (cell) return values[`${cell[1]}${cell[2]}`];
|
||||
const q = /^"((?:[^"]|"")*)"$/.exec(x);
|
||||
if (q) return q[1].replace(/""/g, '"');
|
||||
return Number.isFinite(Number(x)) ? Number(x) : undefined;
|
||||
};
|
||||
const a = read(m[1]);
|
||||
const b = read(m[3]);
|
||||
if (a === undefined || b === undefined) return null;
|
||||
const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)];
|
||||
switch (m[2]) {
|
||||
case "=":
|
||||
return l === r;
|
||||
case "<>":
|
||||
return l !== r;
|
||||
case ">=":
|
||||
return l >= r;
|
||||
case "<=":
|
||||
return l <= r;
|
||||
case ">":
|
||||
return l > r;
|
||||
default:
|
||||
return l < r;
|
||||
}
|
||||
}
|
||||
|
||||
/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */
|
||||
function branchesOf(
|
||||
formula: string,
|
||||
names: Record<string, string>,
|
||||
values: Record<string, CellScalar>,
|
||||
): FormulaBranch[] | undefined {
|
||||
const out: FormulaBranch[] = [];
|
||||
let rest = formula;
|
||||
let taken = false;
|
||||
for (;;) {
|
||||
const body = ifBody(rest);
|
||||
if (body === null) break;
|
||||
const a = splitArgs(body);
|
||||
if (a.length !== 3) return undefined;
|
||||
const ok = holds(a[0], values) === true && !taken;
|
||||
if (ok) taken = true;
|
||||
out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok });
|
||||
rest = a[2];
|
||||
}
|
||||
if (!out.length) return undefined;
|
||||
out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 줄 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */
|
||||
export function formulaLines(box: CfBox, values: Record<string, CellScalar>): FormulaLine[] {
|
||||
const f = box.func;
|
||||
if (!f?.output.cell) return [];
|
||||
const names = cellNames(box);
|
||||
const cells = f.sheet.칸;
|
||||
const order: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (a1: string): void => {
|
||||
const key = upper(a1);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const formula = cells[key]?.식;
|
||||
if (formula === undefined) return;
|
||||
for (const r of refsIn(formula)) visit(r);
|
||||
order.push(key);
|
||||
};
|
||||
visit(f.output.cell);
|
||||
return order.map((key) => {
|
||||
const formula = cells[key].식 as string;
|
||||
const result = showScalar(values[key]);
|
||||
const branches = branchesOf(formula, names, values);
|
||||
return {
|
||||
name: names[key] ?? key,
|
||||
named: namedText(formula, names),
|
||||
valued: `${valuedText(formula, values)} = ${result}`,
|
||||
...(branches ? { branches } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */
|
||||
export function cellValuesOf(
|
||||
box: CfBox,
|
||||
arrived: Record<string, unknown> | undefined,
|
||||
): Record<string, CellScalar> {
|
||||
const f = box.func;
|
||||
if (!f) return {};
|
||||
const sheet = structuredClone(f.sheet);
|
||||
const scalar = (v: unknown): number | string | boolean | undefined =>
|
||||
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined;
|
||||
const put = (cell: string, v: number | string | boolean | undefined): void => {
|
||||
if (!parseA1(cell) || v === undefined) return;
|
||||
sheet.칸[upper(cell)] = { 값: v };
|
||||
};
|
||||
for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port]));
|
||||
for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example);
|
||||
const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
|
||||
const out: Record<string, CellScalar> = {};
|
||||
for (const [key, cell] of Object.entries(sheet.칸)) {
|
||||
const c: CalcValue | undefined = calc[sheet.id]?.[key];
|
||||
out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromCalc(v: CalcValue): CellScalar {
|
||||
if ("수" in v) return Number(v.수);
|
||||
if ("글" in v) return v.글;
|
||||
if ("참" in v) return v.참;
|
||||
return v.오류;
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */
|
||||
export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void {
|
||||
host.classList.add("ui-card", "m02-df-formula");
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-df-formula__title", text: tv("Title") }),
|
||||
...(lines.length
|
||||
? lines.map((l) =>
|
||||
el("div", {
|
||||
className: "m02-df-formula__line",
|
||||
children: [
|
||||
el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }),
|
||||
el("div", { className: "m02-df-formula__valued", text: l.valued }),
|
||||
...(l.branches
|
||||
? [
|
||||
el("ul", {
|
||||
className: "m02-df-formula__branches",
|
||||
children: l.branches.map((b, i) =>
|
||||
el("li", {
|
||||
className: b.now ? "is-now" : "",
|
||||
text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
)
|
||||
: [createEmpty({ text: tv("Empty"), small: true })]),
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FuncExtra.ts
|
||||
* 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것
|
||||
* 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition";
|
||||
import { renderCellTable } from "./M02_DF_CellTable";
|
||||
import type { DetailOptions } from "./M02_DF_Detail";
|
||||
import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView";
|
||||
import { TEXT } from "./M02_DF_FuncText";
|
||||
import { valueChoices, type CfFunc, type CfLogic, type CfVariable } from "./M02_DF_Model";
|
||||
|
||||
export interface ExtraContext {
|
||||
lg: CfLogic;
|
||||
boxId: string;
|
||||
detail: DetailOptions;
|
||||
/** 선 규칙을 지나 들어온 값 */
|
||||
arrived: Record<string, unknown>;
|
||||
readOnly: boolean;
|
||||
place(port: string, cell: string): void;
|
||||
setOutput(cell: string): void;
|
||||
patch(make: (f: CfFunc) => CfFunc): void;
|
||||
used(f: CfFunc): Map<string, string>;
|
||||
}
|
||||
|
||||
const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0];
|
||||
const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId);
|
||||
|
||||
/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */
|
||||
export function drawFormula(host: HTMLElement, x: ExtraContext): void {
|
||||
const box = boxOf(x);
|
||||
if (!box?.func) return;
|
||||
renderFormulaView(host, formulaLines(box, cellValuesOf(box, x.arrived)));
|
||||
}
|
||||
|
||||
/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */
|
||||
export function drawCells(host: HTMLElement, x: ExtraContext): void {
|
||||
const box = boxOf(x);
|
||||
if (!box?.func) return;
|
||||
renderCellTable(host, x.lg, box, x.readOnly, {
|
||||
move: (kind, key, cell) => {
|
||||
if (kind === "in") return x.place(key, cell);
|
||||
if (kind === "out") return x.setOutput(cell);
|
||||
if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error");
|
||||
x.patch((f) => ({
|
||||
...f,
|
||||
variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)),
|
||||
}));
|
||||
},
|
||||
release: (kind, key) =>
|
||||
x.patch((f) =>
|
||||
kind === "in"
|
||||
? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) }
|
||||
: kind === "var"
|
||||
? { ...f, variables: f.variables.filter((v) => v.name !== key) }
|
||||
: { ...f, output: { ...f.output, cell: "" } },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸
|
||||
* get 으로 부를 때마다 지금 상태를 다시 읽음(나중에 놓은 알약 · 만든 변수도 목록에 뜸) */
|
||||
export function conditionsOf(get: () => ExtraContext): ConditionOptions {
|
||||
return {
|
||||
valuesFor: (a1) => {
|
||||
const x = get();
|
||||
const f = boxOf(x)?.func;
|
||||
const at = a1.toUpperCase();
|
||||
const v = f?.variables.find((y) => y.cell?.toUpperCase() === at);
|
||||
if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null;
|
||||
const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port;
|
||||
const link = port
|
||||
? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port)
|
||||
: undefined;
|
||||
return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null;
|
||||
},
|
||||
pills: () => {
|
||||
const x = get();
|
||||
const f = boxOf(x)?.func;
|
||||
return [
|
||||
...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })),
|
||||
...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 변수 만들기 폼 → 변수 · 틀리면 까닭(글 열쇠) — 같은 이름 · 이미 쓰는 칸 · 숫자 아님 · 범위 거꾸로 */
|
||||
export interface VarFormValues {
|
||||
name: string;
|
||||
type: CfVariable["type"];
|
||||
unit: string;
|
||||
min: string;
|
||||
max: string;
|
||||
choices: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export function variableFrom(
|
||||
form: VarFormValues,
|
||||
f: CfFunc,
|
||||
cell: string,
|
||||
used: (f: CfFunc) => Map<string, string>,
|
||||
):
|
||||
| { v: CfVariable; cell: string }
|
||||
| { why: "NeedName" | "SameName" | "Busy" | "NotNumber" | "BadRange" } {
|
||||
const name = form.name.trim();
|
||||
if (!name) return { why: "NeedName" };
|
||||
const mine = f.variables.find((v) => v.name === name);
|
||||
if (!mine && [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)].includes(name))
|
||||
return { why: "SameName" };
|
||||
if (used({ ...f, variables: f.variables.filter((v) => v.name !== name) }).has(cell))
|
||||
return { why: "Busy" };
|
||||
const v: CfVariable = {
|
||||
name,
|
||||
type: form.type,
|
||||
unit: form.unit.trim() || "-",
|
||||
example: null,
|
||||
cell,
|
||||
};
|
||||
let example: number | string | null = form.example.trim() === "" ? null : form.example.trim();
|
||||
if (form.type === "number") {
|
||||
if (example !== null) {
|
||||
const n = Number(String(example).replace(/,/g, ""));
|
||||
if (!Number.isFinite(n)) return { why: "NotNumber" };
|
||||
example = n;
|
||||
}
|
||||
const lo = form.min.trim() === "" ? null : Number(form.min);
|
||||
const hi = form.max.trim() === "" ? null : Number(form.max);
|
||||
if (lo !== null && hi !== null) {
|
||||
if (!(lo < hi)) return { why: "BadRange" };
|
||||
v.range = [lo, hi];
|
||||
}
|
||||
} else if (form.type === "choice") {
|
||||
v.choices = form.choices
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
v.example = example;
|
||||
return { v, cell };
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
.m02-dfpanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m02-dfpanel-head,
|
||||
.m02-dfpanel-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dfpanel-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.m02-dfpanel-title {
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.m02-dfpanel-unitbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m02-dfpanel-unit {
|
||||
width: 6em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m02-dfpanel-label {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 겉 = 카드 부품 · 여기는 아래 맞춤 · 안 여백만 */
|
||||
.m02-dfpanel-form.ui-card {
|
||||
align-items: flex-end;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-dfpanel-fieldbox {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m02-dfpanel-field {
|
||||
width: 9em;
|
||||
}
|
||||
|
||||
.m02-dfpanel-field.is-num {
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
/* 알림 = 띠 알림 부품 — 숨김은 부품 display 를 이김 */
|
||||
.m02-dfpanel-msg {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m02-dfpanel-msg[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 받음 · 변수 · 출력 알약 = 배지 부품(info · warning · success) — 안 놓인 받음은 점선 */
|
||||
.m02-dfpanel-chip {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button.m02-dfpanel-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.m02-dfpanel-chip:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.m02-dfpanel-chip.is-in:not(.is-placed) {
|
||||
border-color: var(--badge-tone);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
|
||||
.m02-dfpanel-sheet {
|
||||
position: relative;
|
||||
height: max(640px, calc(100dvh - 260px));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind="in"] {
|
||||
box-shadow: inset 0 0 0 2px var(--color-info);
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind="var"] {
|
||||
box-shadow: inset 0 0 0 2px var(--color-warning);
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind="out"] {
|
||||
box-shadow: inset 0 0 0 2px var(--color-success);
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind]::after {
|
||||
content: attr(data-cf-name);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: 0 0 0 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-lock]::before {
|
||||
content: attr(data-cf-lock);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 0 4px;
|
||||
background: var(--color-surface);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-err] {
|
||||
outline: 2px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */
|
||||
.m02-dfpanel-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-dfpanel-body > .m02-dfpanel-sheet {
|
||||
flex: 1 1 28em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-dfcells {
|
||||
flex: 0 1 24em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-dfcells__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-dfcells__cell {
|
||||
width: 5em;
|
||||
}
|
||||
|
||||
.m02-dfcells .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* 칸 종류 = 배지 부품(받음 info · 변수 warning · 출력 success) */
|
||||
|
||||
/* 식 보기 */
|
||||
/* 겉 = 카드 부품 · 여기는 쌓기 · 안 여백만 */
|
||||
.m02-df-formula.ui-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-df-formula__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-df-formula__line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: var(--spacing-4);
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.m02-df-formula__valued {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-df-formula__branches {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m02-df-formula__branches .is-now {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
@@ -1,716 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FuncPanel.ts
|
||||
* 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) ·
|
||||
* 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류
|
||||
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_DF_Model 타입 그대로
|
||||
* 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type {
|
||||
CalcValues,
|
||||
SpreadsheetHandle,
|
||||
Workbook,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
|
||||
import { evalSafe, type DetailOptions } from "./M02_DF_Detail";
|
||||
import {
|
||||
conditionsOf,
|
||||
drawCells,
|
||||
drawFormula,
|
||||
variableFrom,
|
||||
type ExtraContext,
|
||||
} from "./M02_DF_FuncExtra";
|
||||
import { createRoundRow } from "./M02_DF_RoundRow";
|
||||
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
|
||||
import "./M02_DF_FuncPanel.css";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_banner.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
|
||||
type Handlers = { change(lg: CfLogic): void };
|
||||
|
||||
interface VarForm {
|
||||
name: string;
|
||||
type: CfVariable["type"];
|
||||
unit: string;
|
||||
min: string;
|
||||
max: string;
|
||||
choices: string;
|
||||
example: string;
|
||||
why: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
boxId: string;
|
||||
lg: CfLogic;
|
||||
on: Handlers;
|
||||
readOnly: boolean;
|
||||
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
|
||||
detail: DetailOptions;
|
||||
handle: SpreadsheetHandle | null;
|
||||
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
|
||||
snap: CalcValues;
|
||||
/** 선 규칙을 지나 이 상자 받는 알약에 들어온 값 */
|
||||
arrived: Record<string, unknown>;
|
||||
/** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */
|
||||
json: string;
|
||||
cell: string;
|
||||
form: VarForm | null;
|
||||
/** 단위 표준이 제안한 출력 소수자리 */
|
||||
suggest: number | null;
|
||||
parts: {
|
||||
head: HTMLElement;
|
||||
formula: HTMLElement;
|
||||
cells: HTMLElement;
|
||||
got: HTMLElement;
|
||||
vars: HTMLElement;
|
||||
bar: HTMLElement;
|
||||
msg: HTMLElement;
|
||||
sheet: HTMLElement;
|
||||
};
|
||||
observer: MutationObserver | null;
|
||||
redraw(): void;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, State>();
|
||||
let sheetModule: Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> | null = null;
|
||||
/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */
|
||||
const loadSheet = (): Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> =>
|
||||
(sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet"));
|
||||
|
||||
const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId);
|
||||
const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func;
|
||||
const bookOf = (sheet: Sheet): Workbook => ({
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "cf",
|
||||
서식: [{}],
|
||||
시트: [structuredClone(sheet)],
|
||||
활성: sheet.id,
|
||||
});
|
||||
|
||||
/** 보이는 시트 — 받은 알약 칸에는 선 규칙을 지나 들어온 값(arrived)을 넣어 보임(저장하는 시트에는 안 넣음) */
|
||||
function withArrived(f: CfFunc, base: Sheet, arrived: Record<string, unknown>): Sheet {
|
||||
const sheet = structuredClone(base);
|
||||
for (const i of f.inputs) {
|
||||
const v = arrived[i.port];
|
||||
if (i.cell && (typeof v === "number" || typeof v === "string" || typeof v === "boolean"))
|
||||
sheet.칸[i.cell.toUpperCase()] = { 값: v };
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
const shownOf = (st: State): Sheet => withArrived(funcOf(st)!, funcOf(st)!.sheet, st.arrived);
|
||||
|
||||
/* ------------------------------ 바꾸기 ------------------------------ */
|
||||
|
||||
function patchBox(st: State, make: (b: CfBox) => CfBox): void {
|
||||
const next: CfLogic = {
|
||||
...st.lg,
|
||||
boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)),
|
||||
};
|
||||
st.lg = next;
|
||||
st.on.change(next);
|
||||
}
|
||||
|
||||
const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void =>
|
||||
patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b));
|
||||
|
||||
const usedCells = (f: CfFunc): Map<string, string> => {
|
||||
const m = new Map<string, string>();
|
||||
for (const i of f.inputs) m.set(i.cell, i.port);
|
||||
for (const v of f.variables) if (v.cell) m.set(v.cell, v.name);
|
||||
if (f.output.cell) m.set(f.output.cell, f.output.name);
|
||||
return m;
|
||||
};
|
||||
|
||||
function placePort(st: State, port: string, cell: string): void {
|
||||
const f = funcOf(st);
|
||||
if (!f || st.readOnly) return;
|
||||
const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell);
|
||||
if (other) return void showToast(tf("Busy"), "error");
|
||||
patchFunc(st, (x) => ({
|
||||
...x,
|
||||
inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }],
|
||||
}));
|
||||
}
|
||||
|
||||
function makeVariable(st: State): void {
|
||||
const form = st.form;
|
||||
const f = funcOf(st);
|
||||
if (!form || !f) return;
|
||||
const made = variableFrom(form, f, st.cell, usedCells);
|
||||
if ("why" in made) {
|
||||
form.why = tf(made.why);
|
||||
return drawVars(st);
|
||||
}
|
||||
const { v, cell } = made;
|
||||
st.form = null;
|
||||
patchFunc(st, (x) => {
|
||||
const sheet = structuredClone(x.sheet);
|
||||
if (v.example !== null) sheet.칸[cell] = { 값: v.example };
|
||||
return {
|
||||
...x,
|
||||
sheet,
|
||||
inputs: x.inputs.filter((i) => i.cell !== cell),
|
||||
variables: [...x.variables.filter((o) => o.name !== v.name && o.cell !== cell), v],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */
|
||||
function incoming(st: State): { port: string; unit: string }[] {
|
||||
const out = new Map<string, string>();
|
||||
for (const l of st.lg.links) {
|
||||
if (l.to.box !== st.boxId || out.has(l.to.name)) continue;
|
||||
const src = st.lg.boxes.find((b) => b.id === l.from.box);
|
||||
const unit =
|
||||
src?.func && src.func.output.name === l.from.name
|
||||
? src.func.output.unit
|
||||
: (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ??
|
||||
src?.input?.variables.find((v) => v.name === l.from.name)?.unit ??
|
||||
src?.unit ??
|
||||
"-");
|
||||
out.set(l.to.name, unit || "-");
|
||||
}
|
||||
// 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리)
|
||||
for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-");
|
||||
return [...out].map(([port, unit]) => ({ port, unit }));
|
||||
}
|
||||
|
||||
function problems(st: State): string[] {
|
||||
const f = funcOf(st);
|
||||
if (!f) return [];
|
||||
const out: string[] = [];
|
||||
for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) {
|
||||
if (typeof v === "object" && "오류" in v) {
|
||||
out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`);
|
||||
}
|
||||
}
|
||||
for (const p of incoming(st)) {
|
||||
if (!f.inputs.some((i) => i.port === p.port && i.cell)) out.push(`${p.port}${tf("Unplaced")}`);
|
||||
}
|
||||
if (!f.output.cell) out.push(tf("OutNone"));
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */
|
||||
|
||||
/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */
|
||||
const extra = (st: State): ExtraContext => ({
|
||||
lg: st.lg,
|
||||
boxId: st.boxId,
|
||||
detail: st.detail,
|
||||
arrived: st.arrived,
|
||||
readOnly: st.readOnly,
|
||||
place: (port, cell) => placePort(st, port, cell),
|
||||
setOutput: (cell) => setOutput(st, cell),
|
||||
patch: (make) => patchFunc(st, make),
|
||||
used: (f) => usedCells(f),
|
||||
});
|
||||
|
||||
/* ------------------------------ 칸 위 표시 ------------------------------ */
|
||||
|
||||
function applyMarks(st: State): void {
|
||||
const f = funcOf(st);
|
||||
if (!f) return;
|
||||
const marks = new Map<string, { kind: string; label: string }>();
|
||||
for (const i of f.inputs.filter((x) => x.cell))
|
||||
marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` });
|
||||
for (const v of f.variables)
|
||||
if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` });
|
||||
if (f.output.cell)
|
||||
marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` });
|
||||
const locked = new Set(f.inputs.filter((i) => i.cell).map((i) => i.cell));
|
||||
const list = problems(st);
|
||||
const bad = new Set(list.map((p) => p.split(" ")[0]));
|
||||
for (const c of st.parts.sheet.querySelectorAll<HTMLElement>(".aislo-cell")) {
|
||||
const addr = c.dataset.addr ?? "";
|
||||
for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a];
|
||||
const m = marks.get(addr);
|
||||
if (m) {
|
||||
c.dataset.cfKind = m.kind;
|
||||
c.dataset.cfName = m.label;
|
||||
}
|
||||
if (locked.has(addr)) c.dataset.cfLock = "🔒";
|
||||
if (bad.has(addr)) c.dataset.cfErr = "1";
|
||||
}
|
||||
st.parts.msg.textContent = list.length
|
||||
? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}`
|
||||
: "";
|
||||
st.parts.msg.hidden = !list.length;
|
||||
}
|
||||
|
||||
/* ------------------------------ 위 줄들 ------------------------------ */
|
||||
|
||||
function drawHead(st: State): void {
|
||||
const box = boxOf(st);
|
||||
const unit = createUnitPick({
|
||||
value: box?.unit ?? "-",
|
||||
readOnly: st.readOnly,
|
||||
extra: st.lg.resultUnit ? [st.lg.resultUnit] : [],
|
||||
ariaLabel: tf("Unit"),
|
||||
onPick: (v) => {
|
||||
patchBox(st, (x) => ({
|
||||
...x,
|
||||
unit: v,
|
||||
func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func,
|
||||
}));
|
||||
void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st)));
|
||||
},
|
||||
});
|
||||
st.parts.head.replaceChildren(
|
||||
el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }),
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "24" },
|
||||
text: `${tf("Cell")} ${st.cell}`,
|
||||
}),
|
||||
el("label", {
|
||||
className: "m02-dfpanel-unitbox",
|
||||
children: [el("span", { className: "ui-text ui-text--muted", text: tf("Unit") }), unit],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function drawGot(st: State): void {
|
||||
const f = funcOf(st);
|
||||
const items = incoming(st);
|
||||
const chips = items.map(({ port, unit }) => {
|
||||
const at = f?.inputs.find((i) => i.port === port)?.cell;
|
||||
const b = el("button", {
|
||||
className: `ui-badge ui-badge--pill ui-badge--info m02-dfpanel-chip is-in${at ? " is-placed" : ""}`,
|
||||
text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`,
|
||||
attrs: { type: "button", "data-size": "24", draggable: st.readOnly ? "false" : "true" },
|
||||
});
|
||||
b.addEventListener("click", () => placePort(st, port, st.cell));
|
||||
b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port));
|
||||
return b;
|
||||
});
|
||||
st.parts.got.replaceChildren(
|
||||
el("span", { className: "m02-dfpanel-label", text: tf("Got") }),
|
||||
...(chips.length
|
||||
? chips
|
||||
: [el("span", { className: "ui-text ui-text--muted", text: tf("NoGot") })]),
|
||||
...(chips.length && !st.readOnly
|
||||
? [el("span", { className: "ui-text ui-text--muted", text: tf("GotHint") })]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
|
||||
function formField(
|
||||
st: State,
|
||||
label: keyof typeof TEXT,
|
||||
key: "name" | "unit" | "min" | "max" | "choices" | "example",
|
||||
cls = "",
|
||||
): HTMLElement {
|
||||
const form = st.form!;
|
||||
if (key === "unit") {
|
||||
return el("label", {
|
||||
className: "m02-dfpanel-fieldbox",
|
||||
children: [
|
||||
el("span", { className: "ui-text ui-text--muted", text: tf(label) }),
|
||||
createUnitPick({
|
||||
value: form.unit || "-",
|
||||
ariaLabel: tf(label),
|
||||
onPick: (u) => ((form.unit = u), drawVars(st)),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
const input = el("input", {
|
||||
className: `ui-input ui-input--sm m02-dfpanel-field ${cls}`,
|
||||
attrs: { type: "text", "aria-label": tf(label) },
|
||||
});
|
||||
input.value = form[key];
|
||||
input.addEventListener("input", () => (form[key] = input.value));
|
||||
input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st));
|
||||
return el("label", {
|
||||
className: "m02-dfpanel-fieldbox",
|
||||
children: [el("span", { className: "ui-text ui-text--muted", text: tf(label) }), input],
|
||||
});
|
||||
}
|
||||
|
||||
function drawVars(st: State): void {
|
||||
const f = funcOf(st);
|
||||
const row: HTMLElement[] = [el("span", { className: "m02-dfpanel-label", text: tf("Vars") })];
|
||||
for (const v of f?.variables ?? []) {
|
||||
const chip = el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--warning m02-dfpanel-chip is-var",
|
||||
attrs: { "data-size": "24" },
|
||||
children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`],
|
||||
});
|
||||
if (!st.readOnly) {
|
||||
chip.append(
|
||||
createButton({
|
||||
label: "×",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
patchFunc(st, (x) => ({
|
||||
...x,
|
||||
variables: x.variables.filter((o) => o.name !== v.name),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`);
|
||||
}
|
||||
row.push(chip);
|
||||
}
|
||||
if (!f?.variables.length)
|
||||
row.push(el("span", { className: "ui-text ui-text--muted", text: tf("NoVars") }));
|
||||
const parts: HTMLElement[] = [el("div", { className: "m02-dfpanel-row", children: row })];
|
||||
if (!st.readOnly) {
|
||||
const form = st.form;
|
||||
if (!form) {
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m02-dfpanel-row",
|
||||
children: [
|
||||
createButton({
|
||||
label: tf("MakeVar"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
st.form = {
|
||||
name: "",
|
||||
type: "number",
|
||||
unit: "",
|
||||
min: "",
|
||||
max: "",
|
||||
choices: "",
|
||||
example: "",
|
||||
why: "",
|
||||
};
|
||||
drawVars(st);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const type = el("select", {
|
||||
className: "ui-select ui-select--sm m02-dfpanel-field",
|
||||
attrs: { "aria-label": tf("VarType") },
|
||||
});
|
||||
for (const [v, k] of [
|
||||
["number", "Number"],
|
||||
["choice", "Choice"],
|
||||
["text", "Text"],
|
||||
] as const) {
|
||||
type.append(el("option", { attrs: { value: v }, text: tf(k) }));
|
||||
}
|
||||
type.value = form.type;
|
||||
type.addEventListener("change", () => {
|
||||
form.type = type.value as CfVariable["type"];
|
||||
drawVars(st);
|
||||
});
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "ui-card m02-dfpanel-row m02-dfpanel-form",
|
||||
children: [
|
||||
formField(st, "VarName", "name"),
|
||||
el("label", {
|
||||
className: "m02-dfpanel-fieldbox",
|
||||
children: [
|
||||
el("span", { className: "ui-text ui-text--muted", text: tf("VarType") }),
|
||||
type,
|
||||
],
|
||||
}),
|
||||
formField(st, "VarUnit", "unit"),
|
||||
...(form.type === "number"
|
||||
? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")]
|
||||
: []),
|
||||
...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []),
|
||||
formField(st, "VarExample", "example"),
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "24" },
|
||||
text: `${tf("VarCell")} ${st.cell}`,
|
||||
}),
|
||||
createButton({
|
||||
label: tf("Make"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => makeVariable(st),
|
||||
}),
|
||||
createButton({
|
||||
label: tf("Cancel"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((st.form = null), drawVars(st)),
|
||||
}),
|
||||
...(form.why
|
||||
? [el("span", { className: "ui-text ui-text--warning", text: `⚠ ${form.why}` })]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
st.parts.vars.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
function drawBar(st: State): void {
|
||||
const f = funcOf(st);
|
||||
if (st.readOnly) {
|
||||
st.parts.bar.replaceChildren(
|
||||
el("span", { className: "ui-text ui-text--muted", text: tf("ReadOnly") }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone");
|
||||
st.parts.bar.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-dfpanel-row",
|
||||
children: [
|
||||
createButton({
|
||||
label: tf("OutBtn"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => setOutput(st, st.cell),
|
||||
}),
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--success m02-dfpanel-chip is-out",
|
||||
attrs: { "data-size": "24" },
|
||||
text: outNow,
|
||||
}),
|
||||
el("span", { className: "ui-text ui-text--muted", text: tf("OutOnly") }),
|
||||
],
|
||||
}),
|
||||
...(f?.output.cell
|
||||
? [
|
||||
createRoundRow({
|
||||
round: f.output.round,
|
||||
suggest: st.suggest,
|
||||
readOnly: st.readOnly,
|
||||
onApply: (round) => writeRound(st, round),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
el("div", {
|
||||
className: "m02-dfpanel-row",
|
||||
children: [
|
||||
el("span", { className: "m02-dfpanel-label", text: tf("Frames") }),
|
||||
...FRAMES.map(([key, formula]) =>
|
||||
createButton({
|
||||
label: tf(key),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => putFormula(st, formula),
|
||||
}),
|
||||
),
|
||||
el("span", { className: "ui-text ui-text--muted", text: tf("FrameHint") }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */
|
||||
function writeRound(st: State, round: CfRound | null): void {
|
||||
patchFunc(st, (x) => {
|
||||
const { round: _old, ...rest } = x.output;
|
||||
return { ...x, output: round ? { ...rest, round } : rest };
|
||||
});
|
||||
}
|
||||
|
||||
/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */
|
||||
function setOutput(st: State, cell: string): void {
|
||||
const f = funcOf(st);
|
||||
if (!f) return;
|
||||
if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) {
|
||||
return void showToast(tf("Busy"), "error");
|
||||
}
|
||||
const row = cell.replace(/^[A-Z]+/, "");
|
||||
const label = f.sheet.칸[`A${row}`];
|
||||
const name = String(label?.값 ?? f.output.name ?? "").trim() || cell;
|
||||
patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } }));
|
||||
}
|
||||
|
||||
/* ------------------------------ 편집기 ------------------------------ */
|
||||
|
||||
/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */
|
||||
function putFormula(st: State, formula: string): void {
|
||||
const root = st.parts.sheet;
|
||||
const bar = root.querySelector<HTMLTextAreaElement>(".ss-formula");
|
||||
const addr = root.querySelector<HTMLInputElement>(".ss-addr");
|
||||
if (!bar || !addr || st.readOnly) return;
|
||||
const at = st.cell;
|
||||
bar.focus();
|
||||
bar.value = formula;
|
||||
bar.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
bar.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
addr.value = at;
|
||||
addr.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */
|
||||
function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] {
|
||||
for (const cell of Object.values(sheet.칸)) delete cell.서식;
|
||||
return sheet;
|
||||
}
|
||||
|
||||
/** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */
|
||||
function onBook(st: State, book: Workbook): void {
|
||||
const f = funcOf(st);
|
||||
if (!f) return;
|
||||
const sheet = plain(structuredClone(book.시트[0]));
|
||||
const shown = shownOf(st);
|
||||
const hit = f.inputs.find(
|
||||
(i) =>
|
||||
i.cell &&
|
||||
JSON.stringify(shown.칸[i.cell.toUpperCase()]) !==
|
||||
JSON.stringify(sheet.칸[i.cell.toUpperCase()]),
|
||||
);
|
||||
if (hit) {
|
||||
showToast(tf("Locked"), "error");
|
||||
st.json = JSON.stringify(shown);
|
||||
return st.handle?.setDoc(bookOf(shown));
|
||||
}
|
||||
// 보이려고 넣은 들어온 값은 저장하는 시트에 남기지 않음
|
||||
for (const i of f.inputs) {
|
||||
const key = i.cell.toUpperCase();
|
||||
if (!i.cell) continue;
|
||||
if (f.sheet.칸[key]) sheet.칸[key] = f.sheet.칸[key];
|
||||
else delete sheet.칸[key];
|
||||
}
|
||||
st.json = JSON.stringify(withArrived(f, sheet, st.arrived));
|
||||
patchFunc(st, (x) => ({ ...x, sheet }));
|
||||
setTimeout(() => refreshSnap(st), 0);
|
||||
}
|
||||
|
||||
/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */
|
||||
function refreshSnap(st: State): void {
|
||||
if (!st.handle) return;
|
||||
st.snap = st.handle.recalc();
|
||||
st.redraw();
|
||||
}
|
||||
|
||||
async function mountSheet(st: State): Promise<void> {
|
||||
const { createSpreadsheet } = await loadSheet();
|
||||
const f = funcOf(st);
|
||||
if (!st.parts.sheet.isConnected || st.handle || !f) return;
|
||||
st.parts.sheet.replaceChildren();
|
||||
const shown = shownOf(st);
|
||||
st.json = JSON.stringify(shown);
|
||||
st.handle = createSpreadsheet(st.parts.sheet, bookOf(shown), {
|
||||
preview: true,
|
||||
tabs: false,
|
||||
readOnly: st.readOnly,
|
||||
conditions: conditionsOf(() => extra(st)),
|
||||
onChange: (b) => onBook(st, b),
|
||||
onSelect: (s) => {
|
||||
st.cell = s.칸;
|
||||
drawHead(st);
|
||||
if (st.form) drawVars(st);
|
||||
},
|
||||
});
|
||||
st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st)));
|
||||
st.observer.observe(st.parts.sheet, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["data-addr"],
|
||||
});
|
||||
st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault());
|
||||
st.parts.sheet.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
const port = e.dataTransfer?.getData("text/plain");
|
||||
const cell = (e.target as Element | null)?.closest<HTMLElement>(".aislo-cell")?.dataset.addr;
|
||||
if (port && cell) placePort(st, port, cell);
|
||||
});
|
||||
refreshSnap(st);
|
||||
}
|
||||
|
||||
/** 이 상자 받는 알약에 선 규칙을 지나 들어온 값 — 셈이 막히면 빈 값 */
|
||||
const arrivedOf = (lg: CfLogic, boxId: string, o: DetailOptions): Record<string, unknown> =>
|
||||
evalSafe(lg, o)?.arrived?.[boxId] ?? {};
|
||||
|
||||
/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
|
||||
export function renderFuncPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: Handlers,
|
||||
opts: DetailOptions & { readOnly?: boolean } = {},
|
||||
): void {
|
||||
const old = states.get(host);
|
||||
const readOnly = !!opts.readOnly;
|
||||
if (old && old.boxId === boxId && old.readOnly === readOnly) {
|
||||
old.lg = lg;
|
||||
old.on = on;
|
||||
old.detail = opts;
|
||||
old.arrived = arrivedOf(lg, boxId, opts);
|
||||
const f = funcOf(old);
|
||||
if (old.handle && f && JSON.stringify(shownOf(old)) !== old.json) {
|
||||
old.json = JSON.stringify(shownOf(old));
|
||||
old.handle.setDoc(bookOf(shownOf(old)));
|
||||
setTimeout(() => refreshSnap(old), 0);
|
||||
return;
|
||||
}
|
||||
return old.redraw();
|
||||
}
|
||||
if (old) {
|
||||
old.observer?.disconnect();
|
||||
old.handle?.destroy();
|
||||
}
|
||||
const parts: State["parts"] = {
|
||||
head: el("div", { className: "m02-dfpanel-head" }),
|
||||
formula: el("div"),
|
||||
cells: el("div"),
|
||||
got: el("div", { className: "m02-dfpanel-row" }),
|
||||
vars: el("div", { className: "m02-dfpanel-vars" }),
|
||||
bar: el("div", { className: "m02-dfpanel-bar" }),
|
||||
msg: el("p", {
|
||||
className: "ui-banner ui-banner--warning m02-dfpanel-msg",
|
||||
attrs: { role: "status" },
|
||||
}),
|
||||
sheet: el("div", { className: "m02-dfpanel-sheet", text: tf("Loading") }),
|
||||
};
|
||||
const st: State = {
|
||||
boxId,
|
||||
lg,
|
||||
on,
|
||||
readOnly,
|
||||
detail: opts,
|
||||
arrived: arrivedOf(lg, boxId, opts),
|
||||
handle: null,
|
||||
snap: {},
|
||||
json: "",
|
||||
cell: "A1",
|
||||
form: null,
|
||||
suggest: null,
|
||||
parts,
|
||||
observer: null,
|
||||
redraw: () => {
|
||||
drawHead(st);
|
||||
drawGot(st);
|
||||
drawVars(st);
|
||||
drawBar(st);
|
||||
drawFormula(st.parts.formula, extra(st));
|
||||
drawCells(st.parts.cells, extra(st));
|
||||
applyMarks(st);
|
||||
},
|
||||
};
|
||||
states.set(host, st);
|
||||
host.classList.add("m02-dfpanel");
|
||||
host.replaceChildren(
|
||||
parts.head,
|
||||
parts.formula,
|
||||
parts.got,
|
||||
parts.vars,
|
||||
parts.bar,
|
||||
parts.msg,
|
||||
el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }),
|
||||
);
|
||||
parts.msg.hidden = true;
|
||||
st.redraw();
|
||||
void mountSheet(st);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FuncText.ts
|
||||
* 함수 상자 판 글 [한, 영] 짝 · 틀 단추 수식 · 수식 오류 말 — 판 파일이 700줄 안에 들도록 뗌
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
export const TEXT = {
|
||||
Unit: ["단위", "Unit"],
|
||||
Cell: ["고른 칸", "Selected cell"],
|
||||
Got: ["받은 알약", "Received pills"],
|
||||
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
|
||||
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
|
||||
Placed: ["놓임", "placed"],
|
||||
Free: ["아직 안 놓은 알약", "not placed yet"],
|
||||
Busy: ["이미 쓰는 칸", "That cell is already in use"],
|
||||
Locked: [
|
||||
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
|
||||
"This cell is filled by the wire — cannot edit",
|
||||
],
|
||||
Vars: ["변수", "Variables"],
|
||||
MakeVar: ["변수 만들기", "Make variable"],
|
||||
NoVars: ["아직 변수가 없음", "No variables yet"],
|
||||
VarName: ["이름", "Name"],
|
||||
VarType: ["갈래", "Type"],
|
||||
Number: ["숫자", "Number"],
|
||||
Choice: ["선택", "Choice"],
|
||||
Text: ["글", "Text"],
|
||||
VarUnit: ["단위", "Unit"],
|
||||
VarMin: ["최소", "Min"],
|
||||
VarMax: ["최대", "Max"],
|
||||
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
|
||||
VarExample: ["예시값", "Example value"],
|
||||
VarCell: ["붙을 칸", "Attach to"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Remove: ["지움", "Remove"],
|
||||
NeedName: ["이름을 적음", "Enter a name"],
|
||||
SameName: ["같은 이름이 이미 있음", "That name already exists"],
|
||||
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
|
||||
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
|
||||
OutBtn: ["출력 칸으로", "Make output cell"],
|
||||
OutNow: ["출력", "Output"],
|
||||
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
|
||||
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
|
||||
Frames: ["틀", "Templates"],
|
||||
FrameMul: ["곱하기", "Multiply"],
|
||||
FrameMore: ["할증", "Markup"],
|
||||
FrameDiv: ["나누기", "Divide"],
|
||||
FrameIf: ["만약", "If"],
|
||||
FrameMin: ["최소", "Min"],
|
||||
FrameMax: ["최대", "Max"],
|
||||
FrameUp: ["올림", "Round up"],
|
||||
FrameLook: ["표에서 찾기", "Look up in table"],
|
||||
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
|
||||
ReadOnly: ["읽기만", "Read only"],
|
||||
Loading: ["시트를 불러오는 중", "Loading sheet"],
|
||||
Problem: ["⚠ ", "⚠ "],
|
||||
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
|
||||
Err_DIV0: ["0으로 나눔", "divides by zero"],
|
||||
Err_NAME: ["알 수 없는 이름", "unknown name"],
|
||||
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
|
||||
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
|
||||
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
|
||||
Err_NA: ["찾는 값이 없음", "value not found"],
|
||||
Err_OTHER: ["수식이 틀림", "formula is wrong"],
|
||||
} as const;
|
||||
|
||||
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
|
||||
export const FRAMES: [keyof typeof TEXT, string][] = [
|
||||
["FrameMul", "=B1*B2"],
|
||||
["FrameMore", "=B1*(1+B2/100)"],
|
||||
["FrameDiv", "=B1/B2"],
|
||||
["FrameIf", '=IF(B3="급",10,0)'],
|
||||
["FrameMin", "=MIN(B1,B2)"],
|
||||
["FrameMax", "=MAX(B1,B2)"],
|
||||
["FrameUp", "=ROUNDUP(B4,3)"],
|
||||
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
|
||||
];
|
||||
|
||||
export const ERR_KEY: Record<string, keyof typeof TEXT> = {
|
||||
"#DIV/0!": "Err_DIV0",
|
||||
"#NAME?": "Err_NAME",
|
||||
"#REF!": "Err_REF",
|
||||
"#VALUE!": "Err_VALUE",
|
||||
"#CYCLE!": "Err_CYCLE",
|
||||
"#N/A": "Err_NA",
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
.m02-dfip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-lg, 1.05rem);
|
||||
}
|
||||
.m02-dfip h4,
|
||||
.m02-dfip h5 {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.m02-dfip h4 {
|
||||
font-size: 1.15em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.m02-dfip__unit {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.m02-dfip__body,
|
||||
.m02-dfip__chosen,
|
||||
.m02-dfip__find,
|
||||
.m02-dfip__stack,
|
||||
.m02-dfip__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.m02-dfip__list {
|
||||
max-height: 28vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.m02-dfip__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
/* 고르는 줄 = 목록 줄 부품(ui-list-row) — 여기는 안 여백 · 두 칸 벌림만 */
|
||||
button.m02-dfip__row {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
}
|
||||
.m02-dfip__rowbox {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
.m02-dfip__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
.m02-dfip__bar > :last-child {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
.m02-dfip__item {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.m02-dfip__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.m02-dfip__value {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
/* 태그 = 배지 부품 — 이름 뒤 띄움만 */
|
||||
.m02-dfip__name .ui-badge {
|
||||
margin-left: var(--spacing-8);
|
||||
}
|
||||
.m02-dfip__new,
|
||||
.m02-dfip__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
.m02-dfip__new > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 미리 보기 = 표 부품 데이터표 — 여기는 줄바꿈 막기 · 가운데 · 찾은 줄만 */
|
||||
.m02-dfip__preview table {
|
||||
width: auto;
|
||||
}
|
||||
.m02-dfip__preview th,
|
||||
.m02-dfip__preview td {
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
.m02-dfip__preview tr.is-hit td {
|
||||
background: color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.m02-dfip__preview {
|
||||
max-height: 24vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m02-dfip__unitfield {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel.ts
|
||||
* 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) ·
|
||||
* 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위
|
||||
* M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/df/user-elements` = 시험 폴더 `_data`)
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchFiles,
|
||||
fetchRows,
|
||||
fetchSubs,
|
||||
type SubInfo,
|
||||
} from "../../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { cfPlain, cfValues } from "./M02_DF_Api";
|
||||
import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model";
|
||||
import {
|
||||
GROUPS,
|
||||
RULE_GROUPS,
|
||||
matchesRow,
|
||||
removeElementNamed,
|
||||
M01_OF,
|
||||
addElement,
|
||||
elementOf,
|
||||
machineFields,
|
||||
queryOf,
|
||||
reconcileUnit,
|
||||
removeElement,
|
||||
setKind,
|
||||
valueOf,
|
||||
type M01Row,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
import { renderLogicInput } from "./M02_DF_InputPanel_Logic";
|
||||
import { renderGroupRule } from "./M02_DF_InputPanel_Rule";
|
||||
import { renderMaterial } from "./M02_DF_InputPanel_Material";
|
||||
import { renderTablePick } from "./M02_DF_InputPanel_Table";
|
||||
import { renderVariables } from "./M02_DF_InputPanel_Vars";
|
||||
import "./M02_DF_InputPanel.css";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
import "@ui/ui_template_list_row.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
|
||||
/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */
|
||||
interface Answer {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | string | null;
|
||||
}
|
||||
const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise<Answer[]>;
|
||||
|
||||
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"],
|
||||
유가전력: ["유가·전력", "Fuel · power"],
|
||||
기계: ["기계", "Machine"],
|
||||
소요량: ["소요량", "Quantity table"],
|
||||
계수: ["계수", "Factor table"],
|
||||
환율: ["환율", "Exchange rate"],
|
||||
요율: ["요율", "Rate"],
|
||||
Chosen: [
|
||||
"넣은 요소 — 알약마다 이름 · 값 · 단위",
|
||||
"Chosen elements — name · value · unit per pill",
|
||||
],
|
||||
NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
Put: ["넣기", "Add"],
|
||||
Search: ["이름으로 찾기", "Search by name"],
|
||||
AllSub: ["전체", "All"],
|
||||
SubLabel: ["구분", "Group"],
|
||||
DetailLabel: ["상세구분", "Detail"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
NoRow: ["찾는 요소 없음", "Nothing found"],
|
||||
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
|
||||
BoxUnit: ["상자 단위", "Box unit"],
|
||||
Field: ["값 칸", "Value"],
|
||||
NoValue: ["—", "—"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
Pills: ["알약 {n} 개", "{n} pills"],
|
||||
NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
|
||||
|
||||
/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */
|
||||
const plain = (e: unknown): string => {
|
||||
try {
|
||||
return cfPlain(e);
|
||||
} catch {
|
||||
return e instanceof Error ? e.message : t("Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const fileCache = new Map<string, string>();
|
||||
/** 그룹의 M01 파일 이름 */
|
||||
async function fileOf(group: CfGroup): Promise<string> {
|
||||
const hit = fileCache.get(group);
|
||||
if (hit) return hit;
|
||||
const { group: g, part } = M01_OF[group];
|
||||
const files = await fetchFiles(g);
|
||||
const f = files.find((x) => !part || x.file.includes(part)) ?? files[0];
|
||||
fileCache.set(group, f.file);
|
||||
return f.file;
|
||||
}
|
||||
|
||||
/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */
|
||||
export function renderInputPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
host.className = "m02-dfip";
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
if (!box || box.kind !== "input") {
|
||||
host.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("NotInput") }));
|
||||
return;
|
||||
}
|
||||
// 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감
|
||||
const fixed = reconcileUnit(lg, boxId);
|
||||
if (fixed !== lg) return on.change(fixed);
|
||||
const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] };
|
||||
const kind: CfGroup | "변수" | "다른 로직" =
|
||||
input.mode === "variable"
|
||||
? "변수"
|
||||
: input.mode === "logic"
|
||||
? "다른 로직"
|
||||
: (input.group ?? "인력");
|
||||
const body = el("div", { className: "m02-dfip__body" });
|
||||
|
||||
// ① 갈래 — 그룹 여덟 + 다른 로직 + 변수
|
||||
const kinds = el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
attrs: { role: "tablist", "aria-label": t("Kind") },
|
||||
children: [...GROUPS, "다른 로직" as const, "변수" as const].map((k) =>
|
||||
createButton({
|
||||
label: k === "변수" ? t("Variable") : k === "다른 로직" ? t("Logic") : t(k),
|
||||
variant: k === kind ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => k !== kind && on.change(setKind(lg, boxId, k)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const unit = el("p", {
|
||||
className: "m02-dfip__unit",
|
||||
text: `${t("BoxUnit")}: ${box.unit || "-"}`,
|
||||
});
|
||||
host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body);
|
||||
|
||||
if (kind === "변수") renderVariables(body, lg, boxId, on);
|
||||
else if (kind === "다른 로직") renderLogicInput(body, lg, boxId, on);
|
||||
else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on);
|
||||
else if (kind === "재료") renderMaterial(body, lg, boxId, on, (l, e) => addElement(l, boxId, e));
|
||||
else renderGroup(body, lg, box, kind, on);
|
||||
}
|
||||
|
||||
/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */
|
||||
function renderGroup(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
box: CfBox,
|
||||
group: CfGroup,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const chosen = el("div", { className: "m02-dfip__chosen" });
|
||||
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);
|
||||
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" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((mode = m), drawChips(), draw()),
|
||||
}),
|
||||
),
|
||||
);
|
||||
host.replaceChildren(chosen, ...(RULE_GROUPS.includes(group) ? [chips] : []), body);
|
||||
drawChosen(chosen, 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 가 마스터에서 */
|
||||
function drawChosen(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
box: CfBox,
|
||||
group: CfGroup,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const els = box.input?.elements ?? [];
|
||||
const values = new Map<string, HTMLElement>();
|
||||
host.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }),
|
||||
...(els.length
|
||||
? els.map((e) => {
|
||||
const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") });
|
||||
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",
|
||||
children: [
|
||||
...(rule
|
||||
? [
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "20" },
|
||||
text: t("RuleTag"),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
el("strong", { text: ` ${e.name}` }),
|
||||
...(rule && e.rule
|
||||
? [
|
||||
el("div", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: t("RuleLine", { f: e.rule.field, n: e.rule.from.name }),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
val,
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: e.unit || "-",
|
||||
}),
|
||||
createButton({
|
||||
label: t("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
on.change(
|
||||
rule
|
||||
? removeElementNamed(lg, box.id, e.name)
|
||||
: removeElement(lg, box.id, e.ref),
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
})
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("NoneChosen") })]),
|
||||
);
|
||||
const items = els
|
||||
.filter((e) => e.mode !== "rule")
|
||||
.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
|
||||
if (!items.length) return;
|
||||
void apiValues(items).then(
|
||||
(answers) =>
|
||||
answers.forEach((a) => {
|
||||
const node = values.get(a.id);
|
||||
if (node && a.ok && a.value !== null && a.value !== undefined)
|
||||
node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value);
|
||||
}),
|
||||
() => undefined, // 값을 못 받아도 요소는 넣을 수 있음 — 값 칸만 「—」
|
||||
);
|
||||
}
|
||||
|
||||
/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */
|
||||
async function drawFind(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
box: CfBox,
|
||||
group: CfGroup,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): Promise<void> {
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
const count = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } });
|
||||
const bar: HTMLElement[] = [];
|
||||
let file = "";
|
||||
let sub = "";
|
||||
let detail = "";
|
||||
let subs: SubInfo[] = [];
|
||||
const all = { value: "", text: t("AllSub") };
|
||||
const subBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: t("SubLabel"),
|
||||
onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()),
|
||||
});
|
||||
const detailBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: t("DetailLabel"),
|
||||
onChange: (v) => ((detail = v), void load()),
|
||||
});
|
||||
const fillDetail = (): void => {
|
||||
const d = subs.find((s) => s.name === sub)?.details ?? [];
|
||||
detailBox.root.hidden = !d.length;
|
||||
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], "");
|
||||
};
|
||||
fillDetail();
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") }));
|
||||
try {
|
||||
const word = q.input.value.trim();
|
||||
const page = await fetchRows(file, 1, 40, word, false, sub, detail);
|
||||
if (mine !== seq) return;
|
||||
const rows = page.rows as M01Row[];
|
||||
count.textContent = t("Count", { n: page.total, m: rows.length });
|
||||
list.replaceChildren(
|
||||
...(rows.length
|
||||
? rows.map((r) => rowOf(r))
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("NoRow") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mine === seq)
|
||||
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: plain(e) }));
|
||||
}
|
||||
};
|
||||
const rowOf = (r: M01Row): HTMLElement => {
|
||||
const fields = group === "기계" ? machineFields(r) : [];
|
||||
const field = createSelectField({
|
||||
options: fields.map((f) => ({ value: f, text: f })),
|
||||
value: fields.includes("가격") ? "가격" : (fields[0] ?? ""),
|
||||
compact: true,
|
||||
ariaLabel: t("Field"),
|
||||
});
|
||||
const pick = (): CfLogic =>
|
||||
addElement(lg, box.id, elementOf(group, r, field.select.value || "가격"));
|
||||
const v = valueOf(group, r, field.select.value || "가격");
|
||||
const unitText = group === "기계" ? "" : String(r["단위"] ?? "");
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row ui-list-row ui-sidebar-row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: String(r["이름"] ?? "") }),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: ` ${r["규격"] ?? ""}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => on.change(pick()));
|
||||
return el("div", {
|
||||
className: "m02-dfip__rowbox",
|
||||
children: [b, ...(fields.length ? [field.root] : [])],
|
||||
});
|
||||
};
|
||||
try {
|
||||
file = await fileOf(group);
|
||||
subs = await fetchSubs({ file }).catch(() => []);
|
||||
subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], "");
|
||||
subBox.root.hidden = !subs.length;
|
||||
bar.push(subBox.root, detailBox.root);
|
||||
} catch (e) {
|
||||
host.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: plain(e) }));
|
||||
return;
|
||||
}
|
||||
let timer = 0;
|
||||
q.input.addEventListener(
|
||||
"input",
|
||||
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
|
||||
);
|
||||
host.replaceChildren(
|
||||
el("div", { className: "ui-toolbar m02-dfip__bar", children: [...bar, q.root] }),
|
||||
count,
|
||||
list,
|
||||
);
|
||||
void load();
|
||||
}
|
||||
@@ -1,672 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Build.ts
|
||||
* 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic)
|
||||
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts`
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
CfBox,
|
||||
CfElementRef,
|
||||
CfGroup,
|
||||
CfInput,
|
||||
CfLogic,
|
||||
CfLogicRef,
|
||||
CfMaterialMode,
|
||||
CfMaterialRule,
|
||||
CfPort,
|
||||
CfScope,
|
||||
CfVariable,
|
||||
} from "./M02_DF_Model";
|
||||
import { matchLogic } from "@ui/ui_template_logic_find";
|
||||
|
||||
/** 입력 상자가 고르는 그룹 — 화면 차례 */
|
||||
export const GROUPS: CfGroup[] = [
|
||||
"인력",
|
||||
"재료",
|
||||
"유가전력",
|
||||
"기계",
|
||||
"소요량",
|
||||
"계수",
|
||||
"환율",
|
||||
"요율",
|
||||
];
|
||||
|
||||
/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */
|
||||
export const M01_OF: Record<CfGroup, { group: string; part: string }> = {
|
||||
인력: { group: "인력", part: "" },
|
||||
재료: { group: "재료", part: "자재품목" },
|
||||
유가전력: { group: "재료", part: "유가전력" },
|
||||
기계: { group: "기계", part: "" },
|
||||
소요량: { group: "소요량", part: "" },
|
||||
계수: { group: "계수", part: "" },
|
||||
환율: { group: "환율", part: "" },
|
||||
요율: { group: "요율", part: "" },
|
||||
};
|
||||
|
||||
/** M01 줄 한 개 — 파일 그대로(한글 칸) */
|
||||
export type M01Row = Record<string, unknown>;
|
||||
|
||||
/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */
|
||||
export const MACHINE_SEP = "#";
|
||||
export const machineFields = (row: M01Row): string[] =>
|
||||
row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : [];
|
||||
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
||||
const dictUnit = (row: M01Row, field: string): string =>
|
||||
row["단위"] && typeof row["단위"] === "object"
|
||||
? text((row["단위"] as Record<string, unknown>)[field])
|
||||
: "";
|
||||
|
||||
/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */
|
||||
export function lowest(row: M01Row): number | null {
|
||||
const found = ["물가자료", "유통물가", "물가정보", "거래가격"]
|
||||
.map((k) => row[k])
|
||||
.filter((v): v is number => typeof v === "number");
|
||||
return found.length ? Math.min(...found) : null;
|
||||
}
|
||||
|
||||
/** 줄 옆에 보일 값 — 모르면 null */
|
||||
export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null {
|
||||
if (group === "재료") return lowest(row);
|
||||
if (group === "기계")
|
||||
return typeof (row["값"] as Record<string, unknown> | undefined)?.[field] === "number"
|
||||
? (row["값"] as Record<string, number>)[field]
|
||||
: null;
|
||||
if (group === "요율") {
|
||||
const v = row["값"] as Record<string, unknown> | undefined;
|
||||
return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null;
|
||||
}
|
||||
return typeof row["값"] === "number" ? row["값"] : null;
|
||||
}
|
||||
|
||||
/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */
|
||||
export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef {
|
||||
const key = text(row["키"]);
|
||||
const spec = text(row["규격"]);
|
||||
// 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름
|
||||
const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec;
|
||||
const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게)
|
||||
if (group === "기계")
|
||||
return {
|
||||
ref: `${key}${MACHINE_SEP}${field}`,
|
||||
name: `${named} · ${field}`,
|
||||
spec,
|
||||
unit: dictUnit(row, field) || "-",
|
||||
};
|
||||
if (group === "요율")
|
||||
return {
|
||||
ref: key,
|
||||
name: text(row["이름"]),
|
||||
spec: "",
|
||||
unit: valueOf(group, row) === null ? "-" : "%",
|
||||
};
|
||||
return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" };
|
||||
}
|
||||
|
||||
/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */
|
||||
export function pillNames(box: CfBox): string[] {
|
||||
const i = box.input;
|
||||
if (!i) return [];
|
||||
if (i.table) return ["표"];
|
||||
if (i.mode === "logic") return (i.logic?.outputs ?? []).map((o) => o.name);
|
||||
return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name);
|
||||
}
|
||||
|
||||
/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */
|
||||
export function queryOf(group: CfGroup, ref: string): Record<string, unknown> {
|
||||
if (group === "기계") {
|
||||
const [key, field] = ref.split(MACHINE_SEP);
|
||||
return { group, ref: key, field: field || "가격" };
|
||||
}
|
||||
return { group, ref };
|
||||
}
|
||||
|
||||
/* ── 재료 요소 방식 셋 · 다른 로직 입력(브레인 정함 — sub7 Model 타입이 오면 그 타입으로 바꿈) ── */
|
||||
|
||||
/* ── 재료 요소 방식 셋 · 다른 로직 입력 — 타입은 Model(`CfMaterialMode` · `CfScope` · `CfMaterialRule` · `CfLogicRef`) ── */
|
||||
|
||||
/** 자재 한 품목 — 서버 `/api/m02/df/materials` 한 줄 */
|
||||
export interface MatItem {
|
||||
ref: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
구분?: string;
|
||||
상세구분?: string;
|
||||
}
|
||||
|
||||
const squash = (v: unknown): string =>
|
||||
String(v ?? "")
|
||||
.replace(/\s+/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
/** 규칙이 이 품목을 잡나 — value = 변수 알약 값 */
|
||||
export function matches(
|
||||
item: Pick<MatItem, "name" | "spec">,
|
||||
rule: Pick<CfMaterialRule, "field" | "op">,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
const v = squash(value);
|
||||
if (!v) return false;
|
||||
const target = squash(rule.field === "규격" ? item.spec : item.name);
|
||||
return rule.op === "같음" ? target === v : target.includes(v);
|
||||
}
|
||||
|
||||
/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */
|
||||
export function catchNote(
|
||||
found: number,
|
||||
firstName = "",
|
||||
noun = "재료",
|
||||
): { kind: "none" | "one" | "many"; text: string } {
|
||||
if (found === 0)
|
||||
return {
|
||||
kind: "none",
|
||||
text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`,
|
||||
};
|
||||
if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` };
|
||||
return {
|
||||
kind: "many",
|
||||
text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 쪽 범위 — total 개를 size 씩 · page 는 1 부터 · 앞 번호 · 뒤 번호(1 부터) */
|
||||
export function pageRange(
|
||||
total: number,
|
||||
page: number,
|
||||
size: number,
|
||||
): { from: number; to: number; pages: number } {
|
||||
const pages = Math.max(1, Math.ceil(total / size));
|
||||
const p = Math.min(Math.max(1, page), pages);
|
||||
return { from: total ? (p - 1) * size + 1 : 0, to: Math.min(total, p * size), pages };
|
||||
}
|
||||
|
||||
/** 이 로직의 변수 알약 — 입력 상자 변수마다(규칙의 「알약」 고르기 목록) */
|
||||
export function variablePills(
|
||||
lg: CfLogic,
|
||||
): { port: CfPort; unit: string; example: CfVariable["example"] }[] {
|
||||
return lg.boxes.flatMap((b) =>
|
||||
b.input?.mode === "variable"
|
||||
? b.input.variables.map((v) => ({
|
||||
port: { box: b.id, name: v.name },
|
||||
unit: v.unit,
|
||||
example: v.example,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** 선택 자리 · 조건 찾기 요소 — ref 는 기본 품목(없으면 빈 글) */
|
||||
export function materialElement(
|
||||
mode: CfMaterialMode,
|
||||
name: string,
|
||||
scope: CfScope,
|
||||
extra: { rule?: CfMaterialRule; item?: Pick<MatItem, "ref" | "spec" | "unit"> } = {},
|
||||
): CfElementRef {
|
||||
const item = extra.item;
|
||||
return {
|
||||
ref: mode === "rule" ? "" : (item?.ref ?? ""),
|
||||
name: name.trim(),
|
||||
slot: name.trim(),
|
||||
spec: item?.spec ?? "",
|
||||
unit: item?.unit || "-",
|
||||
mode,
|
||||
scope: Object.fromEntries(Object.entries(scope).filter(([, v]) => v)) as CfScope,
|
||||
...(mode === "rule" && extra.rule ? { rule: extra.rule } : {}),
|
||||
...(mode === "choice" && item?.ref ? { default: item.ref } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 재료 요소를 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
|
||||
export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string | null {
|
||||
if (!el.name) return "자리 이름을 적어 주세요";
|
||||
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
|
||||
if (el.mode === "rule" && el.rule?.field === "검색어") {
|
||||
if (!el.rule.template?.trim() || !templateSlots(el.rule.template).length)
|
||||
return "검색어 틀에 자리 {이름} 이 하나는 있어야 함";
|
||||
if (
|
||||
(el.rule.slots ?? []).length < templateSlots(el.rule.template).length ||
|
||||
(el.rule.slots ?? []).some((x) => !x.from.name)
|
||||
)
|
||||
return "틀의 자리마다 변수 알약을 골라 주세요";
|
||||
return null;
|
||||
}
|
||||
if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
|
||||
if (
|
||||
el.mode !== "fixed" &&
|
||||
!(el.mode === "choice" && el.default) &&
|
||||
!Object.keys(el.scope ?? {}).length
|
||||
)
|
||||
return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리 · 이어지지 않은 다른 로직 받는 알약)
|
||||
* examples = 받는 알약마다 그 로직 자기 예시값(선이 없을 때 값 차례의 끝) */
|
||||
export function logicRefOf(key: string, other: CfLogic): CfLogicRef {
|
||||
const outputs = other.boxes
|
||||
.filter((b) => b.kind === "output")
|
||||
.map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" }));
|
||||
const designs: { name: string; unit: string }[] = [];
|
||||
const examples: Record<string, number | string> = {};
|
||||
const addDesign = (name: string, unit: string, example?: number | string | null): void => {
|
||||
designs.push({ name, unit: unit || "-" });
|
||||
if (example !== null && example !== undefined && example !== "") examples[name] = example;
|
||||
};
|
||||
for (const b of other.boxes) {
|
||||
for (const v of b.input?.variables ?? b.func?.variables ?? [])
|
||||
addDesign(v.name, v.unit, v.example);
|
||||
if (b.input?.mode === "group")
|
||||
for (const e of b.input.elements)
|
||||
if (e.mode === "choice" || e.mode === "rule") addDesign(e.slot || e.name, e.unit);
|
||||
if (b.input?.mode === "logic")
|
||||
for (const d of b.input.logic?.designs ?? [])
|
||||
if (!other.links.some((l) => l.to.box === b.id && l.to.name === d.name))
|
||||
addDesign(d.name, d.unit, b.input.logic?.examples?.[d.name]);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return {
|
||||
key,
|
||||
name: other.name,
|
||||
outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)),
|
||||
designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))),
|
||||
...(Object.keys(examples).length ? { examples } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 마스터 로직 고르기 — 목록 · 거름 · 쪽 · 순환 막음(36-6) ── */
|
||||
|
||||
/** 마스터 로직 한 줄 — M01 `fetchLogics` 의 줄(자기 시험 로직 DF-… 도 같은 꼴로 합침) */
|
||||
export interface MasterLogicBrief {
|
||||
file: string;
|
||||
구분: string;
|
||||
상세구분: string;
|
||||
키: string;
|
||||
원문번호: string;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
/** 쉼표로 여럿(208-1) */
|
||||
별칭?: string;
|
||||
blocked?: boolean;
|
||||
reasons?: string[];
|
||||
}
|
||||
|
||||
/** 찾기 — 공용 `matchLogic`(띄어 나눈 말마다 이름 · 원문번호 · 키 · 구분 · 상세구분 · 별칭 · 208-1) · 원문(구분) · 장(상세구분) 거름 */
|
||||
export function filterLogics(
|
||||
items: MasterLogicBrief[],
|
||||
q: { text: string; book?: string; chapter?: string },
|
||||
): MasterLogicBrief[] {
|
||||
return items.filter((i) => {
|
||||
if (q.book && i.구분 !== q.book) return false;
|
||||
if (q.chapter && i.상세구분 !== q.chapter) return false;
|
||||
return matchLogic(q.text, i).ok;
|
||||
});
|
||||
}
|
||||
|
||||
/** 원문(구분) 목록 · 장(상세구분) 목록 — 나온 차례대로 */
|
||||
export function logicBooks(items: MasterLogicBrief[]): string[] {
|
||||
return [...new Set(items.map((i) => i.구분).filter(Boolean))];
|
||||
}
|
||||
export function logicChapters(items: MasterLogicBrief[], book: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
items
|
||||
.filter((i) => !book || i.구분 === book)
|
||||
.map((i) => i.상세구분)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** 계산 방법을 흐름도로 읽은 로직은 출력 상자가 비목별이라 노무비 · 재료비 · 경비 · 계 알약 넷으로 보임 */
|
||||
export const COST_OUTPUTS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
|
||||
/** 마스터 로직 고른 것 → 다른 로직 입력 값 — 흐름이면 출력 상자 · 변환이면 비용 넷 */
|
||||
export function masterRef(
|
||||
key: string,
|
||||
flow: CfLogic,
|
||||
converted: boolean,
|
||||
resultUnit: string,
|
||||
): CfLogicRef {
|
||||
const ref = logicRefOf(key, flow);
|
||||
return converted
|
||||
? { ...ref, outputs: COST_OUTPUTS.map((name) => ({ name, unit: resultUnit || "-" })) }
|
||||
: 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,
|
||||
startKey: string,
|
||||
load: (key: string) => Promise<CfLogic | null>,
|
||||
depth = 8,
|
||||
): Promise<boolean> {
|
||||
if (!selfKey) return false;
|
||||
const seen = new Set<string>();
|
||||
const walk = async (key: string, left: number): Promise<boolean> => {
|
||||
if (key === selfKey) return true;
|
||||
if (seen.has(key) || left <= 0) return false;
|
||||
seen.add(key);
|
||||
const lg = await load(key).catch(() => null);
|
||||
for (const b of lg?.boxes ?? []) {
|
||||
const next = b.input?.mode === "logic" ? b.input.logic?.key : undefined;
|
||||
if (next && (await walk(next, left - 1))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return walk(startKey, depth);
|
||||
}
|
||||
|
||||
/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */
|
||||
export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic {
|
||||
return {
|
||||
...lg,
|
||||
boxes: lg.boxes.map((b) => {
|
||||
if (b.id !== id || !b.input) return b;
|
||||
const input: CfInput = ref
|
||||
? { mode: "logic", elements: [], variables: [], logic: ref }
|
||||
: { mode: "variable", elements: [], variables: [] };
|
||||
return { ...b, input, unit: unitOfInput(input) };
|
||||
}),
|
||||
links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), // 나가던 선 · 그 로직의 받는 알약으로 들어오던 선 모두
|
||||
};
|
||||
}
|
||||
|
||||
/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */
|
||||
export function unitOfInput(input: CfInput): string {
|
||||
if (input.table) return "-";
|
||||
if (input.mode === "logic") {
|
||||
const u = (input.logic?.outputs ?? []).map((o) => o.unit || "-");
|
||||
return u.length && u.every((x) => x === u[0]) ? u[0] : "-";
|
||||
}
|
||||
const units = (
|
||||
input.mode === "variable"
|
||||
? input.variables.map((v) => v.unit)
|
||||
: input.elements.map((e) => e.unit)
|
||||
).map((u) => u || "-");
|
||||
return units.length && units.every((u) => u === units[0]) ? units[0] : "-";
|
||||
}
|
||||
|
||||
/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
|
||||
export function checkVariable(v: CfVariable, others: CfVariable[]): string | null {
|
||||
const name = v.name.trim();
|
||||
if (!name) return "이름을 적어 주세요";
|
||||
if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음";
|
||||
if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상";
|
||||
if (v.type === "number") {
|
||||
if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함";
|
||||
if (
|
||||
typeof v.example === "number" &&
|
||||
v.range &&
|
||||
(v.example < v.range[0] || v.example > v.range[1])
|
||||
)
|
||||
return "예시값이 범위 밖";
|
||||
if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자";
|
||||
}
|
||||
if (
|
||||
v.type === "choice" &&
|
||||
v.example !== null &&
|
||||
!(v.choices ?? []).map(String).includes(String(v.example))
|
||||
)
|
||||
return "예시값이 선택 목록에 없음";
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */
|
||||
|
||||
type Pills = (box: CfBox) => string[];
|
||||
const defaultPills: Pills = pillNames;
|
||||
|
||||
const touch = (
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
fn: (b: CfBox) => CfBox,
|
||||
pills: Pills = defaultPills,
|
||||
): CfLogic => {
|
||||
const old = lg.boxes.find((b) => b.id === id);
|
||||
if (!old) return lg;
|
||||
const next = fn(old);
|
||||
const boxes = lg.boxes.map((b) =>
|
||||
b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b,
|
||||
);
|
||||
// 알약이 없어진 선은 지움
|
||||
const keep = new Set(pills(next));
|
||||
const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name));
|
||||
return { ...lg, boxes, links };
|
||||
};
|
||||
|
||||
const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] };
|
||||
|
||||
/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */
|
||||
export function setKind(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
kind: CfGroup | "변수" | "다른 로직",
|
||||
pills?: Pills,
|
||||
): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => {
|
||||
const cur = inputOf(b);
|
||||
if (kind === "다른 로직")
|
||||
return { ...b, input: { mode: "logic", elements: [], variables: [] }, unit: "-" };
|
||||
if (kind === "변수")
|
||||
return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } };
|
||||
const same = cur.mode === "group" && cur.group === kind;
|
||||
return {
|
||||
...b,
|
||||
input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] },
|
||||
};
|
||||
},
|
||||
pills,
|
||||
);
|
||||
}
|
||||
|
||||
/** 요소 넣기 — 같은 요소는 한 번만 */
|
||||
export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => {
|
||||
const cur = inputOf(b);
|
||||
return cur.elements.some((e) => (el.ref !== "" && e.ref === el.ref) || e.name === el.name)
|
||||
? b
|
||||
: { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } };
|
||||
},
|
||||
pills,
|
||||
);
|
||||
}
|
||||
|
||||
/** 이름으로 빼기 — 재료 조건 찾기 요소는 품목 키가 없어 이름(알약 이름)으로 가름 */
|
||||
export function removeElementNamed(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => ({
|
||||
...b,
|
||||
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.name !== name) },
|
||||
}),
|
||||
pills,
|
||||
);
|
||||
}
|
||||
|
||||
export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => ({
|
||||
...b,
|
||||
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) },
|
||||
}),
|
||||
pills,
|
||||
);
|
||||
}
|
||||
|
||||
/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */
|
||||
export function setTable(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
table: { key: string; row?: number } | null,
|
||||
name?: string,
|
||||
pills?: Pills,
|
||||
): CfLogic {
|
||||
const next = touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }),
|
||||
pills,
|
||||
);
|
||||
return name
|
||||
? {
|
||||
...next,
|
||||
boxes: next.boxes.map((b) =>
|
||||
b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b,
|
||||
),
|
||||
}
|
||||
: next;
|
||||
}
|
||||
|
||||
/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */
|
||||
export function putVariable(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
v: CfVariable,
|
||||
oldName?: string,
|
||||
pills?: Pills,
|
||||
): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => {
|
||||
const cur = inputOf(b);
|
||||
const vars =
|
||||
oldName === undefined
|
||||
? [...cur.variables, v]
|
||||
: cur.variables.map((x) => (x.name === oldName ? v : x));
|
||||
return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } };
|
||||
},
|
||||
pills,
|
||||
);
|
||||
}
|
||||
|
||||
export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
|
||||
return touch(
|
||||
lg,
|
||||
id,
|
||||
(b) => ({
|
||||
...b,
|
||||
input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) },
|
||||
}),
|
||||
pills,
|
||||
);
|
||||
}
|
||||
|
||||
/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */
|
||||
export function reconcileUnit(lg: CfLogic, id: string): CfLogic {
|
||||
const box = lg.boxes.find((b) => b.id === id);
|
||||
if (!box?.input) return lg;
|
||||
const want = unitOfInput(box.input);
|
||||
return box.unit === want
|
||||
? 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;
|
||||
}
|
||||
|
||||
/* ── 재료 조건 찾기 field 「검색어」 — 틀(「레미콘 {굵은골재최대치수}-{강도}-{슬럼프}」) 의 자리마다 변수 알약 값을 끼운 글로 찾음 ── */
|
||||
|
||||
/** 틀 안 자리 이름들 — 나온 차례 · 겹침 없이 */
|
||||
export function templateSlots(template: string): string[] {
|
||||
return [
|
||||
...new Set([...template.matchAll(/\{([^{}]+)\}/g)].map((m) => m[1].trim()).filter(Boolean)),
|
||||
];
|
||||
}
|
||||
|
||||
/** 틀을 채운 글 — valueOf 가 알약 값을 줌 · 값이 빈 자리는 missing 에 모음(그 자리는 그대로 둠) */
|
||||
export function fillTemplate(
|
||||
template: string,
|
||||
slots: { name: string; from: CfPort }[],
|
||||
valueOf: (port: CfPort) => unknown,
|
||||
): { text: string; missing: string[] } {
|
||||
const missing: string[] = [];
|
||||
const text = template.replace(/\{([^{}]+)\}/g, (all, raw: string) => {
|
||||
const name = raw.trim();
|
||||
const slot = slots.find((x) => x.name === name);
|
||||
const v = slot ? valueOf(slot.from) : undefined;
|
||||
if (v === null || v === undefined || v === "") {
|
||||
if (!missing.includes(name)) missing.push(name);
|
||||
return all;
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
return { text, missing };
|
||||
}
|
||||
|
||||
/** 검색어 틀 규칙 — 첫 자리의 알약이 from(Model 호환) */
|
||||
export function templateRule(
|
||||
template: string,
|
||||
slots: { name: string; from: CfPort }[],
|
||||
): CfMaterialRule {
|
||||
return {
|
||||
field: "검색어",
|
||||
op: "포함",
|
||||
from: slots[0]?.from ?? { box: "", name: "" },
|
||||
template,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Logic.ts
|
||||
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽) 가운데 하나를 고르면
|
||||
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
|
||||
* 흐름이 없는 로직은 서버가 계산 방법을 흐름도로 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
|
||||
import {
|
||||
fetchAuto,
|
||||
fetchLogic,
|
||||
fetchLogics,
|
||||
} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
|
||||
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
|
||||
import {
|
||||
filterLogics,
|
||||
logicBooks,
|
||||
logicChapters,
|
||||
logicRefOf,
|
||||
masterRef,
|
||||
rowRef,
|
||||
pageRange,
|
||||
setKind,
|
||||
setLogicInput,
|
||||
wouldCycle,
|
||||
type MasterLogicBrief,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
import "@ui/ui_template_list_row.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
|
||||
const TEXT = {
|
||||
Help: [
|
||||
"마스터 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
|
||||
"Use any master logic or a logic of mine as an input",
|
||||
],
|
||||
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
|
||||
Book: ["원문", "Source"],
|
||||
Chapter: ["장", "Chapter"],
|
||||
All: ["전체", "All"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
None: ["찾는 로직 없음", "No logic found"],
|
||||
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
|
||||
Prev: ["이전", "Prev"],
|
||||
Next: ["다음", "Next"],
|
||||
Blocked: ["읽을 수 없음", "Unreadable"],
|
||||
Chosen: ["고른 로직", "Chosen logic"],
|
||||
Converted: [
|
||||
"이 로직의 계산 방법을 흐름도로 읽음 — 파일은 안 바뀜",
|
||||
"Old logic read as a flow — the file is unchanged",
|
||||
],
|
||||
Notes: ["못 옮긴 곳", "Not carried over"],
|
||||
Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"],
|
||||
Designs: [
|
||||
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)",
|
||||
"Pills it takes — its design inputs",
|
||||
],
|
||||
NoOut: ["출력 상자가 없음", "No output box"],
|
||||
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
|
||||
Change: ["다른 로직 고르기", "Pick another"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
Self: ["이 로직 자신은 못 고름", "Can't pick itself"],
|
||||
Cycle: [
|
||||
"돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름",
|
||||
"That would loop — a logic that uses this one can't be picked",
|
||||
],
|
||||
NoFlow: [
|
||||
"이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음",
|
||||
"Can't read this logic's flow yet",
|
||||
],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
Wait: ["값 셈 중…", "Calculating…"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
|
||||
|
||||
const plain = (e: unknown): string => {
|
||||
try {
|
||||
return cfPlain(e);
|
||||
} catch {
|
||||
return e instanceof Error ? e.message : t("Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const PAGE = 12;
|
||||
|
||||
/** 마스터 로직 목록 — 한 번 받아 둠(M01 `fetchLogics`) */
|
||||
let cache: Promise<MasterLogicBrief[]> | null = null;
|
||||
const loadAll = (): Promise<MasterLogicBrief[]> =>
|
||||
(cache ??= fetchLogics().catch((e: unknown) => ((cache = null), Promise.reject(e))));
|
||||
|
||||
/** 고른 로직에서 읽은 것 — 계산 방법을 흐름도로 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
|
||||
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
|
||||
|
||||
/** 마스터 로직 하나를 읽어 흐름도로 — 흐름 받기 길(흐름이 없으면 계산 방법을 흐름도로 읽음) */
|
||||
const flowOf = (
|
||||
item: Pick<MasterLogicBrief, "file" | "키">,
|
||||
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> =>
|
||||
cfLogicFlow(item.file, item.키);
|
||||
|
||||
/** 다른 로직 입력 판 */
|
||||
export function renderLogicInput(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
|
||||
if (ref) return void showChosen(host, lg, boxId, ref, on);
|
||||
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const all = { value: "", text: t("All") };
|
||||
let book = "";
|
||||
let chapter = "";
|
||||
let page = 1;
|
||||
let items: MasterLogicBrief[] = [];
|
||||
const bookBox = createSelectField({
|
||||
label: t("Book"),
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()),
|
||||
});
|
||||
const chapterBox = createSelectField({
|
||||
label: t("Chapter"),
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
onChange: (v) => ((chapter = v), (page = 1), draw()),
|
||||
});
|
||||
const fillChapters = (): void =>
|
||||
chapterBox.setOptions(
|
||||
[all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))],
|
||||
chapter,
|
||||
);
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
const info = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } });
|
||||
const prev = createButton({
|
||||
label: t("Prev"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page -= 1), draw()),
|
||||
});
|
||||
const next = createButton({
|
||||
label: t("Next"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page += 1), draw()),
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
const rows = filterLogics(
|
||||
items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌
|
||||
{ text: q.input.value, book, chapter },
|
||||
);
|
||||
const range = pageRange(rows.length, page, PAGE);
|
||||
page = Math.min(page, range.pages);
|
||||
const shown = rows.slice((page - 1) * PAGE, page * PAGE);
|
||||
info.textContent = t("Total", {
|
||||
n: rows.length,
|
||||
a: range.from,
|
||||
b: range.to,
|
||||
p: page,
|
||||
m: range.pages,
|
||||
});
|
||||
prev.disabled = page <= 1;
|
||||
next.disabled = page >= range.pages;
|
||||
list.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map((i) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row ui-list-row ui-sidebar-row",
|
||||
attrs: {
|
||||
type: "button",
|
||||
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
|
||||
},
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: i.이름 }),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: ` ${i.원문번호}`,
|
||||
}),
|
||||
// 별칭으로 맞았으면 그 별칭을 작게(208-1)
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: ` ${aliasNote(matchLogic(q.input.value, i).alias)}`,
|
||||
}),
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "20" },
|
||||
text: i.blocked ? t("Blocked") : i.상세구분,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: i.결과단위,
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pick(i));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("None") })]),
|
||||
);
|
||||
};
|
||||
|
||||
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
|
||||
const loadForCycle = async (key: string): Promise<CfLogic | null> => {
|
||||
const it = items.find((x) => x.키 === key);
|
||||
return it ? (await flowOf(it)).flow : null;
|
||||
};
|
||||
|
||||
const pick = async (i: MasterLogicBrief): Promise<void> => {
|
||||
try {
|
||||
if (i.키 === lg.key) return void showToast(t("Self"), "error");
|
||||
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 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({
|
||||
...done,
|
||||
boxes: done.boxes.map((b) =>
|
||||
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error");
|
||||
}
|
||||
};
|
||||
|
||||
let timer = 0;
|
||||
q.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => ((page = 1), draw()), 200);
|
||||
});
|
||||
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") }));
|
||||
host.replaceChildren(
|
||||
el("p", { className: "ui-text ui-text--muted", text: t("Help") }),
|
||||
el("div", {
|
||||
className: "ui-toolbar m02-dfip__bar",
|
||||
children: [bookBox.root, chapterBox.root, q.root],
|
||||
}),
|
||||
info,
|
||||
list,
|
||||
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
|
||||
);
|
||||
void loadAll().then(
|
||||
(rows) => {
|
||||
items = rows;
|
||||
bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], "");
|
||||
fillChapters();
|
||||
draw();
|
||||
},
|
||||
(e) => list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: plain(e) })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
|
||||
function showChosen(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
ref: CfLogicRef,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const read = readNotes.get(ref.key);
|
||||
const values = new Map<string, HTMLElement>();
|
||||
const errors = el("p", { className: "ui-text ui-text--error" });
|
||||
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", { className: "m02-dfip__name", text: name }),
|
||||
...(valueNode
|
||||
? [valueNode]
|
||||
: extra
|
||||
? [
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: extra,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: unit || "-",
|
||||
}),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
|
||||
...(read?.converted
|
||||
? [
|
||||
el("p", { className: "ui-text ui-text--muted", text: t("Converted") }),
|
||||
...(read.notes.length
|
||||
? [
|
||||
el("details", {
|
||||
children: [
|
||||
el("summary", { text: `${t("Notes")} · ${read.notes.length}` }),
|
||||
...read.notes.map((n) => el("p", { text: n })),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
|
||||
...(ref.outputs.length
|
||||
? ref.outputs.map((o) => {
|
||||
const v = el("strong", { className: "m02-dfip__value", text: "—" });
|
||||
values.set(o.name, v);
|
||||
return line(o.name, o.unit, v);
|
||||
})
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("NoOut") })]),
|
||||
errors,
|
||||
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
|
||||
...(ref.designs.length
|
||||
? ref.designs.map((d) =>
|
||||
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
|
||||
)
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("NoDesign") })]),
|
||||
el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
children: [
|
||||
createButton({
|
||||
label: t("Change"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => on.change(setKind(lg, boxId, "다른 로직")),
|
||||
}),
|
||||
createButton({
|
||||
label: t("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => on.change(setLogicInput(lg, boxId, null)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
// 값 — 서버 계산 길(받는 알약 예시값으로)
|
||||
errors.textContent = t("Wait");
|
||||
void cfLogicValue(ref.key, ref.examples ?? {}).then(
|
||||
(r) => {
|
||||
for (const [name, node] of values) {
|
||||
const v = r.outputs[name] ?? (r.cost as unknown as Record<string, unknown>)[name];
|
||||
if (typeof v === "number") node.textContent = fmt(v);
|
||||
else if (typeof v === "string") node.textContent = v;
|
||||
}
|
||||
errors.textContent = r.errors.join(" · ");
|
||||
},
|
||||
(e) => (errors.textContent = plain(e)),
|
||||
);
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Material.ts
|
||||
* 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수)
|
||||
* 서버 `/api/m02/df/materials`(cfMaterials)
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { cfMaterials, cfValues } from "./M02_DF_Api";
|
||||
import { templateBox } from "./M02_DF_InputPanel_Template";
|
||||
import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model";
|
||||
import {
|
||||
catchNote,
|
||||
fillTemplate,
|
||||
templateRule,
|
||||
checkMaterial,
|
||||
elementOf,
|
||||
matches,
|
||||
materialElement,
|
||||
pageRange,
|
||||
removeElementNamed,
|
||||
variablePills,
|
||||
type MatItem,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
import "@ui/ui_template_list_row.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
|
||||
const TEXT = {
|
||||
Chosen: ["넣은 재료 — 알약마다 이름 · 값 · 단위", "Chosen materials"],
|
||||
None: ["아직 고른 재료가 없음", "Nothing chosen yet"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
Fixed: ["고정", "Fixed"],
|
||||
Choice: ["선택 자리", "Choice slot"],
|
||||
Rule: ["조건 찾기", "Find by rule"],
|
||||
FixedHelp: ["품목 하나를 못 박음", "One fixed item"],
|
||||
ChoiceHelp: [
|
||||
"자리만 두고 프로젝트에서 품목을 고름(기본 품목 하나)",
|
||||
"A slot — pick the item per project",
|
||||
],
|
||||
RuleHelp: [
|
||||
"변수 알약 값으로 재료를 잡음 — 예: 관경 알약 값이 규격에 들어간 흄관",
|
||||
"Find by a variable pill",
|
||||
],
|
||||
Search: ["찾는 말 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
|
||||
AllSub: ["전체", "All"],
|
||||
SubLabel: ["구분", "Group"],
|
||||
DetailLabel: ["상세구분", "Detail"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
NoRow: ["찾는 재료 없음", "Nothing found"],
|
||||
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
|
||||
Prev: ["이전", "Prev"],
|
||||
Next: ["다음", "Next"],
|
||||
SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"],
|
||||
Scope: ["범위", "Scope"],
|
||||
Word: ["찾는 말(범위 안에서)", "Word in scope"],
|
||||
Default: ["기본 품목", "Default item"],
|
||||
DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"],
|
||||
DefaultClear: ["기본 품목 비움", "Clear"],
|
||||
RuleHow: ["찾는 방식", "How to find"],
|
||||
HowPlain: ["규격 · 이름에 알약 값", "Spec / name vs a pill"],
|
||||
HowTemplate: ["검색어 틀", "Search template"],
|
||||
TemplateLine: ["검색어 틀 「{t}」", "template “{t}”"],
|
||||
Missing: ["자리 「{n}」 알약 값이 비어 있음", "Slot “{n}” has no value"],
|
||||
RuleField: ["찾는 칸", "Field"],
|
||||
FieldSpec: ["규격", "Spec"],
|
||||
FieldName: ["이름", "Name"],
|
||||
RuleOp: ["조건", "Condition"],
|
||||
OpIn: ["에 알약 값이 들어 있음", "contains the pill value"],
|
||||
OpEq: ["이 알약 값과 같음", "equals the pill value"],
|
||||
RulePill: ["변수 알약", "Variable pill"],
|
||||
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
|
||||
Put: ["넣기", "Add"],
|
||||
Pick: ["넣기", "Add"],
|
||||
Preview: ["지금 잡히는 재료", "Items caught now"],
|
||||
Price: ["가장 싼 값 {p} {u}", "cheapest {p} {u}"],
|
||||
RuleLine: ["{f}{o} · 알약 「{n}」 = {v}", "{f} {o} · pill “{n}” = {v}"],
|
||||
ScopeLine: ["범위 {s}", "scope {s}"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
|
||||
|
||||
/* ── 자재 찾기 ── */
|
||||
export interface MatQuery {
|
||||
q: string;
|
||||
sub?: string;
|
||||
detail?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
export interface MatPage {
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
items: MatItem[];
|
||||
}
|
||||
|
||||
/** 서버 자재 찾기 — 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기 · 전체 수 */
|
||||
export async function searchMaterials(q: MatQuery): Promise<MatPage> {
|
||||
return cfMaterials({ q: q.q, sub: q.sub, detail: q.detail, page: q.page, size: q.size });
|
||||
}
|
||||
|
||||
let subsCache: SubInfo[] | null = null;
|
||||
async function materialSubs(): Promise<SubInfo[]> {
|
||||
if (subsCache) return subsCache;
|
||||
const files = await fetchFiles("재료");
|
||||
const f = files.find((x) => x.file.includes("자재품목")) ?? files[0];
|
||||
subsCache = await fetchSubs({ file: f.file });
|
||||
return subsCache;
|
||||
}
|
||||
|
||||
/** 구분 · 상세구분 고르기 칸 — 바뀌면 onChange */
|
||||
function scopeBoxes(
|
||||
init: { sub: string; detail: string },
|
||||
onChange: () => void,
|
||||
): { root: HTMLElement; get(): { sub: string; detail: string } } {
|
||||
const state = { ...init };
|
||||
const all = { value: "", text: t("AllSub") };
|
||||
const subBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: t("SubLabel"),
|
||||
onChange: (v) => ((state.sub = v), (state.detail = ""), fill(), onChange()),
|
||||
});
|
||||
const detailBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: t("DetailLabel"),
|
||||
onChange: (v) => ((state.detail = v), onChange()),
|
||||
});
|
||||
let subs: SubInfo[] = [];
|
||||
const fill = (): void => {
|
||||
const d = subs.find((s) => s.name === state.sub)?.details ?? [];
|
||||
detailBox.root.hidden = !d.length;
|
||||
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], state.detail);
|
||||
};
|
||||
fill();
|
||||
void materialSubs().then(
|
||||
(s) => {
|
||||
subs = s;
|
||||
subBox.setOptions([all, ...s.map((x) => ({ value: x.name, text: x.name }))], state.sub);
|
||||
fill();
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return {
|
||||
root: el("div", {
|
||||
className: "ui-toolbar m02-dfip__bar",
|
||||
children: [subBox.root, detailBox.root],
|
||||
}),
|
||||
get: () => ({ ...state }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 품목 찾기 목록 — 찾는 말 · 구분 · 쪽 넘기기 · 눌러 고르기 */
|
||||
function itemList(
|
||||
onPick: (item: MatItem) => void,
|
||||
base?: () => { sub: string; detail: string },
|
||||
): HTMLElement {
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
const info = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } });
|
||||
let page = 1;
|
||||
let seq = 0;
|
||||
const own = base ? null : scopeBoxes({ sub: "", detail: "" }, () => ((page = 1), void load()));
|
||||
const scope = (): { sub: string; detail: string } => (base ? base() : own!.get());
|
||||
const prev = createButton({
|
||||
label: t("Prev"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page -= 1), void load()),
|
||||
});
|
||||
const next = createButton({
|
||||
label: t("Next"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page += 1), void load()),
|
||||
});
|
||||
const load = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") }));
|
||||
try {
|
||||
const r = await searchMaterials({ q: q.input.value.trim(), ...scope(), page, size: 12 });
|
||||
if (mine !== seq) return;
|
||||
const range = pageRange(r.total, page, 12);
|
||||
info.textContent = t("Total", {
|
||||
n: r.total,
|
||||
a: range.from,
|
||||
b: range.to,
|
||||
p: Math.min(page, range.pages),
|
||||
m: range.pages,
|
||||
});
|
||||
prev.disabled = page <= 1;
|
||||
next.disabled = page >= range.pages;
|
||||
list.replaceChildren(
|
||||
...(r.items.length
|
||||
? r.items.map((it) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row ui-list-row ui-sidebar-row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: it.name }),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: ` ${it.spec}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: `${it.price === null ? "" : fmt(it.price)} ${it.unit}`.trim(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => onPick(it));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("NoRow") })]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq)
|
||||
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Failed") }));
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
q.input.addEventListener(
|
||||
"input",
|
||||
() => (
|
||||
window.clearTimeout(timer),
|
||||
(timer = window.setTimeout(() => ((page = 1), void load()), 250))
|
||||
),
|
||||
);
|
||||
void load();
|
||||
return el("div", {
|
||||
className: "m02-dfip__find",
|
||||
children: [
|
||||
...(own ? [own.root] : []),
|
||||
q.root,
|
||||
info,
|
||||
list,
|
||||
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 잡히는 재료 미리 보기 ── */
|
||||
export async function previewOf(
|
||||
el1: CfElementRef,
|
||||
lg: CfLogic,
|
||||
): Promise<{
|
||||
found: number;
|
||||
names: string[];
|
||||
cheapest: MatItem | null;
|
||||
unit: string;
|
||||
missing?: string[];
|
||||
}> {
|
||||
const scope = el1.scope ?? {};
|
||||
const word = scope.찾는말 ?? "";
|
||||
if (el1.mode === "rule" && el1.rule?.field === "검색어") {
|
||||
// 틀의 자리마다 알약 값을 끼운 글로 찾음(띄어쓴 말은 모두 들어간 것)
|
||||
const valueOf = (port: { box: string; name: string }): unknown =>
|
||||
variablePills(lg).find((p) => p.port.box === port.box && p.port.name === port.name)?.example;
|
||||
const { text, missing } = fillTemplate(el1.rule.template ?? "", el1.rule.slots ?? [], valueOf);
|
||||
if (missing.length) return { found: 0, names: [], cheapest: null, unit: "", missing };
|
||||
const r = await searchMaterials({
|
||||
q: `${word} ${text}`.trim(),
|
||||
sub: scope.구분,
|
||||
detail: scope.상세구분,
|
||||
page: 1,
|
||||
size: 200,
|
||||
});
|
||||
return {
|
||||
found: r.total,
|
||||
names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
||||
cheapest: cheapestOf(r.items),
|
||||
unit: commonUnit(r.items),
|
||||
};
|
||||
}
|
||||
if (el1.mode === "rule" && el1.rule) {
|
||||
const pill = variablePills(lg).find(
|
||||
(p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name,
|
||||
);
|
||||
const value = pill?.example;
|
||||
if (value === null || value === undefined || value === "")
|
||||
return { found: 0, names: [], cheapest: null, unit: "" };
|
||||
const r = await searchMaterials({
|
||||
q: `${word} ${value}`.trim(),
|
||||
sub: scope.구분,
|
||||
detail: scope.상세구분,
|
||||
page: 1,
|
||||
size: 200,
|
||||
});
|
||||
const hit = r.items.filter((i) => matches(i, el1.rule!, value));
|
||||
return {
|
||||
found: hit.length,
|
||||
names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
||||
cheapest: cheapestOf(hit),
|
||||
unit: commonUnit(hit),
|
||||
};
|
||||
}
|
||||
const r = await searchMaterials({
|
||||
q: word,
|
||||
sub: scope.구분,
|
||||
detail: scope.상세구분,
|
||||
page: 1,
|
||||
size: 200,
|
||||
});
|
||||
return {
|
||||
found: r.total,
|
||||
names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
||||
cheapest: cheapestOf(r.items),
|
||||
unit: commonUnit(r.items),
|
||||
};
|
||||
}
|
||||
|
||||
/** 잡힌 품목 단위가 모두 같으면 그 단위 · 아니면 빈 글 */
|
||||
const commonUnit = (items: MatItem[]): string =>
|
||||
items.length && items.every((i) => i.unit === items[0].unit) ? items[0].unit : "";
|
||||
|
||||
const cheapestOf = (items: MatItem[]): MatItem | null =>
|
||||
items
|
||||
.filter((i) => i.price !== null)
|
||||
.sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null;
|
||||
|
||||
/** 미리 보기 글 — 틀 자리 값이 비면 그 까닭 · 아니면 잡힌 수에 따른 알림 */
|
||||
const noteOf = (p: {
|
||||
found: number;
|
||||
names: string[];
|
||||
missing?: string[];
|
||||
}): ReturnType<typeof catchNote> =>
|
||||
p.missing?.length
|
||||
? { kind: "none", text: t("Missing", { n: p.missing.join(" · ") }) }
|
||||
: catchNote(p.found, p.names[0]);
|
||||
|
||||
/** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */
|
||||
function summaryOf(e: CfElementRef, lg: CfLogic): string {
|
||||
const parts: string[] = [];
|
||||
const scope = Object.entries(e.scope ?? {})
|
||||
.map(([, v]) => v)
|
||||
.join(" / ");
|
||||
if (scope) parts.push(t("ScopeLine", { s: scope }));
|
||||
if (e.rule?.field === "검색어") parts.push(t("TemplateLine", { t: e.rule.template ?? "" }));
|
||||
else if (e.rule) {
|
||||
const p = variablePills(lg).find(
|
||||
(x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name,
|
||||
);
|
||||
parts.push(
|
||||
t("RuleLine", {
|
||||
f: e.rule.field,
|
||||
o: e.rule.op === "포함" ? t("OpIn") : t("OpEq"),
|
||||
n: e.rule.from.name,
|
||||
v: p?.example ?? "—",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 재료 판 — 넣은 재료 목록 + 방식 셋 */
|
||||
export function renderMaterial(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
addEl: (lg: CfLogic, el: CfElementRef) => CfLogic,
|
||||
): void {
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
const els = (box?.input?.elements ?? []) as CfElementRef[];
|
||||
const chosen = el("div", { className: "m02-dfip__chosen" });
|
||||
const body = el("div", { className: "m02-dfip__stack" });
|
||||
let mode: CfMaterialMode = "fixed";
|
||||
let how: "plain" | "template" = "plain";
|
||||
|
||||
const drawChosen = (): void => {
|
||||
chosen.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} · ${els.length}` }),
|
||||
...(els.length
|
||||
? els.map((e) => {
|
||||
const val = el("strong", { className: "m02-dfip__value", text: "—" });
|
||||
const note = el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
});
|
||||
const kind =
|
||||
e.mode === "choice" ? t("Choice") : e.mode === "rule" ? t("Rule") : t("Fixed");
|
||||
const row = el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "20" },
|
||||
text: kind,
|
||||
}),
|
||||
el("strong", { text: ` ${e.name}` }),
|
||||
el("div", { className: "ui-text ui-text--muted", text: summaryOf(e, lg) }),
|
||||
note,
|
||||
],
|
||||
}),
|
||||
val,
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: e.unit || "-",
|
||||
}),
|
||||
createButton({
|
||||
label: t("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => on.change(removeElementNamed(lg, boxId, e.name)),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const ref = e.mode === "choice" ? e.default : e.mode === "rule" ? "" : e.ref;
|
||||
if (ref)
|
||||
void cfValues([{ id: ref, group: "재료", ref }] as never)
|
||||
.then((a) =>
|
||||
a.forEach(
|
||||
(x) => x.ok && typeof x.value === "number" && (val.textContent = fmt(x.value)),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
if (e.mode && e.mode !== "fixed")
|
||||
void previewOf(e, lg).then(
|
||||
(p) => {
|
||||
const n = noteOf(p);
|
||||
note.textContent = `${t("Preview")}: ${n.text}`;
|
||||
note.classList.toggle("is-bad", n.kind !== "one");
|
||||
if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return row;
|
||||
})
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("None") })]),
|
||||
);
|
||||
};
|
||||
|
||||
const chips = el("div", { className: "m02-dfip__chips" });
|
||||
const drawChips = (): void =>
|
||||
chips.replaceChildren(
|
||||
...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) =>
|
||||
createButton({
|
||||
label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"),
|
||||
variant: mode === m ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((mode = m), drawChips(), drawBody()),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const drawBody = (): void => {
|
||||
if (mode === "fixed") {
|
||||
body.replaceChildren(
|
||||
el("p", { className: "ui-text ui-text--muted", text: t("FixedHelp") }),
|
||||
itemList((it) =>
|
||||
on.change(
|
||||
addEl(lg, {
|
||||
...elementOf("재료", { 키: it.ref, 이름: it.name, 규격: it.spec, 단위: it.unit }),
|
||||
mode: "fixed",
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 선택 자리 · 조건 찾기 — 이름 · 범위 · (기본 품목 | 규칙) · 미리 보기
|
||||
const name = createInputField({
|
||||
type: "text",
|
||||
label: t("SlotName"),
|
||||
compact: true,
|
||||
placeholder: "예: 흄관",
|
||||
});
|
||||
const word = createInputField({ type: "text", label: t("Word"), compact: true });
|
||||
let def: MatItem | null = null;
|
||||
const scope = scopeBoxes({ sub: "", detail: "" }, () => void refresh());
|
||||
const note = el("p", { className: "m02-dfip__note" });
|
||||
const pills = variablePills(lg);
|
||||
const fieldBox = createSelectField({
|
||||
label: t("RuleField"),
|
||||
options: [
|
||||
{ value: "규격", text: t("FieldSpec") },
|
||||
{ value: "이름", text: t("FieldName") },
|
||||
],
|
||||
value: "규격",
|
||||
compact: true,
|
||||
onChange: () => void refresh(),
|
||||
});
|
||||
const opBox = createSelectField({
|
||||
label: t("RuleOp"),
|
||||
options: [
|
||||
{ value: "포함", text: t("OpIn") },
|
||||
{ value: "같음", text: t("OpEq") },
|
||||
],
|
||||
value: "포함",
|
||||
compact: true,
|
||||
onChange: () => void refresh(),
|
||||
});
|
||||
const pillBox = createSelectField({
|
||||
label: t("RulePill"),
|
||||
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 howBox = createSelectField({
|
||||
label: t("RuleHow"),
|
||||
options: [
|
||||
{ value: "plain", text: t("HowPlain") },
|
||||
{ value: "template", text: t("HowTemplate") },
|
||||
],
|
||||
value: how,
|
||||
compact: true,
|
||||
onChange: (v) => ((how = v as "plain" | "template"), drawBody()),
|
||||
});
|
||||
const tpl = templateBox(lg, () => void refresh());
|
||||
const defNote = el("p", { className: "ui-text ui-text--muted" });
|
||||
const build = (): CfElementRef => {
|
||||
const sc = scope.get();
|
||||
if (mode === "rule" && howBox.select.value === "template") {
|
||||
const g = tpl.get();
|
||||
const sc2 = scope.get();
|
||||
return materialElement(
|
||||
mode,
|
||||
name.input.value,
|
||||
{ 구분: sc2.sub, 상세구분: sc2.detail, 찾는말: word.input.value.trim() },
|
||||
{ rule: templateRule(g.template, g.slots) },
|
||||
);
|
||||
}
|
||||
const rule: CfMaterialRule | undefined =
|
||||
mode === "rule" && pills[Number(pillBox.select.value)]
|
||||
? {
|
||||
field: fieldBox.select.value as CfMaterialRule["field"],
|
||||
op: opBox.select.value as CfMaterialRule["op"],
|
||||
from: pills[Number(pillBox.select.value)].port,
|
||||
}
|
||||
: undefined;
|
||||
return materialElement(
|
||||
mode,
|
||||
name.input.value,
|
||||
{ 구분: sc.sub, 상세구분: sc.detail, 찾는말: word.input.value.trim() },
|
||||
{ rule, item: def ?? undefined },
|
||||
);
|
||||
};
|
||||
let seq = 0;
|
||||
const refresh = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
defNote.textContent = def ? `${t("Default")}: ${def.name} ${def.spec}` : t("DefaultNone");
|
||||
const cand = build();
|
||||
note.textContent = `${t("Preview")}: …`;
|
||||
try {
|
||||
const p = await previewOf(cand, lg);
|
||||
if (mine !== seq) return;
|
||||
const n = noteOf(p);
|
||||
note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`;
|
||||
note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`;
|
||||
} catch {
|
||||
if (mine === seq) note.textContent = t("Failed");
|
||||
}
|
||||
};
|
||||
name.input.addEventListener("input", () => name.setError(undefined));
|
||||
word.input.addEventListener("input", () => void refresh());
|
||||
const add = createButton({
|
||||
label: t("Put"),
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const cand = build();
|
||||
const why = checkMaterial(cand, els);
|
||||
name.setError(why ?? undefined);
|
||||
if (why) return void showToast(why, "error");
|
||||
// 잡히는 재료의 단위가 하나로 같으면 그 단위를 알약 단위로
|
||||
void previewOf(cand, lg).then(
|
||||
(p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)),
|
||||
() => on.change(addEl(lg, cand)),
|
||||
);
|
||||
},
|
||||
});
|
||||
const clear = createButton({
|
||||
label: t("DefaultClear"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((def = null), void refresh()),
|
||||
});
|
||||
body.replaceChildren(
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: mode === "choice" ? t("ChoiceHelp") : t("RuleHelp"),
|
||||
}),
|
||||
name.root,
|
||||
el("h5", { text: t("Scope") }),
|
||||
scope.root,
|
||||
word.root,
|
||||
...(mode === "rule"
|
||||
? [
|
||||
howBox.root,
|
||||
...(howBox.select.value === "template"
|
||||
? [tpl.root]
|
||||
: pills.length
|
||||
? [fieldBox.root, opBox.root, pillBox.root]
|
||||
: [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })]),
|
||||
]
|
||||
: [
|
||||
el("h5", { text: t("Default") }),
|
||||
defNote,
|
||||
clear,
|
||||
itemList((it) => ((def = it), void refresh())),
|
||||
]),
|
||||
note,
|
||||
add,
|
||||
);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
host.replaceChildren(chosen, chips, body);
|
||||
drawChosen();
|
||||
drawChips();
|
||||
drawBody();
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 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";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
import "@ui/ui_template_list_row.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
|
||||
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: "ui-text ui-text--muted", text: t("Help") }),
|
||||
name.root,
|
||||
...(pills.length
|
||||
? [field.root, pill.root, note, add]
|
||||
: [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })]),
|
||||
);
|
||||
if (pills.length) void refresh();
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Table.ts
|
||||
* 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나)
|
||||
* `M02_DF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import type { CfGroup, CfLogic } from "./M02_DF_Model";
|
||||
import { setTable } from "./M02_DF_InputPanel_Build";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
import "@ui/ui_template_list_row.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
import "@ui/ui_template_table.css";
|
||||
|
||||
const TEXT = {
|
||||
Book: ["적용 품셈", "Standard"],
|
||||
Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
NoSection: ["찾는 절 없음", "No section"],
|
||||
SecLine: ["표 {t} 개", "{t} tables"],
|
||||
Tables: ["이 절의 표 — 하나를 고름", "Pick a table"],
|
||||
NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"],
|
||||
TableLine: [
|
||||
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
|
||||
"{b} · {c} conditions · {v} results · {n} rows",
|
||||
],
|
||||
Chosen: ["고른 표", "Chosen table"],
|
||||
Whole: [
|
||||
"표 통째 — 함수 상자가 조건에 맞는 줄을 찾음",
|
||||
"Whole table — a function box looks it up",
|
||||
],
|
||||
OneRow: ["줄 하나 고정", "Fix one row"],
|
||||
RowNo: ["몇 번째 줄", "Row number"],
|
||||
Preview: ["미리 보기", "Preview"],
|
||||
Notes: ["주(註)", "Notes"],
|
||||
Remove: ["표 빼기", "Remove table"],
|
||||
Change: ["다른 표 고르기", "Pick another table"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!res.ok) throw new Error(String(data.detail ?? res.statusText));
|
||||
return data;
|
||||
}
|
||||
|
||||
interface SectionItem {
|
||||
section: string;
|
||||
title: string;
|
||||
chapter: string;
|
||||
tables: number;
|
||||
}
|
||||
interface TableHead {
|
||||
file: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
count: number;
|
||||
}
|
||||
interface RawTable {
|
||||
키: string;
|
||||
원문번호: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
줄: Record<string, unknown>[];
|
||||
주?: string[];
|
||||
}
|
||||
|
||||
const BOOKS = ["산림품셈", "건설품셈"];
|
||||
const files = new Map<string, string>();
|
||||
const tables = new Map<string, RawTable>();
|
||||
|
||||
/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */
|
||||
export async function loadTable(group: CfGroup, key: string): Promise<RawTable> {
|
||||
const hit = tables.get(key);
|
||||
if (hit) return hit;
|
||||
let file = files.get(key);
|
||||
if (!file) {
|
||||
const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find(
|
||||
(x) => x.키 === key,
|
||||
);
|
||||
if (!found) throw new Error(t("Failed"));
|
||||
file = found.file;
|
||||
}
|
||||
const raw = (await get<{ table: RawTable }>("/table", { file, key })).table;
|
||||
tables.set(key, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
const cell = (v: unknown): string =>
|
||||
Array.isArray(v)
|
||||
? v.map((x) => x ?? "").join("~")
|
||||
: v === null || v === undefined
|
||||
? ""
|
||||
: String(v);
|
||||
|
||||
/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */
|
||||
function previewOf(tb: RawTable, row?: number): HTMLElement {
|
||||
const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)];
|
||||
const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1));
|
||||
return el("div", {
|
||||
className: "m02-dfip__preview",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table ui-table--data",
|
||||
children: [
|
||||
el("tr", { children: cols.map((c) => el("th", { text: c })) }),
|
||||
...body.map((r, i) =>
|
||||
el("tr", {
|
||||
className: i === row ? "is-hit" : "",
|
||||
children: cols.map((c) => el("td", { text: cell(r[c]) })),
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
...(tb.줄.length > body.length
|
||||
? [el("p", { className: "ui-text ui-text--muted", text: `… ${tb.줄.length} 줄` })]
|
||||
: []),
|
||||
...(tb.주?.length
|
||||
? [
|
||||
el("details", {
|
||||
children: [
|
||||
el("summary", { text: t("Notes") }),
|
||||
...tb.주.map((n) => el("p", { text: n })),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 소요량 · 계수 표 고르기 화면 */
|
||||
export function renderTablePick(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
group: CfGroup,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
const cur = box?.input?.table;
|
||||
if (cur) return void showChosen(host, lg, boxId, group, cur, on);
|
||||
let book = BOOKS[0];
|
||||
const bookBox = createSelectField({
|
||||
label: t("Book"),
|
||||
options: BOOKS.map((b) => ({ value: b, text: b })),
|
||||
value: book,
|
||||
compact: true,
|
||||
onChange: (v) => ((book = v), load()),
|
||||
});
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
const tableList = el("div", { className: "m02-dfip__list" });
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") }));
|
||||
try {
|
||||
const r = await get<{ sections: SectionItem[] }>("/sections", {
|
||||
book,
|
||||
q: q.input.value.trim(),
|
||||
limit: "60",
|
||||
});
|
||||
if (mine !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(r.sections.length
|
||||
? r.sections.map((s) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row ui-list-row ui-sidebar-row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: `${s.section} ${s.title}` }),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pickSection(s));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("NoSection") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error");
|
||||
}
|
||||
};
|
||||
const pickSection = async (s: SectionItem): Promise<void> => {
|
||||
try {
|
||||
const heads = (
|
||||
await get<{ tables: TableHead[] }>("/tables", {
|
||||
group,
|
||||
section: s.section,
|
||||
sub: book,
|
||||
size: "100",
|
||||
})
|
||||
).tables;
|
||||
tableList.replaceChildren(
|
||||
el("h5", { text: t("Tables") }),
|
||||
...(heads.length
|
||||
? heads.map((h) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row ui-list-row ui-sidebar-row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("strong", { text: h.이름 }),
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: t("TableLine", {
|
||||
b: h.기준 || "—",
|
||||
c: Object.keys(h.조건).length,
|
||||
v: Object.keys(h.값칸).length,
|
||||
n: h.count,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
files.set(h.키, h.file);
|
||||
void loadTable(group, h.키).then(
|
||||
(tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)),
|
||||
(e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"),
|
||||
);
|
||||
});
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("NoTable") })]),
|
||||
);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : t("Failed"), "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
q.input.addEventListener(
|
||||
"input",
|
||||
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
|
||||
);
|
||||
host.replaceChildren(bookBox.root, q.root, list, tableList);
|
||||
void load();
|
||||
}
|
||||
|
||||
/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */
|
||||
function showChosen(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
group: CfGroup,
|
||||
cur: { key: string; row?: number },
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const body = el("div", { className: "m02-dfip__stack" });
|
||||
host.replaceChildren(body);
|
||||
void loadTable(group, cur.key).then(
|
||||
(tb) => {
|
||||
const whole = cur.row === undefined;
|
||||
const modeWhole = createButton({
|
||||
label: t("Whole"),
|
||||
variant: whole ? "filled" : "ghost",
|
||||
onClick: () => on.change(setTable(lg, boxId, { key: cur.key })),
|
||||
});
|
||||
const modeOne = createButton({
|
||||
label: t("OneRow"),
|
||||
variant: whole ? "ghost" : "filled",
|
||||
onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })),
|
||||
});
|
||||
const rowBox = createSelectField({
|
||||
label: t("RowNo"),
|
||||
options: tb.줄.map((r, i) => ({
|
||||
value: String(i),
|
||||
text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)]
|
||||
.slice(0, 4)
|
||||
.map((c) => cell(r[c]))
|
||||
.join(" / ")}`,
|
||||
})),
|
||||
value: String(cur.row ?? 0),
|
||||
compact: true,
|
||||
onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })),
|
||||
});
|
||||
body.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }),
|
||||
el("div", { className: "m02-dfip__chips", children: [modeWhole, modeOne] }),
|
||||
...(whole ? [] : [rowBox.root]),
|
||||
el("h5", { text: t("Preview") }),
|
||||
previewOf(tb, cur.row),
|
||||
el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
children: [
|
||||
createButton({
|
||||
label: t("Change"),
|
||||
variant: "ghost",
|
||||
onClick: () => on.change(setTable(lg, boxId, null)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
(e) =>
|
||||
body.replaceChildren(
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: e instanceof Error ? e.message : t("Failed"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Template.ts
|
||||
* 입력 판 — 재료 조건 찾기 「검색어 틀」: 틀(예: 「레미콘 {굵은골재최대치수}-{강도}-{슬럼프}」) 입력 · 틀 자리 {이름} 마다
|
||||
* 값을 줄 변수 알약 고르기 · 글 [한, 영] 짝은 아래 · 재료 판(`M02_DF_InputPanel_Material.ts`)이 부름
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { CfLogic, CfPort } from "./M02_DF_Model";
|
||||
import { templateSlots, variablePills } from "./M02_DF_InputPanel_Build";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
import "@ui/ui_template_list_row.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
|
||||
const TEXT = {
|
||||
Template: ["검색어 틀 — 자리는 {이름} 으로", "Search template — slots as {name}"],
|
||||
Placeholder: ["예: 레미콘 {굵은골재최대치수}-{강도}-{슬럼프}", "e.g. Ready-mix {a}-{b}-{c}"],
|
||||
Help: [
|
||||
"틀의 자리마다 변수 알약을 고르면 그 값이 끼워진 글로 재료를 찾음(띄어쓴 말은 모두 들어간 것)",
|
||||
"Each slot takes a variable pill's value; words must all match",
|
||||
],
|
||||
Slot: ["자리 「{n}」 ← 알약", "Slot “{n}” ← pill"],
|
||||
NoSlot: ["틀에 자리 {이름} 을 넣어 주세요", "Add a {slot} to the template"],
|
||||
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, x),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
export interface TemplateBox {
|
||||
root: HTMLElement;
|
||||
/** 지금 틀 · 자리별 알약 — 틀이 비었으면 null */
|
||||
get(): { template: string; slots: { name: string; from: CfPort }[] };
|
||||
}
|
||||
|
||||
/** 틀 칸 + 자리 고르기 칸 — 바뀌면 onChange */
|
||||
export function templateBox(lg: CfLogic, onChange: () => void): TemplateBox {
|
||||
const pills = variablePills(lg);
|
||||
const tpl = createInputField({
|
||||
type: "text",
|
||||
label: t("Template"),
|
||||
compact: true,
|
||||
placeholder: t("Placeholder"),
|
||||
});
|
||||
const slotBox = el("div", { className: "m02-dfip__stack" });
|
||||
const picks = new Map<string, HTMLSelectElement>();
|
||||
const draw = (): void => {
|
||||
const names = templateSlots(tpl.input.value);
|
||||
const before = new Map([...picks].map(([k, s]) => [k, s.value]));
|
||||
picks.clear();
|
||||
slotBox.replaceChildren(
|
||||
...(names.length
|
||||
? pills.length
|
||||
? names.map((n) => {
|
||||
// 자리 이름과 같은 알약이 있으면 먼저 고름
|
||||
const same = pills.findIndex((p) => p.port.name === n);
|
||||
const sel = createSelectField({
|
||||
label: t("Slot", { n }),
|
||||
options: pills.map((p, i) => ({
|
||||
value: String(i),
|
||||
text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`,
|
||||
})),
|
||||
value: before.get(n) ?? String(same >= 0 ? same : 0),
|
||||
compact: true,
|
||||
onChange: () => onChange(),
|
||||
});
|
||||
picks.set(n, sel.select);
|
||||
return sel.root;
|
||||
})
|
||||
: [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })]
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("NoSlot") })]),
|
||||
);
|
||||
};
|
||||
tpl.input.addEventListener("input", () => (draw(), onChange()));
|
||||
draw();
|
||||
return {
|
||||
root: el("div", {
|
||||
className: "m02-dfip__stack",
|
||||
children: [
|
||||
el("p", { className: "ui-text ui-text--muted", text: t("Help") }),
|
||||
tpl.root,
|
||||
slotBox,
|
||||
],
|
||||
}),
|
||||
get: () => ({
|
||||
template: tpl.input.value.trim(),
|
||||
slots: templateSlots(tpl.input.value).flatMap((n) => {
|
||||
const p = pills[Number(picks.get(n)?.value)];
|
||||
return p ? [{ name: n, from: p.port }] : [];
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Vars.ts
|
||||
* 입력 상자 판 — 변수(그룹 밖 정보: 면적 · 길이 · 부피 …) 정의: 이름 · 갈래(숫자 / 선택 / 글) · 단위 · 범위 · 예시값
|
||||
* 변수마다 출력 알약 하나 · `M02_DF_InputPanel.ts` 가 부름 · 글 [한, 영] 짝은 아래
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { CfLogic, CfVariable } from "./M02_DF_Model";
|
||||
import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick";
|
||||
import { checkVariable, putVariable, removeVariable } from "./M02_DF_InputPanel_Build";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
import "@ui/ui_template_list_row.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
|
||||
const TEXT = {
|
||||
List: ["변수 — 알약마다 하나", "Variables — one pill each"],
|
||||
None: ["아직 변수가 없음 — 아래에서 만듦", "No variable yet"],
|
||||
Name: ["이름", "Name"],
|
||||
Type: ["갈래", "Type"],
|
||||
Number: ["숫자", "Number"],
|
||||
Choice: ["선택", "Choice"],
|
||||
Text: ["글", "Text"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Min: ["범위 아래", "Range from"],
|
||||
Max: ["범위 위", "Range to"],
|
||||
Choices: ["선택 목록(쉼표로 나눔)", "Choices (comma separated)"],
|
||||
Example: ["예시값", "Example"],
|
||||
Add: ["변수 넣기", "Add variable"],
|
||||
Save: ["고치기 끝", "Save change"],
|
||||
Edit: ["고치기", "Edit"],
|
||||
Remove: ["삭제", "Remove"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Line: ["{n} · {u} · 예시 {e}", "{n} · {u} · e.g. {e}"],
|
||||
Range: ["범위 {a}~{b}", "range {a}~{b}"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const numOf = (s: string): number | null =>
|
||||
s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null;
|
||||
|
||||
/** 변수 한 줄 글 */
|
||||
export function variableLine(v: CfVariable): string {
|
||||
const base = t("Line", {
|
||||
n: v.name,
|
||||
u: v.unit || "-",
|
||||
e: v.example === null ? "—" : String(v.example),
|
||||
});
|
||||
const range =
|
||||
v.type === "number" && v.range ? ` · ${t("Range", { a: v.range[0], b: v.range[1] })}` : "";
|
||||
const choices = v.type === "choice" ? ` · ${(v.choices ?? []).join(" / ")}` : "";
|
||||
return `${base}${range}${choices}`;
|
||||
}
|
||||
|
||||
export function renderVariables(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
const vars = box?.input?.variables ?? [];
|
||||
let editing: CfVariable | null = null;
|
||||
|
||||
const form = (): HTMLElement => {
|
||||
const cur = editing;
|
||||
const name = createInputField({
|
||||
type: "text",
|
||||
label: t("Name"),
|
||||
value: cur?.name ?? "",
|
||||
placeholder: "예: 면적",
|
||||
compact: true,
|
||||
});
|
||||
const type = createSelectField({
|
||||
label: t("Type"),
|
||||
options: [
|
||||
{ value: "number", text: t("Number") },
|
||||
{ value: "choice", text: t("Choice") },
|
||||
{ value: "text", text: t("Text") },
|
||||
],
|
||||
value: cur?.type ?? "number",
|
||||
compact: true,
|
||||
onChange: () => paint(),
|
||||
});
|
||||
// 단위 = 고르기만(표준 단위 목록) — 손글 안 받음
|
||||
let unitValue = cur?.unit ?? "";
|
||||
const unit = el("div", { className: "m02-dfip__unitfield" });
|
||||
const drawUnit = (): void =>
|
||||
unit.replaceChildren(
|
||||
el("span", {
|
||||
className: "ui-text ui-text--muted",
|
||||
attrs: { "data-text": "14" },
|
||||
text: t("Unit"),
|
||||
}),
|
||||
createUnitPick({
|
||||
value: unitValue || NO_UNIT,
|
||||
ariaLabel: t("Unit"),
|
||||
onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()),
|
||||
}),
|
||||
);
|
||||
drawUnit();
|
||||
const lo = createInputField({
|
||||
type: "text",
|
||||
label: t("Min"),
|
||||
value: cur?.range ? String(cur.range[0]) : "",
|
||||
compact: true,
|
||||
});
|
||||
const hi = createInputField({
|
||||
type: "text",
|
||||
label: t("Max"),
|
||||
value: cur?.range ? String(cur.range[1]) : "",
|
||||
compact: true,
|
||||
});
|
||||
const choices = createInputField({
|
||||
type: "text",
|
||||
label: t("Choices"),
|
||||
value: (cur?.choices ?? []).join(", "),
|
||||
compact: true,
|
||||
});
|
||||
const example = createInputField({
|
||||
type: "text",
|
||||
label: t("Example"),
|
||||
value: cur?.example === null || cur?.example === undefined ? "" : String(cur.example),
|
||||
compact: true,
|
||||
});
|
||||
const extra = el("div", { className: "m02-dfip__stack" });
|
||||
const paint = (): void => {
|
||||
const ty = type.select.value;
|
||||
extra.replaceChildren(
|
||||
...(ty === "number" ? [lo.root, hi.root] : ty === "choice" ? [choices.root] : []),
|
||||
);
|
||||
};
|
||||
paint();
|
||||
const submit = (): void => {
|
||||
const ty = type.select.value as CfVariable["type"];
|
||||
const ex = example.input.value.trim();
|
||||
const v: CfVariable = {
|
||||
name: name.input.value.trim(),
|
||||
type: ty,
|
||||
unit: unitValue,
|
||||
example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex,
|
||||
...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null
|
||||
? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] }
|
||||
: {}),
|
||||
...(ty === "choice"
|
||||
? {
|
||||
choices: choices.input.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const why = checkVariable(
|
||||
v,
|
||||
vars.filter((x) => x.name !== cur?.name),
|
||||
);
|
||||
name.setError(why ?? undefined);
|
||||
if (why) return;
|
||||
on.change(putVariable(lg, boxId, v, cur?.name));
|
||||
};
|
||||
const ok = createButton({ label: cur ? t("Save") : t("Add"), onClick: submit });
|
||||
const cancel = cur
|
||||
? createButton({
|
||||
label: t("Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => ((editing = null), draw()),
|
||||
})
|
||||
: null;
|
||||
return el("div", {
|
||||
className: "m02-dfip__form",
|
||||
children: [
|
||||
name.root,
|
||||
type.root,
|
||||
unit,
|
||||
extra,
|
||||
example.root,
|
||||
el("div", { className: "m02-dfip__chips", children: cancel ? [ok, cancel] : [ok] }),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const draw = (): void => {
|
||||
const list = vars.length
|
||||
? vars.map((v) =>
|
||||
el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", { text: variableLine(v) }),
|
||||
createButton({
|
||||
label: t("Edit"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((editing = v), draw()),
|
||||
}),
|
||||
createButton({
|
||||
label: t("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => on.change(removeVariable(lg, boxId, v.name)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: t("None") })];
|
||||
host.replaceChildren(el("h5", { text: t("List") }), ...list, form());
|
||||
};
|
||||
draw();
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
.m02-dflink {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m02-dflink-head,
|
||||
.m02-dflink-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dflink-label {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-dflink-note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dflink-num {
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
/* 겉 = 띠 알림 부품 */
|
||||
.m02-dflink-warn {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 알약 판 */
|
||||
.m02-dfpill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m02-dfpill-title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-dfpill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m02-dfpill-step {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_LinkPanel.ts
|
||||
* 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ·
|
||||
* 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
gateConds,
|
||||
makeGate,
|
||||
portsOut,
|
||||
reaches,
|
||||
valueChoices,
|
||||
type CfGateCond,
|
||||
type CfGateOp,
|
||||
type CfLogic,
|
||||
type CfPort,
|
||||
type CfRound,
|
||||
type CfRule,
|
||||
} from "./M02_DF_Model";
|
||||
import {
|
||||
GATE_OPS,
|
||||
commas,
|
||||
SCALE_OPS,
|
||||
evalSafe,
|
||||
labelOf,
|
||||
linkNotes,
|
||||
portText,
|
||||
preview,
|
||||
unitOf,
|
||||
valueOf,
|
||||
type DetailOptions,
|
||||
} from "./M02_DF_Detail";
|
||||
import { suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import "./M02_DF_LinkPanel.css";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_banner.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["선 규칙", "Line rule"],
|
||||
Kinds: ["규칙 고르기", "Pick a rule"],
|
||||
K_none: ["없음", "None"],
|
||||
K_lookup: ["표에서 찾기", "Look up in table"],
|
||||
K_gate: ["조건 통과", "Pass if"],
|
||||
K_scale: ["바꾸기", "Change"],
|
||||
K_round: ["끝수", "Rounding"],
|
||||
Label: ["선 위 글", "Text on the line"],
|
||||
Before: ["지나가기 전 값", "Value before"],
|
||||
After: ["지난 뒤 값", "Value after"],
|
||||
Col: ["조건 칸", "Condition column"],
|
||||
Match: ["맞출 알약", "Pill to match"],
|
||||
Take: ["꺼낼 칸", "Column to take"],
|
||||
AddCond: ["+ 조건 칸", "+ Condition"],
|
||||
Remove: ["지움", "Remove"],
|
||||
NoHeader: [
|
||||
"보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름",
|
||||
"The sender is not a table or it is not loaded yet",
|
||||
],
|
||||
Pick: ["고르기", "pick"],
|
||||
GateHint: [
|
||||
"알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음",
|
||||
"Sent only when the pill passes",
|
||||
],
|
||||
Value: ["값", "Value"],
|
||||
ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"],
|
||||
Places: ["소수 {n}자리", "{n} decimals"],
|
||||
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
|
||||
Apply: ["적용", "Apply"],
|
||||
WarnBoth: [
|
||||
"같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침",
|
||||
"{n} conditional lines pass at once — overlap",
|
||||
],
|
||||
WarnNone: [
|
||||
"같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음",
|
||||
"None of the {n} conditional lines match",
|
||||
],
|
||||
WarnLookup: [
|
||||
"조건 칸이 없음 — 표에서 줄을 못 고름",
|
||||
"No condition column — a row can't be picked",
|
||||
],
|
||||
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
|
||||
Count: ["선", "Line"],
|
||||
And: ["그리고", "and"],
|
||||
Or: ["또는", "or"],
|
||||
If: ["조건", "If"],
|
||||
AddLine: ["+ 조건 줄", "+ Condition line"],
|
||||
PickOne: ["고르세요", "choose"],
|
||||
Join: ["잇는 말", "Join with"],
|
||||
OutRange: ["범위 {n} 밖", "Outside the range {n}"],
|
||||
Range: ["{n} (범위)", "{n} (range)"],
|
||||
} as const;
|
||||
|
||||
const tk = (key: keyof typeof TEXT, n?: number | string): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
|
||||
|
||||
type Kind = "none" | CfRule["kind"];
|
||||
const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"];
|
||||
const SEP = "\u0001";
|
||||
|
||||
interface Ctx {
|
||||
lg: CfLogic;
|
||||
linkId: string;
|
||||
readOnly: boolean;
|
||||
headers: string[];
|
||||
detail: DetailOptions;
|
||||
change(lg: CfLogic): void;
|
||||
}
|
||||
|
||||
const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`;
|
||||
const fromKey = (k: string): CfPort => {
|
||||
const [box, ...rest] = k.split(SEP);
|
||||
return { box, name: rest.join(SEP) };
|
||||
};
|
||||
|
||||
function setRule(ctx: Ctx, rule: CfRule | undefined): void {
|
||||
ctx.change({
|
||||
...ctx.lg,
|
||||
links: ctx.lg.links.map((l) => {
|
||||
if (l.id !== ctx.linkId) return l;
|
||||
const { rule: _old, ...rest } = l;
|
||||
return rule ? { ...rest, rule } : rest;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function select(
|
||||
ctx: Ctx,
|
||||
options: { value: string; text: string }[],
|
||||
value: string,
|
||||
onPick: (v: string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
|
||||
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
|
||||
if (!options.some((o) => o.value === value))
|
||||
s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") }));
|
||||
s.value = value;
|
||||
s.disabled = ctx.readOnly;
|
||||
s.addEventListener("change", () => onPick(s.value));
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 숫자 칸 — 보일 때만 천 단위 쉼표 · 고치는 동안은 쉼표 없이 */
|
||||
function numberBox(
|
||||
ctx: Ctx,
|
||||
value: number | string,
|
||||
onCommit: (v: number | string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const i = el("input", {
|
||||
className: "ui-input ui-input--sm m02-dflink-num",
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": aria },
|
||||
});
|
||||
const shown = (): string => (typeof value === "number" ? commas(value) : String(value));
|
||||
i.value = shown();
|
||||
i.readOnly = ctx.readOnly;
|
||||
i.addEventListener("focus", () => (i.value = String(value)));
|
||||
i.addEventListener("blur", () => (i.value = shown()));
|
||||
i.addEventListener("change", () => {
|
||||
const t = i.value.trim().replace(/,/g, "");
|
||||
onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t);
|
||||
});
|
||||
return i;
|
||||
}
|
||||
|
||||
/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */
|
||||
const pillOptions = (ctx: Ctx): { value: string; text: string }[] => {
|
||||
const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? "";
|
||||
return ctx.lg.boxes
|
||||
.filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id))
|
||||
.flatMap((b) =>
|
||||
portsOut(b).map((p) => ({
|
||||
value: toKey({ box: b.id, name: p.name }),
|
||||
text: portText(ctx.lg, { box: b.id, name: p.name }),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const row = (...children: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", { className: "m02-dflink-row", children });
|
||||
|
||||
/* ── 규칙마다 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTMLElement[] {
|
||||
// 꺼낼 칸 = 표 머리 칸만
|
||||
const cols = [...new Set([...ctx.headers, rule.col].filter(Boolean))];
|
||||
const colOpts = cols.map((c) => ({ value: c, text: c }));
|
||||
// 조건 칸 — 「이름·아래」 「이름·위」 짝은 「이름 (범위)」 한 줄만(고르면 칸 이름 = 이름 · Rule 이 아래 < 값 ≤ 위 로 찾음)
|
||||
const range = (c: string): string | null => {
|
||||
const m = /^(.+)·(아래|위)$/.exec(c);
|
||||
return m && cols.includes(`${m[1]}·아래`) && cols.includes(`${m[1]}·위`) ? m[1] : null;
|
||||
};
|
||||
const bases = [...new Set(cols.map(range).filter((x): x is string => !!x))];
|
||||
const condOpts = [
|
||||
...cols.filter((c) => !range(c) && !bases.includes(c)).map((c) => ({ value: c, text: c })),
|
||||
...bases.map((x) => ({ value: x, text: tk("Range", x) })),
|
||||
];
|
||||
const pills = pillOptions(ctx);
|
||||
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
|
||||
const rows = rule.conds.map((c, i) =>
|
||||
row(
|
||||
select(
|
||||
ctx,
|
||||
condOpts,
|
||||
c.col,
|
||||
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }),
|
||||
tk("Col"),
|
||||
),
|
||||
"=",
|
||||
select(
|
||||
ctx,
|
||||
pills,
|
||||
toKey(c.from),
|
||||
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }),
|
||||
tk("Match"),
|
||||
),
|
||||
ctx.readOnly
|
||||
? ""
|
||||
: createButton({
|
||||
label: tk("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out: HTMLElement[] = [...rows];
|
||||
if (!ctx.readOnly) {
|
||||
out.push(
|
||||
row(
|
||||
createButton({
|
||||
label: tk("AddCond"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !pills.length || !condOpts.length,
|
||||
onClick: () =>
|
||||
put({
|
||||
conds: [...rule.conds, { col: condOpts[0].value, from: fromKey(pills[0].value) }],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
out.push(
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Take") }),
|
||||
select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")),
|
||||
),
|
||||
);
|
||||
if (!cols.length)
|
||||
out.push(el("p", { className: "ui-text ui-text--muted", text: tk("NoHeader") }));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */
|
||||
function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null {
|
||||
const v = ctx.lg.boxes
|
||||
.find((b) => b.id === p.box)
|
||||
?.input?.variables.find((x) => x.name === p.name);
|
||||
return v?.type === "number" && v.range ? v.range : null;
|
||||
}
|
||||
|
||||
/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */
|
||||
function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] {
|
||||
const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values });
|
||||
if (list?.length) {
|
||||
const key = (v: string | number): string => `${typeof v}:${v}`;
|
||||
const known = list.some((v) => key(v) === key(c.value));
|
||||
const options = [
|
||||
...(known ? [] : [{ value: "", text: tk("PickOne") }]),
|
||||
...list.map((v) => ({ value: key(v), text: String(v) })),
|
||||
];
|
||||
return [
|
||||
select(
|
||||
ctx,
|
||||
options,
|
||||
known ? key(c.value) : "",
|
||||
(k) => {
|
||||
const hit = list.find((v) => key(v) === k);
|
||||
if (hit !== undefined) onPick(hit);
|
||||
},
|
||||
tk("Value"),
|
||||
),
|
||||
];
|
||||
}
|
||||
const range = rangeOf(ctx, c.from);
|
||||
const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))];
|
||||
const n = Number(c.value);
|
||||
if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) {
|
||||
out.push(
|
||||
el("span", {
|
||||
className: "ui-text ui-text--warning",
|
||||
text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function gateEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "gate" }>): HTMLElement[] {
|
||||
const g = gateConds(rule);
|
||||
const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join));
|
||||
const edit = (i: number, patch: Partial<CfGateCond>): void =>
|
||||
put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c)));
|
||||
const out: HTMLElement[] = [
|
||||
el("p", { className: "ui-text ui-text--muted", text: tk("GateHint") }),
|
||||
];
|
||||
g.conds.forEach((c, i) => {
|
||||
out.push(
|
||||
row(
|
||||
el("span", {
|
||||
className: "m02-dflink-label",
|
||||
text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"),
|
||||
}),
|
||||
select(
|
||||
ctx,
|
||||
pillOptions(ctx),
|
||||
toKey(c.from),
|
||||
(v) => {
|
||||
const from = fromKey(v);
|
||||
edit(i, {
|
||||
from,
|
||||
value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0,
|
||||
});
|
||||
},
|
||||
tk("Match"),
|
||||
),
|
||||
select(
|
||||
ctx,
|
||||
GATE_OPS.map((o) => ({ value: o, text: o })),
|
||||
c.op,
|
||||
(v) => edit(i, { op: v as CfGateOp }),
|
||||
tk("Kinds"),
|
||||
),
|
||||
...gateValue(ctx, c, (v) => edit(i, { value: v })),
|
||||
ctx.readOnly || g.conds.length < 2
|
||||
? ""
|
||||
: createButton({
|
||||
label: tk("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => put(g.conds.filter((_, j) => j !== i)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
if (!ctx.readOnly) {
|
||||
const first = g.conds[0];
|
||||
out.push(
|
||||
row(
|
||||
createButton({
|
||||
label: tk("AddLine"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const from = first?.from ?? { box: "", name: "" };
|
||||
// 새 줄 기본 값 — 목록이 있는 알약이면 그 첫 값 · 아니면 0
|
||||
const value = valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0;
|
||||
put([...g.conds, { from, op: "=", value }]);
|
||||
},
|
||||
}),
|
||||
...(g.conds.length > 1
|
||||
? [
|
||||
el("span", { className: "m02-dflink-label", text: tk("Join") }),
|
||||
select(
|
||||
ctx,
|
||||
[
|
||||
{ value: "그리고", text: tk("And") },
|
||||
{ value: "또는", text: tk("Or") },
|
||||
],
|
||||
g.join,
|
||||
(v) => put(g.conds, v as "그리고" | "또는"),
|
||||
tk("Join"),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
|
||||
return [
|
||||
el("p", { className: "ui-text ui-text--muted", text: tk("ScaleHint") }),
|
||||
row(
|
||||
select(
|
||||
ctx,
|
||||
SCALE_OPS.map((o) => ({ value: o, text: o })),
|
||||
rule.op,
|
||||
(v) => setRule(ctx, { ...rule, op: v as typeof rule.op }),
|
||||
tk("Kinds"),
|
||||
),
|
||||
numberBox(
|
||||
ctx,
|
||||
rule.value,
|
||||
(v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }),
|
||||
tk("Value"),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function roundEditor(
|
||||
ctx: Ctx,
|
||||
rule: Extract<CfRule, { kind: "round" }>,
|
||||
unit: string,
|
||||
): HTMLElement[] {
|
||||
const put = (next: Partial<CfRound>): void => setRule(ctx, { ...rule, ...next });
|
||||
const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) }));
|
||||
const hint = el("span", { className: "m02-dflink-note" });
|
||||
void suggestPlaces(unit).then((n) => {
|
||||
if (n === null || n === rule.places || ctx.readOnly) return;
|
||||
hint.append(
|
||||
tk("Suggest", n),
|
||||
" ",
|
||||
createButton({
|
||||
label: tk("Apply"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => put({ places: n }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
return [
|
||||
row(
|
||||
select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")),
|
||||
select(
|
||||
ctx,
|
||||
(["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })),
|
||||
rule.method,
|
||||
(v) => put({ method: v as CfRound["method"] }),
|
||||
tk("K_round"),
|
||||
),
|
||||
hint,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
|
||||
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
|
||||
gate: (from) => makeGate([{ from, op: "≥", value: 0 }]),
|
||||
scale: () => ({ kind: "scale", op: "×", value: 1 }),
|
||||
round: () => ({ kind: "round", places: 0, method: "반올림" }),
|
||||
};
|
||||
|
||||
/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */
|
||||
export function renderLinkPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
linkId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
opts: DetailOptions & { readOnly?: boolean } = {},
|
||||
): void {
|
||||
const link = lg.links.find((l) => l.id === linkId);
|
||||
host.classList.add("m02-dflink");
|
||||
if (!link) return host.replaceChildren();
|
||||
const ev = evalSafe(lg, opts);
|
||||
const table = valueOf(ev, link.from);
|
||||
const headers =
|
||||
Array.isArray(table) && Array.isArray(table[0])
|
||||
? table[0].map((h) => String(h ?? "")).filter(Boolean)
|
||||
: [];
|
||||
const ctx: Ctx = {
|
||||
lg,
|
||||
linkId,
|
||||
readOnly: !!opts.readOnly,
|
||||
headers,
|
||||
detail: opts,
|
||||
change: on.change,
|
||||
};
|
||||
const rule = link.rule;
|
||||
const kind: Kind = rule?.kind ?? "none";
|
||||
const parts: HTMLElement[] = [
|
||||
el("div", {
|
||||
className: "m02-dflink-head",
|
||||
children: [
|
||||
el("strong", {
|
||||
text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`,
|
||||
}),
|
||||
el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
|
||||
],
|
||||
}),
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Kinds") }),
|
||||
...KINDS.map((k) =>
|
||||
createButton({
|
||||
label: tk(`K_${k}` as "K_none"),
|
||||
variant: k === kind ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
disabled: ctx.readOnly,
|
||||
onClick: () =>
|
||||
k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
];
|
||||
if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule));
|
||||
if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule));
|
||||
if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule));
|
||||
if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from)));
|
||||
|
||||
const pv = preview(link, ev);
|
||||
parts.push(
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Before") }),
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "20" },
|
||||
text: pv.before,
|
||||
}),
|
||||
"→",
|
||||
el("span", { className: "m02-dflink-label", text: tk("After") }),
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "20" },
|
||||
text: pv.after,
|
||||
}),
|
||||
),
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Label") }),
|
||||
el("span", { text: labelOf(rule) }),
|
||||
),
|
||||
);
|
||||
|
||||
const warns: string[] = linkNotes(link, ev);
|
||||
if (rule?.kind === "lookup") {
|
||||
if (!rule.conds.length) warns.push(tk("WarnLookup"));
|
||||
if (!rule.col) warns.push(tk("WarnTake"));
|
||||
}
|
||||
parts.push(
|
||||
...warns.map((w) =>
|
||||
el("p", { className: "ui-banner ui-banner--warning m02-dflink-warn", text: `⚠ ${w}` }),
|
||||
),
|
||||
);
|
||||
host.replaceChildren(...parts);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
"""M02 흐름도 — 선 규칙 「표에서 찾기」 · 값 같음(Compute 에서 나눔 · Rule lookup 과 같은 뜻).
|
||||
|
||||
표 블록 = 머리 줄 + 줄 · 범위 칸 「이름·아래」 「이름·위」 · 끝 칸 「·범위규칙」.
|
||||
빈 조건 칸 = 무엇이든 · 꺼낼 칸 빈 줄은 뺌 · 글은 대소문자까지 · 여럿이면 위 끝 최소 줄(옛 find).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
|
||||
|
||||
RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE)
|
||||
|
||||
|
||||
def _num_of(v: Any) -> Decimal | None:
|
||||
if isinstance(v, bool) or v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float, Decimal)):
|
||||
return Decimal(str(v))
|
||||
return None
|
||||
|
||||
|
||||
def _same(a: Any, b: Any) -> bool:
|
||||
"""엑셀 「=」 — 수끼리는 수로 · 글끼리는 대소문자 무시 · 빈 칸은 0 · 빈 글과 같음."""
|
||||
na, nb = _num_of(a), _num_of(b)
|
||||
if a is None or b is None:
|
||||
other = b if a is None else a
|
||||
return other is None or other == "" or _num_of(other) == 0
|
||||
if na is not None and nb is not None:
|
||||
return na == nb
|
||||
if isinstance(a, str) and isinstance(b, str):
|
||||
return a.lower() == b.lower()
|
||||
return False
|
||||
|
||||
|
||||
def _is_err(v: Any) -> bool:
|
||||
return isinstance(v, str) and re.fullmatch(r"#[A-Z/0!?]+", v) is not None
|
||||
|
||||
|
||||
def _exact(a: Any, b: Any) -> bool:
|
||||
"""표 찾기 같음 — 글끼리는 대소문자까지(옛 find · Rule lookup FIND) · 그 밖은 엑셀 「=」."""
|
||||
return a == b if isinstance(a, str) and isinstance(b, str) else _same(a, b)
|
||||
|
||||
|
||||
def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
|
||||
"""표에서 찾기 — 같음 · 범위(아래 < 값 ≤ 위 · 빈 끝 열림 · 아래 = 위면 같음) · 맞은 줄 하나
|
||||
· 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌 · 여럿이면 범위 위 끝이 가장 작은 줄
|
||||
(옛 엔진 find 와 같음 · Rule lookup 과 같음)."""
|
||||
if not isinstance(block, list) or len(block) < 2:
|
||||
return None, "오류"
|
||||
head = [str(h if h is not None else "") for h in block[0]]
|
||||
out = rule.get("col")
|
||||
two = out not in head and {out + dv.RANGE_LOW, out + dv.RANGE_HIGH} <= set(head)
|
||||
if out not in head and not two:
|
||||
return None, "오류"
|
||||
tests, ups = [], []
|
||||
rk = head.index(RANGE_RULE) if RANGE_RULE in head else None
|
||||
for c in rule.get("conds") or []:
|
||||
col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {})
|
||||
if col in head:
|
||||
i = head.index(col)
|
||||
tests.append(lambda row, i=i, want=want: row[i] is None or _exact(row[i], want))
|
||||
elif col + dv.RANGE_LOW in head and col + dv.RANGE_HIGH in head:
|
||||
lo, hi, x = (
|
||||
head.index(col + dv.RANGE_LOW),
|
||||
head.index(col + dv.RANGE_HIGH),
|
||||
_num_of(want),
|
||||
)
|
||||
ups.append(hi)
|
||||
|
||||
def fits(row, lo=lo, hi=hi, x=x) -> bool:
|
||||
low, high = _num_of(row[lo]), _num_of(row[hi])
|
||||
if low is not None and low == high: # 숫자 하나(아래 = 위) = 같음(브레인 정함)
|
||||
return x is not None and x == low
|
||||
word = str(row[rk] or "") if rk is not None else "" # 경계 — 이상 · 미만(Rule 같음)
|
||||
return (
|
||||
x is not None
|
||||
and (
|
||||
row[lo] is None
|
||||
or (low is not None and (low <= x if word[:2] == "이상" else low < x))
|
||||
)
|
||||
and (
|
||||
row[hi] is None
|
||||
or (high is not None and (x < high if word[-2:] == "미만" else x <= high))
|
||||
)
|
||||
)
|
||||
|
||||
tests.append(fits)
|
||||
else:
|
||||
return None, "오류"
|
||||
at = head.index(out + dv.RANGE_LOW if two else out) # 값 칸이 범위로 갈림 — 아래 = 위면 그 값
|
||||
rows = [list(r) + [None] * len(head) for r in block[1:]]
|
||||
hits = [r for r in rows if r[at] not in (None, "") and all(t(r) for t in tests)]
|
||||
if not hits:
|
||||
return None, "표에 없는 조건"
|
||||
for hi in ups: # 위 끝만 적힌 범위(「20 이하」 「40 이하」 …) — 위 끝이 가장 작은 줄
|
||||
tops = [_num_of(r[hi]) for r in hits if _num_of(r[hi]) is not None]
|
||||
hits = [r for r in hits if r[hi] is None or not tops or _num_of(r[hi]) == min(tops)]
|
||||
if len(hits) > 1:
|
||||
return None, "표에 여럿 맞음"
|
||||
if two and not _same(hits[0][at], hits[0][head.index(out + dv.RANGE_HIGH)]):
|
||||
return None, "오류"
|
||||
return hits[0][at], None
|
||||
|
||||
|
||||
def _cond_of(conds: dict, port: dict) -> Any:
|
||||
"""조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름(Rule condOf)."""
|
||||
key = f"{port.get('box')}|{port.get('name')}"
|
||||
return conds[key] if key in conds else conds.get(port.get("name"))
|
||||
@@ -1,49 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Material.ts
|
||||
* 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials`(Api `cfMaterials` ·
|
||||
* 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기) · 단가 = 그 줄의 단가(값을 받는 길과 같은 셈)
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 5. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { cfMaterials, type CfMaterial } from "./M02_DF_Api";
|
||||
|
||||
export type CfMat = CfMaterial;
|
||||
|
||||
export interface CfMatPage {
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
items: CfMat[];
|
||||
}
|
||||
|
||||
/** 재료 찾기 — q = 찾는 말 · sub = 구분 · detail = 상세구분 · page 는 1 부터 */
|
||||
export function findMaterials(cond: {
|
||||
q?: string;
|
||||
sub?: string;
|
||||
detail?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<CfMatPage> {
|
||||
return cfMaterials({ ...cond, page: cond.page ?? 1, size: cond.size ?? 10 });
|
||||
}
|
||||
|
||||
const byKey = new Map<string, CfMat | null>();
|
||||
|
||||
/** 재료 키 → 그 줄(이름 · 규격 · 단가) — 한 번 물은 것은 다시 안 물음 */
|
||||
export async function materialOf(key: string): Promise<CfMat | null> {
|
||||
if (!key) return null;
|
||||
if (byKey.has(key)) return byKey.get(key) ?? null;
|
||||
let hit: CfMat | null = null;
|
||||
try {
|
||||
hit = (await cfMaterials({ q: key, size: 20 })).items.find((m) => m.ref === key) ?? null;
|
||||
} catch {
|
||||
hit = null;
|
||||
}
|
||||
byKey.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** 재료 키 → 단가(원) — 못 구하면 null */
|
||||
export async function priceOf(key: string): Promise<number | null> {
|
||||
return (await materialOf(key))?.price ?? null;
|
||||
}
|
||||
@@ -1,697 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Model.ts
|
||||
* 단가산출 흐름도 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나)
|
||||
* 선 규칙(표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) · 재료 방식 셋 · 다른 로직 입력 · 흐름 따라가기 · 설계 때 정할 것
|
||||
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 4~8 · 12 · 1판 약속 `원가다이어그램_정함.md`
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
export type CfKind = "input" | "function" | "output";
|
||||
export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율";
|
||||
export type CfCost = "노무비" | "재료비" | "경비" | "값";
|
||||
|
||||
/** 재료 요소 방식 — 고정(품목 하나) · 선택 자리(프로젝트에서 고름) · 조건 찾기(규격 / 이름에 알약 값)
|
||||
* 조건 찾기는 인력 · 기계 · 유가전력 요소도 씀(field 「키」 — 입력값으로 요소를 고름) */
|
||||
export type CfMaterialMode = "fixed" | "choice" | "rule";
|
||||
|
||||
/** 재료 찾을 범위 */
|
||||
export interface CfScope {
|
||||
구분?: string;
|
||||
상세구분?: string;
|
||||
찾는말?: string;
|
||||
}
|
||||
|
||||
/** 재료 조건 찾기 — 품목의 규격 / 이름에 [알약 값] 이 포함 · 같음
|
||||
* field 「키」 = 요소 ref 의 자리 표시 {…} 에 알약 값을 끼운 요소(op 같음) — ref 꼴 「EQ:{기계}」 · 「EQ:{기계}#가격」 ·
|
||||
* 「MO:경유:{지역}」 · 「LB:{직종}」 · 재료 검색어 묶음 JSON(검색어 안 {…}) · 옛 엔진 `fill` → `Master.get` 과 같은 뜻 */
|
||||
export interface CfMaterialRule {
|
||||
field: "규격" | "이름" | "키" | "검색어";
|
||||
op: "포함" | "같음";
|
||||
from: CfPort;
|
||||
/** field 「검색어」 — 옛 재료 검색어 찾기(master_pick.search) 의 틀(「레미콘 {a}-{b}-{c}」) · scope 구분 · 상세구분 안 */
|
||||
template?: string;
|
||||
/** 틀의 자리 {이름} 마다 값을 줄 알약 — from = 첫 자리 */
|
||||
slots?: { name: string; from: CfPort }[];
|
||||
}
|
||||
|
||||
/** 재료 조건 찾기가 값을 받는 알약 — from + 틀 자리들(겹침 없이) */
|
||||
export function materialSources(rule: CfMaterialRule): CfPort[] {
|
||||
const all = [rule.from, ...(rule.slots ?? []).map((x) => x.from)];
|
||||
return all.filter((p, i) => all.findIndex((q) => q.box === p.box && q.name === p.name) === i);
|
||||
}
|
||||
|
||||
/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id
|
||||
* 재료면 mode · scope · rule · default · slot(알약 이름 = 자리 이름 · 없으면 name) */
|
||||
export interface CfElementRef {
|
||||
ref: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
mode?: CfMaterialMode;
|
||||
scope?: CfScope;
|
||||
rule?: CfMaterialRule;
|
||||
/** 기본 품목 키(선택 자리 · 조건 찾기) */
|
||||
default?: string;
|
||||
slot?: string;
|
||||
}
|
||||
|
||||
/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */
|
||||
export interface CfVariable {
|
||||
name: string;
|
||||
type: "number" | "choice" | "text";
|
||||
unit: string;
|
||||
choices?: (string | number)[];
|
||||
range?: [number, number];
|
||||
example: number | string | null;
|
||||
/** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */
|
||||
cell?: string;
|
||||
}
|
||||
|
||||
/** 다른 로직 입력 — 저장 로직(DF-…) · 견본 id · outputs · designs 는 고를 때 그 로직에서 읽어 둔 것 */
|
||||
export interface CfLogicRef {
|
||||
key: string;
|
||||
name?: string;
|
||||
/** 내보내는 알약 = 그 로직 출력 상자 이름(비용 · 값) */
|
||||
outputs: CfPill[];
|
||||
/** 받는 알약 = 그 로직의 「설계 때 정할 것」(변수 · 재료 자리) */
|
||||
designs: CfPill[];
|
||||
/** 선 없는 받는 알약의 예시값(키 = 받는 알약 이름) — 값 차례 = 이어진 선 > 넘긴 값 > 이것 > 그 로직 자기 예시값 */
|
||||
examples?: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export interface CfInput {
|
||||
mode: "group" | "variable" | "logic";
|
||||
group?: CfGroup;
|
||||
elements: CfElementRef[];
|
||||
/** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */
|
||||
table?: { key: string; row?: number };
|
||||
variables: CfVariable[];
|
||||
/** mode "logic" */
|
||||
logic?: CfLogicRef;
|
||||
}
|
||||
|
||||
/** 끝수 — 소수 자리 · 방법 */
|
||||
export interface CfRound {
|
||||
places: number;
|
||||
method: "반올림" | "올림" | "버림";
|
||||
}
|
||||
|
||||
export interface CfFunc {
|
||||
/** 우리 스프레드시트 시트 하나 */
|
||||
sheet: Sheet;
|
||||
/** 받는 알약 → 칸 */
|
||||
inputs: { port: string; cell: string }[];
|
||||
variables: CfVariable[];
|
||||
/** 내보내는 알약 하나 */
|
||||
output: { cell: string; name: string; unit: string; round?: CfRound };
|
||||
}
|
||||
|
||||
export interface CfOutput {
|
||||
cost: CfCost;
|
||||
unit: string;
|
||||
round?: CfRound;
|
||||
}
|
||||
|
||||
export interface CfBox {
|
||||
id: string;
|
||||
kind: CfKind;
|
||||
name: string;
|
||||
color: string;
|
||||
/** 오른쪽 위 단위 — 기본 「-」 */
|
||||
unit: string;
|
||||
x: number;
|
||||
y: number;
|
||||
input?: CfInput;
|
||||
func?: CfFunc;
|
||||
output?: CfOutput;
|
||||
}
|
||||
|
||||
/** 알약 한쪽 끝 */
|
||||
export interface CfPort {
|
||||
box: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type CfGateOp = "=" | "≠" | "≥" | "≤" | ">" | "<";
|
||||
|
||||
/** 선 규칙(선 하나에 하나 · 없으면 그대로) — 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌로 셈
|
||||
* lookup = 보낸 표에서 조건 칸마다 맞출 알약 · 꺼낼 칸(범위 칸은 「이름·아래」 「이름·위」)
|
||||
* gate = 알약 값이 조건을 통과할 때만 보냄 · scale = × ÷ +% -% · round = 끝수 */
|
||||
/** 조건 통과의 조건 한 줄 */
|
||||
export interface CfGateCond {
|
||||
from: CfPort;
|
||||
op: CfGateOp;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
/** 조건 통과 — conds 여러 줄을 join(그리고 · 또는)으로 · 옛 한 줄 꼴(from · op · value)도 읽음
|
||||
* from · op · value 는 첫 조건과 같게 둠(옛 화면 코드가 그대로 돌게 · makeGate 가 맞춰 줌) — conds 가 있으면 conds 가 이김 */
|
||||
export type CfGateRule = {
|
||||
kind: "gate";
|
||||
conds?: CfGateCond[];
|
||||
join?: "그리고" | "또는";
|
||||
from: CfPort;
|
||||
op: CfGateOp;
|
||||
value: number | string;
|
||||
};
|
||||
|
||||
export type CfRule =
|
||||
| { kind: "lookup"; conds: { col: string; from: CfPort }[]; col: string }
|
||||
| CfGateRule
|
||||
| { kind: "scale"; op: "×" | "÷" | "+%" | "-%"; value: number }
|
||||
| ({ kind: "round" } & CfRound);
|
||||
|
||||
export interface CfLink {
|
||||
id: string;
|
||||
from: CfPort;
|
||||
to: CfPort;
|
||||
rule?: CfRule;
|
||||
}
|
||||
|
||||
/** 흐름 한 걸음 — 그 알약 · 그 알약으로 들어온(나간) 선의 규칙 */
|
||||
export interface CfStep {
|
||||
box: string;
|
||||
port: string;
|
||||
dir: "in" | "out";
|
||||
link?: string;
|
||||
rule?: CfRule;
|
||||
}
|
||||
|
||||
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직의 받는 알약(선이 없을 때) */
|
||||
export interface CfDesign {
|
||||
box: string;
|
||||
port: string;
|
||||
kind: "변수" | "재료 자리" | "다른 로직";
|
||||
unit: string;
|
||||
variable?: CfVariable;
|
||||
element?: CfElementRef;
|
||||
/** 보일 예시값 — 변수 = variable.example · 다른 로직 = logic.examples(없으면 빈 칸 → 그 로직 자기 예시값) */
|
||||
example?: number | string | null;
|
||||
}
|
||||
|
||||
/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */
|
||||
export interface CfUserElement {
|
||||
id: string;
|
||||
group: CfGroup;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface CfLogic {
|
||||
key: string | null;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
boxes: CfBox[];
|
||||
links: CfLink[];
|
||||
userElements: CfUserElement[];
|
||||
/** 원가 합계 끝수 — 없으면 기본(일위대가표 계금 1원 미만 버림) · null = 끝수 없음(값 그대로 · 옛 로직 변환) */
|
||||
totalRound?: CfRound | null;
|
||||
}
|
||||
|
||||
/** 알약 하나의 모습 */
|
||||
export interface CfPill {
|
||||
name: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Input: ["입력", "Input"],
|
||||
Func: ["함수", "Function"],
|
||||
Output: ["출력", "Output"],
|
||||
Result: ["결과", "Result"],
|
||||
Value: ["값", "Value"],
|
||||
NoPill: ["알약이 없음", "No such pill"],
|
||||
FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"],
|
||||
ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"],
|
||||
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
|
||||
Taken: ["이미 선이 들어온 자리", "This spot already has a line"],
|
||||
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
|
||||
CondPill: ["규칙의 조건 알약이 없음", "The rule's condition pill doesn't exist"],
|
||||
} as const;
|
||||
|
||||
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/** 종류별 기본 색 */
|
||||
export const BOX_COLOR: Record<CfKind, string> = {
|
||||
input: "#2f80ed",
|
||||
function: "#7b4fd6",
|
||||
output: "#27ae60",
|
||||
};
|
||||
|
||||
/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */
|
||||
export const OUTPUT_PORT = "값";
|
||||
export const TABLE_PORT = "표";
|
||||
|
||||
const clone = <T>(v: T): T => structuredClone(v);
|
||||
|
||||
/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */
|
||||
function nextId(prefix: string, ids: string[]): string {
|
||||
const nums = ids
|
||||
.filter((x) => x.startsWith(prefix))
|
||||
.map((x) => Number(x.slice(prefix.length)))
|
||||
.filter((n) => Number.isInteger(n));
|
||||
return `${prefix}${Math.max(0, ...nums) + 1}`;
|
||||
}
|
||||
|
||||
/** 같은 이름이면 「 2」 「 3」 */
|
||||
function uniqueName(used: string[], base: string): string {
|
||||
if (!used.includes(base)) return base;
|
||||
for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`;
|
||||
}
|
||||
|
||||
const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id);
|
||||
|
||||
/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */
|
||||
export function updateBox(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
patch: Partial<Omit<CfBox, "id" | "kind">>,
|
||||
): CfLogic {
|
||||
return {
|
||||
...lg,
|
||||
boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
|
||||
|
||||
export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic {
|
||||
return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] };
|
||||
}
|
||||
|
||||
/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */
|
||||
export function addBox(
|
||||
lg: CfLogic,
|
||||
kind: CfKind,
|
||||
x: number,
|
||||
y: number,
|
||||
): { lg: CfLogic; box: CfBox } {
|
||||
const id = nextId(
|
||||
"b",
|
||||
lg.boxes.map((b) => b.id),
|
||||
);
|
||||
const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output");
|
||||
const name = uniqueName(
|
||||
lg.boxes.map((b) => b.name),
|
||||
base,
|
||||
);
|
||||
const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y };
|
||||
if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] };
|
||||
if (kind === "function") {
|
||||
// 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음
|
||||
const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : []));
|
||||
let n = 1;
|
||||
while (taken.includes(`${tl("Result")} ${n}`)) n++;
|
||||
const out = `${tl("Result")} ${n}`;
|
||||
box.func = {
|
||||
sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } },
|
||||
inputs: [],
|
||||
variables: [],
|
||||
output: { cell: "B1", name: out, unit: "-" },
|
||||
};
|
||||
}
|
||||
if (kind === "output") box.output = { cost: "노무비", unit: "-" };
|
||||
return { lg: { ...lg, boxes: [...lg.boxes, box] }, box };
|
||||
}
|
||||
|
||||
/** 상자 지우기 — 이어진 선도 지움 */
|
||||
export function removeBox(lg: CfLogic, id: string): CfLogic {
|
||||
// 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리
|
||||
let next = lg;
|
||||
for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id);
|
||||
return { ...next, boxes: next.boxes.filter((b) => b.id !== id) };
|
||||
}
|
||||
|
||||
export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic {
|
||||
return updateBox(lg, id, { x, y });
|
||||
}
|
||||
|
||||
export function setColor(lg: CfLogic, id: string, color: string): CfLogic {
|
||||
return updateBox(lg, id, { color });
|
||||
}
|
||||
|
||||
/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */
|
||||
export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic {
|
||||
const box = boxOf(lg, id);
|
||||
if (!box) return lg;
|
||||
const u = unit.trim() || "-";
|
||||
const patch: Partial<CfBox> = { unit: u };
|
||||
if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } };
|
||||
if (box.output) patch.output = { ...box.output, unit: u };
|
||||
return updateBox(lg, id, patch);
|
||||
}
|
||||
|
||||
/* ── 알약 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 요소 알약 이름 — 재료 선택 자리 · 조건 찾기는 자리 이름(slot) · 나머지는 element.name */
|
||||
export const elementPort = (e: CfElementRef): string =>
|
||||
e.mode && e.mode !== "fixed" && e.slot?.trim() ? e.slot.trim() : e.name;
|
||||
|
||||
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소(elementPort) · 표면 「표」 · 변수면 variable.name
|
||||
* · 다른 로직이면 그 로직 출력 · 함수 = func.output.name 하나 · 출력 = 없음 */
|
||||
export function portsOut(box: CfBox): CfPill[] {
|
||||
if (box.kind === "function" && box.func)
|
||||
return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }];
|
||||
if (box.kind !== "input" || !box.input) return [];
|
||||
const inp = box.input;
|
||||
if (inp.mode === "logic") return (inp.logic?.outputs ?? []).map((p) => ({ ...p }));
|
||||
return [
|
||||
...(inp.mode === "group"
|
||||
? inp.elements.map((e) => ({ name: elementPort(e), unit: e.unit || "-" }))
|
||||
: []),
|
||||
...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []),
|
||||
...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })),
|
||||
];
|
||||
}
|
||||
|
||||
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 다른 로직 입력 = 그 로직의 설계 때 정할 것 · 그 밖 입력 = 없음 */
|
||||
export function portsIn(box: CfBox): CfPill[] {
|
||||
if (box.kind === "function" && box.func)
|
||||
return box.func.inputs.map((i) => ({ name: i.port, unit: "-" }));
|
||||
if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }];
|
||||
if (box.input?.mode === "logic") return (box.input.logic?.designs ?? []).map((p) => ({ ...p }));
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 알약 쪽 — 함수 출력 이름이면 out · 출력 상자는 in · 다른 로직 입력은 출력 이름이면 out */
|
||||
export function portDir(box: CfBox, name: string): "in" | "out" {
|
||||
if (box.kind === "output") return "in";
|
||||
if (portsOut(box).some((p) => p.name === name)) return "out";
|
||||
return "in";
|
||||
}
|
||||
|
||||
/* ── 선 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 선 규칙이 기대는 알약(조건 찾기 · 조건 통과) */
|
||||
export function ruleSources(rule: CfRule | undefined): CfPort[] {
|
||||
if (!rule) return [];
|
||||
if (rule.kind === "lookup") return rule.conds.map((c) => c.from);
|
||||
if (rule.kind === "gate") return gateConds(rule).conds.map((c) => c.from);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 조건 통과의 조건 줄 · 잇는 말 — 옛 한 줄 꼴은 조건 하나 */
|
||||
export function gateConds(rule: CfGateRule): { conds: CfGateCond[]; join: "그리고" | "또는" } {
|
||||
const conds = rule.conds?.length
|
||||
? rule.conds
|
||||
: [{ from: rule.from, op: rule.op, value: rule.value }];
|
||||
return { conds, join: rule.join ?? "그리고" };
|
||||
}
|
||||
|
||||
/** 조건 통과 만들기 — 옛 칸(from · op · value)은 첫 조건과 같게 */
|
||||
export function makeGate(conds: CfGateCond[], join: "그리고" | "또는" = "그리고"): CfGateRule {
|
||||
const first = conds[0] ?? { from: { box: "", name: "" }, op: "=" as const, value: "" };
|
||||
return {
|
||||
kind: "gate",
|
||||
conds: clone(conds),
|
||||
join,
|
||||
from: { ...first.from },
|
||||
op: first.op,
|
||||
value: first.value,
|
||||
};
|
||||
}
|
||||
|
||||
/** 알약이 가질 수 있는 값 목록 — 조건 값 고르기 · null = 숫자 칸(범위 검사)
|
||||
* 입력 변수 고르기 = choices · 재료 알약 = 그 요소들의 규격 · 표 알약 = opts.col 칸 값들(opts.values 의 표 덩어리)
|
||||
* · 함수 결과 · 다른 로직 출력 · 수 변수 = null */
|
||||
export function valueChoices(
|
||||
lg: CfLogic,
|
||||
port: CfPort,
|
||||
opts: { col?: string; values?: Record<string, Record<string, unknown>> } = {},
|
||||
): (string | number)[] | null {
|
||||
const box = boxOf(lg, port.box);
|
||||
const inp = box?.input;
|
||||
if (!box || box.kind !== "input" || !inp || inp.mode === "logic") return null;
|
||||
const v = inp.variables.find((x) => x.name === port.name);
|
||||
if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null;
|
||||
if (port.name === TABLE_PORT && inp.table) {
|
||||
const block = opts.values?.[box.id]?.[TABLE_PORT];
|
||||
if (!opts.col || !Array.isArray(block) || !Array.isArray(block[0])) return null;
|
||||
const head = (block[0] as unknown[]).map(String);
|
||||
const at = head.indexOf(opts.col);
|
||||
if (at < 0) return null;
|
||||
const seen: (string | number)[] = [];
|
||||
for (const row of block.slice(1) as unknown[][]) {
|
||||
const x = row[at];
|
||||
if ((typeof x === "number" || typeof x === "string") && x !== "" && !seen.includes(x))
|
||||
seen.push(x);
|
||||
}
|
||||
return seen.length ? seen : null;
|
||||
}
|
||||
const specs = inp.elements
|
||||
.filter((e) => elementPort(e) === port.name && e.spec.trim())
|
||||
.map((e) => e.spec.trim());
|
||||
const uniq = [...new Set(specs)];
|
||||
return uniq.length ? uniq : null;
|
||||
}
|
||||
|
||||
/** 상자 사이 기댐 — 선 + 선 규칙의 조건 알약 + 재료 조건 찾기의 알약(→ 그 상자) */
|
||||
export function depends(lg: CfLogic): { from: string; to: string }[] {
|
||||
const out: { from: string; to: string }[] = [];
|
||||
for (const l of lg.links) {
|
||||
out.push({ from: l.from.box, to: l.to.box });
|
||||
for (const p of ruleSources(l.rule)) out.push({ from: p.box, to: l.to.box });
|
||||
}
|
||||
for (const b of lg.boxes)
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule)
|
||||
for (const p of materialSources(e.rule)) out.push({ from: p.box, to: b.id });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** from 상자에서 기댐을 따라 to 상자에 닿나 */
|
||||
export function reaches(lg: CfLogic, from: string, to: string): boolean {
|
||||
const deps = depends(lg);
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string): boolean => {
|
||||
if (id === to) return true;
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return deps.filter((d) => d.from === id).some((d) => walk(d.to));
|
||||
};
|
||||
return walk(from);
|
||||
}
|
||||
|
||||
/** 선 잇기 — 입력 → 함수 · 출력 · 다른 로직 입력 / 함수 → 함수 · 출력 · 다른 로직 입력 · 출력에서 나감 없음 ·
|
||||
* 순환 · 같은 받는 자리 두 번 막음 · 규칙(rule)은 함께 붙임(조건 알약이 없으면 막음)
|
||||
* 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 ·
|
||||
* 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */
|
||||
export function link(
|
||||
lg: CfLogic,
|
||||
from: CfPort,
|
||||
to: CfPort,
|
||||
rule?: CfRule,
|
||||
): { lg: CfLogic; why: string | null } {
|
||||
const a = boxOf(lg, from.box);
|
||||
const b = boxOf(lg, to.box);
|
||||
if (!a || !b) return { lg, why: tl("NoPill") };
|
||||
if (a.kind === "output") return { lg, why: tl("FromOutput") };
|
||||
if (b.kind === "input" && b.input?.mode !== "logic") return { lg, why: tl("ToInput") };
|
||||
if (a.id === b.id) return { lg, why: tl("Self") };
|
||||
if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") };
|
||||
const has = portsIn(b).some((p) => p.name === to.name);
|
||||
if (!has && b.kind !== "function") return { lg, why: tl("NoPill") };
|
||||
const why = ruleWhy(lg, b.id, rule);
|
||||
if (why) return { lg, why };
|
||||
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
|
||||
if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
|
||||
return { lg, why: tl("Taken") };
|
||||
let next = lg;
|
||||
let port = to.name;
|
||||
if (!has) {
|
||||
const used = portsIn(b).map((p) => p.name);
|
||||
const base = to.name.trim() || from.name;
|
||||
port = used.includes(base) ? uniqueSuffix(used, base) : base;
|
||||
const func = b.func!;
|
||||
next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } });
|
||||
}
|
||||
const id = nextId(
|
||||
"l",
|
||||
next.links.map((l) => l.id),
|
||||
);
|
||||
const made: CfLink = { id, from: { ...from }, to: { box: to.box, name: port } };
|
||||
if (rule) made.rule = clone(rule);
|
||||
return { lg: { ...next, links: [...next.links, made] }, why: null };
|
||||
}
|
||||
|
||||
/** 규칙의 조건 알약 검사 — 없거나 돌고 돌면 까닭 */
|
||||
function ruleWhy(lg: CfLogic, target: string, rule: CfRule | undefined): string | null {
|
||||
for (const p of ruleSources(rule)) {
|
||||
const box = boxOf(lg, p.box);
|
||||
if (!box || !portsOut(box).some((x) => x.name === p.name)) return tl("CondPill");
|
||||
if (box.id === target || reaches(lg, target, box.id)) return tl("Loop");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 선 규칙 바꾸기 · 빼기(null) — 조건 알약이 없거나 돌고 돌면 막고 까닭 */
|
||||
export function setRule(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
rule: CfRule | null,
|
||||
): { lg: CfLogic; why: string | null } {
|
||||
const l = lg.links.find((x) => x.id === id);
|
||||
if (!l) return { lg, why: tl("NoPill") };
|
||||
const why = rule ? ruleWhy(lg, l.to.box, rule) : null;
|
||||
if (why) return { lg, why };
|
||||
const links = lg.links.map((x) => {
|
||||
if (x.id !== id) return x;
|
||||
const { rule: _old, ...rest } = x;
|
||||
return rule ? { ...rest, rule: clone(rule) } : rest;
|
||||
});
|
||||
return { lg: { ...lg, links }, why: null };
|
||||
}
|
||||
|
||||
/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */
|
||||
function uniqueSuffix(used: string[], base: string): string {
|
||||
for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`;
|
||||
}
|
||||
|
||||
/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */
|
||||
export function unlink(lg: CfLogic, id: string): CfLogic {
|
||||
const gone = lg.links.find((l) => l.id === id);
|
||||
const next = { ...lg, links: lg.links.filter((l) => l.id !== id) };
|
||||
const box = gone && boxOf(next, gone.to.box);
|
||||
if (!gone || !box?.func) return next;
|
||||
const port = box.func.inputs.find((i) => i.port === gone.to.name);
|
||||
const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name);
|
||||
if (!port || port.cell.trim() || still) return next;
|
||||
const inputs = box.func.inputs.filter((i) => i !== port);
|
||||
return updateBox(next, box.id, { func: { ...box.func, inputs } });
|
||||
}
|
||||
|
||||
/* ── 흐름 따라가기 ──────────────────────────────────────────────────────── */
|
||||
|
||||
/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터 · 한 번씩) */
|
||||
export function traceBack(lg: CfLogic, port: CfPort): CfStep[] {
|
||||
const steps: CfStep[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => {
|
||||
const box = boxOf(lg, p.box);
|
||||
const key = `${p.box}|${p.name}|${dir}`;
|
||||
if (!box || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
steps.push({
|
||||
box: p.box,
|
||||
port: p.name,
|
||||
dir,
|
||||
...(via ? { link: via.id } : {}),
|
||||
...(via?.rule ? { rule: clone(via.rule) } : {}),
|
||||
});
|
||||
if (dir === "in") {
|
||||
for (const l of lg.links.filter((x) => x.to.box === p.box && x.to.name === p.name)) {
|
||||
visit(l.from, "out");
|
||||
for (const c of ruleSources(l.rule)) visit(c, "out");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 내보내는 알약 — 함수 · 다른 로직은 받는 알약들에서 옴 · 재료 조건 찾기는 그 알약에서 옴
|
||||
for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsIn(box) : [])
|
||||
visit(
|
||||
{ box: box.id, name: q.name },
|
||||
"in",
|
||||
lg.links.find((l) => l.to.box === box.id && l.to.name === q.name),
|
||||
);
|
||||
for (const e of box.input?.elements ?? [])
|
||||
if (e.mode === "rule" && e.rule && elementPort(e) === p.name)
|
||||
for (const q of materialSources(e.rule)) visit(q, "out");
|
||||
};
|
||||
const box = boxOf(lg, port.box);
|
||||
if (!box) return [];
|
||||
const dir = portDir(box, port.name);
|
||||
visit(
|
||||
port,
|
||||
dir,
|
||||
dir === "in"
|
||||
? lg.links.find((l) => l.to.box === port.box && l.to.name === port.name)
|
||||
: undefined,
|
||||
);
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** 그 알약 값이 가는 길 — 선 · 선 규칙의 조건으로 쓰이는 곳 · 재료 조건 찾기 · 함수 · 다른 로직을 지나 출력까지 */
|
||||
export function traceForward(lg: CfLogic, port: CfPort): CfStep[] {
|
||||
const steps: CfStep[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => {
|
||||
const box = boxOf(lg, p.box);
|
||||
const key = `${p.box}|${p.name}|${dir}`;
|
||||
if (!box || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
steps.push({
|
||||
box: p.box,
|
||||
port: p.name,
|
||||
dir,
|
||||
...(via ? { link: via.id } : {}),
|
||||
...(via?.rule ? { rule: clone(via.rule) } : {}),
|
||||
});
|
||||
if (dir === "in") {
|
||||
// 받는 알약 → 그 상자의 내보내는 알약
|
||||
for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsOut(box) : [])
|
||||
visit({ box: box.id, name: q.name }, "out");
|
||||
return;
|
||||
}
|
||||
for (const l of lg.links.filter((x) => x.from.box === p.box && x.from.name === p.name))
|
||||
visit(l.to, "in", l);
|
||||
// 이 알약을 조건으로 쓰는 선 · 재료 조건 찾기
|
||||
for (const l of lg.links.filter((x) =>
|
||||
ruleSources(x.rule).some((c) => c.box === p.box && c.name === p.name),
|
||||
))
|
||||
visit(l.to, "in", l);
|
||||
for (const b of lg.boxes)
|
||||
for (const e of b.input?.elements ?? [])
|
||||
if (
|
||||
e.mode === "rule" &&
|
||||
e.rule &&
|
||||
materialSources(e.rule).some((q) => q.box === p.box && q.name === p.name)
|
||||
)
|
||||
visit({ box: b.id, name: elementPort(e) }, "out");
|
||||
};
|
||||
const box = boxOf(lg, port.box);
|
||||
if (!box) return [];
|
||||
visit(port, portDir(box, port.name));
|
||||
return steps;
|
||||
}
|
||||
|
||||
/* ── 설계 때 정할 것 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 설계 때 정할 것 — 변수(입력 · 함수 상자) · 재료 선택 자리 · 다른 로직의 받는 알약(선이 없을 때) */
|
||||
export function designInputs(lg: CfLogic): CfDesign[] {
|
||||
const out: CfDesign[] = [];
|
||||
for (const b of lg.boxes) {
|
||||
const vars = b.input?.variables ?? b.func?.variables ?? [];
|
||||
for (const v of vars)
|
||||
out.push({
|
||||
box: b.id,
|
||||
port: v.name,
|
||||
kind: "변수",
|
||||
unit: v.unit || "-",
|
||||
variable: clone(v),
|
||||
example: v.example,
|
||||
});
|
||||
for (const e of b.input?.mode === "group" ? b.input.elements : [])
|
||||
if (e.mode === "choice")
|
||||
out.push({
|
||||
box: b.id,
|
||||
port: elementPort(e),
|
||||
kind: "재료 자리",
|
||||
unit: e.unit || "-",
|
||||
element: clone(e),
|
||||
});
|
||||
if (b.input?.mode === "logic")
|
||||
for (const d of b.input.logic?.designs ?? [])
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === d.name))
|
||||
out.push({
|
||||
box: b.id,
|
||||
port: d.name,
|
||||
kind: "다른 로직",
|
||||
unit: d.unit || "-",
|
||||
example: b.input.logic?.examples?.[d.name] ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
.m02-dfout__title {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-dfout__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m02-dfout__got {
|
||||
min-width: 220px;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m02-dfout__got dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
margin: var(--spacing-4) 0 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dfout__got dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-dfout__got dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m02-dfout__num {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-dfout__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m02-dfout__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dfout__input {
|
||||
width: 160px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.m02-dfout__round {
|
||||
margin: var(--spacing-8) 0;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_OutputPanel.ts
|
||||
* 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸
|
||||
* 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름
|
||||
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
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 { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval";
|
||||
import { portsOut, type CfBox, type CfCost, type CfLogic, type CfRound } from "./M02_DF_Model";
|
||||
import { defaultItemRound, roundText } from "./M02_DF_Round";
|
||||
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import "./M02_DF_OutputPanel.css";
|
||||
import "@ui/ui_template_text.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["출력", "Output"],
|
||||
Name: ["이름", "Name"],
|
||||
Cost: ["비용 종류", "Cost kind"],
|
||||
Labor: ["노무비", "Labor cost"],
|
||||
Material: ["재료비", "Material cost"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Plain: ["값(비용 아님)", "Value (not a cost)"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Got: ["받은 값", "Received value"],
|
||||
From: ["어디서", "From"],
|
||||
Value: ["값", "Value"],
|
||||
NoLink: [
|
||||
"아직 이어진 선이 없음 — 입력이나 함수 상자에서 이 상자로 선을 이어야 함",
|
||||
"Nothing is linked yet — draw a line from an input or function box",
|
||||
],
|
||||
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)"],
|
||||
Suggest: ["단위 표준 제안", "Unit standard suggests"],
|
||||
Apply: ["적용", "Apply"],
|
||||
RoundReset: ["기본으로", "Back to default"],
|
||||
RoundUser: ["고른 끝수", "Chosen rounding"],
|
||||
PlainHint: ["원가 합계에 안 넣고 값으로만 보임", "Shown as a value, not added to the cost total"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
const COSTS: [CfCost, TextKey][] = [
|
||||
["노무비", "Labor"],
|
||||
["재료비", "Material"],
|
||||
["경비", "Expense"],
|
||||
["값", "Plain"],
|
||||
];
|
||||
|
||||
/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */
|
||||
export function showValue(v: CfValue | undefined, money = false): string {
|
||||
if (v === undefined || v === null) return "—";
|
||||
if (Array.isArray(v)) return to("Table");
|
||||
if (typeof v === "number")
|
||||
return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
return String(v);
|
||||
}
|
||||
|
||||
const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({
|
||||
...lg,
|
||||
boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)),
|
||||
});
|
||||
|
||||
/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */
|
||||
export function renderOutputPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
opts: { result?: CfEvalResult } = {},
|
||||
): void {
|
||||
const box = lg.boxes.find((b) => b.id === boxId);
|
||||
if (!box || box.kind !== "output") {
|
||||
host.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const msg = el("p", { className: "ui-text ui-text--error" });
|
||||
const emit = (make: () => CfLogic): void => {
|
||||
msg.textContent = "";
|
||||
try {
|
||||
on.change(make());
|
||||
} catch {
|
||||
msg.textContent = to("Cant");
|
||||
}
|
||||
};
|
||||
const cost = box.output?.cost ?? "노무비";
|
||||
|
||||
/* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */
|
||||
const link = lg.links.find((l) => l.to.box === box.id);
|
||||
const from = link && lg.boxes.find((b) => b.id === link.from.box);
|
||||
let result = opts.result;
|
||||
if (!result && link) {
|
||||
try {
|
||||
result = evalLogic(lg);
|
||||
} catch {
|
||||
result = undefined;
|
||||
}
|
||||
}
|
||||
let unit = "";
|
||||
if (from && link) {
|
||||
try {
|
||||
unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit;
|
||||
} catch {
|
||||
unit = from.unit;
|
||||
}
|
||||
}
|
||||
const got = el("div", { className: "m02-dfout__got" });
|
||||
if (link && from) {
|
||||
const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id];
|
||||
got.replaceChildren(
|
||||
el("strong", { text: to("Got") }),
|
||||
el("dl", {
|
||||
children: [
|
||||
el("dt", { text: to("From") }),
|
||||
el("dd", { text: `${from.name} · ${link.from.name}` }),
|
||||
el("dt", { text: to("Value") }),
|
||||
el("dd", {
|
||||
className: "m02-dfout__num",
|
||||
text: `${showValue(value)}${unit && unit !== "-" ? ` ${unit}` : ""}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
got.replaceChildren(
|
||||
el("strong", { text: to("Got") }),
|
||||
el("p", { className: "ui-text ui-text--error", text: to("NoLink") }),
|
||||
);
|
||||
}
|
||||
|
||||
/* 이름 · 비용 종류 · 단위 */
|
||||
const nameBox = el("input", {
|
||||
className: "ui-input m02-dfout__input",
|
||||
attrs: { "data-size": "32", type: "text", "aria-label": to("Name") },
|
||||
});
|
||||
nameBox.value = box.name;
|
||||
nameBox.addEventListener("change", () => {
|
||||
const next = nameBox.value.trim();
|
||||
if (!next || next === box.name) {
|
||||
nameBox.value = box.name;
|
||||
return;
|
||||
}
|
||||
emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next })));
|
||||
});
|
||||
const costField = createSelectField({
|
||||
label: to("Cost"),
|
||||
options: COSTS.map(([value, key]) => ({ value, text: to(key) })),
|
||||
value: cost,
|
||||
compact: true,
|
||||
onChange: (v) =>
|
||||
emit(() =>
|
||||
withBox(lg, box.id, (b) => ({
|
||||
...b,
|
||||
output: { ...b.output, unit: b.output?.unit ?? b.unit, cost: v as CfCost },
|
||||
})),
|
||||
),
|
||||
});
|
||||
const setUnit = (next: string): void =>
|
||||
emit(() =>
|
||||
withBox(lg, box.id, (b) => ({
|
||||
...b,
|
||||
unit: next,
|
||||
output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next },
|
||||
})),
|
||||
);
|
||||
const unitBox = createUnitPick({
|
||||
value: box.output?.unit ?? box.unit,
|
||||
ariaLabel: to("Unit"),
|
||||
extra: lg.resultUnit.includes("/") ? [lg.resultUnit.split("/")[0]] : [],
|
||||
onPick: setUnit,
|
||||
});
|
||||
/** 단위 표준의 소수자리 제안(주 ① 반올림) — 값(비용 아님)이고 끝수를 안 정했을 때 */
|
||||
const suggestNote = el("span", { className: "ui-text ui-text--muted" });
|
||||
if (cost === "값" && !box.output?.round)
|
||||
void suggestPlaces(box.output?.unit ?? box.unit).then((p) => {
|
||||
if (p === null) return;
|
||||
suggestNote.textContent = `${to("Suggest")}: ${to("RoundPlaces")} ${p} · 반올림`;
|
||||
suggestNote.append(
|
||||
createButton({
|
||||
label: to("Apply"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => setRound({ places: p, method: "반올림" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
const field = (label: string, control: HTMLElement): HTMLElement =>
|
||||
el("label", {
|
||||
className: "m02-dfout__field",
|
||||
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: "ui-text ui-text--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,
|
||||
suggestNote,
|
||||
],
|
||||
});
|
||||
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }),
|
||||
el("div", {
|
||||
className: "m02-dfout__body",
|
||||
children: [
|
||||
got,
|
||||
el("div", {
|
||||
className: "m02-dfout__fields",
|
||||
children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
roundBlock,
|
||||
...(cost === "값"
|
||||
? [el("p", { className: "ui-text ui-text--muted", text: to("PlainHint") })]
|
||||
: []),
|
||||
msg,
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */
|
||||
.m02-dfp {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.m02-dfp__open {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-dfp__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dfp__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-dfp__tools {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */
|
||||
.m02-dfp .m02-dfdg__view {
|
||||
height: max(320px, 45vh);
|
||||
}
|
||||
|
||||
.m02-dfp__lock {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.m02-dfp__cost > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
/* 왼쪽 가장자리 끌기 — 늘 보이는 손잡이(세로 막대 + 가운데 잡이) */
|
||||
.m02-dfp__grip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -5px;
|
||||
z-index: 2;
|
||||
width: 10px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
background: color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.m02-dfp__grip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 40px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 2px;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-dfp__grip:hover,
|
||||
.m02-dfp__grip:focus-visible,
|
||||
.m02-dfp.is-resizing .m02-dfp__grip {
|
||||
background: color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.m02-dfp__grip:hover::after,
|
||||
.m02-dfp__grip:focus-visible::after,
|
||||
.m02-dfp.is-resizing .m02-dfp__grip::after {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.m02-dfp.is-resizing {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 접기 = 얇은 띠 — 같은 「흐름도」 단추를 세로로 */
|
||||
.m02-dfp__strip {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: var(--spacing-8) 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 공용 작은 단추 크기는 그대로 — 글 방향만 세로 */
|
||||
.m02-dfp__strip .m02-dfp__toggle {
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.m02-dfp__toggle[aria-expanded="true"] {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m02-dfp__strip[hidden],
|
||||
.m02-dfp__open[hidden],
|
||||
.m02-dfp__grip[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 크게 보기 — 화면 전체 */
|
||||
.m02-dfp-full {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
overflow: auto;
|
||||
background: var(--color-canvas);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.m02-dfp-full__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dfp-full__head strong {
|
||||
flex: 1;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-dfp-full__diagram .m02-dfdg__view {
|
||||
height: max(420px, 60vh);
|
||||
}
|
||||
|
||||
.m02-dfp-full__lower {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-dfp-full__lower h3 {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.m02-dfp-full__lower {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Panel.ts
|
||||
* 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand }
|
||||
* 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) →
|
||||
* 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange.
|
||||
* 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage ·
|
||||
* 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로).
|
||||
* 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움).
|
||||
* ========================================================================== */
|
||||
|
||||
import { isCtrlKey } from "../../A00_Common/key_shortcut";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
import { createPaper } from "./M02_DF_Paper";
|
||||
import { renderStandards } from "./M02_DF_Standards";
|
||||
import "./M02_DF_Panel.css";
|
||||
import "@ui/ui_template_toolbar.css";
|
||||
|
||||
const TEXT = {
|
||||
Big: ["크게 보기", "Full screen"],
|
||||
Standards: ["표준 표", "Standards"],
|
||||
Toggle: ["흐름도", "Flow"],
|
||||
ToggleOpen: ["흐름도 펴기", "Open the flow panel"],
|
||||
ToggleClose: ["흐름도 접기", "Collapse the flow panel"],
|
||||
Close: ["닫기", "Close"],
|
||||
Detail: ["상세 내용", "Details"],
|
||||
Cost: ["원가 · 검증", "Cost · check"],
|
||||
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
|
||||
} as const;
|
||||
|
||||
const tf = (key: keyof typeof TEXT): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
|
||||
|
||||
export interface FlowPanelOpts {
|
||||
logic: CfLogic;
|
||||
readOnly?: boolean;
|
||||
title: string;
|
||||
/** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */
|
||||
onChange(lg: CfLogic): void;
|
||||
/** 다른 로직 입력이 부르는 로직 */
|
||||
logics?(key: string): Promise<CfLogic | null>;
|
||||
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
|
||||
onResize?(px: number): void;
|
||||
/** 접힘을 부르는 쪽이 정함(처음 값) — 주면 접기 단추는 onFold 만 부르고 기억도 부르는 쪽 몫 */
|
||||
folded?: boolean;
|
||||
onFold?(on: boolean): void;
|
||||
}
|
||||
|
||||
export interface FlowPanel {
|
||||
/** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */
|
||||
set(lg: CfLogic): void;
|
||||
destroy(): void;
|
||||
/** 크게 보기 열기 */
|
||||
expand(): void;
|
||||
/** 밖에서 접기 · 펴기(onFold 를 부르지 않음) */
|
||||
fold(on: boolean): void;
|
||||
}
|
||||
|
||||
/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */
|
||||
|
||||
export const PANEL_MIN = 320;
|
||||
export const STRIP_WIDTH = 40;
|
||||
/** 패널에서 저절로 맞출 때 가장 작은 확대 — 글이 읽히는 최소 */
|
||||
export const PANEL_FIT_MIN = 0.6;
|
||||
/** 패널 「한눈에」 단추 — 전체가 보이게(가장 작은 확대) */
|
||||
export const PANEL_FIT_ALL_MIN = 0.25;
|
||||
const WIDTH_KEY = "m02.dfpanel.width";
|
||||
const FOLD_KEY = "m02.dfpanel.folded";
|
||||
|
||||
/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */
|
||||
export function dragWidth(start: number, startX: number, x: number, viewport: number): number {
|
||||
const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85));
|
||||
return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x))));
|
||||
}
|
||||
|
||||
/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */
|
||||
export function storedWidth(raw: string | null): number | null {
|
||||
const n = Number(raw);
|
||||
return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null;
|
||||
}
|
||||
|
||||
const readStore = (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const writeStore = (key: string, value: string): void => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 패널 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel {
|
||||
/** 크게 보기 화면 — 열려 있을 때만 */
|
||||
let full: HTMLElement | null = null;
|
||||
const paper = createPaper({
|
||||
logic: opts.logic,
|
||||
readOnly: opts.readOnly,
|
||||
onChange: opts.onChange,
|
||||
logics: opts.logics,
|
||||
// 패널 처음 배율 = 글이 읽히는 최소(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체 · 크게 보기는 90%
|
||||
fitMin: () => (full ? 0.9 : PANEL_FIT_MIN),
|
||||
fitAllMin: () => (full ? 0.9 : PANEL_FIT_ALL_MIN),
|
||||
});
|
||||
const { diagram, detail, summary, note, undo } = paper.parts;
|
||||
let width = storedWidth(readStore(WIDTH_KEY));
|
||||
let folded = opts.folded ?? readStore(FOLD_KEY) === "1";
|
||||
const tell = (): void =>
|
||||
opts.onResize?.(folded ? STRIP_WIDTH : (width ?? Math.max(PANEL_MIN, host.clientWidth)));
|
||||
|
||||
const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") =>
|
||||
createButton({ label, size: "sm", variant, onClick });
|
||||
|
||||
/* 「흐름도」 단추 하나 — 누르면 접고 · 다시 누르면 펼침(펼치면 머리 · 접으면 얇은 띠에 놓임) */
|
||||
const toggle = createButton({
|
||||
label: tf("Toggle"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => (opts.onFold ? opts.onFold(!folded) : setFolded(!folded)),
|
||||
});
|
||||
toggle.classList.add("m02-dfp__toggle");
|
||||
const head = el("header", {
|
||||
className: "m02-dfp__head",
|
||||
children: [
|
||||
toggle,
|
||||
el("strong", { className: "m02-dfp__title", text: opts.title }),
|
||||
sm(tf("Big"), () => expand()),
|
||||
sm(tf("Standards"), () => openStandards()),
|
||||
],
|
||||
});
|
||||
|
||||
/** 「표준 표」(단위 · 금액 표준) — 고치기는 시스템 관리자만(보기만이면 칸 · 단추를 막음) */
|
||||
const openStandards = (): void => {
|
||||
openModal({
|
||||
title: tf("Standards"),
|
||||
closeLabel: tf("Close"),
|
||||
mount: (box: HTMLElement) => {
|
||||
const lock = el("fieldset", { className: "m02-dfp__lock" });
|
||||
lock.disabled = !!opts.readOnly;
|
||||
box.append(lock);
|
||||
renderStandards(lock, { change: () => paper.redraw() }, { title: false });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* 펼친 패널 */
|
||||
const costBox = el("details", {
|
||||
className: "m02-dfp__cost",
|
||||
attrs: { open: "" },
|
||||
children: [el("summary", { text: tf("Cost") })],
|
||||
});
|
||||
const tools = el("div", { className: "ui-toolbar m02-dfp__tools", children: [undo] });
|
||||
const open = el("div", {
|
||||
className: "m02-dfp__open",
|
||||
children: [head, tools, note, diagram, detail, costBox],
|
||||
});
|
||||
costBox.append(summary);
|
||||
|
||||
/* 접힌 띠 — 같은 「흐름도」 단추만 */
|
||||
const strip = el("div", { className: "m02-dfp__strip" });
|
||||
|
||||
/* 왼쪽 가장자리 — 끌기 · 화살표 키 */
|
||||
const grip = el("div", {
|
||||
className: "m02-dfp__grip",
|
||||
attrs: {
|
||||
role: "separator",
|
||||
"aria-orientation": "vertical",
|
||||
"aria-label": tf("Grip"),
|
||||
tabindex: "0",
|
||||
},
|
||||
});
|
||||
const setWidth = (px: number, save: boolean): void => {
|
||||
width = px;
|
||||
if (save) writeStore(WIDTH_KEY, String(px));
|
||||
tell();
|
||||
};
|
||||
grip.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
const start = root.getBoundingClientRect().width;
|
||||
const startX = ev.clientX;
|
||||
root.classList.add("is-resizing");
|
||||
const move = (m: PointerEvent): void =>
|
||||
setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false);
|
||||
const up = (u: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
root.classList.remove("is-resizing");
|
||||
setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true);
|
||||
paper.refit();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
});
|
||||
grip.addEventListener("keydown", (ev) => {
|
||||
const step = ev.shiftKey ? 80 : 20;
|
||||
const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0;
|
||||
if (!d) return;
|
||||
ev.preventDefault();
|
||||
const now = root.getBoundingClientRect().width;
|
||||
setWidth(dragWidth(now, 0, -d, window.innerWidth), true);
|
||||
});
|
||||
|
||||
const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] });
|
||||
root.addEventListener("keydown", (ev) => {
|
||||
if (isCtrlKey(ev, "z") && !ev.shiftKey) {
|
||||
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
}
|
||||
});
|
||||
|
||||
const setFolded = (on: boolean): void => {
|
||||
// 접기 전 너비를 기억 — 끈 적이 없어도 펴면 그 너비로
|
||||
if (on && !folded && width === null && root.isConnected)
|
||||
width = Math.round(root.getBoundingClientRect().width);
|
||||
folded = on;
|
||||
if (!opts.onFold) writeStore(FOLD_KEY, on ? "1" : "0");
|
||||
root.classList.toggle("is-folded", on);
|
||||
open.hidden = on;
|
||||
strip.hidden = !on;
|
||||
grip.hidden = on;
|
||||
(on ? strip : head).prepend(toggle);
|
||||
toggle.setAttribute("aria-expanded", String(!on));
|
||||
toggle.title = tf(on ? "ToggleOpen" : "ToggleClose");
|
||||
tell();
|
||||
if (!on) paper.refit();
|
||||
};
|
||||
|
||||
/* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */
|
||||
const expand = (): void => {
|
||||
if (full) return;
|
||||
const close = sm(tf("Close"), () => shrink());
|
||||
full = el("div", {
|
||||
className: "m02-dfp-full",
|
||||
attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" },
|
||||
children: [
|
||||
el("header", {
|
||||
className: "m02-dfp-full__head",
|
||||
children: [el("strong", { text: opts.title }), undo, close],
|
||||
}),
|
||||
note,
|
||||
el("div", { className: "m02-dfp-full__diagram", children: [diagram] }),
|
||||
el("div", {
|
||||
className: "m02-dfp-full__lower",
|
||||
children: [
|
||||
el("section", { children: [el("h3", { text: tf("Detail") }), detail] }),
|
||||
el("section", { children: [el("h3", { text: tf("Cost") }), summary] }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
full.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape" && !ev.defaultPrevented) shrink();
|
||||
if (isCtrlKey(ev, "z") && !ev.shiftKey) {
|
||||
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
}
|
||||
});
|
||||
document.body.append(full);
|
||||
full.focus();
|
||||
paper.refit();
|
||||
};
|
||||
const shrink = (): void => {
|
||||
if (!full) return;
|
||||
full.remove();
|
||||
full = null;
|
||||
tools.append(undo);
|
||||
costBox.append(summary);
|
||||
// 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움
|
||||
open.replaceChildren(
|
||||
open.firstElementChild as HTMLElement,
|
||||
tools,
|
||||
note,
|
||||
diagram,
|
||||
detail,
|
||||
costBox,
|
||||
);
|
||||
paper.refit();
|
||||
};
|
||||
|
||||
host.replaceChildren(root);
|
||||
setFolded(folded);
|
||||
|
||||
return {
|
||||
set: (lg) => paper.set(lg),
|
||||
destroy: () => {
|
||||
shrink();
|
||||
root.remove();
|
||||
},
|
||||
expand,
|
||||
fold: (on) => on !== folded && setFolded(on),
|
||||
};
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/* 흐름도 고치는 핵심(M02_DF_Paper) · 상세 내용 틀(M02_DF_Entry_Detail) 꾸밈 — 옛 시험판 Entry.css 에서 옮김 */
|
||||
.m02-df__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-df__part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m02-df__part h3 {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-df__part-body {
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-df__note {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m02-df__note:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-df__color {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-bottom: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-df__color[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-df__color-input {
|
||||
width: 44px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-df__detail-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-df__detail-head h3 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.m02-df__detail-name {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-df__detail-pill {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-df__detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) 0;
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.m02-df__detail-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.m02-df__detail-row[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-df__detail-row h4 {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-df__detail-warn {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Paper.ts
|
||||
* 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소
|
||||
* (옛 시험판 고치는 화면을 옮겨 적은) 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀.
|
||||
* 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로).
|
||||
* readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름).
|
||||
* 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import * as api from "./M02_DF_Api";
|
||||
import { renderDiagram, type DfPillSel } from "./M02_DF_Diagram";
|
||||
import * as ev from "./M02_DF_Eval";
|
||||
import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
|
||||
import { renderFuncPanel } from "./M02_DF_FuncPanel";
|
||||
import { renderInputPanel } from "./M02_DF_InputPanel";
|
||||
import { renderLinkPanel } from "./M02_DF_LinkPanel";
|
||||
import * as model from "./M02_DF_Model";
|
||||
import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model";
|
||||
import { renderOutputPanel } from "./M02_DF_OutputPanel";
|
||||
import { renderPillPanel } from "./M02_DF_PillPanel";
|
||||
import { renderSummary } from "./M02_DF_Summary";
|
||||
import type { CfSel } from "./M02_DF_Verify";
|
||||
import {
|
||||
detailFrame,
|
||||
drawHead,
|
||||
drawWarnings,
|
||||
fetchValues,
|
||||
loadLogics,
|
||||
queriesOf,
|
||||
type DfPick,
|
||||
} from "./M02_DF_Entry_Detail";
|
||||
import "./M02_DF_Paper.css";
|
||||
import "@ui/ui_template_text.css";
|
||||
|
||||
const TEXT = {
|
||||
Undo: ["실행 취소", "Undo"],
|
||||
Color: ["상자 색", "Box colour"],
|
||||
BoxName: ["상자 이름", "Box name"],
|
||||
NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"],
|
||||
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
|
||||
Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"],
|
||||
ReadOnly: ["보기만 — 고치기는 시스템 관리자", "View only — editing is for system admins"],
|
||||
} as const;
|
||||
|
||||
const tp = (key: keyof typeof TEXT, v = ""): string =>
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{v}", v);
|
||||
|
||||
export interface PaperOpts {
|
||||
logic: CfLogic;
|
||||
readOnly?: boolean;
|
||||
/** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */
|
||||
onChange(lg: CfLogic): void;
|
||||
/** 다른 로직 입력이 부르는 로직 — 없으면 못 부름(빈 값) */
|
||||
logics?(key: string): Promise<CfLogic | null>;
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
|
||||
fitMin?(): number;
|
||||
/** 「한눈에」 단추의 가장 작은 확대 — 없으면 fitMin */
|
||||
fitAllMin?(): number;
|
||||
}
|
||||
|
||||
export interface Paper {
|
||||
/** 다이어그램 자리 · 상세 내용 · 원가 · 검증 · 알림 줄 · 실행 취소 단추 */
|
||||
parts: {
|
||||
diagram: HTMLElement;
|
||||
detail: HTMLElement;
|
||||
summary: HTMLElement;
|
||||
note: HTMLElement;
|
||||
undo: HTMLButtonElement;
|
||||
};
|
||||
get(): CfLogic;
|
||||
/** 밖에서 바꿈(고친 것 버리기 등) — 실행 취소 기록은 비움 */
|
||||
set(lg: CfLogic): void;
|
||||
undo(): void;
|
||||
/** 다이어그램을 다시 맞춤(자리를 옮겨 끼운 뒤) */
|
||||
refit(): void;
|
||||
redraw(): void;
|
||||
}
|
||||
|
||||
export function createPaper(opts: PaperOpts): Paper {
|
||||
const readOnly = !!opts.readOnly;
|
||||
let lg = opts.logic;
|
||||
let stack: CfLogic[] = [];
|
||||
let pick: DfPick = null;
|
||||
let values: CfValues = {};
|
||||
let valueSig = "";
|
||||
let logics: NonNullable<CfEvalOptions["logics"]> = {};
|
||||
let logicSig = "";
|
||||
let logicNotes: string[] = [];
|
||||
let notes: string[] = [];
|
||||
let broken: CfBreak[] = [];
|
||||
let result: CfEvalResult;
|
||||
let fitKey = 0;
|
||||
const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null);
|
||||
|
||||
const note = el("p", { className: "m02-df__note", attrs: { role: "status" } });
|
||||
const diagramHost = el("div", { className: "m02-dfp__diagram" });
|
||||
const summaryHost = el("div", { className: "m02-dfp__summary" });
|
||||
const colorBox = el("input", {
|
||||
className: "m02-df__color-input",
|
||||
attrs: { type: "color", "aria-label": tp("Color") },
|
||||
});
|
||||
colorBox.addEventListener("change", () => {
|
||||
const id = pickedBox();
|
||||
if (id) edit(model.setColor(lg, id, colorBox.value));
|
||||
});
|
||||
const colorRow = el("label", {
|
||||
className: "m02-df__color",
|
||||
children: [el("span", { text: tp("Color") }), colorBox],
|
||||
});
|
||||
const boxName = createInputField({ label: tp("BoxName"), compact: true });
|
||||
boxName.input.addEventListener("change", () => {
|
||||
const box = lg.boxes.find((b) => b.id === pickedBox());
|
||||
if (!box) return;
|
||||
const next = boxName.input.value.trim();
|
||||
const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next);
|
||||
if (taken) note.textContent = tp("NameTaken", next);
|
||||
if (!next || taken || next === box.name) return void (boxName.input.value = box.name);
|
||||
edit(model.updateBox(lg, box.id, { name: next }));
|
||||
});
|
||||
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만
|
||||
const funcHost = el("div");
|
||||
const outHost = el("div");
|
||||
const inHost = el("div");
|
||||
const linkHost = el("div");
|
||||
const pillHost = el("div");
|
||||
const detail = detailFrame();
|
||||
detail.settings.append(boxName.root, colorRow);
|
||||
// 보기만 — 판 안 칸 · 단추를 한 번에 막음(고르기 · 흐름 따라가기는 다이어그램에서 됨)
|
||||
const lock = el("fieldset", {
|
||||
className: "m02-dfp__lock",
|
||||
children: [inHost, funcHost, outHost, linkHost, pillHost],
|
||||
});
|
||||
lock.disabled = readOnly;
|
||||
detail.body.append(lock);
|
||||
if (readOnly)
|
||||
detail.root.prepend(el("p", { className: "ui-text ui-text--muted", text: tp("ReadOnly") }));
|
||||
|
||||
const evalNow = (): CfEvalResult => {
|
||||
try {
|
||||
return ev.evalLogic(lg, values, { logics });
|
||||
} catch (e) {
|
||||
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
|
||||
return { ports: {}, outputs: {}, cost, errors: [api.cfPlain(e)] };
|
||||
}
|
||||
};
|
||||
|
||||
/** 고침 — readOnly 면 무시 · 기록에 쌓고 onChange */
|
||||
const edit = (next: CfLogic): void => {
|
||||
if (readOnly || next === lg) return;
|
||||
stack = [...stack, lg];
|
||||
lg = next;
|
||||
opts.onChange(lg);
|
||||
redraw();
|
||||
};
|
||||
/** 검증 칸 예시값 — readOnly 에서도 됨(보기용 · onChange 안 부름) */
|
||||
const editExample = (next: CfLogic): void => {
|
||||
if (!readOnly) return edit(next);
|
||||
lg = next;
|
||||
redraw();
|
||||
};
|
||||
const editFromDiagram = (next: CfLogic): void => {
|
||||
edit(next);
|
||||
setTimeout(redraw);
|
||||
};
|
||||
|
||||
const refreshValues = (): void => {
|
||||
const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null;
|
||||
const sig = JSON.stringify(queriesOf(lg, pill, model.elementPort).map((a) => a.q));
|
||||
if (sig === valueSig) return;
|
||||
valueSig = sig;
|
||||
void fetchValues(api.cfValues, lg, pill, model.elementPort).then(
|
||||
(got) => {
|
||||
if (sig !== valueSig) return;
|
||||
values = got.values;
|
||||
notes = got.errors.length ? [tp("Values", got.errors.join(" · "))] : [];
|
||||
redraw();
|
||||
},
|
||||
(e: unknown) => {
|
||||
notes = [tp("Values", api.cfPlain(e))];
|
||||
redraw();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const refreshLogics = (): void => {
|
||||
const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
|
||||
if (sig === logicSig) return;
|
||||
logicSig = sig;
|
||||
const get = opts.logics ?? (async () => null);
|
||||
void loadLogics(lg, get).then((got) => {
|
||||
if (sig !== logicSig) return;
|
||||
logics = got.logics;
|
||||
logicNotes = got.errors.length ? [tp("Logics", got.errors.join(" · "))] : [];
|
||||
redraw();
|
||||
});
|
||||
};
|
||||
|
||||
const choose = (sel: CfSel): void => {
|
||||
const box = lg.boxes.find((b) => b.id === sel.box);
|
||||
pick = sel.link
|
||||
? { kind: "link", id: sel.link }
|
||||
: box && sel.port
|
||||
? { kind: "pill", box: box.id, port: sel.port, dir: model.portDir(box, sel.port) }
|
||||
: box
|
||||
? { kind: "box", id: box.id }
|
||||
: null;
|
||||
redraw();
|
||||
};
|
||||
|
||||
/** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */
|
||||
const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => {
|
||||
try {
|
||||
if (pick?.kind === "pill") {
|
||||
const p = { box: pick.box, name: pick.port };
|
||||
return { back: model.traceBack(lg, p), forward: model.traceForward(lg, p) };
|
||||
}
|
||||
const link = lg.links.find((l) => pick?.kind === "link" && l.id === pick.id);
|
||||
if (link)
|
||||
return { back: model.traceBack(lg, link.from), forward: model.traceForward(lg, link.to) };
|
||||
} catch {
|
||||
/* 따라갈 수 없으면 칠하지 않음 */
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const diagramOn = {
|
||||
select: (id: string | null, pill?: DfPillSel): void => {
|
||||
pick = pill
|
||||
? { kind: "pill", ...pill }
|
||||
: !id
|
||||
? null
|
||||
: lg.links.some((l) => l.id === id)
|
||||
? { kind: "link", id }
|
||||
: { kind: "box", id };
|
||||
setTimeout(redraw);
|
||||
},
|
||||
move: (id: string, x: number, y: number): void => edit(model.moveBox(lg, id, x, y)),
|
||||
link: (from: CfPort, to: CfPort): string | null => {
|
||||
const got = model.link(lg, from, to);
|
||||
if (got.why) return got.why;
|
||||
editFromDiagram(got.lg);
|
||||
return null;
|
||||
},
|
||||
unlink: (id: string): void => editFromDiagram(model.unlink(lg, id)),
|
||||
add: (kind: CfKind, x: number, y: number): void => {
|
||||
const got = model.addBox(lg, kind, x, y);
|
||||
pick = { kind: "box", id: got.box.id };
|
||||
editFromDiagram(got.lg);
|
||||
},
|
||||
remove: (id: string): void => {
|
||||
if (pickedBox() === id) pick = null;
|
||||
editFromDiagram(model.removeBox(lg, id));
|
||||
},
|
||||
};
|
||||
|
||||
const drawPanel = (): void => {
|
||||
const boxId = pickedBox();
|
||||
if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null;
|
||||
const box = lg.boxes.find((b) => b.id === pickedBox());
|
||||
drawHead(detail, lg, pick);
|
||||
(detail.settings.parentElement as HTMLElement).hidden = !box || readOnly;
|
||||
colorRow.hidden = !box;
|
||||
boxName.root.hidden = !box || box.kind === "output";
|
||||
if (box && document.activeElement !== boxName.input) boxName.input.value = box.name;
|
||||
linkHost.hidden = pick?.kind !== "link";
|
||||
pillHost.hidden = pick?.kind !== "pill";
|
||||
if (pick?.kind === "link")
|
||||
renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics });
|
||||
if (pick?.kind === "pill")
|
||||
renderPillPanel(
|
||||
pillHost,
|
||||
lg,
|
||||
pick.box,
|
||||
pick.port,
|
||||
{ pick: (id) => choose({ box: id }) },
|
||||
{ values, logics },
|
||||
);
|
||||
drawWarnings(detail, pick, broken);
|
||||
if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color;
|
||||
funcHost.hidden = box?.kind !== "function";
|
||||
outHost.hidden = box?.kind !== "output";
|
||||
inHost.hidden = box?.kind !== "input";
|
||||
if (box?.kind === "input") renderInputPanel(inHost, lg, box.id, { change: edit });
|
||||
if (box?.kind === "function")
|
||||
renderFuncPanel(funcHost, lg, box.id, { change: edit }, { values, logics });
|
||||
if (box?.kind === "output")
|
||||
renderOutputPanel(outHost, lg, box.id, { change: edit }, { result });
|
||||
};
|
||||
|
||||
const redraw = (): void => {
|
||||
result = evalNow();
|
||||
refreshValues();
|
||||
refreshLogics();
|
||||
const linkId = pick?.kind === "link" ? pick.id : "";
|
||||
if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null;
|
||||
try {
|
||||
broken = ev.breaks(lg, values, { logics });
|
||||
} catch {
|
||||
broken = result.breaks ?? [];
|
||||
}
|
||||
undo.disabled = readOnly || !stack.length;
|
||||
renderDiagram(diagramHost, lg, diagramOn, {
|
||||
values: result.ports,
|
||||
selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null,
|
||||
selectedPill:
|
||||
pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null,
|
||||
trace: traceNow(),
|
||||
breaks: broken,
|
||||
readOnly,
|
||||
fitKey,
|
||||
fitMin: opts.fitMin?.(),
|
||||
fitAllMin: opts.fitAllMin?.(),
|
||||
});
|
||||
drawPanel();
|
||||
renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values });
|
||||
note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n");
|
||||
};
|
||||
|
||||
const doUndo = (): void => {
|
||||
const back = stack[stack.length - 1];
|
||||
if (!back || readOnly) return;
|
||||
stack = stack.slice(0, -1);
|
||||
lg = back;
|
||||
opts.onChange(lg);
|
||||
redraw();
|
||||
};
|
||||
const undo = createButton({ label: tp("Undo"), variant: "ghost", size: "sm", onClick: doUndo });
|
||||
|
||||
redraw();
|
||||
return {
|
||||
parts: { diagram: diagramHost, detail: detail.root, summary: summaryHost, note, undo },
|
||||
get: () => lg,
|
||||
set: (next) => {
|
||||
lg = next;
|
||||
stack = [];
|
||||
pick = null;
|
||||
fitKey += 1;
|
||||
redraw();
|
||||
},
|
||||
undo: doUndo,
|
||||
refit: () => {
|
||||
fitKey += 1;
|
||||
redraw();
|
||||
},
|
||||
redraw,
|
||||
};
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_PillPanel.ts
|
||||
* 상세 내용 판 — 알약: 알약을 고르면 이름 · 값 · 단위 · 온 길(출처 사슬) · 가는 곳(이어진 선 · 받는 상자) · 끊김 까닭
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 8 · 9 · M02_DF_Model 의 CfStep 그대로 · 읽기만(고치기는 선 · 상자 판에서)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfLogic, CfStep } from "./M02_DF_Model";
|
||||
import {
|
||||
boxOf,
|
||||
breakLines,
|
||||
evalSafe,
|
||||
isOut,
|
||||
labelOf,
|
||||
portText,
|
||||
showValue,
|
||||
trace,
|
||||
unitOf,
|
||||
valueOf,
|
||||
type DetailOptions,
|
||||
} from "./M02_DF_Detail";
|
||||
import "./M02_DF_LinkPanel.css";
|
||||
import "@ui/ui_template_text.css";
|
||||
import "@ui/ui_template_banner.css";
|
||||
import "@ui/ui_template_badge.css";
|
||||
|
||||
const TEXT = {
|
||||
Name: ["이름", "Name"],
|
||||
Value: ["값", "Value"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Kind_out: ["내보내는 알약", "Sending pill"],
|
||||
Kind_in: ["받는 알약", "Receiving pill"],
|
||||
From: ["온 길", "Where it came from"],
|
||||
To: ["가는 곳", "Where it goes"],
|
||||
Start: ["여기서 시작 — 더 거슬러 갈 곳이 없음", "It starts here — nothing before it"],
|
||||
End: ["여기가 끝 — 더 가는 곳이 없음", "It ends here — nothing after it"],
|
||||
Breaks: ["끊긴 곳", "Breaks"],
|
||||
NoBreak: ["끊긴 곳 없음", "No breaks"],
|
||||
Open: ["이 상자 보기", "Open this box"],
|
||||
Line: ["선", "Line"],
|
||||
} as const;
|
||||
|
||||
const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-dflink-row",
|
||||
children: [el("span", { className: "m02-dflink-label", text: label }), ...value],
|
||||
});
|
||||
|
||||
/** 한 줄 = 선 하나면 「보낸 알약 → (규칙 글) → 받는 알약」 · 함수 안 걸음이면 알약 하나 */
|
||||
function steps(
|
||||
lg: CfLogic,
|
||||
list: CfStep[],
|
||||
arrow: string,
|
||||
empty: string,
|
||||
onBox?: (id: string) => void,
|
||||
): HTMLElement {
|
||||
if (!list.length) return el("p", { className: "ui-text ui-text--muted", text: empty });
|
||||
const pill = (box: string, name: string): HTMLElement | string =>
|
||||
onBox
|
||||
? createButton({
|
||||
label: portText(lg, { box, name }),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => onBox(box),
|
||||
})
|
||||
: portText(lg, { box, name });
|
||||
return el("ol", {
|
||||
className: "m02-dfpill-list",
|
||||
children: list.map((s) => {
|
||||
const link = s.link ? lg.links.find((l) => l.id === s.link) : undefined;
|
||||
const parts: (HTMLElement | string)[] = link
|
||||
? [
|
||||
arrow,
|
||||
pill(link.from.box, link.from.name),
|
||||
"→",
|
||||
...(s.rule
|
||||
? [
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "20" },
|
||||
text: `(${labelOf(s.rule)})`,
|
||||
}),
|
||||
"→",
|
||||
]
|
||||
: []),
|
||||
pill(link.to.box, link.to.name),
|
||||
]
|
||||
: [arrow, pill(s.box, s.port)];
|
||||
return el("li", { className: "m02-dfpill-step", children: parts });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 알약 판 그리기 — 알약을 고르면 부름 · on.pick 이 있으면 줄의 상자 이름을 눌러 그 상자로 감 */
|
||||
export function renderPillPanel(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
port: string,
|
||||
on: { pick?(boxId: string): void } = {},
|
||||
opts: DetailOptions = {},
|
||||
): void {
|
||||
host.classList.add("m02-dfpill");
|
||||
const box = boxOf(lg, boxId);
|
||||
if (!box) return host.replaceChildren();
|
||||
const ev = evalSafe(lg, opts);
|
||||
const out = isOut(box, port);
|
||||
// 받는 알약의 값 = 선 규칙을 지나 실제로 들어온 값(없으면 보낸 알약 값)
|
||||
const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port);
|
||||
const value = out
|
||||
? valueOf(ev, { box: boxId, name: port })
|
||||
: (ev?.arrived?.[boxId]?.[port] ?? (incoming ? valueOf(ev, incoming.from) : undefined));
|
||||
const unit = out
|
||||
? unitOf(lg, { box: boxId, name: port })
|
||||
: incoming
|
||||
? unitOf(lg, incoming.from)
|
||||
: box.unit;
|
||||
const back = trace(lg, { box: boxId, name: port }, "in");
|
||||
const forward = trace(lg, { box: boxId, name: port }, "out");
|
||||
const breaks = breakLines(lg, boxId, port, opts);
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-dflink-head",
|
||||
children: [
|
||||
el("strong", { className: "m02-dfpill-title", text: `${box.name} › ${port}` }),
|
||||
el("span", { className: "m02-dflink-note", text: tp(out ? "Kind_out" : "Kind_in") }),
|
||||
],
|
||||
}),
|
||||
line(tp("Name"), port),
|
||||
line(
|
||||
tp("Value"),
|
||||
el("span", {
|
||||
className: "ui-badge ui-badge--pill ui-badge--neutral",
|
||||
attrs: { "data-size": "20" },
|
||||
text: showValue(value),
|
||||
}),
|
||||
),
|
||||
line(tp("Unit"), unit || "-"),
|
||||
el("div", {
|
||||
children: [
|
||||
el("div", { className: "m02-dflink-label", text: tp("From") }),
|
||||
steps(lg, back, "◀", tp("Start"), on.pick),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
children: [
|
||||
el("div", { className: "m02-dflink-label", text: tp("To") }),
|
||||
steps(lg, forward, "▶", tp("End"), on.pick),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
children: [
|
||||
el("div", { className: "m02-dflink-label", text: tp("Breaks") }),
|
||||
...(breaks.length
|
||||
? breaks.map((b) =>
|
||||
el("p", {
|
||||
className: "ui-banner ui-banner--warning m02-dflink-warn",
|
||||
text: `⚠ ${b}`,
|
||||
}),
|
||||
)
|
||||
: [el("p", { className: "ui-text ui-text--muted", text: tp("NoBreak") })]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,16 @@
|
||||
* 끝수 기본값 — 금액 단위 표준(산림품셈 1-2-2 「금액의 단위표준」)을 읽어
|
||||
* 출력(비용) 기본 = 일위대가표 금액란 · 원가 합계 기본 = 일위대가표 계금 (모두 미만 버림)
|
||||
* 표 읽기 = 서버 `GET /api/m02/df/standards`(Api `cfStandards`) → {unit, money, saved}
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. 이 폴더째 지워도 됨.
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12.
|
||||
* ========================================================================== */
|
||||
|
||||
import { cfStandards } from "./M02_DF_Api";
|
||||
import type { CfRound } from "./M02_DF_Model";
|
||||
|
||||
/** 끝수 — 자리 · 방법 */
|
||||
export interface CfRound {
|
||||
places: number;
|
||||
method: "반올림" | "올림" | "버림";
|
||||
}
|
||||
|
||||
/** 표 한 줄 — 원문 그대로(종목 · 단위 · 지위 · 비고) */
|
||||
export interface CfMoneyRow {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_RoundRow.ts
|
||||
* 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추)
|
||||
* 모양은 M02_DF_Model 의 CfRound · 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfRound } from "./M02_DF_Model";
|
||||
import "@ui/ui_template_text.css";
|
||||
|
||||
const TEXT = {
|
||||
Label: ["끝수", "Rounding"],
|
||||
None: ["안 씀", "none"],
|
||||
Places: ["소수 {n}자리", "{n} decimals"],
|
||||
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
|
||||
Apply: ["적용", "Apply"],
|
||||
PlacesAria: ["소수자리", "Decimal places"],
|
||||
ModeAria: ["방법", "Method"],
|
||||
} as const;
|
||||
|
||||
const METHODS: CfRound["method"][] = ["반올림", "올림", "버림"];
|
||||
|
||||
const tr = (key: keyof typeof TEXT, n?: number): string =>
|
||||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
|
||||
|
||||
export interface RoundRowOptions {
|
||||
/** 지금 끝수 — 없으면 undefined */
|
||||
round: CfRound | undefined;
|
||||
/** 단위 표준이 제안한 소수자리 */
|
||||
suggest: number | null;
|
||||
readOnly?: boolean;
|
||||
/** null = 끝수 벗김 */
|
||||
onApply(round: CfRound | null): void;
|
||||
}
|
||||
|
||||
export function createRoundRow(opts: RoundRowOptions): HTMLElement {
|
||||
const now = opts.round ?? null;
|
||||
const places = el("select", {
|
||||
className: "ui-select ui-select--sm",
|
||||
attrs: { "aria-label": tr("PlacesAria") },
|
||||
});
|
||||
places.append(el("option", { attrs: { value: "" }, text: tr("None") }));
|
||||
for (let n = 0; n <= 6; n++) {
|
||||
places.append(el("option", { attrs: { value: String(n) }, text: tr("Places", n) }));
|
||||
}
|
||||
places.value = now ? String(now.places) : "";
|
||||
const method = el("select", {
|
||||
className: "ui-select ui-select--sm",
|
||||
attrs: { "aria-label": tr("ModeAria") },
|
||||
});
|
||||
for (const m of METHODS) method.append(el("option", { attrs: { value: m }, text: m }));
|
||||
method.value = now?.method ?? "반올림";
|
||||
if (opts.readOnly) places.disabled = method.disabled = true;
|
||||
const emit = (): void =>
|
||||
opts.onApply(
|
||||
places.value === ""
|
||||
? null
|
||||
: { places: Number(places.value), method: method.value as CfRound["method"] },
|
||||
);
|
||||
places.addEventListener("change", emit);
|
||||
method.addEventListener("change", () => places.value !== "" && emit());
|
||||
const row = el("div", {
|
||||
className: "m02-dfpanel-row",
|
||||
children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, method],
|
||||
});
|
||||
if (opts.suggest !== null && !now && !opts.readOnly) {
|
||||
row.append(
|
||||
el("span", { className: "ui-text ui-text--muted", text: tr("Suggest", opts.suggest) }),
|
||||
createButton({
|
||||
label: tr("Apply"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => opts.onApply({ places: opts.suggest!, method: "반올림" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user