Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -106,7 +106,6 @@ export function createUnitPick(opts: UnitPickOptions): HTMLElement {
|
||||
const open = createButton({
|
||||
label: opts.value || NO_UNIT,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: opts.readOnly,
|
||||
onClick: () => toggle(),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,54 @@
|
||||
/* 51-1 — 오른쪽 패널(공용 ui_template_side_panels) 높이 = innerHeight − 머리띠 · 스크롤 처음/중간/끝 화면 안
|
||||
* 시작 화면: 패널이 이미 열린 상세 화면 하나(M02 일위대가 상세 · M02 면적산출 · M01 로직 상세 중 하나) · 화면마다 한 번씩 부름
|
||||
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 처음·중간·끝 모두 true
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const sp = [...document.querySelectorAll(".ui-side-panels")].find(
|
||||
(s) => !s.classList.contains("is-collapsed") && s.getClientRects().length,
|
||||
);
|
||||
if (!sp) return JSON.stringify({ error: "펴진 패널 없음" });
|
||||
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
|
||||
const scroller = (() => {
|
||||
for (let n = sp.parentElement; n; n = n.parentElement) {
|
||||
const y = getComputedStyle(n).overflowY;
|
||||
if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n;
|
||||
}
|
||||
return document.scrollingElement;
|
||||
})();
|
||||
const max = scroller.scrollHeight - scroller.clientHeight;
|
||||
const sample = async (name, y) => {
|
||||
scroller.scrollTop = y;
|
||||
window.dispatchEvent(new Event("scroll")); // 뒤에 가린 탭은 스크롤 알림이 안 와서 직접
|
||||
await w(150);
|
||||
const r = sp.getBoundingClientRect();
|
||||
const want = Math.round(
|
||||
innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top),
|
||||
);
|
||||
const body = sp.querySelector(".ui-side-panels__panel:not([hidden]) .ui-side-panels__body");
|
||||
return {
|
||||
name,
|
||||
top: Math.round(r.top),
|
||||
bottom: Math.round(r.bottom),
|
||||
h: Math.round(r.height),
|
||||
want,
|
||||
inView: r.top >= head - 2 && r.bottom <= innerHeight + 2,
|
||||
bodyScrolls: body ? body.scrollHeight > body.clientHeight : null,
|
||||
};
|
||||
};
|
||||
const was = scroller.scrollTop;
|
||||
const rows = [
|
||||
await sample("처음", 0),
|
||||
await sample("중간", Math.round(max / 2)),
|
||||
await sample("끝", max),
|
||||
];
|
||||
scroller.scrollTop = was;
|
||||
return JSON.stringify({
|
||||
innerHeight,
|
||||
head: Math.round(head),
|
||||
scrollMax: Math.round(max),
|
||||
rows,
|
||||
ok: rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView),
|
||||
});
|
||||
})();
|
||||
@@ -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,35 @@
|
||||
/* 51-5 — 단위 고르기 단추(공용 M02_DF_UnitPick) 높이 · 글자 = [저장] 단추와 같음
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자
|
||||
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 하는 일: item1 → 일위대가 [수정] 상세 → 단위 단추 · [저장] 단추 높이 · 글자 크기 잼
|
||||
* 기대: ok = true (|unit.h − save.h| ≤ 1 · 글자 같음)
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
document.querySelector('[data-node="i|item1"]')?.click();
|
||||
await w(2500);
|
||||
const card = [...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();
|
||||
await w(3000);
|
||||
const read = (b) =>
|
||||
b && {
|
||||
h: Math.round(b.getBoundingClientRect().height * 10) / 10,
|
||||
font: getComputedStyle(b).fontSize,
|
||||
};
|
||||
const unit = read(document.querySelector('[data-labor-field="단위"] .ui-btn'));
|
||||
const save = read(
|
||||
[...document.querySelectorAll("button")].find(
|
||||
(b) => b.innerText.trim() === "저장" && b.offsetParent,
|
||||
),
|
||||
);
|
||||
return JSON.stringify({
|
||||
unit,
|
||||
save,
|
||||
ok: !!unit && !!save && Math.abs(unit.h - save.h) <= 1 && unit.font === save.font,
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -180,7 +180,7 @@ function scroller(node: HTMLElement): HTMLElement | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 칸 · 본문을 굴려도 그 자리(몸만 따로 스크롤)
|
||||
* 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 창 높이 − 머리띠(칸이 아래에 있으면 칸 위부터) · 본문을 굴려도 그 자리(몸만 따로 스크롤)
|
||||
* CSS sticky 는 위 칸의 overflow 가 막아 JS 로 — 어느 칸이 굴러도(capture) 다시 잼
|
||||
*/
|
||||
function followView(node: HTMLElement): void {
|
||||
@@ -191,13 +191,16 @@ function followView(node: HTMLElement): void {
|
||||
if (!root.isConnected) return;
|
||||
const box = scroller(root)?.getBoundingClientRect();
|
||||
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
|
||||
const top = Math.max(head, box?.top ?? 0) + 8;
|
||||
const bottom = Math.min(window.innerHeight, box?.bottom ?? window.innerHeight) - 8;
|
||||
const view = Math.max(head, box?.top ?? 0);
|
||||
const bottom = Math.min(window.innerHeight, box?.bottom ?? window.innerHeight);
|
||||
const row = root.parentElement?.getBoundingClientRect();
|
||||
if (!row) return;
|
||||
const height = Math.max(240, bottom - top);
|
||||
// 칸이 아직 화면 아래에 있으면 칸 위에 맞춤 · 올라가 붙으면 보이는 칸 전체 높이
|
||||
const top = Math.max(view, row.top);
|
||||
// 칸 바닥(+ 쪽 아래 여백)을 넘지 않음 · 너무 줄면 최소 높이로 두고 칸 바닥에 맞춰 올라감
|
||||
const height = Math.max(240, Math.min(bottom, row.bottom + 48) - top);
|
||||
root.style.height = `${height}px`;
|
||||
const shift = Math.min(Math.max(0, top - row.top), Math.max(0, row.height - height));
|
||||
const shift = Math.min(Math.max(0, view - row.top), Math.max(0, row.height + 48 - height));
|
||||
root.style.transform = shift ? `translateY(${Math.round(shift)}px)` : "";
|
||||
};
|
||||
const stop = (): void => {
|
||||
|
||||
Reference in New Issue
Block a user