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 {