Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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. 저장
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user