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:
@@ -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;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user