- Labor_Source = 덮개 열쇠 `C:참조|S:사본` 은 알약 값 쓴 SC 블록만(빈 수량 · 옛 손 = `C:참조` 그대로 = 703-1 지난 공사 가져옴 꼴) · SK 속 SC 안 줄 수량 없음 = 엔진엔 0 값묶음(`_Loose`) · 그 줄 = 사전조합 블록(이 공사 입력) — SK 통째 빈칸 안 됨 - Bill_Hopyo(707-7 공사 통째 불러오기) = 이 공사 엑셀 수량을 굳힌 손으로 남김(723-1 `with_pills` 수량 걷기는 M02 저장용) - Daega_Personal 원천 = 블록 origin 참조(덮개 열쇠 꼬리 무관) · 내 자료집 저장 알림 글 = 줄 수량 저장 안 함 - 비고(펼침 · 일위대가표) = 수량 없는 SC 안 줄은 「산근 n호표 · 수량 없음 — 이 공사에서 입력」(`noteOf`) - 낡은 시험 고침(사유 = 723-1 SC 안 줄 수량 저장 안 함 · 이 공사 입력 · 알약으로 이음): test_204_2(서고 SC 수량 없음 · ask) · test_703(자료집 저장 손 걷힘) · test_719_1(서고 사본 = 알약 값으로 같은 값 · 없으면 수량 없음) · test_719_2(SK 속 SC = 빈 줄 → 이 공사 입력 1 = M02 알약 1 금액) · test_702_3(서고 SC 빈 줄 3 → 입력 뒤 금액) - test_723_4 = 구조물 사본 면적 알약 굳힘 · 설계 연동 · 알약 안 이음 = 빈 줄 시험 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
9.1 KiB
TypeScript
213 lines
9.1 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts
|
|
* B09 설계내역서 펼친 일위대가 우클릭(PLAN 703-2) — 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나.
|
|
*
|
|
* 복사 = 이름 · 규격 묻기 → 덮개 `K:…` 에 `@copy` {from · name · spec} + 원본 덮개째(캐시 · 셈 짝이 새 호표 ·
|
|
* 번호 끝) → 「이 줄 단가연결을 새 호표로」 물음 · 복사본 지우기 = 그 덮개 통째(쓰는 줄 있으면 안 지움)
|
|
* 내 자료집 = POST …/estimation/daega/personal(화면 캐시 덮개째 · 서버가 개인 층 사전조합) ·
|
|
* 같은 이름 409 → 「덮어쓰기 · 새 이름」 · 담음 · 안 담음(품 · 단가 덮개) 수 알림
|
|
* 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 그 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { askFields } from "@ui/ui_template_item_picker_ask";
|
|
import { createListRow } from "@ui/ui_template_list_row";
|
|
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
|
import { askConfirm, askText, openModal } from "@ui/ui_template_modal";
|
|
import type { DataRow } from "@ui/ui_template_data_table";
|
|
import { createText } from "@ui/ui_template_text";
|
|
import { currentProjectId } from "../A00_Common/b_project_switch";
|
|
import type { Row } from "./B09_Estimation_Calc_Base";
|
|
import { COPY } from "./B09_Estimation_Calc_Labor";
|
|
import type { TabContext } from "./B09_Estimation_UI_Tabs";
|
|
import { editCell } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
|
|
|
/** 어디 쓰였나 — 같은 품목으로 찾는 줄 */
|
|
const GOODS = ["labor", "material", "machine"];
|
|
/** 부른 대가 따라가는 깊이 */
|
|
const DEPTH = 6;
|
|
|
|
const covers = (ctx: TabContext): Row => JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
|
|
const newKey = (): string => `K:${Math.random().toString(16).slice(2, 10).padEnd(8, "0")}`;
|
|
|
|
/** 복사해 새 일위대가 — 덮개째 · 번호 끝 · 이 줄 연결 바꿀지 물음 */
|
|
async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row): Promise<void> {
|
|
const got = await askFields({
|
|
title: "복사해 새 일위대가",
|
|
note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`,
|
|
fields: [
|
|
{ key: "name", label: "이름", value: `${block.name} 복사`, required: true },
|
|
{ key: "spec", label: "규격", value: block.kind === "사전조합" ? "" : block.spec || "" },
|
|
],
|
|
okLabel: "만들기",
|
|
closeLabel: "닫기",
|
|
emptyText: "{label} 이 빔",
|
|
});
|
|
if (!got) return;
|
|
const key = newKey();
|
|
const all = covers(ctx);
|
|
all[key] = { ...(all[block.cover] || {}), [COPY]: { from: block.cover, ...got } }; // 덮개째
|
|
const relink = await askConfirm({
|
|
title: "단가연결",
|
|
message: `「${row.name || ""}」 줄의 단가연결을 새 호표 「${got.name}」 로 바꿀지`,
|
|
okLabel: "바꿈",
|
|
cancelLabel: "그대로",
|
|
});
|
|
const next = { ...ctx, user: { ...ctx.user, daega_cover: all } };
|
|
const i = raw.findIndex((r) => r.key === row.key);
|
|
if (relink && i >= 0) editCell(next, raw, i, "ref", { tab: "dg", key: `DG:${key}` });
|
|
else ctx.setUser(next.user);
|
|
showToast(`새 호표 「${got.name}」 — 일위대가목록표 끝 · [저장] 때 서버가 다시 셈`, "success");
|
|
}
|
|
|
|
/** 복사본 지우기 — 그 호표를 잇는 내역 줄이 있으면 막음 */
|
|
function removeCopy(ctx: TabContext, raw: DataRow[], block: Row): void {
|
|
const used = raw.filter((r) => (r.ref as Row | undefined)?.key === block.id);
|
|
if (used.length) {
|
|
showToast(`잇는 내역 줄 ${used.length} — 단가연결을 먼저 바꿈`, "error");
|
|
return;
|
|
}
|
|
const all = covers(ctx);
|
|
delete all[block.cover];
|
|
ctx.setUser({ ...ctx.user, daega_cover: all });
|
|
}
|
|
|
|
async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; got: Row }> {
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/daega/personal`,
|
|
{
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
const got = ((await response.json().catch(() => null)) as Row | null) ?? {};
|
|
return { ok: response.ok, status: response.status, got };
|
|
}
|
|
|
|
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 품셈 블록 = 단가산출(SK ·
|
|
* 품 · 단가 고친 칸 = SK 덮개 · 719-2) · 같은 이름 물음 */
|
|
async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
|
|
const sk = block.kind === "품셈";
|
|
let name = await askText({
|
|
title: "내 자료집에 저장",
|
|
message: sk
|
|
? "내 자료집 단가산출로 — 옵션 값 · 고른 자재 · 품 · 단가 고친 칸까지"
|
|
: "내 자료집 일위대가로 — 옵션 값 · 고른 자재까지(줄 수량은 저장 안 함 · 쓰는 곳에서 이음)",
|
|
value: block.name || "",
|
|
okLabel: "저장",
|
|
});
|
|
let overwrite: string | null = null;
|
|
while (name !== null && name.trim()) {
|
|
const body = { cover: block.cover, name: name.trim(), overwrite, user: ctx.user };
|
|
const { ok, status, got } = await postPersonal(body);
|
|
const same = got.detail?.같은이름;
|
|
if (ok) {
|
|
const skip: Row = got.안담음 || {};
|
|
const left = (skip.품 || 0) + (skip.단가 || 0);
|
|
const tail = left
|
|
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가})${sk ? "" : " — 품은 고정 줄(PLAN 705) 뒤 담김"}`
|
|
: "";
|
|
showToast(`내 자료집${sk ? " 단가산출" : ""}에 저장 「${got.이름}」${tail}`, "success", 6000);
|
|
return;
|
|
}
|
|
if (status !== 409 || !same) {
|
|
const detail = got.detail;
|
|
showToast(`저장 못 함 — ${typeof detail === "string" ? detail : status}`, "error");
|
|
return;
|
|
}
|
|
const over = await askConfirm({
|
|
title: "같은 이름",
|
|
message: `내 자료집에 「${name.trim()}」 이 있음 — 덮어쓰기 · 새 이름`,
|
|
okLabel: "덮어쓰기",
|
|
cancelLabel: "새 이름",
|
|
});
|
|
if (over) overwrite = same;
|
|
else {
|
|
overwrite = null;
|
|
name = await askText({ title: "새 이름", message: "내 자료집 이름", value: `${name} 2` });
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 블록 나무에 그 품목(종류 · 키) · 그 호표(id)가 있나 */
|
|
function holds(blocks: Map<string, Row>, bid: string, hit: (ln: Row) => boolean): boolean {
|
|
const seen = new Set<string>();
|
|
const walk = (id: string, depth: number): boolean => {
|
|
const b = blocks.get(id);
|
|
if (!b || seen.has(id) || depth > DEPTH) return false;
|
|
seen.add(id);
|
|
return (b.lines || []).some(
|
|
(ln: Row) => hit(ln) || (ln.kind === "daega" && walk(String(ln.key), depth + 1)),
|
|
);
|
|
};
|
|
return walk(bid, 0);
|
|
}
|
|
|
|
/** 어디 쓰였나 — 그 품목 · 호표를 쓰는 내역 줄 → 누르면 그 줄로 */
|
|
function whereUsed(
|
|
ctx: TabContext,
|
|
raw: DataRow[],
|
|
block: Row,
|
|
line: Row,
|
|
go: (key: string) => void,
|
|
): void {
|
|
const blocks = new Map<string, Row>(
|
|
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.id), b]),
|
|
);
|
|
const goods = GOODS.includes(line.kind);
|
|
const id = line.kind === "daega" ? String(line.key) : String(block.id);
|
|
const label = goods ? `${line.name || line.key}` : `${blocks.get(id)?.name || ""}`;
|
|
const hit = goods
|
|
? (ln: Row) => ln.kind === line.kind && ln.key === line.key
|
|
: (ln: Row) => ln.kind === "daega" && ln.key === id;
|
|
const used = raw.filter((r) => {
|
|
const ref = r.ref as Row | undefined;
|
|
if (ref?.tab !== "dg") return false;
|
|
return (!goods && String(ref.key) === id) || holds(blocks, String(ref.key), hit);
|
|
});
|
|
openModal({
|
|
title: `어디 쓰였나 — 「${label}」`,
|
|
closeLabel: "닫기",
|
|
width: "40rem",
|
|
mount: (body, close) => {
|
|
body.append(
|
|
createText(`쓰는 내역 줄 ${used.length} · 누르면 그 줄로`, "muted"),
|
|
...used.map((r) =>
|
|
createListRow({
|
|
label: String(r.name || ""),
|
|
sub: [r.spec, r.qty != null ? `${r.qty} ${r.unit || ""}` : ""]
|
|
.filter(Boolean)
|
|
.join(" · "),
|
|
onClick: () => {
|
|
close();
|
|
go(String(r.key));
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 펼친 줄 우클릭 덧메뉴 — 복사 · 내 자료집 · 어디 쓰였나(+ 복사본 지우기) */
|
|
export function foldMenu(
|
|
ctx: TabContext,
|
|
raw: DataRow[],
|
|
row: Row,
|
|
block: Row,
|
|
line: Row | undefined,
|
|
go: (key: string) => void,
|
|
): MapContextMenuItem[] {
|
|
return [
|
|
["복사해 새 일위대가", () => void copyBlock(ctx, raw, row, block)],
|
|
["내 자료집에 저장", () => void savePersonal(ctx, block)],
|
|
["어디 쓰였나", () => whereUsed(ctx, raw, block, line || {}, go)],
|
|
...(String(block.cover).startsWith("K:")
|
|
? [["이 복사본 지우기", () => removeCopy(ctx, raw, block)] as MapContextMenuItem]
|
|
: []),
|
|
];
|
|
}
|