Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts
T
eomsangdonandClaude Opus 5.5 1c1dc9c523 feat(B09 설계내역서 펼침): 우클릭 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나 (PLAN 703-2)
- 복사 = 덮개 K:… 의 @copy {from · name · spec} + 원본 덮개째(B09 사용자 값 · 브레인 정함) — 셈 짝 Calc_Labor py·ts 가 원본 블록을 본뜬 새 호표(번호 끝 · 기본값 = 원본) · 이 줄 단가연결을 새 호표로 바꿀지 물음 · 복사본 지우기(잇는 줄 있으면 막음)
- 원본 없음 = 짝 잃음 알림 · 저장 다듬기 @copy 글만 · 서고 사본 원본 = prune 에서 지킴
- 내 자료집 = POST …/estimation/daega/personal → 개인 층 사전조합(B09_Estimation_Daega_Personal) — 넣을 값 · 고른 자재 · 줄 수량 덮개(손) 담음 · 품 · 단가 덮개 = 안 담은 수 알림 · 같은 이름 409 → 덮어쓰기 · 새 이름
- 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로 · 펼침
- 새 파일 B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts · 시험 test_703_b09_daega_copy(거울 · 임시 storage 길)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 13:35:44 +09:00

209 lines
8.8 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.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 };
}
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */
async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
let name = await askText({
title: "내 자료집에 저장",
message: "개인 층 일위대가 사전조합으로 — 넣을 값 · 고른 자재 · 줄 수량 고친 칸까지",
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.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
: "";
showToast(`내 자료집에 저장 「${got.이름}」(${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()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`,
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]
: []),
];
}