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", {