Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab.ts
|
||||
* 관리자 화면 — 로직 칸. 가운데 단가산출 상세(호표) 고치기 / 오른쪽 시험 계산
|
||||
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도
|
||||
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
|
||||
*
|
||||
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
|
||||
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
@@ -37,12 +37,7 @@ import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail"
|
||||
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 { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New";
|
||||
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
|
||||
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry";
|
||||
// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
import { createDataFlowButton } from "../M02_MasterTemplete/DataFlow/M02_DF_Entry";
|
||||
import { buildSplit } from "./M01_MasterData_UI_LogicLab_Flow";
|
||||
import "./M01_MasterData_UI_Logic_Style.css";
|
||||
|
||||
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
|
||||
@@ -72,49 +67,6 @@ interface Opened extends Draft {
|
||||
const cacheKey = "m01_logic_drafts_lab";
|
||||
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
|
||||
|
||||
/** 시험판 묶음 — 「시험판 ▾」 단추 하나 · 누르면 시험판 단추들이 목록으로(바깥 · Esc · 고르면 닫힘) */
|
||||
function trialMenu(items: HTMLElement[]): HTMLElement {
|
||||
const list = el("div", { attrs: { role: "menu" }, children: items });
|
||||
Object.assign(list.style, {
|
||||
position: "absolute",
|
||||
top: "calc(100% + 4px)",
|
||||
left: "0",
|
||||
zIndex: "20",
|
||||
display: "none",
|
||||
flexDirection: "column",
|
||||
gap: "var(--spacing-4)",
|
||||
padding: "var(--spacing-8)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
background: "var(--color-surface-raised)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
const shut = (): void => {
|
||||
list.style.display = "none";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("click", outside, true);
|
||||
};
|
||||
const outside = (ev: Event): void => {
|
||||
if (!wrap.contains(ev.target as Node)) shut();
|
||||
};
|
||||
const button = createButton({
|
||||
label: ["시험판 ▾", "Trials ▾"][currentLanguageIndex === 1 ? 1 : 0],
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
if (list.style.display !== "none") return shut();
|
||||
list.style.display = "flex";
|
||||
button.setAttribute("aria-expanded", "true");
|
||||
document.addEventListener("click", outside, true);
|
||||
},
|
||||
});
|
||||
button.setAttribute("aria-haspopup", "menu");
|
||||
list.addEventListener("click", () => shut()); // 고른 시험판은 제 단추가 엶
|
||||
const wrap = el("div", { children: [button, list] });
|
||||
Object.assign(wrap.style, { position: "relative", display: "inline-flex" });
|
||||
wrap.addEventListener("keydown", (ev) => ev.key === "Escape" && shut());
|
||||
return wrap;
|
||||
}
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
|
||||
function loadDrafts(): Record<string, Draft> {
|
||||
@@ -137,13 +89,13 @@ export async function mountM01LogicLab(
|
||||
): Promise<LogicHandle> {
|
||||
let drafts = loadDrafts();
|
||||
let opened: Opened | null = null;
|
||||
let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음
|
||||
let calcInputs = "";
|
||||
let recalc: number | undefined;
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
// 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억
|
||||
const split = buildSplit(editor);
|
||||
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
|
||||
const calc = el("div", { className: "m01lab__calc" });
|
||||
const newHost = el("div", { className: "m01lab__new", attrs: { hidden: "" } });
|
||||
const filterHost = el("div");
|
||||
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
|
||||
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
|
||||
@@ -233,12 +185,11 @@ export async function mountM01LogicLab(
|
||||
|
||||
const drawEditor = (): void => {
|
||||
// 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
|
||||
const listing = !opened && !creating;
|
||||
const listing = !opened;
|
||||
list.root.hidden = !listing || !loaded;
|
||||
waiting.hidden = loaded;
|
||||
editor.hidden = !opened;
|
||||
split.root.hidden = !opened;
|
||||
back.hidden = listing;
|
||||
newHost.hidden = !creating;
|
||||
if (!opened) return;
|
||||
if (!opened.row) {
|
||||
editor.replaceChildren(
|
||||
@@ -264,7 +215,6 @@ export async function mountM01LogicLab(
|
||||
|
||||
const show = (next: Opened | null): void => {
|
||||
opened = next;
|
||||
creating = false;
|
||||
errors.hidden = true;
|
||||
drawEditor();
|
||||
drawCalc();
|
||||
@@ -326,7 +276,7 @@ export async function mountM01LogicLab(
|
||||
restore: true,
|
||||
onPick: (sub, detail) => {
|
||||
list.setPick(sub, detail);
|
||||
if (opened || creating) show(null);
|
||||
if (opened) show(null);
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -410,65 +360,36 @@ export async function mountM01LogicLab(
|
||||
}
|
||||
};
|
||||
|
||||
/** 식으로 새로 만들기 — 저장하면 목록을 다시 받아 그 로직의 상세 화면을 엶 */
|
||||
const startNew = (): void => {
|
||||
opened = null;
|
||||
creating = true;
|
||||
newHost.replaceChildren(
|
||||
buildNewLogic({
|
||||
onSaved: (key) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await reload();
|
||||
await openKeyOn(key);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
})();
|
||||
},
|
||||
onClose: () => show(null),
|
||||
}),
|
||||
);
|
||||
drawEditor();
|
||||
drawCalc();
|
||||
};
|
||||
|
||||
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
|
||||
const discardButton = createButton({
|
||||
label: tx("Bar_Discard"),
|
||||
variant: "ghost",
|
||||
onClick: () => void onDiscard(),
|
||||
});
|
||||
const copyButton = createButton({
|
||||
label: tl("Copy_Open"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
opened?.origKey
|
||||
? void openCopyScreen({
|
||||
key: opened.origKey,
|
||||
afterSave: async (newKey) => {
|
||||
await reload();
|
||||
await open(logicId("자체", newKey), "자체", newKey);
|
||||
},
|
||||
})
|
||||
: showToast(tl("Copy_NeedOpen"), "info"),
|
||||
});
|
||||
const bar = el("div", {
|
||||
className: "m01-logic__bar",
|
||||
children: [
|
||||
back,
|
||||
el("h2", { text: tx("Title") }),
|
||||
pending,
|
||||
createButton({ label: tn("Open"), variant: "filled", onClick: startNew }),
|
||||
trialMenu([
|
||||
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createCostFlowButton(),
|
||||
// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움
|
||||
createDataFlowButton(),
|
||||
]),
|
||||
copyButton,
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
discardButton,
|
||||
saveButton,
|
||||
createButton({
|
||||
label: tl("Copy_Open"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
opened?.origKey
|
||||
? void openCopyScreen({
|
||||
key: opened.origKey,
|
||||
afterSave: async (newKey) => {
|
||||
await reload();
|
||||
await open(logicId("자체", newKey), "자체", newKey);
|
||||
},
|
||||
})
|
||||
: showToast(tl("Copy_NeedOpen"), "info"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(
|
||||
@@ -477,7 +398,7 @@ export async function mountM01LogicLab(
|
||||
children: [
|
||||
el("main", {
|
||||
className: "m01-logic__main",
|
||||
children: [bar, errors, waiting, list.root, editor, newHost],
|
||||
children: [bar, errors, waiting, list.root, split.root],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -72,6 +72,9 @@ export interface DetailContext {
|
||||
onChange: () => void;
|
||||
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
|
||||
calcHost: HTMLElement;
|
||||
/** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 ·
|
||||
* 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */
|
||||
flowHost?: HTMLElement;
|
||||
}
|
||||
|
||||
type Group = "labor" | "material" | "cost" | "other";
|
||||
@@ -482,8 +485,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
: null;
|
||||
host.replaceChildren(
|
||||
infoBox(ctx),
|
||||
hoBox(ctx),
|
||||
section(tl("Formula_Title"), [formula]),
|
||||
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
|
||||
section(tl("Calc_Title"), [ctx.calcHost]),
|
||||
);
|
||||
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* 로직 상세 두 칸 — 왼쪽 지금 내용 · 끌기 막대 · 오른쪽 흐름도 */
|
||||
.m01lab-flow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.m01lab-flow[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01lab-flow__left,
|
||||
.m01lab-flow__right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab-flow__bar {
|
||||
align-self: stretch;
|
||||
width: 8px;
|
||||
margin: 0 var(--spacing-4);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-border);
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.m01lab-flow__bar:hover,
|
||||
.m01lab-flow__bar:focus-visible {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Flow.ts
|
||||
* 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임)
|
||||
* 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import "./M01_MasterData_UI_LogicLab_Flow.css";
|
||||
|
||||
const TEXT = {
|
||||
Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"],
|
||||
Waiting: ["흐름도 자리", "Flow diagram"],
|
||||
} as const;
|
||||
|
||||
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
const STORE = "m01.logic.flowRatio";
|
||||
const MIN = 0.25;
|
||||
const MAX = 0.75;
|
||||
|
||||
const clamp = (r: number): number => Math.min(MAX, Math.max(MIN, r));
|
||||
|
||||
function readRatio(): number {
|
||||
try {
|
||||
const r = Number(localStorage.getItem(STORE));
|
||||
return r ? clamp(r) : 0.5;
|
||||
} catch {
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SplitHandle {
|
||||
root: HTMLElement;
|
||||
/** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */
|
||||
right: HTMLElement;
|
||||
}
|
||||
|
||||
/** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */
|
||||
export function buildSplit(left: HTMLElement): SplitHandle {
|
||||
const right = el("div", {
|
||||
className: "m01lab-flow__right",
|
||||
children: [el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })],
|
||||
});
|
||||
const bar = el("div", {
|
||||
className: "m01lab-flow__bar",
|
||||
attrs: {
|
||||
role: "separator",
|
||||
"aria-orientation": "vertical",
|
||||
"aria-label": tf("Resize"),
|
||||
title: tf("Resize"),
|
||||
tabindex: "0",
|
||||
},
|
||||
});
|
||||
const leftCol = el("div", { className: "m01lab-flow__left", children: [left] });
|
||||
const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] });
|
||||
let ratio = readRatio();
|
||||
const apply = (): void => {
|
||||
root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`;
|
||||
};
|
||||
const keep = (): void => {
|
||||
try {
|
||||
localStorage.setItem(STORE, String(ratio));
|
||||
} catch {
|
||||
/* 기억 못 해도 지금 화면은 그대로 */
|
||||
}
|
||||
};
|
||||
bar.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
const box = root.getBoundingClientRect();
|
||||
const move = (m: PointerEvent): void => {
|
||||
ratio = clamp((m.clientX - box.left) / box.width);
|
||||
apply();
|
||||
};
|
||||
const up = (): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
keep();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
});
|
||||
// 키보드 — 왼쪽 · 오른쪽 화살표로 조금씩
|
||||
bar.addEventListener("keydown", (ev) => {
|
||||
const step = ev.key === "ArrowLeft" ? -0.05 : ev.key === "ArrowRight" ? 0.05 : 0;
|
||||
if (!step) return;
|
||||
ev.preventDefault();
|
||||
ratio = clamp(ratio + step);
|
||||
apply();
|
||||
keep();
|
||||
});
|
||||
apply();
|
||||
return { root, right };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/* =============================================================================
|
||||
* 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 },
|
||||
): 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),
|
||||
};
|
||||
}).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),
|
||||
funcs,
|
||||
errors: ev.errors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.m01flow__warn {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/* =============================================================================
|
||||
* 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 type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
import { itemRoundNow, totalRoundNow } 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";
|
||||
|
||||
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: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"],
|
||||
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",
|
||||
};
|
||||
|
||||
/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */
|
||||
interface FormulaLine {
|
||||
name: string;
|
||||
named: string;
|
||||
valued: string;
|
||||
branches?: { when: string; then: string; now: boolean }[];
|
||||
}
|
||||
type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[];
|
||||
const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>(
|
||||
"../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts",
|
||||
);
|
||||
const loadFormula = async (): Promise<FormulaFn | null> => {
|
||||
const load = Object.values(FORMULA)[0];
|
||||
return load ? ((await load()).formulaLines ?? null) : null;
|
||||
};
|
||||
|
||||
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: "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) {
|
||||
host.replaceChildren(
|
||||
section(tf("Detail"), [el("p", { className: "m01-logic__muted", text: String(e) })]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() });
|
||||
const detail = section(tf("Detail"), [
|
||||
...(rows.groups.length
|
||||
? rows.groups.map(groupTable)
|
||||
: [el("p", { className: "m01-logic__muted", text: tf("None") })]),
|
||||
el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": String(rows.total) },
|
||||
text: `${tf("Total")} ${rows.totalText}`,
|
||||
}),
|
||||
el("p", { className: "m01-logic__muted", text: tf("TotalNote") }),
|
||||
...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })),
|
||||
]);
|
||||
const bodies = rows.funcs.map((f) => funcBlock(f, null));
|
||||
const text = section(
|
||||
tf("Formula"),
|
||||
bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })],
|
||||
);
|
||||
host.replaceChildren(detail, text);
|
||||
// sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함
|
||||
void loadFormula().then((fn) => {
|
||||
if (!fn || !bodies.length) return;
|
||||
const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func);
|
||||
const merged = { ...ev.ports, ...ev.arrived } as CfValues;
|
||||
text.replaceChildren(
|
||||
text.firstElementChild!,
|
||||
...rows.funcs.map((f, i) => {
|
||||
try {
|
||||
return funcBlock(f, fn(boxes[i], merged));
|
||||
} catch {
|
||||
return bodies[i];
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */
|
||||
.m01lab-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab-new__step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab-new__text {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: var(--text-body);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.m01lab-new__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab-new__vars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab-new__var {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01lab-new__var-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab-new__var-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab-new__wide {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
.m01lab-new__picked {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab-new__src {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-paper);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.m01lab-new__no {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01lab-new__src mark {
|
||||
background: var(--color-danger);
|
||||
color: var(--color-bg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.m01lab-new__bad {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m01lab-new__soft {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01lab-new__calc {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_New.ts
|
||||
* 「단가산출 로직」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장)
|
||||
* ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름
|
||||
* ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX)
|
||||
* 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import {
|
||||
ApiError,
|
||||
draftLogic,
|
||||
type DraftAnswer,
|
||||
type DraftDecided,
|
||||
type DraftHead,
|
||||
type DraftProblem,
|
||||
type DraftVar,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
|
||||
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
|
||||
import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var";
|
||||
import "./M01_MasterData_UI_LogicLab_New.css";
|
||||
|
||||
/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */
|
||||
const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요
|
||||
넓이 = 길이 * 폭
|
||||
인부 = 넓이 * 품
|
||||
잡재료비 = 노무비 * 0.05
|
||||
공사비 = 인부 * 보통인부 + 잡재료비
|
||||
`;
|
||||
|
||||
export interface NewOptions {
|
||||
/** 저장 끝 — 새 자체 로직의 키 */
|
||||
onSaved: (key: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function buildNewLogic(opts: NewOptions): HTMLElement {
|
||||
const decided: Record<string, DraftDecided> = {};
|
||||
const rows = new Map<string, VarRow>();
|
||||
const values: Record<string, string> = {};
|
||||
let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림
|
||||
let answer: DraftAnswer | null = null;
|
||||
let calcSig = "";
|
||||
let seq = 0;
|
||||
|
||||
const text = el("textarea", {
|
||||
className: "m01lab-new__text",
|
||||
attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") },
|
||||
});
|
||||
text.value = SAMPLE;
|
||||
const stale = el("p", {
|
||||
className: "m01-logic__muted",
|
||||
text: tn("Stale"),
|
||||
attrs: { hidden: "" },
|
||||
});
|
||||
const varsHost = el("div", { className: "m01lab-new__vars" });
|
||||
const problemHost = el("div", { className: "m01lab-new__problems" });
|
||||
const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" });
|
||||
const nameBox = createInputField({
|
||||
type: "text",
|
||||
label: tn("Name"),
|
||||
onInput: () => onEdit(),
|
||||
});
|
||||
nameBox.input.setAttribute("data-key", "name");
|
||||
const unitBox = createInputField({
|
||||
type: "text",
|
||||
label: tn("ResultUnit"),
|
||||
placeholder: "원/㎡",
|
||||
onInput: () => onEdit(),
|
||||
});
|
||||
unitBox.input.setAttribute("data-key", "unit");
|
||||
const ownerBox = createInputField({ type: "text", label: tn("Owner") });
|
||||
const state = el("p", { className: "m01-logic__muted" });
|
||||
const save = createButton({ label: tn("Save"), onClick: () => void doSave() });
|
||||
save.setAttribute("data-act", "save");
|
||||
save.disabled = true;
|
||||
|
||||
const head = (): DraftHead | undefined => {
|
||||
const 이름 = nameBox.input.value.trim();
|
||||
const 결과단위 = unitBox.input.value.trim();
|
||||
return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined;
|
||||
};
|
||||
|
||||
/** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */
|
||||
const drawProblems = (problems: DraftProblem[]): void => {
|
||||
if (!analyzed) return problemHost.replaceChildren();
|
||||
const lines = analyzed.split("\n");
|
||||
const flagged = new Map<number, DraftProblem[]>();
|
||||
for (const p of problems) {
|
||||
if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]);
|
||||
}
|
||||
const shown = [...flagged.keys()]
|
||||
.sort((a, b) => a - b)
|
||||
.filter((n) => n <= lines.length)
|
||||
.map((n) => {
|
||||
const raw = lines[n - 1];
|
||||
const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1;
|
||||
return el("div", {
|
||||
className: "m01lab-new__src",
|
||||
children: [
|
||||
el("span", { className: "m01lab-new__no", text: String(n) }),
|
||||
at >= 0
|
||||
? el("code", {
|
||||
children: [
|
||||
raw.slice(0, at),
|
||||
el("mark", { text: raw.slice(at, at + 1) }),
|
||||
raw.slice(at + 1),
|
||||
],
|
||||
})
|
||||
: el("code", { text: raw }),
|
||||
],
|
||||
});
|
||||
});
|
||||
const items = problems.map((p) =>
|
||||
el("li", {
|
||||
className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad",
|
||||
attrs: { "data-kind": p.갈래 },
|
||||
text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`,
|
||||
}),
|
||||
);
|
||||
problemHost.replaceChildren(
|
||||
el("h4", { text: tn("Problems") }),
|
||||
...(items.length
|
||||
? [...shown, el("ul", { children: items })]
|
||||
: [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]),
|
||||
);
|
||||
};
|
||||
|
||||
const drawCalc = (): void => {
|
||||
const row = answer?.logic ?? null;
|
||||
save.disabled = !row || (answer?.문제.length ?? 1) > 0;
|
||||
state.textContent = !answer
|
||||
? ""
|
||||
: answer.문제.some((p) => p.갈래 !== "덜정함")
|
||||
? tn("SaveBlocked")
|
||||
: !head()
|
||||
? tn("NeedHead")
|
||||
: answer.다됨
|
||||
? ""
|
||||
: tn("Undecided");
|
||||
if (!row) {
|
||||
calcSig = "";
|
||||
calcHost.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const sig = JSON.stringify(row.입력 ?? []);
|
||||
if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림
|
||||
calcSig = sig;
|
||||
buildCalc(calcHost, {
|
||||
savedKey: null,
|
||||
row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄
|
||||
dirty: () => true,
|
||||
values,
|
||||
onLines: () => undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const drawVars = (vars: DraftVar[]): void => {
|
||||
rows.clear();
|
||||
const shown = vars.filter((v) => v.갈래 !== "비목합");
|
||||
for (const name of Object.keys(decided)) {
|
||||
if (!shown.some((v) => v.이름 === name)) delete decided[name];
|
||||
}
|
||||
const result = answer?.결과 ?? "";
|
||||
const order = [
|
||||
...shown.filter((v) => v.갈래 === "미정"),
|
||||
...shown.filter((v) => v.갈래 !== "미정"),
|
||||
];
|
||||
for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit }));
|
||||
varsHost.replaceChildren(...[...rows.values()].map((r) => r.root));
|
||||
};
|
||||
|
||||
const evaluate = async (rebuild: boolean): Promise<void> => {
|
||||
if (!analyzed) return;
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const got = await draftLogic({ text: analyzed, decided, head: head() });
|
||||
if (mine !== seq) return; // 최신 응답만
|
||||
answer = got;
|
||||
if (rebuild) drawVars(got.변수);
|
||||
for (const v of got.변수) rows.get(v.이름)?.update(v);
|
||||
drawProblems(got.문제);
|
||||
drawCalc();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
function onEdit(): void {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void evaluate(false), 250);
|
||||
}
|
||||
|
||||
const analyzeButton = createButton({
|
||||
label: tn("Analyze"),
|
||||
onClick: () => {
|
||||
analyzed = text.value;
|
||||
stale.hidden = true;
|
||||
void evaluate(true);
|
||||
},
|
||||
});
|
||||
analyzeButton.setAttribute("data-act", "analyze");
|
||||
text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed));
|
||||
|
||||
async function doSave(): Promise<void> {
|
||||
const h = head();
|
||||
if (!h) return void showToast(tn("NeedHead"), "info");
|
||||
save.disabled = true;
|
||||
showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림
|
||||
try {
|
||||
const owner = ownerBox.input.value.trim();
|
||||
const done = await draftLogic({
|
||||
text: analyzed,
|
||||
decided,
|
||||
head: h,
|
||||
save: true,
|
||||
...(owner ? { owner } : {}),
|
||||
});
|
||||
showToast(tn("Saved", { v: done.key ?? "" }), "success");
|
||||
if (done.key) opts.onSaved(done.key);
|
||||
} catch (error) {
|
||||
const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null;
|
||||
if (detail?.문제) drawProblems(detail.문제);
|
||||
else showToast(error instanceof Error ? error.message : tn("Failed"), "error");
|
||||
save.disabled = false;
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
const section = (title: string, ...children: HTMLElement[]): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m01lab__box m01lab-new__step",
|
||||
children: [el("h3", { text: title }), ...children],
|
||||
});
|
||||
|
||||
return el("div", {
|
||||
className: "m01lab-new",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [
|
||||
el("h2", { text: tn("Title") }),
|
||||
createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }),
|
||||
],
|
||||
}),
|
||||
section(
|
||||
tn("Step1"),
|
||||
el("p", { className: "m01-logic__muted", text: tn("Guide") }),
|
||||
text,
|
||||
el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }),
|
||||
),
|
||||
section(tn("Step2"), varsHost, problemHost),
|
||||
section(
|
||||
tn("Step3"),
|
||||
el("div", {
|
||||
className: "m01lab-new__row",
|
||||
children: [nameBox.root, unitBox.root, ownerBox.root],
|
||||
}),
|
||||
),
|
||||
section(tn("Step4"), calcHost, state, save),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_New_Find.ts
|
||||
* 새 로직 만들기의 고르기 창 둘
|
||||
* ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기
|
||||
* ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchFiles,
|
||||
fetchRows,
|
||||
fetchSubsAll,
|
||||
type FileInfo,
|
||||
type SubInfo,
|
||||
} from "./M01_MasterData_Api_Fetch";
|
||||
import type { DraftFind } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { loadTable } from "./M01_MasterData_UI_Logic_Note";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
|
||||
|
||||
export interface PickedElement {
|
||||
ref: string;
|
||||
/** 이름 + 규격 */
|
||||
name: string;
|
||||
/** 수량에 쓰는 단위(「원/일」 → 「일」) */
|
||||
unit: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
const GROUPS = ["인력", "재료", "기계"];
|
||||
const SHOWN = 50;
|
||||
|
||||
/** 창 뼈대 — 제목 · 닫기 · 몸통 */
|
||||
function shell(title: string, body: HTMLElement[]): { close: () => void } {
|
||||
const close = (): void => backdrop.remove();
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick",
|
||||
attrs: { role: "dialog", "aria-label": title },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [
|
||||
el("h3", { text: title }),
|
||||
createButton({ label: tn("Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
...body,
|
||||
],
|
||||
});
|
||||
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
|
||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
document.body.append(backdrop);
|
||||
return { close };
|
||||
}
|
||||
|
||||
/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */
|
||||
async function fileOf(group: string): Promise<FileInfo | undefined> {
|
||||
const files = await fetchFiles(group);
|
||||
return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0];
|
||||
}
|
||||
|
||||
const options = (values: string[]): { value: string; text: string }[] => [
|
||||
{ value: "", text: tn("All") },
|
||||
...values.map((v) => ({ value: v, text: v })),
|
||||
];
|
||||
|
||||
export function openElementPick(done: (picked: PickedElement) => void): void {
|
||||
let file: FileInfo | undefined;
|
||||
let subs: SubInfo[] = [];
|
||||
const groupBox = createSelectField({
|
||||
options: GROUPS.map((g) => ({ value: g, text: g })),
|
||||
value: GROUPS[0],
|
||||
compact: true,
|
||||
label: tn("Group"),
|
||||
});
|
||||
const subBox = createSelectField({
|
||||
options: options([]),
|
||||
value: "",
|
||||
compact: true,
|
||||
label: tn("Sub"),
|
||||
});
|
||||
const detailBox = createSelectField({
|
||||
options: options([]),
|
||||
value: "",
|
||||
compact: true,
|
||||
label: tn("Detail"),
|
||||
});
|
||||
const searchField = createInputField({ type: "search", placeholder: tn("Search") });
|
||||
const count = el("span", { className: "m01-logic__muted" });
|
||||
const list = el("div", { className: "m01-logic__pick-list" });
|
||||
const win = shell(tn("K_master"), [
|
||||
el("div", {
|
||||
className: "m01-logic__pick-bar",
|
||||
children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count],
|
||||
}),
|
||||
list,
|
||||
]);
|
||||
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
if (!file) return;
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const got = await fetchRows(
|
||||
file.file,
|
||||
1,
|
||||
SHOWN,
|
||||
searchField.input.value.trim(),
|
||||
false,
|
||||
subBox.select.value,
|
||||
detailBox.select.value,
|
||||
);
|
||||
if (mine !== seq) return;
|
||||
count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) });
|
||||
list.replaceChildren(
|
||||
...got.rows.map((row) => {
|
||||
const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim();
|
||||
const unitText = String(row["단위"] ?? "");
|
||||
const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? "");
|
||||
const button = el("button", {
|
||||
className: "m01-logic__pick-row m01-logic__pick-row--button",
|
||||
attrs: { type: "button", "data-ref": String(row["키"]) },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__stack",
|
||||
children: [
|
||||
el("strong", { text: name }),
|
||||
el("span", { className: "m01-logic__muted", text: String(row["키"]) }),
|
||||
],
|
||||
}),
|
||||
el("span", {
|
||||
className: "m01-logic__money",
|
||||
text: `${formatNumber(row["값"])} ${unitText}`.trim(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
button.addEventListener("click", () => {
|
||||
win.close();
|
||||
done({ ref: String(row["키"]), name, unit, group: groupBox.select.value });
|
||||
});
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const changeGroup = async (): Promise<void> => {
|
||||
file = await fileOf(groupBox.select.value);
|
||||
subs = file ? (await fetchSubsAll({ file: file.file })).subs : [];
|
||||
subBox.setOptions(options(subs.map((s) => s.name)), "");
|
||||
detailBox.setOptions(options([]), "");
|
||||
detailBox.select.disabled = true;
|
||||
await load();
|
||||
};
|
||||
groupBox.select.addEventListener("change", () => void changeGroup());
|
||||
subBox.select.addEventListener("change", () => {
|
||||
const details = subs.find((s) => s.name === subBox.select.value)?.details ?? [];
|
||||
detailBox.setOptions(options(details), "");
|
||||
detailBox.select.disabled = details.length === 0;
|
||||
void load();
|
||||
});
|
||||
detailBox.select.addEventListener("change", () => void load());
|
||||
let timer = 0;
|
||||
searchField.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void load(), 250);
|
||||
});
|
||||
void changeGroup();
|
||||
}
|
||||
|
||||
/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */
|
||||
export function openTablePick(done: (find: DraftFind, label: string) => void): void {
|
||||
openPicker("table", "소요량", (item, column) => {
|
||||
void (async () => {
|
||||
const table = await loadTable(item.ref);
|
||||
const conds = Object.keys(table?.조건 ?? {});
|
||||
const label = `${item.이름 ?? item.ref} · ${item.ref} → ${column ?? ""}`;
|
||||
const find = { 표: item.ref, 값칸: column ?? "", 조건: {} as Record<string, string> };
|
||||
if (!conds.length) {
|
||||
done(find, label);
|
||||
return;
|
||||
}
|
||||
const inputs = conds.map((c) => {
|
||||
const box = createInputField({ type: "text", value: c });
|
||||
box.input.setAttribute("data-cond", c);
|
||||
return { c, box };
|
||||
});
|
||||
const win = shell(tn("TableTitle"), [
|
||||
el("p", { className: "m01-logic__muted", text: label }),
|
||||
el("p", { text: tn("Cond") }),
|
||||
...inputs.map(({ c, box }) =>
|
||||
el("label", {
|
||||
className: "m01-logic__field",
|
||||
children: [el("span", { text: c }), box.root],
|
||||
}),
|
||||
),
|
||||
createButton({
|
||||
label: tn("Ok"),
|
||||
onClick: () => {
|
||||
for (const { c, box } of inputs) find.조건[c] = box.input.value.trim() || c;
|
||||
win.close();
|
||||
done(find, label);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
})();
|
||||
});
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_New_Text.ts
|
||||
* 「식으로 새 로직 만들기」 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["식으로 새로 만들기", "New from formula"],
|
||||
Title: ["식으로 새 로직 만들기", "New logic from a formula"],
|
||||
Close: ["닫기", "Close"],
|
||||
Step1: ["① 식 적기", "① Write the formula"],
|
||||
Guide: [
|
||||
"한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀",
|
||||
"One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped",
|
||||
],
|
||||
Analyze: ["분석", "Analyze"],
|
||||
Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"],
|
||||
Step2: ["② 변수 정하기", "② Decide each variable"],
|
||||
Step3: ["③ 이름·단위", "③ Name and unit"],
|
||||
Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"],
|
||||
Problems: ["고칠 곳", "To fix"],
|
||||
Line: ["{n}번 줄", "Line {n}"],
|
||||
AllGood: ["검사에 걸린 것 없음", "No problems found"],
|
||||
Result: ["결과", "Result"],
|
||||
Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"],
|
||||
Kind: ["무엇인가", "What is it"],
|
||||
K_master: ["마스터 요소", "Master element"],
|
||||
K_input: ["설계 입력", "Design input"],
|
||||
K_fixed: ["고정값", "Fixed value"],
|
||||
K_table: ["표 찾기", "Table lookup"],
|
||||
Pick: ["고르기", "Pick"],
|
||||
Repick: ["다시 고르기", "Pick again"],
|
||||
NotPicked: ["아직 안 골랐음", "Not picked"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Cost: ["비목", "Cost item"],
|
||||
CostNone: ["넣지 않음", "None"],
|
||||
CostExtra: ["덧줄 비목", "Extra-line cost item"],
|
||||
Value: ["값", "Value"],
|
||||
Note: ["설명", "Note"],
|
||||
Derived: ["식으로 정해짐", "Set by the formula"],
|
||||
SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"],
|
||||
Name: ["로직 이름", "Logic name"],
|
||||
ResultUnit: ["결과 단위", "Result unit"],
|
||||
Owner: ["소유", "Owner"],
|
||||
Save: ["저장", "Save"],
|
||||
Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"],
|
||||
SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"],
|
||||
NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"],
|
||||
Group: ["종류", "Kind"],
|
||||
Sub: ["구분", "Group"],
|
||||
Detail: ["상세구분", "Detail"],
|
||||
All: ["(전체)", "(All)"],
|
||||
Search: ["이름 찾기", "Search name"],
|
||||
Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"],
|
||||
TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"],
|
||||
TablePick: ["표 고르기", "Pick a table"],
|
||||
TableCol: ["값 칸", "Value column"],
|
||||
Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"],
|
||||
Ok: ["확인", "OK"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Failed: ["못 함", "Failed"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type NewTextKey = keyof typeof TEXT;
|
||||
|
||||
export function tn(key: NewTextKey, fill: Record<string, string | number> = {}): string {
|
||||
const entry = TEXT[key];
|
||||
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
|
||||
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_New_Var.ts
|
||||
* 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름
|
||||
* 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find";
|
||||
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
|
||||
|
||||
export interface VarCtx {
|
||||
decided: Record<string, DraftDecided>;
|
||||
/** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */
|
||||
onEdit: () => void;
|
||||
}
|
||||
export interface VarRow {
|
||||
root: HTMLElement;
|
||||
/** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */
|
||||
update: (v: DraftVar) => void;
|
||||
}
|
||||
|
||||
const KINDS: [string, () => string][] = [
|
||||
["마스터요소", () => tn("K_master")],
|
||||
["설계입력", () => tn("K_input")],
|
||||
["고정값", () => tn("K_fixed")],
|
||||
["표찾기", () => tn("K_table")],
|
||||
];
|
||||
const COSTS = ["노무비", "재료비", "경비"];
|
||||
/** 요소 종류 → 비목 첫 제안 */
|
||||
const COST_OF: Record<string, string> = { 인력: "노무비", 재료: "재료비", 기계: "경비" };
|
||||
|
||||
const costOptions = (): { value: string; text: string }[] => [
|
||||
{ value: "", text: tn("CostNone") },
|
||||
...COSTS.map((c) => ({ value: c, text: c })),
|
||||
];
|
||||
|
||||
const field = (label: string, control: HTMLElement): HTMLElement =>
|
||||
el("label", {
|
||||
className: "m01-logic__field",
|
||||
children: [el("span", { text: label }), control],
|
||||
});
|
||||
|
||||
export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow {
|
||||
const name = v.이름;
|
||||
const info = el("span", { className: "m01-logic__muted m01lab-new__derived" });
|
||||
const body = el("div", { className: "m01lab-new__var-body" });
|
||||
const root = el("div", {
|
||||
className: "m01lab-new__var",
|
||||
attrs: { "data-var": name },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01lab-new__var-head",
|
||||
children: [el("strong", { text: name }), info],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
});
|
||||
const now = (): DraftDecided => (ctx.decided[name] ??= {});
|
||||
const set = (patch: DraftDecided): void => {
|
||||
ctx.decided[name] = { ...now(), ...patch };
|
||||
for (const [k, val] of Object.entries(ctx.decided[name])) {
|
||||
if (val === "" || val === undefined) delete (ctx.decided[name] as Record<string, unknown>)[k];
|
||||
}
|
||||
ctx.onEdit();
|
||||
};
|
||||
const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => {
|
||||
const box = createInputField({
|
||||
type: "text",
|
||||
value: now()[key] ?? "",
|
||||
onInput: (val) => set({ [key]: val.trim() }),
|
||||
});
|
||||
box.input.setAttribute("data-key", key);
|
||||
const cell = field(label, box.root);
|
||||
if (wide) cell.classList.add("m01lab-new__wide");
|
||||
return cell;
|
||||
};
|
||||
|
||||
let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글
|
||||
const draw = (): void => {
|
||||
const kind = now().무엇 ?? "";
|
||||
if (v.갈래 === "비목합") {
|
||||
body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") }));
|
||||
return;
|
||||
}
|
||||
if (v.갈래 === "정의됨") {
|
||||
if (isResult) return body.replaceChildren();
|
||||
const cost = createSelectField({
|
||||
options: costOptions(),
|
||||
value: now().비목 ?? "",
|
||||
compact: true,
|
||||
label: tn("CostExtra"),
|
||||
onChange: (val) => set({ 비목: val }),
|
||||
});
|
||||
body.replaceChildren(cost.root);
|
||||
return;
|
||||
}
|
||||
const kindBox = createSelectField({
|
||||
options: [
|
||||
{ value: "", text: "—" },
|
||||
...KINDS.map(([value, label]) => ({ value, text: label() })),
|
||||
],
|
||||
value: kind,
|
||||
compact: true,
|
||||
label: tn("Kind"),
|
||||
onChange: (val) => {
|
||||
ctx.decided[name] = val ? { 무엇: val, ...(now().단위 ? { 단위: now().단위 } : {}) } : {};
|
||||
picked = "";
|
||||
draw();
|
||||
ctx.onEdit();
|
||||
},
|
||||
});
|
||||
const parts: HTMLElement[] = [kindBox.root];
|
||||
if (kind === "마스터요소") {
|
||||
const cost = createSelectField({
|
||||
options: costOptions(),
|
||||
value: now().비목 ?? "",
|
||||
compact: true,
|
||||
label: tn("Cost"),
|
||||
onChange: (val) => set({ 비목: val }),
|
||||
});
|
||||
const choose = createButton({
|
||||
label: now().요소 ? tn("Repick") : tn("Pick"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
openElementPick((p) => {
|
||||
picked = `${p.name} · ${p.ref}`;
|
||||
set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now().비목 });
|
||||
draw();
|
||||
}),
|
||||
});
|
||||
choose.setAttribute("data-act", "pick-element");
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m01lab-new__picked",
|
||||
children: [choose, el("span", { text: picked || now().요소 || tn("NotPicked") })],
|
||||
}),
|
||||
text(tn("Unit"), "단위"),
|
||||
cost.root,
|
||||
);
|
||||
} else if (kind === "설계입력") {
|
||||
parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true));
|
||||
} else if (kind === "고정값") {
|
||||
parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위"));
|
||||
} else if (kind === "표찾기") {
|
||||
const choose = createButton({
|
||||
label: now().찾기 ? tn("Repick") : tn("TablePick"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
openTablePick((find, label) => {
|
||||
picked = label;
|
||||
set({ 찾기: find });
|
||||
draw();
|
||||
}),
|
||||
});
|
||||
choose.setAttribute("data-act", "pick-table");
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m01lab-new__picked",
|
||||
children: [choose, el("span", { text: picked || tn("NotPicked") })],
|
||||
}),
|
||||
text(tn("Unit"), "단위"),
|
||||
);
|
||||
}
|
||||
body.replaceChildren(...parts);
|
||||
};
|
||||
draw();
|
||||
|
||||
const update = (next: DraftVar): void => {
|
||||
v = next;
|
||||
info.textContent =
|
||||
next.갈래 === "정의됨"
|
||||
? `${tn("Derived")}${next.단위 ? ` · ${next.단위}` : ""}`
|
||||
: (next.단위 ?? "");
|
||||
};
|
||||
update(v);
|
||||
return { root, update };
|
||||
}
|
||||
@@ -81,7 +81,7 @@ const TEXT = {
|
||||
"지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음",
|
||||
"No row matches the current values — change them, or this table is on an unused branch",
|
||||
],
|
||||
Copy_Open: ["본떠 만들기", "Copy as mine"],
|
||||
Copy_Open: ["로직 복사/생성", "Copy / create logic"],
|
||||
Copy_NeedOpen: ["먼저 로직을 고르세요", "Pick a logic first"],
|
||||
Copy_Title: ["복사해서 만들기", "Copy to edit"],
|
||||
Copy_Name: ["새 이름", "New name"],
|
||||
|
||||
@@ -206,6 +206,25 @@ export async function cfLogicValue(
|
||||
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();
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
restParts,
|
||||
linkLabel,
|
||||
midOf,
|
||||
pillInfo,
|
||||
svgAdd as add,
|
||||
svgLabel as label,
|
||||
pillsOf,
|
||||
resolveDrop,
|
||||
showValue,
|
||||
@@ -46,6 +49,7 @@ interface HostState {
|
||||
panX: number;
|
||||
panY: number;
|
||||
fitted: boolean;
|
||||
fitKey?: number;
|
||||
watch?: ResizeObserver;
|
||||
}
|
||||
|
||||
@@ -79,6 +83,9 @@ export function renderDiagram(
|
||||
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 ?? {};
|
||||
@@ -243,7 +250,7 @@ export function renderDiagram(
|
||||
className: "m02-dfdg__ins",
|
||||
children: [
|
||||
...ins.map((p) => pillEl(b, p, "in")),
|
||||
...(b.kind === "function" ? [plusSlot(b)] : []),
|
||||
...(b.kind === "function" && !ro ? [plusSlot(b)] : []),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
@@ -282,13 +289,13 @@ export function renderDiagram(
|
||||
ArrowUp: [0, -step],
|
||||
ArrowDown: [0, step],
|
||||
}[ev.key];
|
||||
if (d && st.selected === b.id) {
|
||||
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) dragBox(ev, b, node);
|
||||
if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node);
|
||||
});
|
||||
return node;
|
||||
};
|
||||
@@ -350,15 +357,8 @@ export function renderDiagram(
|
||||
);
|
||||
} else if (st.pill) {
|
||||
const p = st.pill;
|
||||
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) => endName(p.dir === "in" ? l.from : l.to))
|
||||
.join(", ");
|
||||
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`;
|
||||
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
|
||||
tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") }));
|
||||
if (p.dir === "out")
|
||||
tools.push(el("span", { text: pillInfo(lg, p, values) }));
|
||||
if (p.dir === "out" && !ro)
|
||||
tools.push(
|
||||
sm(
|
||||
"Connect",
|
||||
@@ -371,9 +371,9 @@ export function renderDiagram(
|
||||
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
|
||||
tools.push(
|
||||
el("span", { text: rule ? `${line} · ${rule}` : line }),
|
||||
sm("Disconnect", () => on.unlink(selLink.id), "danger"),
|
||||
...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]),
|
||||
);
|
||||
} else if (selBox) {
|
||||
} else if (selBox && !ro) {
|
||||
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
|
||||
}
|
||||
const legend =
|
||||
@@ -388,18 +388,26 @@ export function renderDiagram(
|
||||
className: "m02-dfdg__bar",
|
||||
children: [
|
||||
el("strong", { className: "m02-dfdg__title", text: tt("Title") }),
|
||||
addBtn("input", "AddInput"),
|
||||
addBtn("function", "AddFunc"),
|
||||
addBtn("output", "AddOutput"),
|
||||
...(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),
|
||||
sm("Arrange", () => {
|
||||
for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y);
|
||||
}),
|
||||
...(ro
|
||||
? []
|
||||
: [
|
||||
sm("Arrange", () =>
|
||||
Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)),
|
||||
),
|
||||
]),
|
||||
],
|
||||
});
|
||||
const root = el("div", {
|
||||
@@ -426,7 +434,7 @@ export function renderDiagram(
|
||||
st.pill = null;
|
||||
redraw();
|
||||
}
|
||||
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
|
||||
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);
|
||||
});
|
||||
@@ -477,32 +485,6 @@ export function renderDiagram(
|
||||
const right = (dir === "out") === (side === "near");
|
||||
return toWorld(right ? r.right : r.left, r.top + r.height / 2);
|
||||
};
|
||||
const add = <K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
tag: K,
|
||||
attrs: Record<string, string>,
|
||||
) => {
|
||||
const n = document.createElementNS(SVG, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
||||
parent.append(n);
|
||||
return n;
|
||||
};
|
||||
const label = (parent: Element, x: number, y: number, text: string, cls: string): number => {
|
||||
const tag = add(parent, "g", { class: cls });
|
||||
const box = add(tag, "rect", { rx: "4", ry: "4" });
|
||||
const t = add(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;
|
||||
};
|
||||
const defs = add(svg, "defs", {});
|
||||
const arrow = add(defs, "marker", {
|
||||
id: "m02-dfdg-arrow",
|
||||
@@ -657,7 +639,7 @@ export function renderDiagram(
|
||||
}
|
||||
|
||||
function dragLink(ev: PointerEvent, from: CfPort): void {
|
||||
if (ev.button !== 0 || spaceDown) return;
|
||||
if (ev.button !== 0 || spaceDown || ro) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const pill = ev.currentTarget as HTMLElement;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Diagram_Parts.ts
|
||||
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
|
||||
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`.
|
||||
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -118,6 +118,10 @@ export interface DiagramOpts {
|
||||
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
|
||||
/** 끊긴 곳 — Eval breaks */
|
||||
breaks?: CfBreak[];
|
||||
/** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */
|
||||
readOnly?: boolean;
|
||||
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
|
||||
fitKey?: number;
|
||||
}
|
||||
|
||||
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
|
||||
@@ -330,6 +334,25 @@ export function restParts(
|
||||
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 한 번만 삼킴 */
|
||||
@@ -360,3 +383,37 @@ export function follow(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
/* 시작 화면 */
|
||||
.m02-df__start {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,11 @@ const loadMods = () =>
|
||||
import("./M02_DF_FuncPanel"),
|
||||
import("./M02_DF_OutputPanel"),
|
||||
import("./M02_DF_Summary"),
|
||||
import("./M02_DF_Sample"),
|
||||
import("./M02_DF_InputPanel"),
|
||||
import("./M02_DF_PillPanel"),
|
||||
import("./M02_DF_LinkPanel"),
|
||||
import("./M02_DF_Standards"),
|
||||
]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({
|
||||
]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({
|
||||
model,
|
||||
ev,
|
||||
api,
|
||||
@@ -60,7 +59,6 @@ const loadMods = () =>
|
||||
func,
|
||||
output,
|
||||
summary,
|
||||
sample,
|
||||
input,
|
||||
pill,
|
||||
line,
|
||||
@@ -78,7 +76,6 @@ const TEXT = {
|
||||
Start_New: ["새로 만들기", "New"],
|
||||
Start_Mine: ["내 로직", "My logics"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
|
||||
Name: ["로직 이름", "Logic name"],
|
||||
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
|
||||
Unit: ["결과 단위", "Result unit"],
|
||||
@@ -217,7 +214,6 @@ function openDataFlow(): void {
|
||||
paper = null;
|
||||
strip.replaceChildren();
|
||||
const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] });
|
||||
const sampleHost = el("div", { className: "m02-df__list" });
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-df__start",
|
||||
@@ -232,28 +228,10 @@ function openDataFlow(): void {
|
||||
mineHost,
|
||||
],
|
||||
}),
|
||||
el("section", {
|
||||
className: "m02-df__start-col",
|
||||
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const M = await mods;
|
||||
sampleHost.replaceChildren(
|
||||
...M.sample.sampleList().map((s) =>
|
||||
listRow(
|
||||
s.name,
|
||||
s.resultUnit,
|
||||
() =>
|
||||
run(async (M2) => {
|
||||
const lg = M2.sample.sampleLoad(s.id);
|
||||
if (lg) await showPaper(M2, lg, false);
|
||||
}),
|
||||
s.note,
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
|
||||
mineHost.replaceChildren(
|
||||
@@ -379,7 +357,7 @@ function openDataFlow(): void {
|
||||
if (sig === logicSig) return;
|
||||
logicSig = sig;
|
||||
const get = async (key: string): Promise<CfLogic | null> =>
|
||||
key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key);
|
||||
key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null);
|
||||
void loadLogics(lg, get).then((got) => {
|
||||
if (sig !== logicSig) return;
|
||||
logics = got.logics;
|
||||
|
||||
@@ -236,19 +236,28 @@ export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string
|
||||
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) {
|
||||
if (b.kind !== "input" || !b.input) continue;
|
||||
if (b.input.mode === "variable")
|
||||
for (const v of b.input.variables) designs.push({ name: v.name, unit: v.unit || "-" });
|
||||
for (const e of b.input.elements)
|
||||
if (e.mode === "choice" || e.mode === "rule")
|
||||
designs.push({ name: e.slot || e.name, unit: e.unit || "-" });
|
||||
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 {
|
||||
@@ -256,9 +265,93 @@ export function logicRefOf(key: string, other: CfLogic): CfLogicRef {
|
||||
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;
|
||||
blocked?: boolean;
|
||||
reasons?: string[];
|
||||
}
|
||||
|
||||
/** 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 원문번호 · 키 · 구분 · 상세구분 · 띄어쓰기는 가리지 않음) · 원문(구분) · 장(상세구분) 거름 */
|
||||
export function filterLogics(
|
||||
items: MasterLogicBrief[],
|
||||
q: { text: string; book?: string; chapter?: string },
|
||||
): MasterLogicBrief[] {
|
||||
const words = q.text.split(/\s+/).map(squash).filter(Boolean);
|
||||
return items.filter((i) => {
|
||||
if (q.book && i.구분 !== q.book) return false;
|
||||
if (q.chapter && i.상세구분 !== q.chapter) return false;
|
||||
const hay = squash(`${i.이름} ${i.원문번호} ${i.키} ${i.구분} ${i.상세구분}`);
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
}
|
||||
|
||||
/** 원문(구분) 목록 · 장(상세구분) 목록 — 나온 차례대로 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `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 {
|
||||
|
||||
@@ -1,42 +1,87 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Logic.ts
|
||||
* 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면
|
||||
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값)
|
||||
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면
|
||||
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
|
||||
* 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
|
||||
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
|
||||
import {
|
||||
filterLogics,
|
||||
logicBooks,
|
||||
logicChapters,
|
||||
logicRefOf,
|
||||
masterRef,
|
||||
pageRange,
|
||||
setKind,
|
||||
setLogicInput,
|
||||
wouldCycle,
|
||||
type MasterLogicBrief,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
|
||||
const TEXT = {
|
||||
Help: [
|
||||
"저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀",
|
||||
"Use a saved logic or sample as an input",
|
||||
"마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
|
||||
"Use any master logic or a logic of mine as an input",
|
||||
],
|
||||
Search: ["로직 이름으로 찾기", "Search by name"],
|
||||
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
|
||||
Book: ["원문", "Source"],
|
||||
Chapter: ["장", "Chapter"],
|
||||
All: ["전체", "All"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
None: ["찾는 로직 없음", "No logic found"],
|
||||
Sample: ["견본", "Sample"],
|
||||
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
|
||||
Prev: ["이전", "Prev"],
|
||||
Next: ["다음", "Next"],
|
||||
Mine: ["내가 만든 것", "Mine"],
|
||||
Blocked: ["읽을 수 없음", "Unreadable"],
|
||||
Chosen: ["고른 로직", "Chosen logic"],
|
||||
Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"],
|
||||
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 use itself"],
|
||||
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): string => TEXT[key][currentLanguageIndex] as string;
|
||||
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 {
|
||||
@@ -46,6 +91,40 @@ const plain = (e: unknown): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const PAGE = 12;
|
||||
const MINE = "흐름도";
|
||||
|
||||
/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */
|
||||
let cache: Promise<MasterLogicBrief[]> | null = null;
|
||||
const loadAll = (): Promise<MasterLogicBrief[]> =>
|
||||
(cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])])
|
||||
.then(([master, mine]) => [
|
||||
...mine
|
||||
.filter((i) => !i.sample)
|
||||
.map((i): MasterLogicBrief => ({
|
||||
file: MINE,
|
||||
구분: MINE,
|
||||
상세구분: t("Mine"),
|
||||
키: i.key,
|
||||
원문번호: "",
|
||||
이름: i.name,
|
||||
결과단위: i.resultUnit,
|
||||
})),
|
||||
...master,
|
||||
])
|
||||
.catch((e: unknown) => ((cache = null), Promise.reject(e))));
|
||||
|
||||
/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
|
||||
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
|
||||
|
||||
/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */
|
||||
async function flowOf(
|
||||
item: Pick<MasterLogicBrief, "file" | "키">,
|
||||
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
|
||||
if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] };
|
||||
return cfLogicFlow(item.file, item.키);
|
||||
}
|
||||
|
||||
/** 다른 로직 입력 판 */
|
||||
export function renderLogicInput(
|
||||
host: HTMLElement,
|
||||
@@ -55,32 +134,86 @@ export function renderLogicInput(
|
||||
): 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 list = el("div", { className: "m02-dfip__list" });
|
||||
let all: CfListItem[] = [];
|
||||
const draw = (): void => {
|
||||
const word = q.input.value.trim().toLowerCase();
|
||||
const rows = all.filter(
|
||||
(i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)),
|
||||
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: "m02-dfip__muted" });
|
||||
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(
|
||||
...(rows.length
|
||||
? rows.map((i) => {
|
||||
...(shown.length
|
||||
? shown.map((i) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row",
|
||||
attrs: { type: "button" },
|
||||
attrs: {
|
||||
type: "button",
|
||||
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
|
||||
},
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: i.name }),
|
||||
el("strong", { text: i.이름 }),
|
||||
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
|
||||
el("span", {
|
||||
className: "m02-dfip__tag",
|
||||
text: i.sample ? t("Sample") : t("Mine"),
|
||||
text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("span", { className: "m02-dfip__muted", text: i.resultUnit }),
|
||||
el("span", { className: "m02-dfip__muted", text: i.결과단위 }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pick(i));
|
||||
@@ -89,56 +222,114 @@ export function renderLogicInput(
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
|
||||
);
|
||||
};
|
||||
const pick = async (i: CfListItem): Promise<void> => {
|
||||
|
||||
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
|
||||
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 {
|
||||
const other = await cfGet(i.key);
|
||||
const next = setLogicInput(lg, boxId, logicRefOf(i.key, other));
|
||||
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 picked = got.converted
|
||||
? masterRef(i.키, got.flow, true, i.결과단위)
|
||||
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
|
||||
const done = setLogicInput(lg, boxId, picked);
|
||||
// 기본 이름이면 고른 로직 이름으로
|
||||
on.change({
|
||||
...next,
|
||||
boxes: next.boxes.map((b) =>
|
||||
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b,
|
||||
...done,
|
||||
boxes: done.boxes.map((b) =>
|
||||
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(plain(e), "error");
|
||||
showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error");
|
||||
}
|
||||
};
|
||||
q.input.addEventListener("input", draw);
|
||||
|
||||
let timer = 0;
|
||||
q.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => ((page = 1), draw()), 200);
|
||||
});
|
||||
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
|
||||
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list);
|
||||
void cfList().then(
|
||||
(items) => ((all = items), draw()),
|
||||
host.replaceChildren(
|
||||
el("p", { className: "m02-dfip__muted", text: t("Help") }),
|
||||
el("div", { className: "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: "m02-dfip__muted", text: plain(e) })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 로직 — 내보내는 알약 · 받는 알약 */
|
||||
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
|
||||
function showChosen(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
ref: NonNullable<CfLogic["boxes"][number]["input"]>["logic"],
|
||||
ref: CfLogicRef,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
if (!ref) return;
|
||||
const pillLine = (p: { name: string; unit: string }): HTMLElement =>
|
||||
const read = readNotes.get(ref.key);
|
||||
const values = new Map<string, HTMLElement>();
|
||||
const errors = el("p", { className: "m02-dfip__muted is-bad" });
|
||||
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", { className: "m02-dfip__name", text: p.name }),
|
||||
el("span", { className: "m02-dfip__muted", text: p.unit || "-" }),
|
||||
el("span", { className: "m02-dfip__name", text: name }),
|
||||
...(valueNode
|
||||
? [valueNode]
|
||||
: extra
|
||||
? [el("span", { className: "m02-dfip__muted", text: extra })]
|
||||
: []),
|
||||
el("span", { className: "m02-dfip__muted", text: unit || "-" }),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
|
||||
...(read?.converted
|
||||
? [
|
||||
el("p", { className: "m02-dfip__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(pillLine)
|
||||
? 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: "m02-dfip__muted", text: t("NoOut") })]),
|
||||
errors,
|
||||
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
|
||||
...(ref.designs.length
|
||||
? ref.designs.map(pillLine)
|
||||
? ref.designs.map((d) =>
|
||||
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
|
||||
)
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]),
|
||||
el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
@@ -158,4 +349,17 @@ function showChosen(
|
||||
],
|
||||
}),
|
||||
);
|
||||
// 값 — 서버 계산 길(받는 알약 예시값으로)
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Paper.ts
|
||||
* 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소
|
||||
* Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(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_Entry.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;
|
||||
/** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */
|
||||
logics?(key: string): Promise<CfLogic | null>;
|
||||
}
|
||||
|
||||
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: "m02-df__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 (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : 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 });
|
||||
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,
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string {
|
||||
maximumFractionDigits: Math.max(0, r.places),
|
||||
});
|
||||
}
|
||||
|
||||
/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */
|
||||
let totalNow: CfRound = { places: 0, method: "버림" };
|
||||
void defaultTotalRound().then((r) => (totalNow = r));
|
||||
export const totalRoundNow = (): CfRound => totalNow;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Sample.ts
|
||||
* 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬)
|
||||
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비
|
||||
* ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비
|
||||
* ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비
|
||||
* ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비
|
||||
* ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직
|
||||
* 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호).
|
||||
* 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음.
|
||||
* 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함
|
||||
* `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
|
||||
const FILES = import.meta.glob<CfLogic>("./_sample/*.json", { eager: true, import: "default" });
|
||||
|
||||
/** 견본마다 한 줄 설명 — [한, 영] */
|
||||
const NOTE: Record<string, readonly [string, string]> = {
|
||||
masonry: [
|
||||
"변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비",
|
||||
"Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost",
|
||||
],
|
||||
ground: [
|
||||
"지반이 토사면 굴삭기로 · 아니면 브레이커로 — 조건 통과 선 둘 → 경비",
|
||||
"Soil goes to the excavator, rock to the breaker — two gate lines → expense",
|
||||
],
|
||||
hume: [
|
||||
"관경으로 흄관 품목을 찾음(재료 자리 · 규격에 관경 포함) → 본수 × 단가 → 재료비",
|
||||
"Pipe size picks the hume pipe item (spec contains size) → count × price → material cost",
|
||||
],
|
||||
linked: [
|
||||
"작업량 로직의 출력을 받아 장비 시간 → 경비",
|
||||
"Takes the output of a productivity logic → machine hours → expense",
|
||||
],
|
||||
value: [
|
||||
"q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)",
|
||||
"q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)",
|
||||
],
|
||||
};
|
||||
|
||||
/** 보여 줄 차례 */
|
||||
const ORDER = ["masonry", "ground", "hume", "linked", "value"];
|
||||
|
||||
export interface CfSampleItem {
|
||||
/** 파일 이름(확장자 뺌) */
|
||||
id: string;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
/** 쉬운 말 한 줄 */
|
||||
note: string;
|
||||
}
|
||||
|
||||
const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, "");
|
||||
const byId = (): Map<string, CfLogic> =>
|
||||
new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg]));
|
||||
|
||||
/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */
|
||||
export function sampleList(): CfSampleItem[] {
|
||||
const all = byId();
|
||||
const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length);
|
||||
return [...all.entries()]
|
||||
.sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name))
|
||||
.map(([id, lg]) => ({
|
||||
id,
|
||||
name: lg.name,
|
||||
resultUnit: lg.resultUnit,
|
||||
note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */
|
||||
export function sampleLoad(id: string): CfLogic | null {
|
||||
const lg = byId().get(id);
|
||||
return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 ·
|
||||
화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, file] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => undefined) });
|
||||
const cache = {};
|
||||
function load(f) {
|
||||
if (cache[f]) return cache[f].exports;
|
||||
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = (cache[f] = { exports: {} });
|
||||
const req = (id) => {
|
||||
if (id.startsWith(".")) {
|
||||
const p = path.resolve(path.dirname(f), id);
|
||||
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
|
||||
}
|
||||
return stub;
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
return box.exports;
|
||||
}
|
||||
const m = load(file);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round));
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
"""
|
||||
|
||||
ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}}
|
||||
|
||||
|
||||
def box(id_, kind, name, unit="원", **extra):
|
||||
return {
|
||||
"id": id_,
|
||||
"kind": kind,
|
||||
"name": name,
|
||||
"color": "#000",
|
||||
"unit": unit,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def out_box(id_, name, cost, unit="원", round_=None):
|
||||
output = {"cost": cost, "unit": unit}
|
||||
if round_:
|
||||
output["round"] = round_
|
||||
return box(id_, "output", name, unit, output=output)
|
||||
|
||||
|
||||
def func_box(id_, name, out_name, unit="시간"):
|
||||
func = {
|
||||
"sheet": {"id": "s-" + id_, "이름": name, "칸": {}},
|
||||
"inputs": [],
|
||||
"variables": [],
|
||||
"output": {"cell": "B1", "name": out_name, "unit": unit},
|
||||
}
|
||||
return box(id_, "function", name, unit, func=func)
|
||||
|
||||
|
||||
def logic(boxes):
|
||||
return {
|
||||
"key": None,
|
||||
"name": "시험",
|
||||
"resultUnit": "원/㎡",
|
||||
"boxes": boxes,
|
||||
"links": [],
|
||||
"userElements": [],
|
||||
}
|
||||
|
||||
|
||||
def result(outputs, cost, ports=None, errors=None):
|
||||
return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []}
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
node = "node"
|
||||
done = subprocess.run(
|
||||
[node, "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps(jobs),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sample():
|
||||
lg = logic(
|
||||
[
|
||||
out_box("o1", "인건비", "노무비"),
|
||||
out_box("o2", "재료값", "재료비"),
|
||||
out_box("o3", "시간당 작업량", "값", "㎥/시간"),
|
||||
out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}),
|
||||
func_box("f1", "운반 시간", "시간", "시간"),
|
||||
]
|
||||
)
|
||||
ev = result(
|
||||
{"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239},
|
||||
{
|
||||
"노무비": 3216.69,
|
||||
"재료비": 100.04,
|
||||
"경비": 1.239,
|
||||
"값들": {"시간당 작업량": 12.5},
|
||||
"계": 3317.969,
|
||||
},
|
||||
ports={"f1": {"시간": 6.024}},
|
||||
)
|
||||
return run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
|
||||
|
||||
|
||||
def group(rows, key):
|
||||
return next(g for g in rows["groups"] if g["key"] == key)
|
||||
|
||||
|
||||
def test_group_order_and_costs_only(sample):
|
||||
assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"]
|
||||
|
||||
|
||||
def test_line_uses_output_round(sample):
|
||||
# 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음)
|
||||
assert group(sample, "labor")["lines"][0]["text"] == "3,216.6"
|
||||
assert group(sample, "material")["lines"][0]["text"] == "100"
|
||||
# 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림)
|
||||
assert group(sample, "cost")["lines"][0]["text"] == "1.24"
|
||||
|
||||
|
||||
def test_plain_value_is_not_rounded_and_has_no_subtotal(sample):
|
||||
plain = group(sample, "plain")
|
||||
assert plain["lines"][0]["text"] == "12.5"
|
||||
assert plain["lines"][0]["unit"] == "㎥/시간"
|
||||
assert plain["sub"] is None
|
||||
|
||||
|
||||
def test_subtotal_and_total_floor_to_won(sample):
|
||||
assert group(sample, "labor")["sub"] == 3216
|
||||
assert group(sample, "labor")["subText"] == "3,216"
|
||||
assert sample["total"] == 3317
|
||||
assert sample["totalText"] == "3,317"
|
||||
|
||||
|
||||
def test_function_rows(sample):
|
||||
assert sample["funcs"] == [
|
||||
{"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"}
|
||||
]
|
||||
|
||||
|
||||
def test_empty_group_is_hidden():
|
||||
lg = logic([out_box("o1", "인건비", "노무비")])
|
||||
ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10})
|
||||
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
|
||||
assert [g["key"] for g in rows["groups"]] == ["labor"]
|
||||
assert rows["total"] == 10
|
||||
|
||||
|
||||
def test_missing_value_shows_dash():
|
||||
lg = logic([out_box("o1", "인건비", "노무비")])
|
||||
ev = result(
|
||||
{"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"]
|
||||
)
|
||||
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
|
||||
assert group(rows, "labor")["lines"][0]["text"] == "—"
|
||||
assert rows["errors"] == ["끊김"]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`.
|
||||
"""M02 단가산출 다이어그램 견본(35-7) 시험 — 시험 견본 `resources/tester/fixtures_df/costflow/*.json` · `M02_CF_Sample.ts`.
|
||||
|
||||
· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
|
||||
· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진
|
||||
@@ -21,7 +21,7 @@ import pytest
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow"
|
||||
SAMPLE_TS = FOLDER / "M02_CF_Sample.ts"
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df" / "costflow").glob("*.json"))} # fmt: skip
|
||||
|
||||
HOOKS = r"""
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `M02_DF_InputPanel_Build.ts`).
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림. 견본 로직 줄 · 흐름은 이 시험 안에 둠(마스터 내용에 기대지 않음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, file] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
|
||||
const m = box.exports;
|
||||
(async () => {
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = [];
|
||||
for (const j of jobs) {
|
||||
if (j.name === "wouldCycle") {
|
||||
const graph = j.args[2];
|
||||
out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null));
|
||||
} else out.push(await m[j.name](...j.args));
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def call(name: str, *args):
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)[0]
|
||||
|
||||
|
||||
def row(key, name, book, chapter, no="", unit="원/㎡", **more):
|
||||
return {
|
||||
"file": f"로직_{book}.json",
|
||||
"구분": book,
|
||||
"상세구분": chapter,
|
||||
"키": key,
|
||||
"원문번호": no,
|
||||
"이름": name,
|
||||
"결과단위": unit,
|
||||
**more,
|
||||
}
|
||||
|
||||
|
||||
ITEMS = [
|
||||
row("GF000219", "돌쌓기 메쌓기(인력)", "산림품셈", "13장 구조물", "13-4-1"),
|
||||
row("GF000220", "돌쌓기 찰쌓기(인력)", "산림품셈", "13장 구조물", "13-4-2"),
|
||||
row("GF000025", "떼붙임(재배잔디)", "산림품셈", "05장 녹화", "5-12"),
|
||||
row("GC000051", "보행자 안전통로", "건설품셈", "01장 공통", "1-3-9", "원/m"),
|
||||
row("DF-0001", "내 흄관 로직", "흐름도", "내가 만든 것", "", "원/m"),
|
||||
]
|
||||
|
||||
|
||||
def keys(items):
|
||||
return [i["키"] for i in items]
|
||||
|
||||
|
||||
def test_찾기는_띄어쓰기로_나눈_말을_모두_포함():
|
||||
got = call("filterLogics", ITEMS, {"text": "돌쌓기 메쌓기"})
|
||||
assert keys(got) == ["GF000219"]
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "돌쌓기"})) == ["GF000219", "GF000220"]
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "돌 쌓기메쌓기"})) == [
|
||||
"GF000219"
|
||||
] # 띄어쓰기는 가리지 않음
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "13-4"})) == [
|
||||
"GF000219",
|
||||
"GF000220",
|
||||
] # 원문번호
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "gc000051"})) == [
|
||||
"GC000051"
|
||||
] # 번호 · 대소문자
|
||||
assert call("filterLogics", ITEMS, {"text": ""}) == ITEMS
|
||||
|
||||
|
||||
def test_원문_장으로_거름():
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "", "book": "건설품셈"})) == ["GC000051"]
|
||||
got = call("filterLogics", ITEMS, {"text": "", "book": "산림품셈", "chapter": "05장 녹화"})
|
||||
assert keys(got) == ["GF000025"]
|
||||
assert call("filterLogics", ITEMS, {"text": "메쌓기", "book": "건설품셈"}) == []
|
||||
|
||||
|
||||
def test_원문_장_목록은_나온_차례():
|
||||
assert call("logicBooks", ITEMS) == ["산림품셈", "건설품셈", "흐름도"]
|
||||
assert call("logicChapters", ITEMS, "산림품셈") == ["13장 구조물", "05장 녹화"]
|
||||
assert call("logicChapters", ITEMS, "") == [
|
||||
"13장 구조물",
|
||||
"05장 녹화",
|
||||
"01장 공통",
|
||||
"내가 만든 것",
|
||||
]
|
||||
|
||||
|
||||
def box(id_, kind, name, **more):
|
||||
return {
|
||||
"id": id_,
|
||||
"kind": kind,
|
||||
"name": name,
|
||||
"color": "#fff",
|
||||
"unit": "-",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
**more,
|
||||
}
|
||||
|
||||
|
||||
FLOW = {
|
||||
"key": "GF000219",
|
||||
"name": "돌쌓기 메쌓기(인력)",
|
||||
"resultUnit": "원/㎡",
|
||||
"userElements": [],
|
||||
"links": [],
|
||||
"boxes": [
|
||||
box(
|
||||
"b1",
|
||||
"input",
|
||||
"입력",
|
||||
input={
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30},
|
||||
{
|
||||
"name": "돌",
|
||||
"type": "choice",
|
||||
"unit": "",
|
||||
"choices": ["깬돌", "견치돌"],
|
||||
"example": "깬돌",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
box("o1", "output", "노무비", unit="원/㎡", output={"cost": "노무비", "unit": "원/㎡"}),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_흐름_로직_고르기는_출력_상자와_설계_때_정할_것():
|
||||
ref = call("masterRef", "GF000219", FLOW, False, "원/㎡")
|
||||
assert ref["key"] == "GF000219"
|
||||
assert ref["outputs"] == [{"name": "노무비", "unit": "원/㎡"}]
|
||||
assert ref["designs"] == [{"name": "뒷길이", "unit": "㎝"}, {"name": "돌", "unit": "-"}]
|
||||
assert ref["examples"] == {"뒷길이": 30, "돌": "깬돌"}
|
||||
|
||||
|
||||
def test_옛_로직_변환은_노무비_재료비_경비_계_넷():
|
||||
ref = call("masterRef", "GF000219", FLOW, True, "원/㎡")
|
||||
assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"]
|
||||
assert {o["unit"] for o in ref["outputs"]} == {"원/㎡"}
|
||||
assert [d["name"] for d in ref["designs"]] == ["뒷길이", "돌"] # 받는 알약은 흐름 그대로
|
||||
|
||||
|
||||
def test_이어지지_않은_다른_로직_받는_알약도_설계_때_정할_것():
|
||||
inner = {
|
||||
"mode": "logic",
|
||||
"elements": [],
|
||||
"variables": [],
|
||||
"logic": {
|
||||
"key": "GF000250",
|
||||
"outputs": [{"name": "작업량", "unit": "㎥/시간"}],
|
||||
"designs": [{"name": "지역", "unit": "-"}, {"name": "기계", "unit": "-"}],
|
||||
"examples": {"지역": "서울"},
|
||||
},
|
||||
}
|
||||
flow = {**FLOW, "boxes": [*FLOW["boxes"], box("b2", "input", "작업량", input=inner)]}
|
||||
flow["links"] = [
|
||||
{"id": "l1", "from": {"box": "b1", "name": "돌"}, "to": {"box": "b2", "name": "기계"}}
|
||||
]
|
||||
ref = call("logicRefOf", "X", flow)
|
||||
names = [d["name"] for d in ref["designs"]]
|
||||
assert "지역" in names and "기계" not in names # 선이 이어진 받는 알약은 뺌
|
||||
assert ref["examples"]["지역"] == "서울"
|
||||
|
||||
|
||||
def test_자기_자신과_돌고_도는_로직은_막음():
|
||||
graph = {
|
||||
"A": {
|
||||
"boxes": [
|
||||
box(
|
||||
"b",
|
||||
"input",
|
||||
"x",
|
||||
input={"mode": "logic", "elements": [], "variables": [], "logic": {"key": "B"}},
|
||||
)
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"boxes": [
|
||||
box(
|
||||
"b",
|
||||
"input",
|
||||
"x",
|
||||
input={
|
||||
"mode": "logic",
|
||||
"elements": [],
|
||||
"variables": [],
|
||||
"logic": {"key": "SELF"},
|
||||
},
|
||||
)
|
||||
]
|
||||
},
|
||||
"C": {"boxes": []},
|
||||
}
|
||||
assert call("wouldCycle", "SELF", "SELF", graph) is True # 자기 자신
|
||||
assert call("wouldCycle", "SELF", "A", graph) is True # A → B → SELF
|
||||
assert call("wouldCycle", "SELF", "C", graph) is False
|
||||
assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음
|
||||
assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음
|
||||
@@ -646,7 +646,9 @@ def test_쉬는_갈래와_문제_무게() -> None:
|
||||
|
||||
|
||||
def _ground() -> dict:
|
||||
return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8"))
|
||||
return json.loads(
|
||||
(ROOT / "resources" / "tester" / "fixtures_df" / "ground.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _server(lg: dict, values: dict) -> dict:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`.
|
||||
"""M02 단가산출 흐름도 견본(35-9) 시험 — 시험 견본 `resources/tester/fixtures_df/*.json`(36-7 · 화면 견본 목록은 지움).
|
||||
|
||||
견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value)
|
||||
· 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
|
||||
@@ -6,7 +6,7 @@
|
||||
· 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음
|
||||
· 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 ·
|
||||
입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133
|
||||
· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀
|
||||
· 파일 안 씀
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,7 +14,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,8 +21,7 @@ import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow"
|
||||
SAMPLE_TS = FOLDER / "M02_DF_Sample.ts"
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df").glob("*.json"))} # fmt: skip
|
||||
NAMES = ["masonry", "ground", "hume", "linked", "value"]
|
||||
BOX_COLOR = dict(
|
||||
re.findall(
|
||||
@@ -34,40 +32,6 @@ BOX_COLOR = dict(
|
||||
)
|
||||
)
|
||||
|
||||
HOOKS = r"""
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { stripTypeScriptTypes } from "node:module";
|
||||
export async function load(url, ctx, next) {
|
||||
if (!url.endsWith(".ts")) return next(url, ctx);
|
||||
let src = readFileSync(fileURLToPath(url), "utf8");
|
||||
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples");
|
||||
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => {
|
||||
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
|
||||
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
|
||||
});
|
||||
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _node(body: str) -> object:
|
||||
files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()}
|
||||
script = (
|
||||
"import { register } from 'node:module';\n"
|
||||
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
|
||||
"globalThis.__stub = { currentLanguageIndex: 0 };\n"
|
||||
f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n"
|
||||
f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n"
|
||||
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--no-warnings", "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr[-2000:]
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
# ── 알약 · 선 ─────────────────────────────────────────────────────────────
|
||||
def _ports_out(box: dict) -> list[str]:
|
||||
@@ -215,16 +179,6 @@ def test_견본_흐름_규칙_방식() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_견본_목록_열기() -> None:
|
||||
got = _node(
|
||||
"out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });"
|
||||
)
|
||||
assert [x["id"] for x in got["list"]] == NAMES
|
||||
assert all(x["note"] for x in got["list"])
|
||||
assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"]
|
||||
assert got["none"] is None
|
||||
|
||||
|
||||
# ── 값(내부 확인) ─────────────────────────────────────────────────────────
|
||||
@pytest.fixture(scope="module")
|
||||
def master():
|
||||
|
||||
Reference in New Issue
Block a user