Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3

This commit is contained in:
2026-09-19 18:46:04 +09:00
12 changed files with 1954 additions and 7 deletions
+8 -1
View File
@@ -20,6 +20,8 @@ class CalcBody(BaseModel):
book: str
key: str
inputs: dict[str, Any] = {}
row: dict[str, Any] | None = None # 저장 전 고친 로직 줄(없으면 저장된 파일로)
file: str | None = None # 새 로직이면 더할 로직 파일
class Change(BaseModel):
@@ -91,9 +93,14 @@ def get_logic(book: str, key: str) -> dict:
return _call(store.logic, book, key)
@router.get("/elements")
def get_elements(group: str, q: str = "", limit: int = 50) -> dict:
return _call(store.elements, group, q, limit)
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
return _call(store.calc, body.book, body.key, body.inputs)
return _call(store.calc, body.book, body.key, body.inputs, body.row, body.file)
@router.post("/save")
+56 -3
View File
@@ -173,7 +173,8 @@ def logic(book: str, key: str) -> dict:
continue
for row in data.get("") or []:
if str(row.get("열쇠")) == key:
reasons = cm.mf.check_logic(cm.mf.Master(files), book, row)[0]
whole = cm.mf.Master(files)
reasons = cm.mf.check_logic(whole, book, row)[0]
_, version = read(name)
return {
"file": name,
@@ -181,15 +182,67 @@ def logic(book: str, key: str) -> dict:
"logic": row,
"blocked": bool(reasons),
"reasons": reasons,
"prices": _prices(whole, row),
}
raise StoreError(404, f"없는 로직 「{book}:{key}")
def _brief(ref: str, row: dict) -> dict:
return {"ref": ref, **{k: row.get(k) for k in ("이름", "규격", "단위", "", "값칸")}}
def _prices(whole, row: dict) -> dict:
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 열쇠·하위 로직은 계산 때 정해짐."""
out = {}
for item in row.get("호표") or []:
ref = str(item.get("요소", ""))
if item.get("종류") != "로직" and "{" not in ref:
try:
out[ref] = _brief(ref, whole.get(ref))
except cm.mf.FormulaError:
out[ref] = None
return out
def elements(group: str, q: str, limit: int) -> dict:
"""요소 찾기 창 — 그룹 전체에서 이름·열쇠 찾기 · `ref` 는 식에 그대로 넣는 `그룹:원문:열쇠`."""
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}")
hits = []
for name, data in cm.load(folder=FOLDER).items():
if data.get("그룹") != group:
continue
for row in data.get(items_key(data)) or []:
if _hit(row, q):
ref = f"{group}:{data.get('원문')}:{row.get('열쇠')}"
hits.append({**_brief(ref, row), "file": name})
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
# ── 시험 계산 ──────────────────────────────────────────────────────────
def calc(book: str, key: str, inputs: dict) -> dict:
def _swap(files: dict, book: str, key: str, row: dict, file: str | None) -> None:
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함)."""
row = _dec(row)
for name, data in _logic_files(files):
if data.get("원문") == book:
items = data.get("") or []
for i, old in enumerate(items):
if str(old.get("열쇠")) == key:
items[i] = row
return
if file not in files or files[file].get("그룹") != "로직":
raise StoreError(404, f"없는 로직 파일 「{file}")
files[file].setdefault("", []).append(row)
def calc(book: str, key: str, inputs: dict, row: dict | None = None, file: str | None = None):
given = {k: _dec(v) for k, v in inputs.items()}
files = cm.load(folder=FOLDER)
if row is not None:
_swap(files, book, key, row, file)
key = str(row.get("열쇠", key))
try:
result = cm.calc(cm.load(folder=FOLDER), f"{book}:{key}", given)
result = cm.calc(files, f"{book}:{key}", given)
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
if "결과" in result:
@@ -0,0 +1,164 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Api.ts
* 로직 화면이 부르는 서버 길 — 계약 `resources/master_data/_화면_계약.md` · `/api/m01`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 로직 한 줄 — 파일 그대로(한글 칸) · 틀 `_틀.md` 6장 */
export interface LogicInput {
이름: string;
단위?: string;
고르기?: string[];
?: [number, number];
}
export interface HoLine {
종류: string;
요소: string;
이름?: string;
규격?: string;
단위?: string;
수량: string;
비목?: string;
}
export interface NamedFormula {
이름: string;
: string;
비목?: string;
출처?: string;
}
export interface LogicRow {
열쇠: string;
이름: string;
결과단위: string;
출처: string;
소유?: string;
입력: LogicInput[];
중간: NamedFormula[];
호표?: HoLine[];
?: { : string };
덧줄?: NamedFormula[];
끝수?: string | null;
비고?: string;
[extra: string]: unknown;
}
export interface LogicSummary {
file: string;
book: string;
chapter: string;
열쇠: string;
이름: string;
결과단위: string;
출처: string;
blocked: boolean;
reasons: string[];
}
export interface ElementBrief {
ref: string;
file?: string;
이름?: string;
규격?: string;
단위?: unknown;
값?: unknown;
값칸?: Record<string, string> | null;
}
export interface LogicOne {
file: string;
version: string;
logic: LogicRow;
blocked: boolean;
reasons: string[];
prices: Record<string, ElementBrief | null>;
}
export interface CalcLine {
이름: string;
단위: string;
수량: number;
단가: number;
금액: number;
비목: Record<string, number>;
}
export type CalcAnswer =
| {
ok: true;
lines?: CalcLine[];
sums?: Record<string, number>;
result?: unknown;
middle: Record<string, unknown>;
}
| { ok: false; reason: string };
export interface LogicFile {
file: string;
book: string;
chapter: string;
version: string;
}
export interface SaveChange {
op: "edit" | "add" | "delete";
key?: string;
row?: LogicRow;
}
export interface SaveFile {
file: string;
version: string;
changes: SaveChange[];
}
/** 서버가 준 몸과 상태 번호 — 409·422 는 몸(`detail`)을 화면이 풀어 보임 */
export class ApiError extends Error {
constructor(
public status: number,
public detail: unknown,
) {
super(typeof detail === "string" ? detail : JSON.stringify(detail));
}
}
async function request<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
export const fetchLogics = (): Promise<LogicSummary[]> =>
request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics);
export const fetchLogic = (book: string, key: string): Promise<LogicOne> =>
request(`/logic?${query({ book, key })}`);
export const fetchLogicFiles = (): Promise<LogicFile[]> =>
request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then(
(d) => d.files,
);
export const searchElements = (
group: string,
q: string,
): Promise<{ total: number; items: ElementBrief[] }> =>
request(`/elements?${query({ group, q, limit: "100" })}`);
export const runCalc = (body: {
book: string;
key: string;
inputs: Record<string, unknown>;
row?: LogicRow;
file?: string;
}): Promise<CalcAnswer> => request("/calc", body);
export const saveFiles = (
files: SaveFile[],
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files });
@@ -0,0 +1,164 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Calc.ts
* 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로)
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import {
runCalc,
type CalcAnswer,
type CalcLine,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export interface CalcContext {
book: string;
/** 저장된 열쇠 — 새 로직은 null */
savedKey: string | null;
file: string;
row: LogicRow;
dirty: () => boolean;
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines: (lines: CalcLine[] | null) => void;
}
const SUMS = ["노무비", "재료비", "경비", "계"];
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const fields = (ctx.row. ?? []).map((spec) => {
const name = spec.;
let control: HTMLInputElement | HTMLSelectElement;
if (spec.?.length) {
control = el("select", { className: "m01-logic__input" });
for (const option of ["", ...spec.]) {
control.append(el("option", { text: option, attrs: { value: option } }));
}
} else {
control = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
});
if (spec.) control.placeholder = `${spec.[0]} ${spec.[1]}`;
}
control.value = ctx.values[name] ?? "";
control.addEventListener("input", () => (ctx.values[name] = control.value));
control.addEventListener("change", () => (ctx.values[name] = control.value));
const label = spec. ? `${name} (${spec.})` : name;
return el("label", {
className: "m01-logic__field",
children: [el("span", { text: label }), control],
});
});
const out = el("div", { className: "m01-logic__calc-out" });
const run = async (): Promise<void> => {
const inputs: Record<string, unknown> = {};
for (const spec of ctx.row. ?? []) {
const raw = (ctx.values[spec.] ?? "").trim();
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
inputs[spec.] = spec.?.length || Number.isNaN(Number(raw)) ? raw : Number(raw);
}
const draft = ctx.dirty() || ctx.savedKey === null;
try {
const answer = await runCalc({
book: ctx.book,
key: ctx.savedKey ?? ctx.row.,
inputs,
...(draft ? { row: ctx.row, file: ctx.file } : {}),
});
ctx.onLines(answer.ok ? (answer.lines ?? null) : null);
out.replaceChildren(...answerView(answer, draft));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
host.replaceChildren(
el("h3", { text: tx("Calc_Title") }),
...(fields.length
? fields
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
out,
);
}
function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : [];
if (!answer.ok) {
return [
...note,
el("div", {
className: "m01-logic__reasons",
children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: answer.reason })],
}),
];
}
const parts: HTMLElement[] = [...note];
if (answer.lines) {
const rows = answer.lines.map((line) =>
el("tr", {
children: [
el("td", { text: line.이름 }),
el("td", { text: `${formatNumber(line.)} ${line.}` }),
el("td", { className: "m01-logic__money", text: formatNumber(line.) }),
],
}),
);
parts.push(
el("table", {
className: "m01-logic__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [tx("Head_Name"), tx("Ho_Qty"), tx("Ho_Amount")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", { children: rows }),
],
}),
);
}
if (answer.sums) {
parts.push(
el("dl", {
className: "m01-logic__sums",
children: SUMS.flatMap((k) => [
el("dt", { text: k === "계" ? tx("Calc_Sum") : k }),
el("dd", { className: "m01-logic__money", text: formatNumber(answer.sums?.[k] ?? 0) }),
]),
}),
);
}
if (answer.result !== undefined) {
parts.push(
el("dl", {
className: "m01-logic__sums",
children: [
el("dt", { text: tx("Calc_Result") }),
el("dd", { text: formatNumber(answer.result) }),
],
}),
);
}
const middle = Object.entries(answer.middle ?? {});
if (middle.length) {
parts.push(
el("h4", { text: tx("Middle_Title") }),
el("dl", {
className: "m01-logic__sums",
children: middle.flatMap(([k, v]) => [
el("dt", { text: k }),
el("dd", { text: formatNumber(v) }),
]),
}),
);
}
return parts;
}
@@ -0,0 +1,423 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Edit.ts
* 로직 한 줄 고치기 — 실무 일위대가표(호표) 모양
* 머리(이름·결과단위·출처·소유) · 설계에서 받을 값 · 호표 줄 · 중간 값 · 결과 식 · 덧줄 · 끝수
*
* 칸을 고치면 `row` 를 그 자리에서 바꾸고 `onChange` 만 부름(다시 그리지 않음).
* 줄 더하기·지우기 · 요소 고르기처럼 모양이 바뀔 때만 `rerender`.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type {
CalcLine,
ElementBrief,
HoLine,
LogicInput,
LogicRow,
NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export type PickMode = "element" | "table";
export type PickDone = (item: ElementBrief, column?: string) => void;
export interface EditContext {
row: LogicRow;
file: string;
isNew: boolean;
files: string[];
reasons: string[];
prices: Record<string, ElementBrief | null>;
/** 마지막 시험 계산의 줄 — 호표 차례와 같음(덧줄은 뒤에 붙음) */
lines: CalcLine[] | null;
onChange: () => void;
onFile: (file: string) => void;
onPick: (mode: PickMode, group: string, done: PickDone) => void;
}
const KINDS = ["인력", "재료", "기계", "로직"];
const COSTS = ["노무비", "재료비", "경비"];
const COST_OF: Record<string, string> = { : "노무비", : "재료비", : "경비" };
export function formatNumber(value: unknown): string {
if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
if (value === null || value === undefined) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function textBox(value: unknown, onInput: (v: string) => void, className = ""): HTMLInputElement {
const input = el("input", {
className: `m01-logic__input ${className}`,
attrs: { type: "text" },
});
input.value = value === null || value === undefined ? "" : String(value);
input.addEventListener("input", () => onInput(input.value));
return input;
}
function choice(
options: string[],
value: string | undefined,
onPick: (v: string) => void,
): HTMLSelectElement {
const select = el("select", { className: "m01-logic__input" });
for (const option of value && !options.includes(value) ? [value, ...options] : options) {
select.append(el("option", { text: option, attrs: { value: option } }));
}
select.value = value ?? options[0];
select.addEventListener("change", () => onPick(select.value));
return select;
}
function dropButton(onClick: () => void): HTMLButtonElement {
const button = el("button", {
className: "m01-logic__drop",
text: "×",
attrs: { type: "button", title: tx("DeleteRow") },
});
button.addEventListener("click", onClick);
return button;
}
function section(title: string, body: HTMLElement, onAdd?: () => void): HTMLElement {
const head = el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: title })],
});
if (onAdd) head.append(createButton({ label: tx("AddRow"), variant: "ghost", onClick: onAdd }));
return el("section", { className: "m01-logic__section", children: [head, body] });
}
function grid(headers: string[], rows: HTMLElement[][], className: string): HTMLElement {
const table = el("table", { className: `m01-logic__grid ${className}` });
table.append(
el("thead", { children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })] }),
);
const body = el("tbody");
for (const cells of rows)
body.append(el("tr", { children: cells.map((c) => el("td", { children: [c] })) }));
table.append(body);
return el("div", { className: "m01-logic__scroll", children: [table] });
}
export function qtyKind(qty: string): string {
const text = qty.trim();
if (/^-?\d+(\.\d+)?$/.test(text)) return tx("Ho_QtyNumber");
if (text.startsWith("찾기(")) return tx("Ho_QtyTable");
return tx("Ho_QtyFormula");
}
export function buildEditor(host: HTMLElement, ctx: EditContext): void {
const rerender = (): void => {
ctx.onChange();
buildEditor(host, ctx);
};
const row = ctx.row;
const set = (patch: () => void): void => {
patch();
ctx.onChange();
};
host.innerHTML = "";
host.append(head(ctx, set, rerender));
if (ctx.reasons.length) {
host.append(
el("div", {
className: "m01-logic__reasons",
children: [
el("strong", { text: tx("Head_Blocked") }),
...ctx.reasons.map((r) => el("div", { text: r })),
],
}),
);
}
host.append(inputsSection(row, set, rerender));
const money = !("결과" in row) && (row. ?? "").startsWith("원");
if (money) host.append(hoSection(ctx, set, rerender));
host.append(middleSection(row, set, rerender));
if (!money) {
const formula = textBox(row.?. ?? "", (v) =>
set(() => {
row. = { : v };
}),
);
host.append(section(tx("Result_Title"), formula));
} else {
host.append(extraSection(row, set, rerender));
const rounding = textBox(row. ?? "", (v) =>
set(() => {
row. = v.trim() ? v : null;
}),
);
host.append(section(tx("Rounding"), rounding));
}
}
function field(label: string, control: HTMLElement, wide = false): HTMLElement {
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), control],
});
}
function head(ctx: EditContext, set: (p: () => void) => void, rerender: () => void): HTMLElement {
const row = ctx.row;
const file = ctx.isNew
? choice(ctx.files, ctx.file, (v) => ctx.onFile(v))
: el("span", { className: "m01-logic__file", text: ctx.file });
const unit = textBox(row., (v) => set(() => (row. = v)));
unit.addEventListener("change", rerender); // 돈 로직 ↔ 돈 아닌 로직 칸이 바뀜
const note = el("textarea", { className: "m01-logic__input m01-logic__note" });
note.value = row. ?? "";
note.addEventListener("input", () =>
set(() => {
if (note.value.trim()) row. = note.value;
else delete row.;
}),
);
return el("div", {
className: "m01-logic__head",
children: [
field(tx("Head_File"), file, true),
field(
tx("Head_Key"),
textBox(row., (v) => set(() => (row. = v))),
),
field(
tx("Head_Name"),
textBox(row., (v) => set(() => (row. = v))),
),
field(tx("Head_Unit"), unit),
field(
tx("Head_Source"),
textBox(row., (v) => set(() => (row. = v))),
),
field(
tx("Head_Owner"),
choice(["공용", "개인"], row., (v) => set(() => (row. = v))),
),
field(tx("Head_Note"), note, true),
],
});
}
function inputsSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row. ?? [];
const rows = list.map((spec: LogicInput, i) => {
const low = textBox(spec.?.[0] ?? "", () => range(), "m01-logic__num");
const high = textBox(spec.?.[1] ?? "", () => range(), "m01-logic__num");
const range = (): void =>
set(() => {
if (low.value.trim() === "" && high.value.trim() === "") delete spec.;
else spec. = [Number(low.value), Number(high.value)];
});
return [
textBox(spec., (v) => set(() => (spec. = v))),
textBox(spec. ?? "", (v) =>
set(() => {
if (v.trim()) spec. = v;
else delete spec.;
}),
),
textBox((spec. ?? []).join(", "), (v) =>
set(() => {
const items = v
.split(",")
.map((x) => x.trim())
.filter(Boolean);
if (items.length) spec. = items;
else delete spec.;
}),
),
el("div", {
className: "m01-logic__range",
children: [low, el("span", { text: "" }), high],
}),
dropButton(() => {
list.splice(i, 1);
rerender();
}),
];
});
const headers = [
tx("Head_Name"),
tx("Inputs_Unit"),
tx("Inputs_Choices"),
tx("Inputs_Range"),
"",
];
return section(tx("Inputs_Title"), grid(headers, rows, "m01-logic__grid--inputs"), () => {
(row. ??= []).push({ : "" });
rerender();
});
}
function priceCell(ctx: EditContext, item: HoLine, i: number): HTMLElement {
const line = ctx.lines?.[i];
if (line) return el("span", { className: "m01-logic__money", text: formatNumber(line.) });
if (item. === "로직" || item..includes("{")) {
return el("span", { className: "m01-logic__muted", text: tx("Ho_PriceLater") });
}
const brief = ctx.prices[item.];
if (brief === undefined) return el("span", { className: "m01-logic__muted", text: "" });
if (brief === null) return el("span", { className: "m01-logic__bad", text: tx("Ho_Missing") });
return el("span", { className: "m01-logic__money", text: formatNumber(brief.) });
}
function hoSection(
ctx: EditContext,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = ctx.row. ?? [];
const rows = list.map((item: HoLine, i) => {
const element = textBox(item., (v) => set(() => (item. = v)), "m01-logic__ref");
const pick = createButton({
label: tx("Ho_Pick"),
variant: "ghost",
onClick: () =>
ctx.onPick("element", item., (found) => {
const [group, book] = found.ref.split(":");
const key = found.ref.slice(group.length + book.length + 2);
item. = group === "로직" ? `로직(${book}:${key})` : found.ref;
if (KINDS.includes(group)) item. = group;
item. = found. ?? item.;
if (found.) item. = found.;
else delete item.;
if (!item. && COST_OF[item.]) item. = COST_OF[item.];
if (group !== "로직") ctx.prices[found.ref] = found;
rerender();
}),
});
const brief = ctx.prices[item.];
const spec = item. ?? brief?. ?? "";
const qty = textBox(item., (v) => {
set(() => (item. = v));
tag.textContent = qtyKind(v);
});
const tag = el("span", { className: "m01-logic__tag", text: qtyKind(item.) });
const table = createButton({
label: tx("Ho_PickTable"),
variant: "ghost",
onClick: () =>
ctx.onPick("table", "소요량", (found, column) => {
const ref = found.ref;
item. = `찾기(${ref})${column ? `.${column}` : ""}`;
rerender();
}),
});
const cost =
item. === "로직" && !item.
? choice(["", ...COSTS], "", (v) => set(() => (item. = v || undefined)))
: choice(COSTS, item., (v) => set(() => (item. = v)));
return [
choice(KINDS, item., (v) => {
item. = v;
rerender();
}),
el("div", { className: "m01-logic__pair", children: [element, pick] }),
el("div", {
className: "m01-logic__stack",
children: [
textBox(item. ?? "", (v) => set(() => (item. = v))),
el("span", { className: "m01-logic__muted", text: spec }),
],
}),
textBox(item. ?? "", (v) => set(() => (item. = v)), "m01-logic__unit"),
el("div", { className: "m01-logic__pair", children: [tag, qty, table] }),
priceCell(ctx, item, i),
el("span", { className: "m01-logic__money", text: formatNumber(ctx.lines?.[i]?. ?? "") }),
cost,
dropButton(() => {
list.splice(i, 1);
rerender();
}),
];
});
const headers = [
tx("Ho_Kind"),
tx("Ho_Element"),
tx("Ho_Name"),
tx("Ho_Unit"),
tx("Ho_Qty"),
tx("Ho_Price"),
tx("Ho_Amount"),
tx("Ho_Cost"),
"",
];
return section(tx("Ho_Title"), grid(headers, rows, "m01-logic__grid--ho"), () => {
(ctx.row. ??= []).push({
: "인력",
: "",
: "",
: "인",
: "",
: "노무비",
});
rerender();
});
}
function formulaRows(
list: NamedFormula[],
set: (p: () => void) => void,
rerender: () => void,
withCost: boolean,
): HTMLElement[][] {
return list.map((item, i) => {
const cells: HTMLElement[] = [
textBox(item., (v) => set(() => (item. = v))),
textBox(item., (v) => set(() => (item. = v)), "m01-logic__formula"),
];
if (withCost) {
cells.push(
choice(COSTS, item., (v) => set(() => (item. = v))),
textBox(item. ?? "", (v) => set(() => (item. = v))),
);
}
cells.push(
dropButton(() => {
list.splice(i, 1);
rerender();
}),
);
return cells;
});
}
function middleSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row. ?? [];
const table = grid(
[tx("Head_Name"), tx("Formula"), ""],
formulaRows(list, set, rerender, false),
"",
);
return section(tx("Middle_Title"), table, () => {
(row. ??= []).push({ : "", : "" });
rerender();
});
}
function extraSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row. ?? [];
const headers = [tx("Head_Name"), tx("Formula"), tx("Ho_Cost"), tx("Head_Source"), ""];
return section(
tx("Extra_Title"),
grid(headers, formulaRows(list, set, rerender, true), ""),
() => {
(row. ??= []).push({ : "", : "", : "재료비", : "" });
rerender();
},
);
}
@@ -0,0 +1,155 @@
/* =============================================================================
* M01_MasterData_UI_Logic_List.ts
* 왼쪽 로직 목록 — 원문 · 장 · 이름 찾기 · 막힘만 · 막힘(⛔)·고침(●) 표시
* 거르기는 화면에서(목록은 한 번 받음 · 391 줄 남짓)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export type ListMark = "edited" | "new" | "deleted";
export interface ListItem {
id: string;
book: string;
chapter: string;
key: string;
name: string;
blocked: boolean;
reasons: string[];
}
export interface ListHandle {
root: HTMLElement;
setItems: (items: LogicSummary[]) => void;
/** 저장 안 한 새 로직도 목록 맨 위에 */
setMarks: (marks: Map<string, ListMark>, extra: ListItem[]) => void;
setActive: (id: string | null) => void;
}
export const logicId = (book: string, key: string): string => `${book}\n${key}`;
export function buildList(onOpen: (item: ListItem) => void): ListHandle {
let items: ListItem[] = [];
let extra: ListItem[] = [];
let marks = new Map<string, ListMark>();
let active: string | null = null;
const book = el("select", { className: "m01-logic__input" });
const chapter = el("select", { className: "m01-logic__input" });
const search = el("input", {
className: "m01-logic__input",
attrs: { type: "search", placeholder: tx("List_Search") },
});
const blockedOnly = el("input", { attrs: { type: "checkbox" } });
const count = el("span", { className: "m01-logic__muted" });
const list = el("ul", { className: "m01-logic__list" });
const options = (select: HTMLSelectElement, all: string, values: string[]): void => {
const keep = select.value;
select.replaceChildren(
el("option", { text: all, attrs: { value: "" } }),
...values.map((v) => el("option", { text: v, attrs: { value: v } })),
);
select.value = values.includes(keep) ? keep : "";
};
const chapters = (): void =>
options(chapter, tx("List_AllChapters"), [
...new Set(items.filter((x) => !book.value || x.book === book.value).map((x) => x.chapter)),
]);
const draw = (): void => {
const q = search.value.trim().toLowerCase();
const shown = [...extra, ...items].filter(
(x) =>
(!book.value || x.book === book.value) &&
(!chapter.value || x.chapter === chapter.value) &&
(!blockedOnly.checked || x.blocked) &&
(!q || x.key.toLowerCase().includes(q) || x.name.toLowerCase().includes(q)),
);
count.textContent = tx("List_Count", { n: shown.length });
list.replaceChildren(...shown.slice(0, 500).map(line));
};
const line = (item: ListItem): HTMLElement => {
const mark = marks.get(item.id);
const badges: HTMLElement[] = [];
if (item.blocked) {
badges.push(
el("span", {
className: "m01-logic__badge m01-logic__badge--blocked",
text: `${tx("List_Blocked")}`,
attrs: { title: item.reasons.join("\n") },
}),
);
}
if (mark) {
const label = { edited: "List_Edited", new: "List_New", deleted: "List_Deleted" } as const;
badges.push(el("span", { className: "m01-logic__badge", text: `${tx(label[mark])}` }));
}
const button = el("button", {
className: `m01-logic__item${item.id === active ? " is-active" : ""}`,
attrs: { type: "button", title: item.key },
children: [
el("span", { className: "m01-logic__muted", text: `${item.book} ${item.chapter}` }),
el("span", { text: item.name || item.key }),
el("span", { className: "m01-logic__badges", children: badges }),
],
});
button.addEventListener("click", () => onOpen(item));
return el("li", { children: [button] });
};
book.addEventListener("change", () => {
chapters();
draw();
});
chapter.addEventListener("change", draw);
search.addEventListener("input", draw);
blockedOnly.addEventListener("change", draw);
const root = el("aside", {
className: "m01-logic__side",
children: [
el("div", {
className: "m01-logic__filters",
children: [
book,
chapter,
search,
el("label", {
className: "m01-logic__check",
children: [blockedOnly, el("span", { text: tx("List_BlockedOnly") })],
}),
count,
],
}),
list,
],
});
return {
root,
setItems: (logics) => {
items = logics.map((x) => ({
id: logicId(x.book, x.),
book: x.book,
chapter: x.chapter,
key: x.열쇠,
name: x.이름,
blocked: x.blocked,
reasons: x.reasons,
}));
options(book, tx("List_AllBooks"), [...new Set(items.map((x) => x.book))]);
chapters();
draw();
},
setMarks: (next, more) => {
marks = next;
extra = more;
draw();
},
setActive: (id) => {
active = id;
draw();
},
};
}
@@ -0,0 +1,360 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Page.ts
* 관리자 화면 — 로직 칸. 왼쪽 목록 / 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산
* 진입: 공용 진입 파일이 `mountM01Logic(host)` 한 줄로 붙임(요소 칸은 sub_laptop_3 몫)
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
* ========================================================================== */
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
ApiError,
fetchLogic,
fetchLogicFiles,
fetchLogics,
saveFiles,
type CalcLine,
type ElementBrief,
type LogicFile,
type LogicRow,
type SaveFile,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
interface Draft {
book: string;
file: string;
version: string;
origKey: string | null;
row: LogicRow | null;
}
interface Opened extends Draft {
id: string;
row: LogicRow | null;
original: string | null;
prices: Record<string, ElementBrief | null>;
reasons: string[];
lines: CalcLine[] | null;
values: Record<string, string>;
}
const CACHE_KEY = "m01_logic_drafts";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
try {
return JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "{}") as Record<string, Draft>;
} catch {
return {};
}
}
export async function mountM01Logic(host: HTMLElement): Promise<void> {
let drafts = loadDrafts();
let files: LogicFile[] = [];
let opened: Opened | null = null;
let calcInputs = "";
const editor = el("div", { className: "m01-logic__editor" });
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("aside", { className: "m01-logic__calc" });
const pending = el("span", { className: "m01-logic__muted" });
const list = buildList((item) => void open(item.id, item.book, item.key));
const persist = (): void => {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify(drafts));
} catch {
/* 캐시가 막혀도 화면 안의 고친 것은 남음 */
}
const marks = new Map<string, ListMark>();
const extra: ListItem[] = [];
for (const [id, d] of Object.entries(drafts)) {
marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited");
if (d.origKey === null && d.row) {
extra.push({
id,
book: d.book,
chapter: "",
key: d.row.열쇠,
name: d.row.이름,
blocked: false,
reasons: [],
});
}
}
list.setMarks(marks, extra);
const n = Object.keys(drafts).length;
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
saveButton.disabled = discardButton.disabled = n === 0;
};
const touch = (): void => {
if (!opened?.row) return;
const now = JSON.stringify(opened.row);
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
else drafts[opened.id] = pick(opened, clone(opened.row));
persist();
const inputs = JSON.stringify(opened.row. ?? []);
if (inputs !== calcInputs) drawCalc();
};
const pick = (o: Opened, row: LogicRow | null): Draft => ({
book: o.book,
file: o.file,
version: o.version,
origKey: o.origKey,
row,
});
const drawCalc = (): void => {
if (!opened?.row) {
calc.replaceChildren();
return;
}
const current = opened;
calcInputs = JSON.stringify(current.row?. ?? []);
buildCalc(calc, {
book: current.book,
savedKey: current.origKey,
file: current.file,
row: current.row as LogicRow,
dirty: () => current.id in drafts,
values: current.values,
onLines: (lines) => {
current.lines = lines;
drawEditor();
},
});
};
const drawEditor = (): void => {
list.setActive(opened?.id ?? null);
if (!opened) {
editor.replaceChildren(el("p", { className: "m01-logic__empty", text: tx("Empty") }));
return;
}
if (!opened.row) {
editor.replaceChildren(
el("p", { className: "m01-logic__empty", text: `${tx("List_Deleted")}` }),
);
return;
}
const current = opened;
buildEditor(editor, {
row: current.row as LogicRow,
file: current.file,
isNew: current.origKey === null,
files: files.map((f) => f.file),
reasons: current.reasons,
prices: current.prices,
lines: current.lines,
onChange: touch,
onFile: (file) => {
const f = files.find((x) => x.file === file);
if (!f) return;
Object.assign(current, { file: f.file, book: f.book, version: f.version });
touch();
},
onPick: openPicker,
});
};
const show = (next: Opened | null): void => {
opened = next;
errors.hidden = true;
drawEditor();
drawCalc();
};
const open = async (id: string, book: string, key: string): Promise<void> => {
const draft = drafts[id];
if (draft?.origKey === null) {
show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} });
return;
}
try {
const one = await fetchLogic(book, key);
show({
id,
book,
file: draft?.file ?? one.file,
version: draft?.version ?? one.version,
origKey: key,
row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic),
original: JSON.stringify(one.logic),
prices: one.prices,
reasons: one.reasons,
lines: null,
values: {},
});
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const reload = async (): Promise<void> => {
const [logics, logicFiles] = await Promise.all([fetchLogics(), fetchLogicFiles()]);
files = logicFiles;
list.setItems(logics);
persist();
};
const onNew = (): void => {
const f = files.find((x) => x.file === opened?.file) ?? files[0];
if (!f) return;
const id = `new:${Date.now()}`;
const row: LogicRow = {
: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`,
: "",
: "원/",
: "",
: "공용",
: [],
: [],
: [],
: [],
끝수: null,
};
const next: Opened = {
id,
book: f.book,
file: f.file,
version: f.version,
origKey: null,
row,
original: null,
prices: {},
reasons: [],
lines: null,
values: {},
};
drafts[id] = pick(next, clone(row));
persist();
show(next);
};
const onDelete = async (): Promise<void> => {
if (!opened?.row) return;
if (!(await showConfirmDialog(tx("Confirm_Delete", { v: opened.row.열쇠 }), tx("Bar_Delete"))))
return;
if (opened.origKey === null) {
delete drafts[opened.id];
persist();
show(null);
return;
}
drafts[opened.id] = pick(opened, null);
opened.row = null;
persist();
show(opened);
};
const onDiscard = async (): Promise<void> => {
if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return;
drafts = {};
persist();
const was = opened;
if (was && was.origKey !== null)
await open(logicId(was.book, was.origKey), was.book, was.origKey);
else show(null);
};
const onSave = async (): Promise<void> => {
const byFile = new Map<string, SaveFile>();
for (const d of Object.values(drafts)) {
const part = byFile.get(d.file) ?? { file: d.file, version: d.version, changes: [] };
if (d.origKey === null) {
if (d.row) part.changes.push({ op: "add", row: d.row });
} else if (d.row === null) part.changes.push({ op: "delete", key: d.origKey });
else part.changes.push({ op: "edit", key: d.origKey, row: d.row });
byFile.set(d.file, part);
}
if (!byFile.size) {
showToast(tx("Save_Nothing"), "info");
return;
}
showLoadingOverlay();
try {
await saveFiles([...byFile.values()]);
const was = opened;
drafts = {};
await reload();
showToast(tx("Save_Done"), "success");
if (was?.row) await open(logicId(was.book, was.row.), was.book, was.row.);
else show(null);
} catch (error) {
failed(error);
} finally {
hideLoadingOverlay();
}
};
const failed = (error: unknown): void => {
const detail =
error instanceof ApiError ? (error.detail as { stale?: string[]; errors?: string[] }) : null;
if (error instanceof ApiError && error.status === 409) {
showToast(tx("Save_Stale", { v: (detail?.stale ?? []).join(", ") }), "error");
} else if (error instanceof ApiError && error.status === 422) {
errors.replaceChildren(
el("strong", { text: tx("Save_Errors") }),
...(detail?.errors ?? []).map((x) => el("div", { text: x })),
);
errors.hidden = false;
} else {
showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error");
}
};
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const discardButton = createButton({
label: tx("Bar_Discard"),
variant: "ghost",
onClick: () => void onDiscard(),
});
const bar = el("div", {
className: "m01-logic__bar",
children: [
el("h2", { text: tx("Title") }),
pending,
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }),
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
],
});
host.replaceChildren(
el("div", {
className: "m01-logic",
children: [
list.root,
el("main", { className: "m01-logic__main", children: [bar, errors, editor] }),
calc,
],
}),
);
show(null);
showLoadingOverlay();
try {
await reload();
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
@@ -0,0 +1,109 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Pick.ts
* 요소 찾기 창 — 그룹 전체에서 이름·열쇠로 찾아 호표 줄에 넣음(`GET /elements`)
* 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { searchElements, type ElementBrief } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber, type PickDone, type PickMode } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
const GROUPS: Record<PickMode, string[]> = {
element: ["인력", "재료", "기계", "로직"],
table: ["소요량", "계수"],
};
export function openPicker(mode: PickMode, group: string, done: PickDone): void {
const groups = GROUPS[mode];
const select = el("select", { className: "m01-logic__input" });
for (const g of groups) select.append(el("option", { text: g, attrs: { value: g } }));
select.value = groups.includes(group) ? group : groups[0];
const search = el("input", {
className: "m01-logic__input",
attrs: { type: "search", placeholder: tx("Pick_Search") },
});
const count = el("span", { className: "m01-logic__muted" });
const list = el("div", { className: "m01-logic__pick-list" });
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick",
attrs: { role: "dialog", "aria-label": tx("Pick_Title") },
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: tx("Pick_Title") }),
createButton({ label: tx("Pick_Close"), variant: "ghost", onClick: close }),
],
}),
el("div", { className: "m01-logic__pick-bar", children: [select, search, count] }),
list,
],
});
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());
const choose = (item: ElementBrief, column?: string): void => {
close();
done(item, column);
};
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
try {
const found = await searchElements(select.value, search.value.trim());
if (mine !== seq) return; // 최신 응답만
count.textContent = tx("Pick_Total", { n: found.total });
list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose)));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
let timer = 0;
search.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(load, 250);
});
select.addEventListener("change", load);
document.body.append(backdrop);
search.focus();
void load();
}
function itemRow(
item: ElementBrief,
mode: PickMode,
choose: (item: ElementBrief, column?: string) => void,
): HTMLElement {
const text = el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: `${item. ?? ""} ${item. ?? ""}`.trim() }),
el("span", { className: "m01-logic__muted", text: item.ref }),
],
});
if (mode === "table") {
const columns = Object.keys(item. ?? {});
const chips = columns.map((column) =>
createButton({ label: column, variant: "pill", onClick: () => choose(item, column) }),
);
return el("div", {
className: "m01-logic__pick-row",
children: [text, el("div", { className: "m01-logic__chips", children: chips })],
});
}
const unit = typeof item. === "string" ? item. : "";
const value = typeof item. === "object" && item. !== null ? "{…}" : formatNumber(item.);
const row = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button" },
children: [
text,
el("span", { className: "m01-logic__money", text: `${value} ${unit}`.trim() }),
],
});
row.addEventListener("click", () => choose(item));
return row;
}
@@ -0,0 +1,388 @@
/* M01 로직 칸 — 왼쪽 목록 / 가운데 일위대가표 / 오른쪽 시험 계산 */
.m01-logic [hidden] {
display: none !important;
}
.m01-logic {
display: grid;
grid-template-columns: 240px minmax(0, 1fr) 280px;
gap: var(--spacing-12);
height: 100%;
min-height: 0;
padding: var(--spacing-12);
box-sizing: border-box;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-logic__side,
.m01-logic__main,
.m01-logic__calc {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
overflow: auto;
}
.m01-logic__side,
.m01-logic__calc {
padding: var(--spacing-8);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.m01-logic__filters {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-logic__check {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.m01-logic__list {
margin: 0;
padding: 0;
list-style: none;
overflow: auto;
}
.m01-logic__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-radius: var(--radius-buttons);
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-logic__item:hover {
background: var(--color-paper);
}
.m01-logic__item.is-active {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.m01-logic__badges {
display: flex;
gap: var(--spacing-4);
}
.m01-logic__badge {
font-size: var(--text-caption);
color: var(--color-warning);
}
.m01-logic__badge--blocked {
color: var(--color-danger);
}
.m01-logic__bar {
display: flex;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01-logic__bar h2 {
margin: 0 auto 0 0;
font-size: 18px;
color: var(--color-text);
}
.m01-logic__empty {
color: var(--color-text-muted);
}
.m01-logic__head {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--spacing-8);
}
.m01-logic__field {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m01-logic__field--wide {
grid-column: 1 / -1;
}
.m01-logic__file {
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-logic__input {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m01-logic__note {
min-height: 48px;
resize: vertical;
}
.m01-logic__section {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-logic__section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.m01-logic__section-head h3,
.m01-logic__calc h3 {
margin: 0;
font-size: var(--text-body-sm);
color: var(--color-text);
}
.m01-logic__grid {
width: 100%;
border-collapse: collapse;
}
.m01-logic__grid th,
.m01-logic__grid td {
padding: 2px 4px;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
text-align: left;
}
.m01-logic__grid th {
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-weight: 600;
}
.m01-logic__grid th,
.m01-logic__grid .ui-btn {
white-space: nowrap;
}
.m01-logic__scroll {
overflow-x: auto;
}
.m01-logic__grid--ho {
min-width: 1124px;
table-layout: fixed;
}
.m01-logic__grid--ho th:nth-child(1) {
width: 76px;
}
.m01-logic__grid--ho th:nth-child(2) {
width: 220px;
}
.m01-logic__grid--ho th:nth-child(3) {
width: 150px;
}
.m01-logic__grid--ho th:nth-child(4) {
width: 56px;
}
.m01-logic__grid--ho th:nth-child(5) {
width: 300px;
}
.m01-logic__grid--ho th:nth-child(6),
.m01-logic__grid--ho th:nth-child(7) {
width: 100px;
}
.m01-logic__grid--ho th:nth-child(8) {
width: 90px;
}
.m01-logic__grid--ho th:nth-child(9) {
width: 32px;
}
.m01-logic__pair {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.m01-logic__stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.m01-logic__range {
display: flex;
align-items: center;
gap: 2px;
}
.m01-logic__num {
width: 64px;
}
.m01-logic__tag {
flex: none;
padding: 0 6px;
border-radius: var(--radius-pills);
background: var(--color-mist-violet);
color: var(--color-accent);
font-size: var(--text-caption);
}
.m01-logic__money {
display: block;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.m01-logic__muted {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01-logic__bad {
color: var(--color-danger);
font-size: var(--text-caption);
}
.m01-logic__drop {
border: 0;
background: none;
color: var(--color-text-muted);
font-size: 16px;
cursor: pointer;
}
.m01-logic__drop:hover {
color: var(--color-danger);
}
.m01-logic__reasons {
padding: var(--spacing-8);
border-left: 3px solid var(--color-danger);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-danger);
}
.m01-logic__sums {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px var(--spacing-8);
margin: 0;
}
.m01-logic__sums dt {
color: var(--color-text-secondary);
}
.m01-logic__sums dd {
margin: 0;
text-align: right;
}
.m01-logic__calc-out {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01-logic__backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-ink) 30%, transparent);
}
.m01-logic__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: min(720px, 92vw);
max-height: 80vh;
padding: var(--spacing-16);
border-radius: var(--radius-xl);
background: var(--color-surface-raised);
font-size: var(--text-body-sm);
}
.m01-logic__pick-bar {
display: grid;
grid-template-columns: 120px 1fr auto;
align-items: center;
gap: var(--spacing-8);
}
.m01-logic__pick-list {
overflow: auto;
}
.m01-logic__pick-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
width: 100%;
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-bottom: 1px solid var(--color-border);
background: none;
color: inherit;
font: inherit;
text-align: left;
}
.m01-logic__pick-row--button {
cursor: pointer;
}
.m01-logic__pick-row--button:hover {
background: var(--color-paper);
}
.m01-logic__chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
@@ -0,0 +1,99 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Text.ts
* 로직 화면 글자 — [한국어, 영어] · 공용 사전과 같은 모양 · 창끼리 공용 사전 줄이 겹치지 않게 따로 둠
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["일위대가 로직", "Unit-cost logic"],
List_Search: ["이름 찾기", "Search name"],
List_AllBooks: ["원문 모두", "All sources"],
List_AllChapters: ["장 모두", "All chapters"],
List_BlockedOnly: ["막힘만", "Blocked only"],
List_Count: ["{n} 개", "{n} items"],
List_Blocked: ["막힘", "Blocked"],
List_Edited: ["고침", "Edited"],
List_New: ["새것", "New"],
List_Deleted: ["지움", "Deleted"],
Bar_New: ["로직 새로 만들기", "New logic"],
Bar_Delete: ["로직 지우기", "Delete logic"],
Bar_Save: ["저장", "Save"],
Bar_Discard: ["고친 것 버리기", "Discard edits"],
Bar_Pending: ["저장 안 한 로직 {n} 개", "{n} unsaved logics"],
Empty: ["왼쪽 목록에서 로직을 고르세요", "Pick a logic from the list"],
Head_File: ["파일", "File"],
Head_Key: ["열쇠", "Key"],
Head_Name: ["이름", "Name"],
Head_Unit: ["결과단위", "Result unit"],
Head_Source: ["출처", "Source"],
Head_Owner: ["소유", "Owner"],
Head_Note: ["비고", "Note"],
Head_Blocked: ["막힌 까닭", "Why blocked"],
Inputs_Title: ["설계에서 받을 값", "Values from design"],
Inputs_Unit: ["단위", "Unit"],
Inputs_Choices: ["고르기(쉼표로)", "Choices (comma)"],
Inputs_Range: ["범위", "Range"],
Ho_Title: ["호표", "Unit-cost lines"],
Ho_Kind: ["종류", "Kind"],
Ho_Element: ["요소", "Element"],
Ho_Name: ["이름·규격", "Name · spec"],
Ho_Unit: ["단위", "Unit"],
Ho_Qty: ["수량", "Qty"],
Ho_Price: ["단가", "Unit price"],
Ho_Amount: ["금액", "Amount"],
Ho_Cost: ["비목", "Cost item"],
Ho_Pick: ["찾기", "Find"],
Ho_PickTable: ["표", "Table"],
Ho_QtyNumber: ["수", "Number"],
Ho_QtyTable: ["표 찾기", "Table lookup"],
Ho_QtyFormula: ["식", "Formula"],
Ho_PriceLater: ["계산 때", "At calc"],
Ho_Missing: ["없는 요소", "Missing element"],
Middle_Title: ["중간 값", "Intermediate values"],
Result_Title: ["결과 식(돈 아닌 로직)", "Result formula (non-money)"],
Extra_Title: ["덧줄", "Extra lines"],
Rounding: ["끝수", "Rounding"],
Formula: ["식", "Formula"],
AddRow: ["줄 더하기", "Add line"],
DeleteRow: ["줄 지우기", "Delete line"],
Calc_Title: ["시험 계산", "Test calculation"],
Calc_Run: ["계산", "Calculate"],
Calc_NoInputs: ["받을 값 없음", "No inputs"],
Calc_Draft: ["고친 줄로 계산(저장 전)", "Using unsaved edits"],
Calc_Stopped: ["멈춤", "Stopped"],
Calc_Result: ["결과", "Result"],
Calc_Sum: ["계", "Total"],
Pick_Title: ["요소 찾기", "Find element"],
Pick_Search: ["이름·열쇠", "Name · key"],
Pick_Total: ["{n} 개 중 앞 100 개", "first 100 of {n}"],
Pick_Close: ["닫기", "Close"],
Save_Done: ["저장했음", "Saved"],
Save_Stale: [
"그 사이 파일이 바뀜 — 고친 것을 버리고 다시 여세요: {v}",
"Files changed meanwhile — discard and reopen: {v}",
],
Save_Errors: ["검사에 걸려 저장 안 함", "Check failed — nothing saved"],
Save_Failed: ["저장 못 함", "Save failed"],
Save_Nothing: ["고친 것 없음", "Nothing to save"],
Load_Failed: ["불러오지 못함", "Load failed"],
Confirm_Delete: [
"로직 「{v}」 을 지울까요? [저장] 때 파일에서 빠짐",
"Delete logic “{v}”? Removed on save",
],
Confirm_Discard: ["고친 것을 모두 버릴까요?", "Discard all edits?"],
Confirm_Leave: [
"저장 안 한 로직이 있음 — 다른 로직을 열어도 고친 것은 남음",
"Unsaved logics stay in the draft",
],
New_Key: ["새 로직", "New logic"],
} as const satisfies Record<string, readonly [string, string]>;
export type TextKey = keyof typeof TEXT;
/** 공용 `t()` 와 같은 뜻 — `{n}`·`{v}` 는 채움 */
export function tx(key: TextKey, 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));
}
+6 -3
View File
@@ -21,21 +21,24 @@
| `GET /tables` | `file` · `q` | `{file, version, tables: [{열쇠, 이름, 기준, 출처, 조건, 값칸, count}]}` |
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
| `GET /logics` | `book` · `chapter` · `q` · `blocked`(0/1) | `{logics: [{file, book, chapter, 열쇠, 이름, 결과단위, 출처, blocked, reasons}]}` |
| `GET /logic` | `book` · `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons}` |
| `GET /logic` | `book` · `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 이름, 규격, 단위, 값, 값칸}]}` |
- `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄.
- `q` = 이름 찾기 — `열쇠`·`이름` 에 든 글(대소문자 무시).
- `chapter` = 파일 이름의 장(`공통3장` · `13장` · `01장`) · 없으면 "".
- `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글.
- `prices` = 호표 `요소``{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 열쇠·하위 로직 줄은 빠짐(계산 때 정해짐).
- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 `그룹:원문:열쇠` · 표형 그룹은 `값칸` 이 옴.
## 3. 시험 계산
`POST /calc` 받음 `{book, key, inputs: {이름: 값}}`
`POST /calc` 받음 `{book, key, inputs: {이름: 값}, row?, file?}`
- 됨 — `{ok: true, lines: [{이름, 단위, 수량, 단가, 금액, 비목: {비목: 금액}}], sums: {노무비, 재료비, 경비, 계}, middle: {이름: 값}}`
- 돈 아닌 로직 — `{ok: true, result, middle}`
- 멈춤 — `{ok: false, reason}` (200). 까닭 = 엔진 글 그대로(「입력 「돌」 없음」 · 「값 없음 — 관리자가 채울 값」 …).
- 계산은 저장된 파일로만 — 화면 캐시의 고친 값은 [저장] 뒤에 반영.
- `row` 없음 = 저장된 파일로 계산 · `row` 있음 = 그 로직만 고친 줄로 바꿔(없던 로직이면 `file` 에 더해) 메모리에서 계산 — 파일에 안 씀. 요소 값은 늘 저장된 파일.
## 4. 저장
+22
View File
@@ -163,6 +163,28 @@ def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None:
)
def test_단가_자동_요소_찾기_저장전_시험계산(client: TestClient) -> None:
one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY)
labor = one["prices"]["인력:건설노임:1016"]
assert labor["이름"] == "화약취급공" and labor[""] > 0
found = _get(client, "/api/m01/elements", group="인력", q="보통인부")
assert "인력:건설노임:1002" in [x["ref"] for x in found["items"]]
tables = _get(client, "/api/m01/elements", group="소요량", q="암발파", limit=5)
assert tables["items"] and all(x["값칸"] for x in tables["items"])
raw = (store.FOLDER / one["file"]).read_bytes()
before = _calc(client)["sums"][""]
half = {**one["logic"], "호표": one["logic"]["호표"][:1]} # 보통인부 줄 뺌
res = client.post(
"/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": {}, "row": half}
)
lines = res.json()["lines"]
assert [x["이름"] for x in lines] == ["화약취급공"] and res.json()["sums"][""] < before
new = {**half, "열쇠": "새 로직"}
body = {"book": LOGIC_BOOK, "key": "새 로직", "inputs": {}, "row": new, "file": one["file"]}
assert client.post("/api/m01/calc", json=body).json()["ok"] is True
assert (store.FOLDER / one["file"]).read_bytes() == raw # 시험 계산은 안 씀
def test_쓰기_모양은_읽은_값과_같음() -> None:
for name in ("소요량_건설품셈_기계설비13장.json", "로직_건설품셈_토목2장.json", LABOR):
data = json.loads((REAL / name).read_text(encoding="utf-8"), parse_float=Decimal)