feat(b09): 단가연결 모달 — 연결 하위 메뉴 다섯 → 한 창(종류 고르기 · 검색) · 이름 단가연결 (251-3 · 251-6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 01:18:31 +09:00
co-authored by Claude Opus 5.5
parent 0e6da95c95
commit 8e7a795abc
10 changed files with 178 additions and 127 deletions
@@ -8,7 +8,7 @@
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
* 「단가연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
* ========================================================================== */
@@ -37,7 +37,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
const rows: DataRow[] = raw.map((r: Row) => ({
...r,
name: r.no ? `${r.no} ${r.name}` : r.name,
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "연결"} ▾` : "",
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"} ▾` : "",
}));
const direct: Row = bill.totals?.direct || {};
const pair = (key: string, label: string) => [
@@ -57,14 +57,14 @@ export const buildBill: TabBuild = (ctx): BillTable => {
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
text("connect", "연결", 96),
text("connect", "단가연결", 96),
text("note", "비고", 200),
],
rows,
total: { name: "순공사비 계(관급 제외)", ...direct },
frozen: 1,
hint:
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" +
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「단가연결 ▾」로 일위대가 · 단가산출 연결" +
(direct.blank
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
: ""),
@@ -252,8 +252,8 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent));
});
if (linkable(row)) {
const [picks, more] = linkFor(ctx, rows, i);
parts[1].push({ label: "연결", children: picks }, ...more);
const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯)
parts[1].push(...picks, ...more);
}
// 옮김 · 레벨 — 같은 부모 형제와 자손째
if (!fixed(key)) {
@@ -355,7 +355,8 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void
const box = lastCell?.getBoundingClientRect();
if (!shared || !box || !linkable(rows[i])) return;
const [picks, more] = linkFor(ctx, rows, i);
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
if (!more.length) return picks[0][1](); // 251-6 아직 연결 없음 = 단가연결 모달 바로
shared.open(box.left, box.bottom, [...picks, "-", ...more]);
}
/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */
@@ -4,6 +4,8 @@
* 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새
* 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 ·
* 연결 풀기.
* 251 단가연결 = 한 모달(`openPriceLink` · 종류 고르기 + 검색) — B09 설계내역서 · B08 거리이정표 같이 씀 ·
* 단가산출 = 옵션 값 창(`askInputs` · 160)
*
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
* 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 ·
@@ -12,9 +14,8 @@
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { openItemPicker } from "@ui/ui_template_item_picker";
import { openModal } from "@ui/ui_template_modal";
import { loadRows } from "@ui/ui_template_item_picker";
import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
@@ -24,47 +25,14 @@ import type { TabContext } from "./B09_Estimation_UI_Tabs";
/** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */
export type ApplyRef = (ref: Row | null, from?: Row) => void;
export type Leaf = [string, () => void];
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
function pickHopyo(ctx: TabContext, apply: ApplyRef, tab: "dg" | "sg"): void {
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
openModal({
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
closeLabel: "닫기",
mount: (body, close) => {
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
const find = (): void => {
const q = finder.search.value.trim().toLowerCase();
const found = listing.filter(
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
);
finder.hint.textContent = `${found.length} / ${listing.length}`;
finder.setRows(
found.map((r) => ({
key: String(r.key),
tag: label(r),
name: String(r.name || ""),
sub: String(r.spec || ""),
unit: String(r.unit || ""),
onPick: () => {
close();
apply({ tab, key: r.key }, r);
},
})),
);
};
finder.search.addEventListener("input", find);
find();
body.append(finder.root);
requestAnimationFrame(() => finder.search.focus());
},
});
}
const LAYER_TEXT: Record<PickerLayer, string> = {
system: "마스터",
company: "회사",
personal: "개인",
};
/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
fetchLogicInputs([code]).then(
(got) => {
const logic = got.로직[0] ?? null;
@@ -94,10 +62,10 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void
/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만
* 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */
async function importLib(
ctx: TabContext,
apply: ApplyRef,
o: { apply: ApplyRef; onPayload?: (payload: Row) => void },
pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string },
): Promise<void> {
const apply = o.apply;
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) return;
showToast("가져오는 중…", "info");
@@ -115,7 +83,7 @@ async function importLib(
}
if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦");
const { user: _user, ...rest } = got.payload as Row;
Object.assign(ctx.payload, rest);
o.onPayload?.(rest);
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
showToast(
@@ -127,39 +95,79 @@ async function importLib(
}
}
/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */
function pickCombo(ctx: TabContext, apply: ApplyRef): void {
void openItemPicker({ current: null, canCopy: false }).then((got) => {
if (got.action !== "apply") return;
const { layer, owner, key } = got.item;
void importLib(ctx, apply, { kind: "combo", layer, owner, key });
});
}
/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */
function pickPrecombo(ctx: TabContext, apply: ApplyRef): void {
openLogicPicker(() => undefined, {
layer: "personal",
owner: null,
libOnly: true,
title: "일위대가 사전조합 고르기",
pick: (ref) =>
void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
});
}
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */
/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 */
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
const more: Leaf[] = [];
if (ref?.tab === "logic")
more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]);
if (ref) more.push(["연결 풀기", () => apply(null)]);
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
["구조물 조합", () => pickCombo(ctx, apply)],
["일위대가 사전조합", () => pickPrecombo(ctx, apply)],
["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))],
if (ref) more.push(["단가연결 풀기", () => apply(null)]);
const open: Leaf = [
"단가연결",
() =>
openPriceLink({
apply,
hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] },
onPayload: (p) => Object.assign(ctx.payload, p),
}),
];
return [picks, more];
return [[open], more];
}
/** 단가연결 모달(251 · 159 · 160) — 한 목록에 단가산출 로직 · 일위대가 사전조합 · 구조물 조합(마스터 · 회사 ·
* 개인 층 단추) · 호표(B09 · 있을 때) · 종류 고르기 + 검색 + 구분 · 상세구분 거름 ·
* 단가산출 = 옵션 값 창(`askInputs` · 160) · 사전조합 · 구조물 조합 = 프로젝트 사본 들여옴 → `{tab: dg, key, lib}` */
export interface PriceLinkOpts {
apply: ApplyRef;
/** B09 호표 목록(일위대가 · 단가산출) — 없으면 그 종류 없음(B08) */
hopyo?: { dg: Row[]; sg: Row[] };
/** 서고 사본 들여온 뒤 받은 원가 자료(B09 = 화면 자료에 끼움) */
onPayload?: (payload: Row) => void;
}
export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
const hop = (tab: "dg" | "sg") =>
((tab === "dg" ? o.hopyo?.dg : o.hopyo?.sg) ?? []).map((r) => ({
kind: tab,
key: String(r.key),
tag: tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`,
name: String(r.name || ""),
sub: String(r.spec || ""),
unit: String(r.unit || ""),
onPick: () => o.apply({ tab, key: r.key }, r),
}));
// 구조물 조합 = 마스터 · 회사 · 개인 한 목록(B08 [선택] 나무와 같은 원천)
const combos = (await loadRows().catch(() => [])).map((r) => ({
kind: "combo",
key: `${r.layer}:${r.owner ?? ""}:${r.key}`,
tag: "구조물 조합",
name: itemName(r),
sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`,
unit: String(r.단위 || ""),
onPick: () =>
void importLib(o, {
kind: "combo",
layer: r.layer ?? "system",
owner: r.owner ?? null,
key: r.key,
}),
}));
openLogicPicker((logic) => askInputs(o.apply, logic.키, logic.이름), {
layer: "personal",
owner: null,
title: "단가연결",
extra: [...combos, ...hop("dg"), ...hop("sg")],
kindFilter: [
{ value: "logic", text: "단가산출" },
{ value: "precombo", text: "일위대가 사전조합" },
{ value: "combo", text: "구조물 조합" },
...(o.hopyo
? [
{ value: "dg", text: "일위대가 호표" },
{ value: "sg", text: "단가산출 호표" },
]
: []),
],
pick: (ref) =>
void importLib(o, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
});
}