feat(M02): 서고 구분 · 상세구분 · 연결 화면(51-12)

- 서고 목록표에 구분 · 상세구분 칸(키 뒤)
- 단가승격 상세 정보 칸 = 구분 · 상세구분 · 연결(원 로직 키 · 출처) 읽기만
- 사전조합 상세 정보 칸 = 구분 · 상세구분 고르기(로직 구분 목록 logic-lib/subs · 구분 바꾸면 상세구분 = 그 첫 줄)
- 새 사전조합 처음 값 = 로직 구분 목록 첫 줄
- 로직 고르기 창 구분 · 상세구분 거름에 승격 · 사전조합 줄도 걸림
- 화면 시나리오 51-12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-05 20:49:43 +09:00
co-authored by Claude Opus 5.5
parent c7a84a0ed9
commit dda5f6233f
6 changed files with 275 additions and 17 deletions
@@ -233,6 +233,9 @@ export interface LibraryRow {
이름: string;
제목?: string | null;
단위?: string | null;
/** 51-12 — 승격 = 원 로직 것 · 사전조합 = 고른 것 */
구분?: string | null;
상세구분?: string | null;
}
/** 서고 목록 — 그 층(주인) 단가승격 `promote` · 사전조합 `precombo` 문서 */
export const fetchLibrary = async (
@@ -9,15 +9,23 @@
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) ·
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { ROUTES } from "@config/config_frontend";
import { navigateTo } from "../A00_Common/router";
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import type { SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
import {
@@ -33,6 +41,7 @@ import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import {
areaLinks,
calcLabor,
fetchLibLogicSubs,
fetchLogicInputs,
normalizeLabor,
type LaborCalc,
@@ -457,6 +466,69 @@ export function mountLaborDetail(
opt.onChanged(dirty());
});
/** 서고 구분 · 상세구분(51-12) — 승격 = 원 로직 것 읽기만 + 연결(원 로직 키 · 출처) ·
* 사전조합 = 로직 구분 목록(logic-lib/subs)에서 고름(구분 바꾸면 상세구분 = 그 첫 줄) */
const meta = cur as { 구분?: string | null; 상세구분?: string | null; 연결?: unknown };
const readField = (label: string, field: string, value: string): HTMLElement => {
const f = createInputField({ type: "text", label });
f.root.dataset.laborField = field;
f.input.value = value;
f.input.readOnly = true;
return f.root;
};
const libMeta = (): HTMLElement[] => {
if (promote) {
const link = (meta.연결 ?? {}) as { 로직?: string; 키?: string; 출처?: string };
return [
readField(tc("Sub"), "구분", meta.구분 ?? ""),
readField(tc("Detail"), "상세구분", meta.상세구분 ?? ""),
readField(
tc("Lib_Link"),
"연결",
[link.로직 ?? link.키, link.출처].filter(Boolean).join(" · "),
),
];
}
const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit });
const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit });
sub.root.dataset.laborField = "구분";
detail.root.dataset.laborField = "상세구분";
let subs: SubBrief[] = [];
// 목록에 없는 지금 값도 선택지로 둠
const opts = (names: string[], now: string | null | undefined) =>
[...new Set([...(now ? [now] : []), ...names])].map((v) => ({ value: v, text: v }));
const fill = (): void => {
sub.setOptions(
opts(
subs.map((s) => s.name),
meta.구분,
),
meta.구분 ?? "",
);
const details = subs.find((s) => s.name === meta.구분)?.details ?? [];
detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? "");
};
sub.select.addEventListener("change", () => {
meta.구분 = sub.select.value;
meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null;
fill();
opt.onChanged(dirty());
});
detail.select.addEventListener("change", () => {
meta.상세구분 = detail.select.value;
opt.onChanged(dirty());
});
fill();
void fetchLibLogicSubs().then(
(got) => {
subs = got;
fill();
},
() => undefined,
);
return [sub.root, detail.root];
};
const editor = (body: HTMLElement): HTMLElement =>
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
host.replaceChildren(
@@ -473,7 +545,11 @@ export function mountLaborDetail(
children: [
el("div", {
className: "m01lab__info",
children: [...(lib ? [nameField.root] : []), unit.root, origin.root],
children: [
...(lib ? [nameField.root, ...libMeta()] : []),
unit.root,
origin.root,
],
}),
],
}),
@@ -82,9 +82,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
(!detail.select.value || x.상세구분 === detail.select.value) &&
(!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)),
);
const libFound = sub.select.value
? []
: libRows.filter((x) => !q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q));
// 서고 줄도 구분 · 상세구분으로 거름(51-12)
const libFound = libRows.filter(
(x) =>
(!sub.select.value || x.row.구분 === sub.select.value) &&
(!detail.select.value || x.row.상세구분 === detail.select.value) &&
(!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)),
);
const total = all.length + libRows.length;
const shown = found.length + libFound.length;
finder.hint.textContent = loading
@@ -110,7 +114,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
key: x.row.이름,
tag: tc(x.kind),
name: x.row.제목 || x.row.이름,
sub: x.row.이름,
sub: `${x.row.이름} · ${x.row.구분 ?? ""} ${x.row.상세구분 ?? ""}`.trim(),
unit: x.row.단위 ?? "",
onPick: () => {
close();
@@ -124,6 +124,8 @@ const TEXT = {
Lib_Unit: ["단위", "Unit"],
Lib_Base: ["기준 금액", "Base amount"],
Lib_Date: ["고친 날", "Modified"],
// 서고 구분 · 연결(51-12)
Lib_Link: ["연결", "Linked logic"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_Library_List.ts
* 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸.
* 표 = 고름 · 키 · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세.
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세.
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
* 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import.
* ========================================================================== */
@@ -30,12 +30,19 @@ import {
type LibraryBody,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { fetchLibLogicSubs } from "./M02_MasterTemplete_Labor_Api";
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 };
type Row = TemplateInfo & {
제목?: string | null;
단위?: string | null;
기준금액?: number | null;
구분?: string | null;
상세구분?: string | null;
};
export interface LibraryListOptions {
layer: Layer;
@@ -99,9 +106,15 @@ export function mountLibraryList(
attrs: { "data-lib-key": row.이름 },
children: [
pick,
...[row.이름, row.제목 ?? "", row.단위 ?? "", money(row.기준금액), row.수정일].map(
(v) => el("td", { text: v, attrs: { title: v } }),
),
...[
row.이름,
row.구분 ?? "",
row.상세구분 ?? "",
row.제목 ?? "",
row.단위 ?? "",
money(row.기준금액),
row.수정일,
].map((v) => el("td", { text: v, attrs: { title: v } })),
],
});
tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row)));
@@ -109,7 +122,7 @@ export function mountLibraryList(
})
: [
el("tr", {
children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "6" } })],
children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "8" } })],
}),
]),
);
@@ -164,7 +177,8 @@ export function mountLibraryList(
const create = async (body: LibraryBody, label: string): Promise<void> =>
made((await newLibrary(opt.layer, opt.kind, body)).이름, label);
/** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것 · 사전조합 = 이름만 · 빈 묶음 */
/** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위 · 구분 · 연결은 로직 것(서버가 채움) ·
* 사전조합 = 이름 + 구분 · 상세구분 처음 값 = 로직 구분 목록 첫 줄(51-12 · 상세에서 바꿈) · 빈 묶음 */
btnNew.addEventListener("click", () => {
if (opt.kind === "promote")
return openLogicPicker((x) => {
@@ -177,9 +191,17 @@ export function mountLibraryList(
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"),
);
});
nameDialog(L("M02_NewTitle"), "", (name) =>
create({ 문서: { 이름: name, 단위: null, 담은로직: [] } }, name),
);
nameDialog(L("M02_NewTitle"), "", async (name) => {
const first = (await fetchLibLogicSubs())[0];
const 문서 = {
이름: name,
구분: first?.name ?? null,
상세구분: first?.details[0] ?? null,
단위: null,
담은로직: [],
};
await create({ 문서 }, name);
});
});
/** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */
@@ -221,7 +243,16 @@ export function mountLibraryList(
page = 1;
draw();
});
const heads = ["", "Lib_Key", "Lib_Name", "Lib_Unit", "Lib_Base", "Lib_Date"] as const;
const heads = [
"",
"Lib_Key",
"Sub",
"Detail",
"Lib_Name",
"Lib_Unit",
"Lib_Base",
"Lib_Date",
] as const;
host.replaceChildren(
buildHeadFilters([searchField.root, pager]),
el("div", {
@@ -0,0 +1,142 @@
/* 51-12 — 서고(단가승격 · 사전조합) 구분 · 상세구분 · 연결
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 쓰기 없음 — GET · calc 밖 요청은 가짜 400(새로 만들기 POST 본문만 잡음) · 마스터 SP-000001 · SC-000001 읽기만
* 기대 수치:
* - heads = ["", "키", "구분", "상세구분", "이름", ...] · listSP = SP-000001 줄 구분 · 상세구분 빈 것 아님
* - promote = { 구분, 상세구분, 연결 } 셋 다 글 있음 · readOnly = true
* - precombo = { selects: 2, 구분 = 문서 값 · subOptions ≥ 1 } · switched.상세구분 = 바꾼 구분의 첫 상세구분 · back = 처음 값
* - created = { 구분: 목록 첫 구분, 상세구분: 그 첫 상세구분 }
* - picker = 구분(SP-000001 것) 고르면 SP-000001 줄 보임 · 다른 구분이면 안 보임
*/
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 6000) => {
for (let t = 0; t < ms; t += 100) {
const v = fn();
if (v) return v;
await wait(100);
}
return null;
};
const json = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const real = window.fetch;
let posted = null;
window.fetch = async (input, init = {}) => {
const url = String(input?.url ?? input);
const method = (init.method ?? "GET").toUpperCase();
if (method === "POST" && url.includes("/library/precombo")) {
posted = JSON.parse(init.body);
return json({ detail: "시험 — 쓰기 막음" }, 400);
}
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 list = (kind) => document.querySelector(`[data-lib-list="${kind}"]`);
const edit = () => document.querySelector(".m02-main__host [data-labor]");
const field = (name) => edit()?.querySelector(`[data-labor-field="${name}"]`);
const btn = (root, text) =>
[...(root?.querySelectorAll("button") ?? [])].find((b) => b.textContent === text);
const subs = await (
await real("/api/m02/logic-lib/subs", { credentials: "include" })
)
.json()
.then((d) => d.subs);
try {
// ① 승격 목록 · 상세
items[0].click();
const spRow = await until(() => list("promote")?.querySelector('[data-lib-key="SP-000001"]'));
const heads = [...list("promote").querySelectorAll("thead th")].map((th) => th.textContent);
const cells = [...spRow.querySelectorAll("td")].map((td) => td.textContent);
const listSP = { 구분: cells[2], 상세구분: cells[3] };
spRow.querySelector("td:nth-child(5)").click();
await until(() => field("연결"));
const val = (name) => field(name)?.querySelector("input")?.value ?? null;
const promote = {
구분: val("구분"),
상세구분: val("상세구분"),
연결: val("연결"),
readOnly: ["구분", "상세구분", "연결"].every(
(n) => field(n)?.querySelector("input")?.readOnly,
),
};
// ② 사전조합 상세 — 고르기 · 바꿔 보고 되돌림
items[1].click();
(
await until(() =>
list("precombo")?.querySelector('[data-lib-key="SC-000001"] td:nth-child(5)'),
)
).click();
const subSel = await until(() => field("구분")?.querySelector("select"));
await until(() => subSel.options.length > 1, 4000);
const detSel = field("상세구분").querySelector("select");
const start = { 구분: subSel.value, 상세구분: detSel.value };
const precombo = {
selects: edit().querySelectorAll(".m01lab__info select").length,
...start,
subOptions: subSel.options.length,
};
const other = subs.find((s) => s.name !== start.구분 && s.details.length);
subSel.value = other.name;
subSel.dispatchEvent(new Event("change"));
const switched = {
구분: field("구분").querySelector("select").value,
상세구분: field("상세구분").querySelector("select").value,
want: other.details[0],
};
const s2 = field("구분").querySelector("select");
s2.value = start.구분;
s2.dispatchEvent(new Event("change"));
const d2 = field("상세구분").querySelector("select");
d2.value = start.상세구분;
d2.dispatchEvent(new Event("change"));
const back = {
구분: field("구분").querySelector("select").value,
상세구분: field("상세구분").querySelector("select").value,
};
// ③ 고르기 창 — 구분 거름에 승격 줄
btn(edit(), "로직 추가").click();
const pick = await until(() => document.querySelector(".m01-logic__pick"));
await until(() => pick.querySelector('[data-pick="SP-000001"]'), 20000);
const [fSub, fDet] = pick.querySelectorAll(".ui-find__filters select");
const pickerWith = async (s, d) => {
fSub.value = s;
fSub.dispatchEvent(new Event("change"));
if (d != null) {
fDet.value = d;
fDet.dispatchEvent(new Event("change"));
}
await wait(100);
return !!pick.querySelector('[data-pick="SP-000001"]');
};
const picker = {
same: await pickerWith(promote.구분, promote.상세구분),
other: await pickerWith(subs.find((s) => s.name !== promote.구분)?.name ?? "", null),
};
pick.closest(".m01-logic__backdrop")?.remove();
// ④ 새 사전조합 처음 값(POST 본문만 잡음)
items[1].click();
await until(() => list("precombo"));
btn(document.querySelector(".m02-main__slot"), "생성").click();
const box = await until(() => document.querySelector(".m02-modal"));
box.querySelector("input").value = "시험 51-12";
btn(box, "생성").click();
await until(() => posted);
btn(document.querySelector(".m02-modal"), "닫기")?.click();
document.querySelector(".m02-modal")?.closest(".ui-modal__backdrop, .ui-modal")?.remove();
const created = {
구분: posted?.문서?.구분,
상세구분: posted?.문서?.상세구분,
want: [subs[0].name, subs[0].details[0] ?? null],
};
return JSON.stringify({ heads, listSP, promote, precombo, switched, back, picker, created });
} finally {
window.fetch = real;
}
})();