Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_6

This commit is contained in:
2026-10-05 19:43:51 +09:00
16 changed files with 1025 additions and 344 deletions
+20 -3
View File
@@ -183,6 +183,8 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
)
me = {"층": at_[0], "주인": at_[1], "키": key}
for one in lines:
if ref_id(one["참조"]) == ref_id(me):
raise _bad(f"순환 — 자기 자신을 담음 「{key}」", 422)
_no_loop(one["참조"], [me, one["참조"]])
return lines
@@ -219,13 +221,28 @@ def create(kind: str, doc: dict[str, Any]) -> dict[str, Any]:
raise _bad("새 키를 못 잡음 — 다시 누름", 409)
def summary(folder: Path, kind: str, key: str) -> dict[str, Any]:
"""층 목록 한 줄에 붙일 것 — `{제목, 단위}`."""
def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> dict[str, Any]:
"""층 목록 한 줄에 붙일 것 — `{제목, 단위}` · `expand` 면 1 단위 기준 계(`기준금액` · 51-4)."""
try:
doc = json.loads((Path(folder) / kind / f"{key}.json").read_bytes())
except (OSError, ValueError):
return {}
return {"제목": doc.get("이름"), "단위": doc.get("단위")}
out = {"제목": doc.get("이름"), "단위": doc.get("단위")}
if expand is not None:
layer, owner = where(folder)
base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {}
out["기준금액"] = float(base["계"]) if "계" in base else None
return out
def summaries(folder: Path, rows: list[dict[str, Any]]) -> None:
"""층 목록의 서고 줄마다 `summary`(값 포함) — 한 번 셈 캐시를 함께 씀."""
from M01_MasterData import M01_MasterData_Store as m01
expand = Expand(m01.loaded()[1])
for row in rows:
if row["종류"] in layers.LIBRARY_KINDS:
row.update(summary(folder, row["종류"], row["이름"], expand))
# ── 쓰는 곳 · 지우기 ──────────────────────────────────────────────────
@@ -9,7 +9,7 @@
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음)
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = [추가] · 수량 손 입력(기본 1) · 순서 · 삭제
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
@@ -380,6 +380,7 @@ export function mountLaborDetail(
readOnly: !edit,
sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill },
goArea: opt.goArea,
...(lib ? { handOnly: true } : {}),
done: (r) => {
if (r.kind === "keep") return;
if (r.kind === "hand") target.수량 = { 손: r.손 };
@@ -482,10 +483,17 @@ export function mountLaborDetail(
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량: null });
const 수량 = lib === "precombo" ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 });
changed();
},
};
/** 사전조합 [추가] — 서고 줄만 고르기(위 층만 · 자기 자신 · 순환은 [저장] 때 서버 422) */
const addRef = createButton({
label: tc("Lib_AddRow"),
variant: "filled",
onClick: () => openLogicPicker(() => undefined, { ...library, only: true }),
});
const addLogic = createButton({
label: tc("AddLogic"),
variant: "filled",
@@ -580,8 +588,12 @@ export function mountLaborDetail(
],
}),
]),
// 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 고르기는 고르기 창 탭(50-3 ④)
section(tc("Logics_Title"), opt.card || lib ? null : addLogic, logicHost),
// 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = [추가] 서고 줄만 고르기(51-6)
section(
tc("Logics_Title"),
opt.card || promote ? null : lib ? addRef : addLogic,
logicHost,
),
...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]),
...(opt.card || lib ? [] : [dgBox]),
],
@@ -1,8 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Pick.ts
* [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음) ·
* 탭 단가산출 · 단가승격 · 사전조합(50-3 ④) — 서고 탭은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) +
* 그 층 목록 · 고르면 참조 {층 · 주인 · 키} · 프로젝트 · 카드엔 서고 탭 없음
* [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) ·
* 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} ·
* 프로젝트 · 카드엔 서고 줄 없음
* ========================================================================== */
import {
@@ -37,6 +37,8 @@ export interface LibraryPick {
layer: Layer;
owner: string | null;
pick: (ref: LaborRef, name: string) => void;
/** 서고 줄만(사전조합 상세 [추가] · 51-6) — 단가산출 로직 · 구분 거름 없음 */
only?: boolean;
}
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
@@ -65,9 +67,39 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
detail.select.disabled = true;
const searchField = createInputField({ type: "search", placeholder: tc("Pick_Search") });
const list = el("div", { className: "m01-logic__pick-list" });
const hint = el("p", { className: "m01-logic__muted", text: tc("Pick_Hint") });
const hint = el("p", { className: "m01-logic__muted" });
const close = (): void => backdrop.remove();
/* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
let scopes: Scope[] = [];
let scope: Scope | null = null;
let libRows: { kind: "Kind_Promote" | "Kind_Combo"; row: LibraryRow }[] = [];
let loading = false;
let seq = 0;
const pickRow = (
key: string,
kind: string,
title: string,
sub2: string,
unit: string,
): HTMLElement =>
el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-pick": key },
children: [
el("span", { className: "m01-logic__muted", attrs: { "data-pick-kind": "" }, text: kind }),
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: title }),
el("span", { className: "m01-logic__muted", text: sub2 }),
],
}),
el("span", { text: unit }),
],
});
const find = (): void => {
const q = searchField.input.value.trim().toLowerCase();
const found = all.filter(
@@ -76,96 +108,68 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
(!detail.select.value || x.상세구분 === detail.select.value) &&
(!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)),
);
hint.textContent = found.length ? `${found.length} / ${all.length}` : tc("Pick_None");
const libFound = sub.select.value
? []
: libRows.filter((x) => !q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q));
const total = all.length + libRows.length;
const shown = found.length + libFound.length;
hint.textContent = loading ? tc("Loading") : shown ? `${shown} / ${total}` : tc("Pick_None");
list.replaceChildren(
...found.slice(0, 100).map((x) => {
const row = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-pick": x.키 },
children: [
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: x.이름 }),
el("span", {
className: "m01-logic__muted",
text: `${x.키} · ${x.구분} ${x.상세구분}`.trim(),
}),
],
}),
el("span", { text: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}` }),
],
});
const row = pickRow(
x.키,
tc("Kind_Logic"),
x.이름,
`${x.키} · ${x.구분} ${x.상세구분}`.trim(),
`${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`,
);
row.addEventListener("click", () => {
close();
done(x);
});
return row;
}),
);
};
sub.select.addEventListener("change", () => {
const d = subs.find((s) => s.name === sub.select.value)?.details ?? [];
detail.setOptions(opts(d), "");
detail.select.disabled = d.length === 0;
});
/* 서고 탭(50-3 ④) — 탭 · 층 단추 · 목록 */
const filters = el("div", {
className: "m01-logic__filters",
children: [
sub.root,
detail.root,
searchField.root,
createButton({ label: tc("Pick_Find"), variant: "filled", onClick: find }),
],
});
const tabs = el("div", { className: "ui-btn-group", attrs: { "data-pick-tabs": "" } });
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
let tab: "logic" | "promote" | "precombo" = "logic";
let scopes: Scope[] = [];
let scope: Scope | null = null;
let seq = 0;
const libList = async (): Promise<void> => {
if (!lib || !scope || tab === "logic") return;
const mine = ++seq;
const at = scope;
list.replaceChildren();
hint.textContent = tc("Loading");
let rows: LibraryRow[] = [];
try {
rows = await fetchLibrary(at, tab);
} catch (error) {
if (mine === seq)
hint.textContent = error instanceof Error ? error.message : tc("Load_Failed");
return;
}
if (mine !== seq) return;
hint.textContent = rows.length ? `${rows.length}` : tc("Pick_None");
list.replaceChildren(
...rows.map((x) => {
const row = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-pick": x.이름 },
children: [
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: x.제목 || x.이름 }),
el("span", { className: "m01-logic__muted", text: x.이름 }),
],
}),
el("span", { text: x.단위 ?? "" }),
],
});
...libFound.map((x) => {
const at = scope as Scope;
const row = pickRow(
x.row.이름,
tc(x.kind),
x.row.제목 || x.row.이름,
x.row.이름,
x.row.단위 ?? "",
);
row.addEventListener("click", () => {
close();
lib.pick({ ...at, 키: x.이름 }, x.제목 || x.이름);
lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름);
});
return row;
}),
);
};
const loadLib = async (): Promise<void> => {
if (!lib || !scope) return;
const mine = ++seq;
const at = scope;
loading = true;
find();
try {
const [promote, combo] = await Promise.all([
fetchLibrary(at, "promote"),
fetchLibrary(at, "precombo"),
]);
if (mine !== seq) return;
libRows = [
...promote.map((row) => ({ kind: "Kind_Promote" as const, row })),
...combo.map((row) => ({ kind: "Kind_Combo" as const, row })),
];
} catch (error) {
if (mine !== seq) return;
libRows = [];
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
loading = false;
find();
};
const drawLayers = (): void =>
layerBar.replaceChildren(
...scopes.map((x) =>
@@ -175,41 +179,33 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
onClick: () => {
scope = x;
drawLayers();
void libList();
void loadLib();
},
}),
),
);
const drawTabs = (): void => {
tabs.replaceChildren(
...(["logic", "promote", "precombo"] as const).map((k) =>
createButton({
label: tc(
k === "logic" ? "Kind_Logic" : k === "promote" ? "Kind_Promote" : "Lib_Precombo",
),
variant: k === tab ? "filled" : "ghost",
onClick: () => {
tab = k;
drawTabs();
filters.style.display = k === "logic" ? "" : "none"; // 공용 꼴 display 가 hidden 을 이김
layerBar.style.display = k === "logic" ? "none" : "";
if (k === "logic") {
list.replaceChildren();
hint.textContent = tc("Pick_Hint");
} else void libList();
},
}),
),
);
};
sub.select.addEventListener("change", () => {
const d = subs.find((s) => s.name === sub.select.value)?.details ?? [];
detail.setOptions(opts(d), "");
detail.select.disabled = d.length === 0;
find();
});
detail.select.addEventListener("change", find);
searchField.input.addEventListener("input", find);
const filters = el("div", {
className: "m01-logic__filters",
children: [...(lib?.only ? [] : [sub.root, detail.root]), searchField.root],
});
layerBar.style.display = "none";
if (lib)
void upper(lib.layer, lib.owner).then((got) => {
scopes = got;
scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층
if (!got.length) return;
drawTabs();
layerBar.style.display = "";
drawLayers();
void loadLib();
});
const dialog = el("div", {
@@ -223,7 +219,6 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }),
],
}),
tabs,
filters,
layerBar,
hint,
@@ -234,13 +229,14 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
document.body.append(backdrop);
searchField.input.addEventListener("keydown", (ev) => ev.key === "Enter" && find());
searchField.input.focus();
if (lib?.only) return;
void Promise.all([fetchLogicSubs(), fetchLogics()])
.then(([s, l]) => {
subs.push(...s);
all = l;
find();
sub.setOptions(opts(s.map((x) => x.name)), "");
})
.catch((error) =>
@@ -42,6 +42,8 @@ export interface QtyModalOptions {
sheet: { doc: Workbook | null; layer: Layer; column: string; pill: string | null };
/** [면적산출 상세로] — 없으면 단추 없음 */
goArea: GoArea | null;
/** 손 입력만(서고 사전조합 줄 · 51-6) — 면적산출 시트 없음 */
handOnly?: boolean;
done: (result: QtyResult) => void;
}
@@ -104,7 +106,9 @@ export function openQtyModal(opt: QtyModalOptions): void {
className: "m02-cellpick__sheet",
attrs: { "data-labor-qty-sheet": "" },
});
if (opt.sheet.pill) {
if (opt.handOnly) {
// 시트 없음
} else if (opt.sheet.pill) {
sheet = mountAreaSheet(host, {
doc: opt.sheet.doc,
layer: opt.sheet.layer,
@@ -153,8 +157,9 @@ export function openQtyModal(opt: QtyModalOptions): void {
],
}),
state,
el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }),
host,
...(opt.handOnly
? []
: [el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }), host]),
el("div", {
className: "m02-labin__foot",
children: opt.readOnly
@@ -45,11 +45,6 @@ const TEXT = {
Open: ["열기", "Open"],
Pick_Title: ["로직 고르기", "Pick a logic"],
Pick_Search: ["이름·키로 찾기", "Search by name or key"],
Pick_Find: ["찾기", "Find"],
Pick_Hint: [
"구분 → 상세구분을 고르고 [찾기]를 누름 — 하나씩 직접 고름",
"Choose category, detail, then Find — pick one by one",
],
Pick_None: ["찾은 로직 없음", "No logics found"],
Pick_Close: ["닫기", "Close"],
Pick_Blocked: ["막힘", "Blocked"],
@@ -92,7 +87,6 @@ const TEXT = {
Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"],
Labor_LineBase: ["기준", "Base"],
Labor_FromLogic: ["원 로직", "Source logic"],
Lib_Precombo: ["사전조합", "Pre-combination"],
Lib_System: ["마스터", "Master"],
Lib_Company: ["회사", "Company"],
Lib_Personal: ["개인", "Personal"],
@@ -119,6 +113,17 @@ const TEXT = {
In_Cancel: ["취소", "Cancel"],
In_Close: ["닫기", "Close"],
Lib_Name: ["이름", "Name"],
// 서고 목록표(51-4)
Lib_Promote: ["단가산출 승격", "Promoted logic"],
Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"],
Lib_Create: ["생성", "Create"],
Lib_Copy: ["복사", "Copy"],
Lib_Delete: ["삭제", "Delete"],
Lib_Key: ["키", "Key"],
Lib_Unit: ["단위", "Unit"],
Lib_Base: ["기준 금액", "Base amount"],
Lib_Date: ["고친 날", "Modified"],
Lib_AddRow: ["추가", "Add"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
@@ -0,0 +1,229 @@
/* =============================================================================
* M02_MasterTemplete_Library_List.ts
* 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸.
* 표 = 고름 · 키 · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세.
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import {
deleteLayerTemplate,
deleteTemplate,
getActiveOwner,
listTemplates,
newLibrary,
readTemplate,
saveTemplate,
type Layer,
type LibraryBody,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo";
/** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */
type Row = TemplateInfo & { 제목?: string | null; 단위?: string | null; 기준금액?: number | null };
export interface LibraryListOptions {
layer: Layer;
kind: PrecomboKind;
canEdit: boolean;
/** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */
open: (key: string, label: string) => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const money = (v: number | null | undefined): string =>
v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름);
/** `tools` = 상세 머리 단추 자리(페이지 slot) · 돌려준 `destroy` 는 안 씀(칸째 비움) */
export function mountLibraryList(
host: HTMLElement,
tools: HTMLElement,
opt: LibraryListOptions,
): { getDoc: () => null; destroy: () => void } {
let rows: Row[] = [];
let picked: string | null = null;
const body = el("tbody");
const btnNew = createButton({ label: tc("Lib_Create"), variant: "filled" });
const btnCopy = createButton({ label: tc("Lib_Copy"), variant: "ghost" });
const btnDel = createButton({ label: tc("Lib_Delete"), variant: "danger" });
const paintButtons = (): void => {
btnCopy.disabled = btnDel.disabled = !rows.some((r) => r.이름 === picked);
};
const draw = (): void => {
body.replaceChildren(
...(rows.length
? rows.map((row) => {
const radio = el("input", {
attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 },
}) as HTMLInputElement;
radio.checked = row.이름 === picked;
const pick = el("td", { className: "m02-lib__pick", children: [radio] });
pick.addEventListener("click", (event) => {
event.stopPropagation(); // 고름 칸은 열지 않음
picked = row.이름;
radio.checked = true;
paintButtons();
});
const tr = el("tr", {
className: "m02-lib__row",
attrs: { "data-lib-key": row.이름 },
children: [
pick,
...[row.이름, row.제목 ?? "", row.단위 ?? "", money(row.기준금액), row.수정일].map(
(v) => el("td", { text: v, attrs: { title: v } }),
),
],
});
tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row)));
return tr;
})
: [
el("tr", {
children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "6" } })],
}),
]),
);
paintButtons();
};
const load = async (): Promise<void> => {
try {
rows = (await listTemplates(opt.layer, null)).filter((r) => r.종류 === opt.kind) as Row[];
} catch (error) {
rows = [];
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
}
draw();
};
const nameDialog = (title: string, start: string, onOk: (name: string) => Promise<void>) => {
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
name.input.value = start;
openModal({
title,
closeLabel: L("M02_Close"),
dialogClass: "m02-modal",
mount: (box, close) => {
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
ok.addEventListener("click", async () => {
const text = name.input.value.trim();
if (!text) return showToast(L("M02_NameNeeded"), "error");
ok.disabled = true; // 연달아 눌러도 한 번만
try {
await onOk(text);
close();
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
} finally {
ok.disabled = false;
}
});
name.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) ok.click();
});
box.append(name.root, ok);
},
});
};
/** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */
const made = (key: string, label: string): void => {
showToast(L("M02_Created"), "success");
opt.open(key, label);
};
const create = async (body: LibraryBody, label: string): Promise<void> =>
made((await newLibrary(opt.layer, opt.kind, body)).이름, label);
/** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것 · 사전조합 = 이름만 · 빈 묶음 */
btnNew.addEventListener("click", () => {
if (opt.kind === "promote")
return openLogicPicker((x) => {
const doc = {
이름: x.이름,
단위: x.결과단위 || null,
담은로직: [{ 로직: x.키, 입력값: {} }],
};
void create({ 문서: doc }, x.이름).catch((error) =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"),
);
});
nameDialog(L("M02_NewTitle"), "", (name) =>
create({ 문서: { 이름: name, 단위: null, 담은로직: [] } }, name),
);
});
/** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */
btnCopy.addEventListener("click", () => {
const from = rows.find((r) => r.이름 === picked);
if (!from) return;
nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => {
const got = await newLibrary(opt.layer, opt.kind, {
본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 },
});
const doc = await readTemplate(opt.layer, opt.kind, got.이름, null);
await saveTemplate(opt.layer, opt.kind, got.이름, null, got.판, {
...(doc.문서 as object),
이름: name,
});
made(got.이름, name);
});
});
btnDel.addEventListener("click", async () => {
await load(); // 판을 최신으로
const info = rows.find((r) => r.이름 === picked);
if (!info) return;
const label = libraryLabel(info);
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", label)))) return;
try {
if (opt.layer === "system") await deleteTemplate(opt.kind, info.이름, info.판);
else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판);
showToast(L("M02_Deleted"), "success");
picked = null;
await load();
} catch (error) {
// 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로
showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error");
}
});
const heads = ["", "Lib_Key", "Lib_Name", "Lib_Unit", "Lib_Base", "Lib_Date"] as const;
host.replaceChildren(
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
attrs: { "data-lib-list": opt.kind },
children: [
el("table", {
className: "ui-general-block__table m02-lib__table",
children: [
el("thead", {
children: [
el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }),
],
}),
body,
],
}),
],
}),
);
if (opt.canEdit)
tools.replaceChildren(
el("span", { className: "m02-main__tools", children: [btnNew, btnCopy, btnDel] }),
);
void load();
return { getDoc: () => null, destroy: () => undefined };
}
@@ -279,8 +279,8 @@ async def list_layer(
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음
row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])]
if row["종류"] in layers.LIBRARY_KINDS: # 서고(50-3) — 보이는 이름 · 단위
row.update(refs.summary(folder, row["종류"], row["이름"]))
# 서고(50-3) — 보이는 이름 · 단위 · 기준 금액(51-4)
await asyncio.to_thread(refs.summaries, folder, rows)
return {"층": layer, "양식": rows}
@@ -454,6 +454,19 @@ export function createMain(
return empty("");
}
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
if (isPrecombo(next.kind) && !next.name) {
// 서고 목록표(51-4) — 이름 "" · 저장할 것 없음 · 줄 누름 = 그 문서 상세
save.hidden = true;
const { mountLibraryList } = await import("./M02_MasterTemplete_Library_List");
if (mine !== seq) return;
editor = mountLibraryList(host, slot, {
layer: next.layer,
kind: next.kind,
canEdit: canEdit(next.layer, isAdmin),
open: (name, label) => void open({ ...next, name, label }),
});
return;
}
const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
try {
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
@@ -137,12 +137,10 @@ export function buildSide(opt: SideOptions): SideHandle {
// 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인)
const precombo = buildPrecomboSection({
layer,
canEdit: canEdit(layer, opt.isAdmin),
bar: opt.precomboBar ?? [],
confirmLeave: opt.confirmLeave,
onOpen: opt.onOpen,
isOpen: (kind, name) => isPicked(kind, name),
refresh: () => refresh(),
openKind: () => (opt.getOpen()?.layer === layer ? (opt.getOpen()?.kind ?? null) : null),
});
const precomboSec = section(PRECOMBO_TITLE, precombo.body);
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
@@ -221,7 +219,7 @@ export function buildSide(opt: SideOptions): SideHandle {
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
function paint(): void {
paintList();
precombo.paint(items);
precombo.paint();
paintButtons();
if (opt.getOpen()) options.clear();
if (opt.getOpen()) practice?.clear();
@@ -245,7 +243,7 @@ export function buildSide(opt: SideOptions): SideHandle {
if (mine !== seq) return;
paintList();
paintButtons();
precombo.paint(items);
precombo.paint();
}
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
@@ -1,240 +1,68 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side_Precombo.ts
* 좌측 「일위대가 조합」 컨테이너(50-3 ③) — 구조물 조합 아래 · 층 단추(페이지가 만듦) ·
* 하위 둘 「단가승격」(promote · 단가산출 로직 하나를 입력값 고정해 일위대가 꼴로) ·
* 「사전조합」(precombo · 승격 + 다른 사전조합 묶음) — 목록 · 새로 · 복사 · 지우기.
* 상세는 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드 · 도면 · 면적 · 자재 없음).
* 파일 이름 = 키(SP- · SC-) · 화면 이름 = 문서 `이름`(목록 `제목`) · 쓰는 곳 있으면 서버가 지우기를 막음(409 까닭).
* 좌측 「일위대가 조합」 컨테이너(50-3 ③ · 51-4) — 구조물 조합 아래 · 층 단추(페이지가 만듦) ·
* 항목 둘 「단가산출 승격」(promote) · 「일위대가 사전조합」(precombo)만.
* 항목 누름 = 상세 칸에 그 종류 목록표(`Library_List` · 생성 · 복사 · 삭제는 거기) ·
* 표 줄 누름 = 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드).
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import {
deleteLayerTemplate,
deleteTemplate,
getActiveOwner,
newLibrary,
readTemplate,
saveTemplate,
type Kind,
type Layer,
type LibraryBody,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { el } from "@ui/ui_template_elements";
import type { Kind, Layer } from "./M02_MasterTemplete_Api_Fetch";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
export type PrecomboKind = Extract<Kind, "promote" | "precombo">;
/** 종류 · 화면 이름(= 문서 `종류` 글 · 키 SP- · SC- 는 서버가 줌) */
const PARTS: { kind: PrecomboKind; label: string }[] = [
{ kind: "promote", label: "단가승격" },
{ kind: "precombo", label: "사전조합" },
];
const PARTS = [
{ kind: "promote", label: "Lib_Promote" },
{ kind: "precombo", label: "Lib_PrecomboAll" },
] as const;
export const PRECOMBO_TITLE = "일위대가 조합";
export const isPrecombo = (kind: Kind): kind is PrecomboKind =>
kind === "promote" || kind === "precombo";
/** 목록 줄 — 서버가 문서 이름을 `제목` 으로 실어 보냄(없으면 키만) */
type Info = TemplateInfo & { 제목?: string };
export const precomboLabel = (info: Info): string =>
info.제목 ? `${info.이름} ${info.제목}` : info.이름;
export interface PrecomboOptions {
layer: Layer;
canEdit: boolean;
/** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */
bar: HTMLElement[];
confirmLeave: () => Promise<boolean>;
onOpen: (sel: Selection | null) => void;
isOpen: (kind: Kind, name: string) => boolean;
/** 목록 다시 받음(좌측 전체) — 받은 뒤 `paint` 가 불림 */
refresh: () => Promise<void>;
/** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */
openKind: () => Kind | null;
}
export interface PrecomboHandle {
body: HTMLElement;
/** 좌측이 받은 층 목록 중 승격 · 사전조합만 그림 */
paint: (items: TemplateInfo[]) => void;
paint: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const failed = (error: unknown): void =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
let items: Info[] = [];
const lists = new Map<PrecomboKind, HTMLElement>();
const buttons = new Map<PrecomboKind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
const mine = (kind: PrecomboKind): Info[] => items.filter((i) => i.종류 === kind);
const current = (kind: PrecomboKind): Info | undefined =>
mine(kind).find((i) => opt.isOpen(kind, i.이름));
const select = async (info: Info): Promise<void> => {
if (!opt.isOpen(info.종류, info.이름) && !(await opt.confirmLeave())) return;
opt.onOpen({
layer: opt.layer,
projectId: null,
kind: info.종류,
name: info.이름,
label: precomboLabel(info),
const items = PARTS.map((part) => {
const b = el("button", {
className: "m02-side__item ui-sidebar-row",
text: tc(part.label),
attrs: { type: "button", "data-precombo": part.kind },
});
};
/** 새로 — 서버가 그 층 다음 키로 씀 → 엶 */
const create = async (kind: PrecomboKind, body: LibraryBody): Promise<void> => {
const key = (await newLibrary(opt.layer, kind, body)).이름;
showToast(L("M02_Created"), "success");
await opt.refresh();
const info = mine(kind).find((i) => i.이름 === key);
if (info) await select(info);
};
const nameDialog = (title: string, start: string, onOk: (name: string) => Promise<void>) => {
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
name.input.value = start;
openModal({
title,
closeLabel: L("M02_Close"),
dialogClass: "m02-modal",
mount: (body, close) => {
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
ok.addEventListener("click", async () => {
const text = name.input.value.trim();
if (!text) return showToast(L("M02_NameNeeded"), "error");
ok.disabled = true; // 연달아 눌러도 한 번만
try {
await onOk(text);
close();
} catch (error) {
failed(error);
} finally {
ok.disabled = false;
}
});
name.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) ok.click();
});
body.append(name.root, ok);
},
});
};
/** 단가승격 새로 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것(입력값은 상세에서 고정) */
const newPromote = (): void =>
openLogicPicker((x) => {
void opt.confirmLeave().then((go) =>
!go
? undefined
: create("promote", {
문서: {
이름: x.이름,
단위: x.결과단위 || null,
담은로직: [{ 로직: x.키, 입력값: {} }],
},
}).catch(failed),
);
});
/** 사전조합 새로 = 이름만 · 빈 묶음(승격 · 사전조합은 상세 [로직 추가]로) */
const newPrecombo = (): void =>
nameDialog(L("M02_NewTitle"), "", async (name) => {
// 이름 "" = 목록표(UI_Main)
b.addEventListener("click", async () => {
if (!(await opt.confirmLeave())) return;
await create("precombo", { 문서: { 이름: name, 단위: null, 담은로직: [] } });
});
/** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */
const copy = (kind: PrecomboKind): void => {
const from = current(kind);
if (!from) return;
nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => {
if (!(await opt.confirmLeave())) return;
const made = await newLibrary(opt.layer, kind, {
본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 },
opt.onOpen({
layer: opt.layer,
projectId: null,
kind: part.kind,
name: "",
label: tc(part.label),
});
const got = await readTemplate(opt.layer, kind, made.이름, null);
await saveTemplate(opt.layer, kind, made.이름, null, made.판, {
...(got.문서 as object),
이름: name,
});
showToast(L("M02_Created"), "success");
await opt.refresh();
const info = mine(kind).find((i) => i.이름 === made.이름);
if (info) await select(info);
});
};
const remove = async (kind: PrecomboKind): Promise<void> => {
await opt.refresh(); // 저장 직후여도 최신 판으로
const info = current(kind);
if (!info) return;
const name = precomboLabel(info);
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", name)))) return;
try {
if (opt.layer === "system") await deleteTemplate(kind, info.이름, info.판);
else await deleteLayerTemplate(opt.layer, kind, info.이름, info.판);
showToast(L("M02_Deleted"), "success");
opt.onOpen(null);
await opt.refresh();
} catch (error) {
// 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로
showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error");
}
};
const block = (part: (typeof PARTS)[number]): HTMLElement => {
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
lists.set(part.kind, list);
const row: HTMLElement[] = [];
if (opt.canEdit) {
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
buttons.set(part.kind, { copy: btnCopy, del });
btnNew.addEventListener("click", part.kind === "promote" ? newPromote : newPrecombo);
btnCopy.addEventListener("click", () => copy(part.kind));
del.addEventListener("click", () => void remove(part.kind));
row.push(el("div", { className: "m02-side__row", children: [btnNew, btnCopy, del] }));
}
return el("div", {
className: "m02-side__sub",
attrs: { "data-precombo": part.kind },
children: [el("h4", { className: "m02-side__subtitle", text: part.label }), ...row, list],
});
};
return b;
});
const body = el("div", {
className: "m02-side__items ui-sidebar-list",
children: [...opt.bar, ...PARTS.map(block)],
children: [...opt.bar, ...items],
});
function paint(all: TemplateInfo[]): void {
items = all.filter((i) => isPrecombo(i.종류));
for (const part of PARTS) {
const found = mine(part.kind);
lists.get(part.kind)?.replaceChildren(
...(found.length
? found.map((info) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${opt.isOpen(part.kind, info.이름) ? " is-active" : ""}`,
text: precomboLabel(info),
attrs: { type: "button", title: info.수정일, "data-precombo-key": info.이름 },
});
b.addEventListener("click", () => void select(info));
return b;
})
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
);
const b = buttons.get(part.kind);
if (b) b.copy.disabled = b.del.disabled = !current(part.kind);
}
}
const paint = (): void => {
const on = opt.openKind();
items.forEach((b, i) => b.classList.toggle("is-active", PARTS[i].kind === on));
};
return { body, paint };
}
@@ -56,18 +56,18 @@
gap: var(--spacing-4);
}
/* 일위대가 조합 서고(50-3) — 하위 단가승격 · 사전조합 */
.m02-side__sub {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-8);
/* 일위대가 조합 서고 목록표(51-4) — 줄 누름 = 상세 · 첫 칸 = 고름 */
.m02-lib__row {
cursor: pointer;
}
.m02-side__subtitle {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
.m02-lib__row:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.m02-lib__table .m02-lib__pick {
width: 2.5rem;
text-align: center;
}
.m02-main {
@@ -92,3 +92,15 @@
## 오른쪽 패널 높이 (10-5 · 원문 그대로)
하나씩 수정 내용에 대해 요청할께. M01 단가산출 로직 상세와 M02의 구조물 조합의 일위대가와 면적산출에는 우측 패널이 존재함. 이 우측 패널의 높이는 화면에 맞춰서 높이를 결정하게 했으면 함. 스크롤 동작시 움직임.
추가로 너가 오해한게 있는데 구조물 조합의 일위대가 상세페이지에서 단가산출 로직 행을 선택했을때 나오는 상세정보를 해당행이 아래에 별도의 행으로 구분하여 접었다 폈다하면서 내용을 보줬으면 해. 별도의 공간에 M01처럼 보여달라는게 아니라 테이블에 맞춰서 정보를 넣어달라는 의미였어.
## 서고 · 고르기 창 (10-5 · 원문 그대로)
1. 로직고르기 모달창에서 찾기 버튼은 필요없어 보임. 다른 찾기 기능을 보면 없어도 될것같아. 그리고 단가 승격과 사전 조합을 별도의 버튼으로 활성화하는데 이건 그냥 구분정보를 보여줘서 선택을 유도해도 되지 않을까?
2. 일위대가 조합 컨테이너에는 새로, 본떠 만들기, 지우기 버튼은 필요없고 단가산출 승격 이라는 항목과 일위대가 사전조합이라는 항목 두게만 있으면 될것같음. 그리고 해당 항목을 누르면 상세에 테이블형태로 리스트 들을 보여주고 거기에 생성, 복사, 삭제 버튼이 있으면 됨.
3. 사전 조합도 동일함
단위를 선택하게 하는 항목들 중 선택 후 나오는 단위 표기 버튼이 너무 작음 저장같은 버튼과 같은 크기로 변경
사전조합에 상세에서 보여지는 단가산출 로직의 선택 기능이 없음
+34
View File
@@ -323,3 +323,37 @@ def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path):
_promote(world["system"])
got = layers.seed_project(tmp_path / "p1")
assert not any(k.startswith(layers.LIBRARY_KINDS) for k in got["작업본"])
def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world):
sp = _promote(world["system"])
sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋")
rows = [
{"종류": "promote", "이름": sp},
{"종류": "precombo", "이름": sc},
{"종류": "labor", "이름": "rv"},
]
refs.summaries(world["system"], rows)
one = float(Decimal(str(_base(TEN)["계"])) / 10)
assert rows[0]["기준금액"] == pytest.approx(one)
assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3))
assert "기준금액" not in rows[2]
def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world):
sp = _promote(world["system"])
a = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "가")
with store.at(world["system"]):
have = store.read("precombo", a)
assert have["문서"]["담은로직"][0]["수량"] == {"손": 1}
with pytest.raises(store.StoreError) as e:
store.write(
"precombo",
a,
have["판"],
{
**have["문서"],
"담은로직": [*have["문서"]["담은로직"], {"참조": {**SYS, "키": a}}],
},
)
assert e.value.status == 422 and "자기 자신" in e.value.detail
@@ -0,0 +1,171 @@
/* 51-3 — 일위대가 상세 [로직 추가] 고르기 창 한 목록(찾기 단추 · 탭 없음 · 구분 칸 · 치는 대로 걸러짐)
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · item1 있음
* 부르는 법: orca eval --expression "$(tr '
' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
* 공유 마스터에 서고 문서를 안 씀 — 이 탭 fetch 만 바꿔 끼움(층 목록 = 가짜 SP-990001 · SC-990001 더함 · 끝나면 되돌림)
* 하는 일: item1 → 일위대가 [수정] → [로직 추가] → 목록 점검 → "SP-990001" 침 → 고름 → 표 구분 · 삭제(저장 안 함)
* 기대 수치:
* - tabs = 0 · findBtn = 0 · kinds 에 "단가산출" · "단가승격" · "일위조합" 셋 다 · layers = ["마스터"]
* - typed = ["SP-990001"] · typedKinds = ["단가승격"]
* - added.kinds = ["단가승격"] · added.keys = ["SP-990001"] · removed = true · askedLeave = false · back = true
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 8000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await w(150);
}
return null;
};
const n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0;
const SP = { 층: "system", 주인: null, 키: "SP-990001" };
const SC = { 층: "system", 주인: null, 키: "SC-990001" };
const rid = (r) => `참조|${r.층}|${r.주인 ?? ""}|${r.키}`;
const real = window.fetch;
let first = null;
window.fetch = async (url, init) => {
const u = String(url);
if (/\/layers\/system\/templates$/.test(u)) {
const got = await (await real(url, init)).json();
got.양식.push(
{ 종류: "promote", 이름: SP.키, 제목: "시험 승격", 단위: "㎡" },
{ 종류: "precombo", 이름: SC.키, 제목: "시험 조합", 단위: "식" },
);
return new Response(JSON.stringify(got), { status: 200 });
}
if (/\/labor\/[^/]+\/calc/.test(u) && init?.body) {
const body = JSON.parse(init.body);
const refs = (body.doc?.담은로직 ?? []).filter((r) => r.참조);
if (!refs.length) return real(url, init);
body.doc.담은로직 = body.doc.담은로직.filter((r) => !r.참조);
const got = await (await real(url, { ...init, body: JSON.stringify(body) })).json();
first = got.줄[0];
for (const r of refs) {
const base = { ...first.기준 };
const line = {
참조: r.참조,
이름: r.참조.키 === SP.키 ? "시험 승격" : "시험 조합",
알약: rid(r.참조),
분모: "㎡",
배: 1,
기준: base,
반영: null,
수량: null,
수량출처: "없음",
까닭: null,
};
if (r.참조.키 === SP.키) Object.assign(line, { 원로직: first.로직, 줄: first.줄 });
else
line.안 = [{ 참조: SP, 수량: 1, 이름: "시험 승격", 단위: "㎡", 기준: base, 까닭: null }];
got.줄.push(line);
}
return new Response(JSON.stringify(got), { status: 200 });
}
return real(url, init);
};
const out = {};
try {
const node = await until(() => document.querySelector('[data-node="i|item1"]'));
if (!node) return JSON.stringify({ error: "item1 없음" });
node.click();
const card = await until(() =>
[...document.querySelectorAll(".m02-struct__card")].find((c) =>
c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"),
),
);
if (!card) return JSON.stringify({ error: "일위대가 카드 없음" });
[...card.querySelectorAll(".m02-struct__card-tools button")]
.find((b) => b.innerText.trim() === "수정")
?.click();
const table = () =>
[...document.querySelectorAll("table.m02-labrow")].find(
(t) => !t.closest(".m02-struct__card") && t.querySelector("tr[data-labor-logic]"),
);
await until(() => table() && !/…/.test(table().querySelector("tbody").innerText), 12000);
const host = table().closest("[data-labor]");
const before = table().querySelectorAll("tbody tr[data-labor-logic]").length;
[...host.querySelectorAll(".m01-logic__section-head button")]
.find((b) => b.innerText.trim() === "로직 추가")
.click();
const dlg = await until(() => document.querySelector(".m01-logic__pick"));
await until(
() =>
dlg.querySelector(`[data-pick="${SP.키}"]`) &&
[...dlg.querySelectorAll("[data-pick-kind]")].some(
(k) => k.innerText.trim() === "단가산출",
),
);
const kinds = () =>
[...dlg.querySelectorAll("[data-pick-kind]")].map((k) => k.innerText.trim());
out.tabs = dlg.querySelectorAll("[data-pick-tabs]").length;
out.findBtn = [...dlg.querySelectorAll("button")].filter(
(b) => b.innerText.trim() === "찾기",
).length;
out.kinds = [...new Set(kinds())];
out.total = dlg.querySelectorAll("[data-pick]").length;
out.layers = [...dlg.querySelectorAll("[data-pick-layers] button")].map((b) =>
b.innerText.trim(),
);
const search = dlg.querySelector('input[type="search"]');
search.value = "SP-990001";
search.dispatchEvent(new Event("input", { bubbles: true }));
await w(200);
out.typed = [...dlg.querySelectorAll("[data-pick]")].map((b) => b.dataset.pick);
out.typedKinds = kinds();
dlg.querySelector(`[data-pick="${SP.키}"]`).click();
await until(
() =>
table()?.querySelector(`tr[data-labor-logic="${rid(SP)}"]`) &&
!/…/.test(table().querySelector("tbody").innerText),
12000,
);
const rows = [...table().querySelectorAll("tbody tr[data-labor-logic]")];
out.added = {
kinds: rows.slice(-1).map((r) => r.querySelector(".m02-labrow__kind").innerText.trim()),
keys: rows.slice(-1).map((r) => r.querySelector(".m02-labrow__key").innerText.trim()),
optBtn: rows
.slice(-1)
.flatMap((r) =>
[...r.querySelectorAll("button")].filter((b) => b.innerText.trim() === "옵션"),
).length,
};
const open = async (ref) => {
table()
.querySelector(`tr[data-labor-logic="${rid(ref)}"] .m02-labrow__name`)
.click();
await w(300);
return host.querySelector("[data-labor-linedet]");
};
let det = await open(SP);
const grid = det.querySelector("table.m01-logic__grid");
out.promote = {
from: det.querySelector("p")?.innerText.trim(),
rows: grid?.querySelectorAll("tbody tr").length ?? 0,
sum: [...(grid?.querySelectorAll("tbody tr") ?? [])].reduce(
(a, r) => a + n(r.lastElementChild.innerText),
0,
),
firstBase: first?.기준?.계 ?? null,
};
for (const ref of [SP]) {
const tr = table().querySelector(`tr[data-labor-logic="${rid(ref)}"]`);
[...tr.querySelectorAll("button")].find((b) => b.innerText.trim() === "삭제").click();
await w(600);
}
out.removed = table().querySelectorAll("tbody tr[data-labor-logic]").length === before;
} finally {
window.fetch = real;
}
document.querySelector('[data-node="i|item1"]')?.click();
await w(800);
const ask = [...document.querySelectorAll(".ui-modal button, dialog button")].find((b) =>
/확인|버림|떠나기/.test(b.innerText),
);
out.askedLeave = !!ask; /* 저장 안 한 고침이 남았으면 물음 — 지운 뒤라 없어야 함 */
ask?.click();
await w(1200);
out.back = !!document.querySelector(".m02-struct__card table.m02-labrow");
return JSON.stringify(out);
})();
@@ -0,0 +1,153 @@
/* 51-4 — M02 좌측 「일위대가 조합」 = 층 단추 + 항목 둘 · 항목 누름 = 상세 목록표 · 머리 [생성] · [복사] · [삭제]
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 쓰기 없음 — 서고 목록 · 문서 읽기 · 지우기는 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림) · 새로 창은 열고 닫기만
* 기대 수치:
* - side.items = ["단가산출 승격", "일위대가 사전조합"] · side.otherButtons = 0(새로 · 본떠 · 지우기 없음)
* - promote.heads = ["", "키", "이름", "단위", "기준 금액", "고친 날"] · rows = 2 · base = ["1,234.5", ""]
* - promote.tools = ["생성", "복사", "삭제"] · disabledBefore = [false, true, true] · disabledAfter = [false, false, false]
* - promote.active = true(좌측 항목 고름) · picker = true(생성 = 로직 고르기 창)
* - blocked = 쓰는 곳 까닭 알림 · rowsAfterBlocked = 2
* - open = { title: "SP-999901 시험 승격", name: "시험 승격" }(줄 누름 = 그 문서 상세)
* - precombo.rows = 1 · dialog = true(생성 = 이름 창)
*/
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 4000) => {
for (let t = 0; t < ms; t += 100) {
const v = fn();
if (v) return v;
await wait(100);
}
return null;
};
const rows = [
{
종류: "promote",
이름: "SP-999901",
판: "a",
수정일: "2026-10-05T10:00:00",
제목: "시험 승격",
단위: "㎡",
기준금액: 1234.5,
},
{
종류: "promote",
이름: "SP-999902",
판: "b",
수정일: "2026-10-05T11:00:00",
제목: "값 없음",
단위: null,
기준금액: null,
},
{
종류: "precombo",
이름: "SC-999901",
판: "c",
수정일: "2026-10-05T12:00:00",
제목: "시험 묶음",
단위: "개소",
기준금액: 10,
},
];
const doc = {
종류: "단가승격",
키: "SP-999901",
이름: "시험 승격",
단위: "㎡",
담은로직: [{ 로직: "L-0001", 입력값: {} }],
};
const json = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const real = window.fetch;
window.fetch = async (input, init = {}) => {
const url = String(input?.url ?? input);
const method = (init.method ?? "GET").toUpperCase();
if (method === "GET" && /\/m02\/layers\/system\/templates(\?|$)/.test(url)) {
const got = await (await real(input, init)).json();
return json({ ...got, 양식: [...got.양식.filter((r) => !/^S[PC]-/.test(r.이름)), ...rows] });
}
if (method === "GET" && url.includes("/templates/promote/SP-999901"))
return json({ 종류: "promote", 이름: "SP-999901", 판: "a", 문서: doc });
if (method === "DELETE" && /SP-9999/.test(url))
return json({ detail: "쓰는 곳이 있어 못 지움 「SP-999901」 — 일위대가 시험" }, 409);
return real(input, init);
};
try {
const sec = [...document.querySelectorAll(".m02-side__lists > section")].find(
(s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() === "일위대가 조합",
);
const items = [...sec.querySelectorAll("[data-precombo]")];
const side = {
items: items.map((b) => b.textContent),
otherButtons: [
...sec.querySelectorAll(".ui-collapsible__body button, .m02-side__items button"),
].filter((b) => !b.matches("[data-precombo], .m02-layers__btn")).length,
};
const tools = () => [...document.querySelectorAll(".m02-main__slot .m02-main__tools button")];
const list = (kind) => document.querySelector(`[data-lib-list="${kind}"]`);
// 단가산출 승격
items[0].click();
await until(() => list("promote")?.querySelectorAll("[data-lib-key]").length);
const L = list("promote");
const promote = {
title: document.querySelector(".m02-main__title")?.textContent,
active: items[0].classList.contains("is-active"),
heads: [...L.querySelectorAll("th")].map((x) => x.textContent),
rows: L.querySelectorAll("[data-lib-key]").length,
base: [...L.querySelectorAll("[data-lib-key]")].map((tr) => tr.children[4].textContent),
tools: tools().map((b) => b.textContent),
disabledBefore: tools().map((b) => b.disabled),
};
L.querySelector('[data-lib-key="SP-999901"] .m02-lib__pick').click();
await wait(100);
promote.disabledAfter = tools().map((b) => b.disabled);
promote.stillList = !!list("promote"); // 고름 칸 누름은 안 엶
// [생성] = 로직 고르기 창 → 닫기
tools()[0].click();
await wait(400);
const pick = document.querySelector(".m01-logic__pick");
promote.picker = !!pick;
pick?.closest(".m01-logic__backdrop")?.remove();
// [삭제] — 확인 창 [확인] → 쓰는 곳 409 까닭 알림
tools()[2].click();
const okBtn = await until(() =>
document.querySelector(
".ui-confirm__actions .ui-button--danger, .ui-confirm__actions button:last-child",
),
);
okBtn?.click();
const toast = await until(() =>
[...document.querySelectorAll(".ui-toast")]
.map((x) => x.textContent)
.find((t) => t?.includes("쓰는 곳")),
);
const blocked = toast ?? null;
const rowsAfterBlocked = list("promote")?.querySelectorAll("[data-lib-key]").length ?? 0;
// 줄 누름 = 그 문서 상세
list("promote").querySelector('[data-lib-key="SP-999901"] td:nth-child(3)').click();
const nameInput = await until(() => document.querySelector('[data-labor-field="이름"] input'));
const open = {
title: document.querySelector(".m02-main__title")?.textContent,
name: nameInput?.value ?? null,
sideActive: items[0].classList.contains("is-active"),
};
// 일위대가 사전조합
items[1].click();
await until(() => list("precombo")?.querySelectorAll("[data-lib-key]").length);
const precombo = {
rows: list("precombo")?.querySelectorAll("[data-lib-key]").length ?? 0,
active: items[1].classList.contains("is-active") && !items[0].classList.contains("is-active"),
};
tools()[0].click();
await wait(300);
const modal = document.querySelector(".m02-modal");
precombo.dialog = !!modal;
modal?.querySelector(".ui-modal__top button")?.click();
await wait(200);
precombo.dialogClosed = !document.querySelector(".m02-modal");
return JSON.stringify({ side, promote, blocked, rowsAfterBlocked, open, precombo });
} finally {
window.fetch = real;
}
})();
@@ -0,0 +1,208 @@
/* 51-6 — 서고 사전조합 상세(일위대가 상세 서고 모드) 줄 넣는 길
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 쓰기 없음 — 서고 목록 · 문서 읽기 · [저장] PUT 은 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림) · calc 만 서버
* 기대 수치:
* - promote = { add: false, rows: 1, move: 1 }(단가승격 상세는 한 줄 고정 · [옵션]만 · 위로 · 아래로 · 삭제 없음)
* - precombo.add = true · picker = { filters: 0, kinds: ["단가승격", "단가승격", "일위조합"], layers: 1 }
* - added = 2 · again = 「이미 담은」 알림
* - moved = ["SP-999902", "SP-999901"](위로) · qty = { start: "1"(새 줄 기본), sheet: false, after: { 손: 3 } } · addedQty = { 손: 3 }
* - removed = 1 · selfSave = 「순환」 알림(PUT 422) · putBody 에 자기 키 · left = 버림 확인 뒤 목록
*/
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 4000) => {
for (let t = 0; t < ms; t += 100) {
const v = fn();
if (v) return v;
await wait(100);
}
return null;
};
const SYS = { 층: "system", 주인: null };
const rows = [
{
종류: "promote",
이름: "SP-999901",
판: "a",
수정일: "2026-10-05T10:00:00",
제목: "시험 승격",
단위: "㎡",
기준금액: 100,
},
{
종류: "promote",
이름: "SP-999902",
판: "b",
수정일: "2026-10-05T11:00:00",
제목: "시험 승격 둘",
단위: "㎡",
기준금액: 200,
},
{
종류: "precombo",
이름: "SC-999901",
판: "c",
수정일: "2026-10-05T12:00:00",
제목: "시험 묶음",
단위: "개소",
기준금액: 100,
},
];
const docs = {
"promote/SP-999901": {
종류: "단가승격",
키: "SP-999901",
이름: "시험 승격",
단위: "㎡",
담은로직: [{ 로직: "GC000421", 입력값: {} }],
},
"precombo/SC-999901": {
종류: "사전조합",
키: "SC-999901",
이름: "시험 묶음",
단위: "개소",
담은로직: [{ 참조: { ...SYS, 키: "SP-999901" }, 수량: { 손: 1 } }],
},
};
const json = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const real = window.fetch;
let putBody = null;
window.fetch = async (input, init = {}) => {
const url = String(input?.url ?? input);
const method = (init.method ?? "GET").toUpperCase();
if (method === "GET" && /\/m02\/layers\/system\/templates(\?|$)/.test(url)) {
const got = await (await real(input, init)).json();
return json({ ...got, 양식: [...got.양식.filter((r) => !/^S[PC]-/.test(r.이름)), ...rows] });
}
const hit = Object.keys(docs).find((k) => url.includes(`/templates/${k}`));
if (hit && method === "GET")
return json({ 종류: hit.split("/")[0], 이름: docs[hit].키, 판: "a", 문서: docs[hit] });
if (hit && method === "PUT") {
putBody = JSON.parse(init.body);
const self = JSON.stringify(putBody).includes('"키":"SC-999901"}');
return self
? json({ detail: "순환 — 자기 자신을 담음 「SC-999901」" }, 422)
: json({ 이름: "SC-999901", 판: "z" });
}
if (method !== "GET" && !url.includes("/calc"))
return json({ detail: "시험 — 쓰기 막음" }, 400);
return real(input, init);
};
const sec = [...document.querySelectorAll(".m02-side__lists > section")].find(
(s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() === "일위대가 조합",
);
const items = [...sec.querySelectorAll("[data-precombo]")];
const btn = (root, text) =>
[...root.querySelectorAll("button")].find((b) => b.textContent === text);
const edit = () => document.querySelector(".m02-main__host [data-labor]");
const lines = () => [...(edit()?.querySelectorAll("[data-labor-logic]") ?? [])];
const keys = () => lines().map((tr) => tr.querySelector(".m02-labrow__key")?.textContent);
const toast = (re) =>
until(() =>
[...document.querySelectorAll(".ui-toast")]
.map((x) => x.textContent)
.find((t) => re.test(t ?? "")),
);
const openDoc = async (i, key) => {
items[i].click();
const row = await until(() =>
document.querySelector(`[data-lib-key="${key}"] td:nth-child(3)`),
);
row.click();
await until(() => lines().length);
await wait(300);
};
const pickerRow = async (key) => {
btn(edit(), "추가").click();
const row = await until(() => document.querySelector(`.m01-logic__pick [data-pick="${key}"]`));
return row;
};
try {
// 단가승격 상세 — 한 줄 고정
await openDoc(0, "SP-999901");
const promote = {
add: !!btn(edit(), "추가"),
rows: lines().length,
move: edit().querySelectorAll(".m02-labrow__move button").length,
};
// 사전조합 상세
await openDoc(1, "SC-999901");
const precombo = { add: !!btn(edit(), "추가"), rows: lines().length };
const first = await pickerRow("SP-999902");
const pick = document.querySelector(".m01-logic__pick");
const picker = {
filters: pick.querySelectorAll(".m01-logic__filters select").length,
kinds: [...pick.querySelectorAll("[data-pick-kind]")].map((x) => x.textContent),
layers: pick.querySelectorAll("[data-pick-layers] button").length,
};
first.click();
await until(() => lines().length === 2);
await wait(300);
const added = lines().length;
// 같은 참조 두 번 — 막힘
(await pickerRow("SP-999901")).click();
const again = await toast(/이미 담은/);
// 위로 — 둘째 줄을 첫째로
btn(lines()[1], "위로").click();
await wait(300);
const moved = keys();
// 수량 손 입력 — 시트 없음 · 3
lines()[0].querySelector("[data-labor-qty-open]").click();
const hand = await until(() => document.querySelector('.m02-labqty [data-labor-qty="hand"]'));
const qty = {
start: hand?.value,
sheet: !!document.querySelector(".m02-labqty [data-labor-qty-sheet]"),
};
hand.value = "3";
hand.dispatchEvent(new Event("input"));
btn(document.querySelector(".m02-labqty"), "확인").click();
await wait(400);
// 저장 본문으로 수량 확인(가짜 PUT)
const save = [...document.querySelectorAll(".m02-main__head button")].find(
(b) => b.textContent === "저장",
);
save.click();
await until(() => putBody);
const sent = putBody.문서.담은로직;
const addedQty = sent.find((x) => x.참조.키 === "SP-999902")?.수량 ?? null;
qty.after = sent[0]?.수량 ?? null;
// 삭제 — 줄 하나 뺌
btn(lines()[1], "삭제").click();
await wait(300);
const removed = lines().length;
// 자기 자신 담고 [저장] → 서버 422 알림
putBody = null;
(await pickerRow("SC-999901")).click();
await until(() => lines().length === 2);
save.click();
const selfSave = await toast(/순환/);
const selfSent = JSON.stringify(putBody ?? {}).includes("SC-999901");
// 떠남 — 버림 확인 → 목록
items[1].click();
const ok = await until(() =>
document.querySelector(
".ui-confirm__actions .ui-button--danger, .ui-confirm__actions button:last-child",
),
);
ok?.click();
const left = !!(await until(() => document.querySelector('[data-lib-list="precombo"]')));
return JSON.stringify({
promote,
precombo,
picker,
added,
addedQty,
again,
moved,
qty,
removed,
selfSave,
selfSent,
left,
});
} finally {
window.fetch = real;
}
})();