feat(b09): 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 자동 줄 칸 덮개 · gone · 위로 · 아래로 · 새 공종 · 자동값으로 · 고친 칸 강조 · 내역 초기화 · 연결 고르기 창 떼어 냄(184-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
@@ -112,3 +112,8 @@
|
||||
background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas));
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
|
||||
.b09-estimation .ui-data-table__table tbody td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
@@ -297,6 +298,21 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
|
||||
});
|
||||
refresh.dataset.b09Refresh = "";
|
||||
// 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역
|
||||
const reset = createButton({
|
||||
label: "내역 초기화",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void showConfirmDialog(
|
||||
"설계내역서에서 고친 칸 · 더한 줄 · 지운 줄 · 차례를 모두 지우고 기본 로직 내역으로 되돌립니다. 바로 저장됩니다.",
|
||||
"초기화",
|
||||
).then((ok) => {
|
||||
if (!ok) return;
|
||||
const { bill: _bill, ...rest } = user;
|
||||
void run("/estimation", "PUT", "설계내역서를 초기값으로 되돌렸습니다.", { user: rest });
|
||||
}),
|
||||
});
|
||||
reset.dataset.b09BillReset = "";
|
||||
const section = document.createElement("section");
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
const heading = document.createElement("h3");
|
||||
@@ -305,7 +321,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
section.append(heading, made, refresh);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(save);
|
||||
actions.append(save, reset);
|
||||
const left = document.createElement("div");
|
||||
left.className = "ui-sidebar-panel b09-options";
|
||||
left.append(section, actions);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill.ts
|
||||
* B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
|
||||
* B09 설계내역서 탭(PLAN 145-3 · 184-3) — 실무 엑셀 꼴 칸(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
|
||||
*
|
||||
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
|
||||
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) ·
|
||||
* 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
|
||||
* 초기값 = 기본 로직이 B08 수량으로 짠 줄 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(184 · `…_UI_Tabs_Bill_Edit`)
|
||||
* 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개
|
||||
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`)
|
||||
* 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침
|
||||
* 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS)
|
||||
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -16,25 +16,32 @@ import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
import {
|
||||
billMenu,
|
||||
editQty,
|
||||
editText,
|
||||
handEditable,
|
||||
editCell,
|
||||
linkable,
|
||||
openLinkMenu,
|
||||
type BillTable,
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
|
||||
export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const { sheets, user, setUser } = ctx;
|
||||
const bill: Row = sheets.bill || { rows: [], totals: null };
|
||||
const rows: DataRow[] = bill.rows.map((r: Row) => ({
|
||||
// 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례)
|
||||
const raw: DataRow[] = bill.rows;
|
||||
const rows: DataRow[] = raw.map((r: Row) => ({
|
||||
...r,
|
||||
connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "",
|
||||
name: r.no ? `${r.no} ${r.name}` : r.name,
|
||||
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "연결"} ▾` : "",
|
||||
}));
|
||||
const direct: Row = bill.totals?.direct || {};
|
||||
const pair = (key: string, label: string) => [
|
||||
money(`price_${key}`, "단가", [label]),
|
||||
money(key, "금액", [label]),
|
||||
];
|
||||
/** 고친 칸 — 셈 줄에 자동 값(auto_칸)이 있음 · 연결 칸 = auto_ref */
|
||||
const edited = (r: DataRow | undefined, key: string): boolean =>
|
||||
!!r && `auto_${key === "connect" ? "ref" : key}` in r;
|
||||
return {
|
||||
columns: [
|
||||
text("name", "공종 · 명칭", 220),
|
||||
@@ -52,41 +59,45 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
total: { name: "순공사비 계(관급 제외)", ...direct },
|
||||
frozen: 1,
|
||||
hint:
|
||||
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
|
||||
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
|
||||
"레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " +
|
||||
"기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " +
|
||||
"금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " +
|
||||
"조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " +
|
||||
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
|
||||
editable: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (r?.kind === "group") return key === "name";
|
||||
if (!r || r.kind !== "item") return false;
|
||||
return r.hand ? handEditable(key) : key === "qty";
|
||||
if (!r) return false;
|
||||
if (r.key === "fee") return key === "qty";
|
||||
if (r.kind !== "item") return key === "name";
|
||||
return key === "qty" || TEXT_KEYS.includes(key);
|
||||
},
|
||||
onEdit: (i, key, value) => {
|
||||
const r = rows[i];
|
||||
if (!r) return;
|
||||
if (r.hand && key !== "qty") return editText(ctx, String(r.key), key, value);
|
||||
if (key !== "qty") return;
|
||||
// 번호 = 셈이 붙임 — 고친 이름 앞 번호는 뗌
|
||||
const head = `${r.no} `;
|
||||
if (key === "name" && r.no && value.startsWith(head)) value = value.slice(head.length);
|
||||
if (key !== "qty") return editCell(ctx, raw, i, key, value.trim());
|
||||
const v = parseNumber(value);
|
||||
if (v === undefined) return;
|
||||
if (r.key !== "fee") return editQty(ctx, r, v);
|
||||
if (r.key !== "fee") return editCell(ctx, raw, i, key, v);
|
||||
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
|
||||
},
|
||||
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (key !== "qty" || !r || !("qty_auto" in r)) return null;
|
||||
return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`;
|
||||
if (!r || !edited(r, key)) return null;
|
||||
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
|
||||
return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
|
||||
},
|
||||
// 182 레벨 — 글자 크기 · 들여쓰기(is-lvN) · 제목 · 묶음 줄 굵게
|
||||
rowClass: (i) => {
|
||||
const r = rows[i];
|
||||
if (!r) return null;
|
||||
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
|
||||
return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" ");
|
||||
return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ");
|
||||
},
|
||||
menu: (i) => billMenu(ctx, rows, i),
|
||||
menu: (i) => billMenu(ctx, raw, i),
|
||||
onCell: (i, key) => {
|
||||
if (key === "connect") openLinkMenu(ctx, rows, i);
|
||||
if (key === "connect") openLinkMenu(ctx, raw, i);
|
||||
},
|
||||
link: (i, key) => {
|
||||
if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Edit.ts
|
||||
* B09 설계내역서 손질(PLAN 166-2) — 우클릭 메뉴 · 손 줄 더하기 · 지우기 · 연결 고르기 · 수량 덮어쓰기.
|
||||
* B09 설계내역서 손질(PLAN 166-2 · 182 · 184-3) — 모든 줄 우클릭 메뉴 · 칸 고침 · 연결 · 차례 · 레벨.
|
||||
*
|
||||
* 저장 = 사용자 값 `user.bill`(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
|
||||
* qty{줄 키: 수} = 자동 줄 수량 덮어쓰기(빈칸 · [수량 자동으로] = 키 지움 → B08 값)
|
||||
* rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref}
|
||||
* 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가)
|
||||
* 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 ·
|
||||
* 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴
|
||||
* 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
|
||||
* {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]}
|
||||
* 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천)
|
||||
* 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct
|
||||
* 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김)
|
||||
* 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level ·
|
||||
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
|
||||
* 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기
|
||||
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -17,322 +19,300 @@ import {
|
||||
type MapContextMenu,
|
||||
type MapContextMenuItem,
|
||||
} from "@ui/ui_template_context_menu";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
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";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
|
||||
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
|
||||
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
|
||||
export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] };
|
||||
|
||||
const NEW_NAME = "새 줄";
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */
|
||||
const MAX_GROUP = 2;
|
||||
const NEW_SECTION = "새 공종";
|
||||
/** 고칠 칸 — 자동 줄은 덮개 */
|
||||
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
|
||||
/** 깊이 한도 — 1 = 맨 위 공종 */
|
||||
const MAX_LEVEL = 5;
|
||||
|
||||
interface Bill {
|
||||
qty: Record<string, number>;
|
||||
v: 2;
|
||||
rows: Row[];
|
||||
gone: string[];
|
||||
}
|
||||
|
||||
function billOf(user: Row): Bill {
|
||||
/** 손질 한 번 = 굳힌 덮개 + 보이는 줄(키 → 셈 출력 줄) */
|
||||
interface Work {
|
||||
bill: Bill;
|
||||
shown: Map<string, DataRow>;
|
||||
}
|
||||
|
||||
/** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */
|
||||
const fixed = (key: string): boolean => key === "fee" || key === "sec:G";
|
||||
|
||||
/** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */
|
||||
export const linkable = (row: DataRow | undefined): boolean =>
|
||||
row?.kind === "item" && !fixed(String(row.key)) && !String(row.key).startsWith("mat:");
|
||||
|
||||
const find = (list: Row[], key: string): number => list.findIndex((e) => e.key === key);
|
||||
const pull = (list: Row[], key: string): Row => list.splice(find(list, key), 1)[0];
|
||||
const same = (a: unknown, b: unknown): boolean =>
|
||||
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
/** 옛 꼴(166-2 · 182) 줄 → v2 칸 — 자동 줄 = 수량 덮어쓰기 · 손 줄 = 모든 칸 */
|
||||
function oldEntry(row: DataRow, bill: Row): Row {
|
||||
const key = String(row.key);
|
||||
if (row.auto) return key in (bill.qty || {}) ? { qty: bill.qty[key] } : {};
|
||||
const hand: Row = (bill.rows || []).find((h: Row) => h.key === key) || {};
|
||||
const out: Row = row.kind === "item" ? {} : { kind: "group" };
|
||||
for (const f of FIELDS) if (f in hand) out[f] = hand[f];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 굳힘 — v2 면 그대로 + 덮개에 없는 보이는 줄(새 자동 줄)을 보이는 앞 줄 뒤에 · 옛 꼴이면 보이는 줄로 */
|
||||
function freeze(user: Row, rows: DataRow[]): Work {
|
||||
const bill: Row = user.bill || {};
|
||||
return { qty: { ...(bill.qty || {}) }, rows: (bill.rows || []).map((r: Row) => ({ ...r })) };
|
||||
const v2 = bill.v === 2;
|
||||
const list: Row[] = v2 ? (bill.rows || []).map((e: Row) => ({ ...e })) : [];
|
||||
const gone: string[] = v2 ? [...(bill.gone || [])] : [];
|
||||
const shown = new Map(rows.map((r) => [String(r.key), r]));
|
||||
let prev = -1;
|
||||
for (const row of rows) {
|
||||
const key = String(row.key);
|
||||
if (key === "fee") continue; // 셈이 관급 묶음 끝에 둠
|
||||
let at = find(list, key);
|
||||
if (at < 0 && !gone.includes(key)) {
|
||||
const entry: Row = { key, ...(row.parent ? { parent: row.parent } : {}) };
|
||||
list.splice(prev + 1, 0, v2 ? entry : { ...entry, ...oldEntry(row, bill) });
|
||||
at = prev + 1;
|
||||
}
|
||||
if (at >= 0) prev = at;
|
||||
}
|
||||
return { bill: { v: 2, rows: list, gone }, shown };
|
||||
}
|
||||
|
||||
function put(ctx: TabContext, bill: Bill): void {
|
||||
ctx.setUser({ ...ctx.user, bill });
|
||||
}
|
||||
|
||||
/** 손 줄 고칠 칸 — 명칭 · 규격 · 단위 · 수량 */
|
||||
export const handEditable = (key: string): boolean => key === "qty" || TEXT_KEYS.includes(key);
|
||||
|
||||
/** 수량 고침 — 손 줄 = 그 줄 수량 · 자동 줄 = 덮어쓰기(빈칸 = 자동으로) */
|
||||
export function editQty(ctx: TabContext, row: DataRow, value: number | null): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const key = String(row.key);
|
||||
if (row.hand) {
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return;
|
||||
own.qty = value;
|
||||
} else if (value === null) delete bill.qty[key];
|
||||
else bill.qty[key] = value;
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 손 줄 글 칸(명칭 · 규격 · 단위) */
|
||||
export function editText(ctx: TabContext, key: string, field: string, value: string): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own || !TEXT_KEYS.includes(field)) return;
|
||||
own[field] = value.trim();
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0);
|
||||
|
||||
/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */
|
||||
function subtreeEnd(rows: DataRow[], i: number): number {
|
||||
let k = i + 1;
|
||||
while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1;
|
||||
return k;
|
||||
}
|
||||
|
||||
/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */
|
||||
function reparent(h: Row, parent: unknown): void {
|
||||
if (parent) h.parent = parent;
|
||||
else delete h.parent;
|
||||
}
|
||||
|
||||
/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */
|
||||
function fits(bill: Bill): boolean {
|
||||
const byKey = new Map(bill.rows.map((h) => [String(h.key), h]));
|
||||
return bill.rows.every((h) => {
|
||||
let n = 0;
|
||||
const seen = new Set([h.key]);
|
||||
let at = byKey.get(String(h.parent ?? ""));
|
||||
while (at && at.kind === "group" && !seen.has(at.key)) {
|
||||
seen.add(at.key);
|
||||
n += 1;
|
||||
at = byKey.get(String(at.parent ?? ""));
|
||||
}
|
||||
return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP);
|
||||
});
|
||||
}
|
||||
|
||||
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */
|
||||
function slot(rows: DataRow[], i: number, below: boolean): Row {
|
||||
const row = rows[i];
|
||||
const section = String(row.section);
|
||||
if (row.kind === "title") return { section, after: `sec:${section}` };
|
||||
const parent = row.hand && row.parent ? { parent: String(row.parent) } : {};
|
||||
if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent };
|
||||
const prev = rows[i - 1];
|
||||
const same = prev && prev.kind !== "title" && prev.section === section;
|
||||
return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent };
|
||||
}
|
||||
|
||||
function newRow(ctx: TabContext, at: Row): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null });
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
|
||||
newRow(ctx, slot(rows, i, below));
|
||||
}
|
||||
|
||||
/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */
|
||||
function addChild(ctx: TabContext, rows: DataRow[], i: number): void {
|
||||
const after = String(rows[subtreeEnd(rows, i) - 1].key);
|
||||
newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) });
|
||||
}
|
||||
|
||||
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */
|
||||
function removeRow(ctx: TabContext, key: string): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const gone = bill.rows.find((h) => h.key === key);
|
||||
if (!gone) return;
|
||||
bill.rows = bill.rows.filter((h) => h.key !== key);
|
||||
for (const h of bill.rows) if (h.after === key) h.after = gone.after;
|
||||
for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent);
|
||||
put(ctx, bill);
|
||||
}
|
||||
|
||||
/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */
|
||||
function levelAction(
|
||||
ctx: TabContext,
|
||||
key: string,
|
||||
change: (own: Row, bill: Bill) => void,
|
||||
): (() => void) | null {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return null;
|
||||
change(own, bill);
|
||||
return fits(bill) ? () => put(ctx, bill) : null;
|
||||
}
|
||||
|
||||
/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */
|
||||
function indentTarget(rows: DataRow[], i: number): DataRow | null {
|
||||
const row = rows[i];
|
||||
for (let j = i - 1; j >= 0; j -= 1) {
|
||||
const r = rows[j];
|
||||
if (lv(r) > lv(row)) continue;
|
||||
return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null;
|
||||
/** 위로 올라가는 줄 — [자기, 부모, …, 맨 위] */
|
||||
function chain(list: Row[], key: string): Row[] {
|
||||
const out: Row[] = [];
|
||||
let at: Row | undefined = list[find(list, key)];
|
||||
while (at && out.length <= list.length) {
|
||||
out.push(at);
|
||||
at = at.parent ? list[find(list, at.parent)] : undefined;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */
|
||||
function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
|
||||
const row = rows[i];
|
||||
const key = String(row.key);
|
||||
const out: MapContextMenuItem[] = [];
|
||||
const add = (label: string, action: (() => void) | null): void => {
|
||||
if (action) out.push([label, action]);
|
||||
};
|
||||
if (row.kind === "group")
|
||||
add(
|
||||
"항목으로 바꾸기",
|
||||
levelAction(ctx, key, (own, bill) => {
|
||||
delete own.kind;
|
||||
for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent);
|
||||
}),
|
||||
);
|
||||
else
|
||||
add(
|
||||
"묶음 줄로 바꾸기",
|
||||
levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })),
|
||||
);
|
||||
const target = indentTarget(rows, i);
|
||||
if (target)
|
||||
add(
|
||||
"들여쓰기 →",
|
||||
levelAction(ctx, key, (own) => reparent(own, String(target.key))),
|
||||
);
|
||||
if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row))
|
||||
add(
|
||||
"내어쓰기 ←",
|
||||
levelAction(ctx, key, (own, bill) =>
|
||||
reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent),
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
|
||||
function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void {
|
||||
const bill = billOf(ctx.user);
|
||||
const own = bill.rows.find((h) => h.key === key);
|
||||
if (!own) return;
|
||||
own.ref = ref;
|
||||
if (from && (!own.name || own.name === NEW_NAME)) {
|
||||
own.name = from.name || own.name;
|
||||
own.spec = own.spec || from.spec || "";
|
||||
own.unit = own.unit || from.unit || "";
|
||||
}
|
||||
put(ctx, bill);
|
||||
/** 차례 바로잡음 — 부모 먼저 · 자손 이어서(목록 차례 = 형제 차례) · 못 찾는 부모 · 돎 = 맨 위 */
|
||||
function tidy(list: Row[]): Row[] {
|
||||
const keys = new Set(list.map((e) => e.key));
|
||||
for (const e of list) if (!e.parent || !keys.has(e.parent)) delete e.parent;
|
||||
for (const e of list) if (chain(list, e.key).length > list.length) delete e.parent;
|
||||
const out: Row[] = [];
|
||||
// ponytail: 형제 찾기 O(n²) — 내역 줄 수(수백)면 충분
|
||||
const walk = (parent?: string): void => {
|
||||
for (const e of list)
|
||||
if (e.parent === parent) {
|
||||
out.push(e);
|
||||
walk(e.key);
|
||||
}
|
||||
};
|
||||
walk();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
|
||||
function pickHopyo(ctx: TabContext, key: string, 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();
|
||||
setRef(ctx, key, { tab, key: r.key }, r);
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
finder.search.addEventListener("input", find);
|
||||
find();
|
||||
body.append(finder.root);
|
||||
requestAnimationFrame(() => finder.search.focus());
|
||||
},
|
||||
});
|
||||
/** 맨 위 묶음 돈 갈래 — direct(직접비) | supply(관급자재대) */
|
||||
function costOf(w: Work, list: Row[], key: string): string {
|
||||
const top = chain(list, key).at(-1);
|
||||
return top?.cost ?? w.shown.get(String(top?.key))?.cost ?? "direct";
|
||||
}
|
||||
|
||||
/** M01 로직 → 입력값 창 → 손 줄 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
|
||||
function askInputs(ctx: TabContext, key: string, code: string, name: string, old?: Row): void {
|
||||
fetchLogicInputs([code]).then(
|
||||
(got) => {
|
||||
const logic = got.로직[0] ?? null;
|
||||
openLaborInput({
|
||||
title: `${code} ${name}`.trim(),
|
||||
logic,
|
||||
values: old?.given || {},
|
||||
memo: null,
|
||||
rate: old?.rate ?? null,
|
||||
readOnly: false,
|
||||
okLabel: "연결",
|
||||
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈",
|
||||
done: (next) => {
|
||||
const unit = String(logic?.결과단위 || "")
|
||||
.replace(/^원\//, "")
|
||||
.replace(/^[\d.]+/, "");
|
||||
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
|
||||
setRef(ctx, key, { ...ref, picks: old?.picks ?? null }, { name, unit });
|
||||
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success");
|
||||
},
|
||||
});
|
||||
},
|
||||
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
|
||||
/** 바꾼 뒤 지킬 것 — 깊이 5 · 자재 줄 돈 갈래 그대로 */
|
||||
function fits(w: Work, before: Row[]): boolean {
|
||||
return w.bill.rows.every(
|
||||
(e) =>
|
||||
chain(w.bill.rows, e.key).length <= MAX_LEVEL &&
|
||||
(!String(e.key).startsWith("mat:") ||
|
||||
costOf(w, w.bill.rows, e.key) === costOf(w, before, e.key)),
|
||||
);
|
||||
}
|
||||
|
||||
function pickLogic(ctx: TabContext, key: string): void {
|
||||
openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름));
|
||||
/** 손질 시도 — 지킬 것 안이면 [동작] · 아니면 null(메뉴에 안 보임) */
|
||||
function attempt(
|
||||
ctx: TabContext,
|
||||
rows: DataRow[],
|
||||
change: (w: Work) => boolean | void,
|
||||
): (() => void) | null {
|
||||
const w = freeze(ctx.user, rows);
|
||||
const before = w.bill.rows.map((e) => ({ ...e }));
|
||||
if (change(w) === false) return null;
|
||||
w.bill.rows = tidy(w.bill.rows);
|
||||
return fits(w, before) ? () => put(ctx, w.bill) : null;
|
||||
}
|
||||
|
||||
type Leaf = [string, () => void];
|
||||
const entryOf = (w: Work, key: string): Row | undefined => w.bill.rows[find(w.bill.rows, key)];
|
||||
const isGroup = (w: Work, key: string): boolean =>
|
||||
(entryOf(w, key)?.kind ?? w.shown.get(key)?.kind ?? "item") !== "item";
|
||||
/** 보이는 형제(차례대로) */
|
||||
const siblings = (w: Work, parent: unknown): Row[] =>
|
||||
w.bill.rows.filter((e) => (e.parent ?? null) === (parent ?? null) && w.shown.has(e.key));
|
||||
|
||||
/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */
|
||||
function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] {
|
||||
const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null;
|
||||
const more: Leaf[] = [];
|
||||
if (ref?.tab === "logic")
|
||||
more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
|
||||
if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]);
|
||||
const picks: Leaf[] = [
|
||||
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
|
||||
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
|
||||
];
|
||||
return [picks, more];
|
||||
const newKey = (): string =>
|
||||
`hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const newItem = (parent?: string): Row => ({
|
||||
key: newKey(),
|
||||
...(parent ? { parent } : {}),
|
||||
name: NEW_NAME,
|
||||
spec: "",
|
||||
unit: "",
|
||||
qty: null,
|
||||
ref: null,
|
||||
});
|
||||
|
||||
/** 칸 고침 — 자동 줄은 덮개(자동 값과 같으면 덮개 지움) · 새 줄은 그 칸 */
|
||||
export function editCell(
|
||||
ctx: TabContext,
|
||||
rows: DataRow[],
|
||||
i: number,
|
||||
field: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const row = rows[i];
|
||||
if (!row || !FIELDS.includes(field)) return;
|
||||
const w = freeze(ctx.user, rows);
|
||||
const e = entryOf(w, String(row.key));
|
||||
if (!e) return;
|
||||
const auto = `auto_${field}` in row ? row[`auto_${field}`] : row[field];
|
||||
if (row.auto && same(auto, value)) delete e[field];
|
||||
else e[field] = value;
|
||||
put(ctx, w.bill);
|
||||
}
|
||||
|
||||
/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */
|
||||
/** 연결 적용 — 새 줄이 「새 줄」 · 빈 이름이면 고른 호표 명칭 · 규격 · 단위를 채움 */
|
||||
function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef {
|
||||
return (ref, from) => {
|
||||
const row = rows[i];
|
||||
const w = freeze(ctx.user, rows);
|
||||
const e = entryOf(w, String(row?.key));
|
||||
if (!row || !e) return;
|
||||
e.ref = ref;
|
||||
if (from && !row.auto && (!e.name || e.name === NEW_NAME)) {
|
||||
e.name = from.name || e.name;
|
||||
e.spec = e.spec || from.spec || "";
|
||||
e.unit = e.unit || from.unit || "";
|
||||
}
|
||||
put(ctx, w.bill);
|
||||
};
|
||||
}
|
||||
|
||||
/** 줄 연결 메뉴 — 지금 연결 = 덮개(로직 입력값 등) 있으면 덮개 · 없으면 셈 줄 */
|
||||
function linkFor(ctx: TabContext, rows: DataRow[], i: number) {
|
||||
const row = rows[i];
|
||||
const e = entryOf(freeze(ctx.user, rows), String(row.key));
|
||||
const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null);
|
||||
return linkItems(ctx, ref, applyRef(ctx, rows, i));
|
||||
}
|
||||
|
||||
/** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */
|
||||
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
|
||||
const row = rows[i];
|
||||
if (!row) return [];
|
||||
const key = String(row.key);
|
||||
const bill = billOf(ctx.user);
|
||||
const items: MapContextMenuItem[] = [];
|
||||
if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]);
|
||||
items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]);
|
||||
if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]);
|
||||
if (row.hand) {
|
||||
if (row.kind !== "group") {
|
||||
const [picks, more] = linkItems(ctx, key);
|
||||
items.push("-", { label: "연결", children: picks }, ...more);
|
||||
}
|
||||
items.push("-", ...levelMenu(ctx, rows, i));
|
||||
items.push("-", ["줄 지우기", () => removeRow(ctx, key)]);
|
||||
} else if (row.kind === "item" && key in bill.qty) {
|
||||
items.push("-", [
|
||||
"수량 자동으로 되돌리기",
|
||||
() => {
|
||||
delete bill.qty[key];
|
||||
put(ctx, bill);
|
||||
},
|
||||
]);
|
||||
const parts: MapContextMenuItem[][] = [[], [], [], []];
|
||||
const add = (part: number, label: string, change: (w: Work, e: Row) => boolean | void): void => {
|
||||
const run = attempt(ctx, rows, (w) => {
|
||||
// 조달수수료 = 차례에 없음(셈이 관급자재대 끝에 둠) — 부모만 빌림
|
||||
const e = entryOf(w, key) ?? (key === "fee" ? { key, parent: row.parent } : undefined);
|
||||
return e ? change(w, e) : false;
|
||||
});
|
||||
if (run) parts[part].push([label, run]);
|
||||
};
|
||||
|
||||
// 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞)
|
||||
add(0, "위에 줄 추가", (w, e) => {
|
||||
if (!e.parent) return false;
|
||||
const at = find(w.bill.rows, key);
|
||||
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent));
|
||||
});
|
||||
add(0, "아래에 줄 추가", (w, e) => {
|
||||
if (!e.parent || key === "fee") return false;
|
||||
w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent));
|
||||
});
|
||||
add(0, "아래에 하위 줄 추가", (w) => {
|
||||
if (!isGroup(w, key)) return false;
|
||||
w.bill.rows.push(newItem(key));
|
||||
});
|
||||
add(0, "새 공종 추가(1단)", (w, e) => {
|
||||
const list = w.bill.rows;
|
||||
const from = find(list, key) < 0 ? String(e.parent) : key;
|
||||
const top = chain(list, from).at(-1)!;
|
||||
const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1);
|
||||
list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION });
|
||||
});
|
||||
if (linkable(row)) {
|
||||
const [picks, more] = linkFor(ctx, rows, i);
|
||||
parts[1].push({ label: "연결", children: picks }, ...more);
|
||||
}
|
||||
return items;
|
||||
// 옮김 · 레벨 — 같은 부모 형제와 자손째
|
||||
if (!fixed(key)) {
|
||||
add(2, "위로 ↑", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const k = sib.indexOf(e);
|
||||
if (k < 1) return false;
|
||||
pull(w.bill.rows, key);
|
||||
w.bill.rows.splice(find(w.bill.rows, sib[k - 1].key), 0, e);
|
||||
});
|
||||
add(2, "아래로 ↓", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const k = sib.indexOf(e);
|
||||
if (k < 0 || k + 1 >= sib.length) return false;
|
||||
pull(w.bill.rows, key);
|
||||
w.bill.rows.splice(find(w.bill.rows, sib[k + 1].key) + 1, 0, e);
|
||||
});
|
||||
add(2, "들여쓰기 →", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const up = sib[sib.indexOf(e) - 1];
|
||||
if (!up || !isGroup(w, up.key)) return false;
|
||||
e.parent = up.key;
|
||||
w.bill.rows.push(pull(w.bill.rows, key));
|
||||
});
|
||||
add(2, "내어쓰기 ←", (w, e) => {
|
||||
const up = e.parent ? entryOf(w, e.parent) : undefined;
|
||||
if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴)
|
||||
e.parent = up.parent;
|
||||
pull(w.bill.rows, key);
|
||||
w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e);
|
||||
});
|
||||
}
|
||||
if (!row.auto && row.kind === "item")
|
||||
add(2, "묶음 줄로 바꾸기", (_w, e) => {
|
||||
Object.assign(e, { kind: "group", qty: null, ref: null });
|
||||
});
|
||||
if (!row.auto && row.kind !== "item")
|
||||
add(2, "항목으로 바꾸기", (w, e) => {
|
||||
if (!e.parent) return false;
|
||||
delete e.kind;
|
||||
for (const c of w.bill.rows) if (c.parent === key) c.parent = e.parent;
|
||||
});
|
||||
// 자동값으로 — 그 줄 고친 칸 지움(차례 · 부모는 그대로)
|
||||
if (row.auto && FIELDS.some((f) => `auto_${f}` in row))
|
||||
add(3, "자동값으로 되돌리기", (_w, e) => {
|
||||
for (const f of FIELDS) delete e[f];
|
||||
});
|
||||
// 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움
|
||||
if (!fixed(key) && !key.startsWith("mat:"))
|
||||
add(3, "줄 지우기", (w, e) => {
|
||||
const list = w.bill.rows;
|
||||
if (!e.parent && list.some((c) => c.parent === key)) return false;
|
||||
for (const c of list) if (c.parent === key) c.parent = e.parent;
|
||||
pull(list, key);
|
||||
if (row.auto) w.bill.gone.push(key);
|
||||
});
|
||||
const out: MapContextMenuItem[] = [];
|
||||
for (const part of parts)
|
||||
if (part.length) out.push(...(out.length ? ["-" as const] : []), ...part);
|
||||
return out;
|
||||
}
|
||||
|
||||
let shared: MapContextMenu | null = null;
|
||||
@@ -341,10 +321,9 @@ let lastCell: Element | null = null;
|
||||
|
||||
/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */
|
||||
export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void {
|
||||
const row = rows[i];
|
||||
const box = lastCell?.getBoundingClientRect();
|
||||
if (!shared || !box || !row?.hand || row.kind !== "item") return;
|
||||
const [picks, more] = linkItems(ctx, String(row.key));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Link.ts
|
||||
* B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) ·
|
||||
* M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기.
|
||||
*
|
||||
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
|
||||
* 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
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";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
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());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
|
||||
function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
|
||||
fetchLogicInputs([code]).then(
|
||||
(got) => {
|
||||
const logic = got.로직[0] ?? null;
|
||||
openLaborInput({
|
||||
title: `${code} ${name}`.trim(),
|
||||
logic,
|
||||
values: old?.given || {},
|
||||
memo: null,
|
||||
rate: old?.rate ?? null,
|
||||
readOnly: false,
|
||||
okLabel: "연결",
|
||||
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈",
|
||||
done: (next) => {
|
||||
const unit = String(logic?.결과단위 || "")
|
||||
.replace(/^원\//, "")
|
||||
.replace(/^[\d.]+/, "");
|
||||
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
|
||||
apply({ ...ref, picks: old?.picks ?? null }, { name, unit });
|
||||
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success");
|
||||
},
|
||||
});
|
||||
},
|
||||
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
|
||||
);
|
||||
}
|
||||
|
||||
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · 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")],
|
||||
[
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
() => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)),
|
||||
],
|
||||
];
|
||||
return [picks, more];
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"""184-3 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 지움 → gone · 옮김 · 새 공종 ·
|
||||
자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선.
|
||||
|
||||
- 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄
|
||||
- 저장 = `user.bill` v2 {v: 2, rows: 표 차례 {key · parent · 고친 칸}, gone}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||||
|
||||
DG = {"tab": "dg", "key": "DG:a"}
|
||||
|
||||
|
||||
def _row(key, parent, level, kind="item", cost="direct", **more):
|
||||
auto = not key.startswith("hand:")
|
||||
row = {"key": key, "kind": kind, "parent": parent, "level": level, "cost": cost, "no": "",
|
||||
"name": key, "spec": "", "unit": "", "qty": None, "ref": None, "note": ""} # fmt: skip
|
||||
return {**row, **({"auto": True} if auto else {"hand": True}), **more}
|
||||
|
||||
|
||||
ROWS = [
|
||||
_row("sec:1", None, 1, "title", no="1", name="토공사"),
|
||||
_row("sec:1.2", "sec:1", 2, "title", no="1.1", name="절토"),
|
||||
_row("earth:4", "sec:1.2", 3, qty=10, ref={"tab": "sg_list", "key": "D01341"}),
|
||||
_row("earth:5", "sec:1.2", 3, qty=9, auto_qty=5),
|
||||
_row("hand:g", "sec:1.2", 3, "group"),
|
||||
_row("hand:a", "hand:g", 4, ref=DG, note="대가3"),
|
||||
_row("hand:b", "sec:1.2", 3),
|
||||
_row("sec:4", None, 1, "title", no="4", name="자재대"),
|
||||
_row("mat:M1", "sec:4", 2, qty=3),
|
||||
_row("sec:G", None, 1, "title", "supply", name="관급자재대"),
|
||||
_row("mat:M2", "sec:G", 2, cost="supply", qty=4),
|
||||
_row("fee", "sec:G", 2, cost="supply", qty=None),
|
||||
]
|
||||
#: 옛 꼴 덮개(166-2 · 182) — 자동 줄 수량 · 손 줄
|
||||
OLD = {
|
||||
"bill": {
|
||||
"qty": {"earth:5": 9},
|
||||
"rows": [
|
||||
{"key": "hand:g", "section": "1.2", "after": "earth:5", "kind": "group", "name": "g"},
|
||||
{"key": "hand:a", "section": "1.2", "after": "hand:g", "parent": "hand:g", "name": "a",
|
||||
"qty": 2, "ref": DG},
|
||||
{"key": "hand:b", "section": "1.2", "after": "hand:a", "name": "b", "qty": 1},
|
||||
],
|
||||
}
|
||||
} # fmt: skip
|
||||
|
||||
MENU_JS = """
|
||||
const rows = __ROWS__;
|
||||
const start = __USER__;
|
||||
let user = start;
|
||||
const ctx = { sheets: {}, payload: {}, get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label);
|
||||
const at = (key) => rows.findIndex((r) => r.key === key);
|
||||
const labels = (key) => billMenu(ctx, rows, at(key)).map(name);
|
||||
const run = (key, label) => {
|
||||
user = start;
|
||||
const item = billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label);
|
||||
if (!item) return null;
|
||||
item[1]();
|
||||
return user.bill;
|
||||
};
|
||||
const edit = (key, field, value) => {
|
||||
user = start;
|
||||
editCell(ctx, rows, at(key), field, value);
|
||||
return user.bill;
|
||||
};
|
||||
const out = { labels: {}, runs: {}, edits: {} };
|
||||
for (const key of __LABELS__) out.labels[key] = labels(key);
|
||||
for (const [key, label] of __RUNS__) out.runs[`${key}|${label}`] = run(key, label);
|
||||
for (const [key, field, value] of __EDITS__) out.edits[`${key}|${field}`] = edit(key, field, value);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _node(source: str) -> str:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript"],
|
||||
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _ts(names: list[str]) -> str:
|
||||
return "".join(
|
||||
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
|
||||
for n in names
|
||||
)
|
||||
|
||||
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]
|
||||
|
||||
|
||||
def _menu(user, labels=(), runs=(), edits=(), rows=ROWS):
|
||||
body = (
|
||||
MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
||||
.replace("__USER__", json.dumps(user, ensure_ascii=False))
|
||||
.replace("__LABELS__", json.dumps(list(labels), ensure_ascii=False))
|
||||
.replace("__RUNS__", json.dumps([list(r) for r in runs], ensure_ascii=False))
|
||||
.replace("__EDITS__", json.dumps([list(e) for e in edits], ensure_ascii=False))
|
||||
)
|
||||
return json.loads(_node(_ts(SRC) + body))
|
||||
|
||||
|
||||
def _order(bill):
|
||||
return [(e["key"], e.get("parent")) for e in bill["rows"]]
|
||||
|
||||
|
||||
ORDER = [(r["key"], r["parent"]) for r in ROWS if r["key"] != "fee"]
|
||||
|
||||
|
||||
def test_메뉴_모든_줄():
|
||||
out = _menu({}, labels=["sec:1", "earth:4", "earth:5", "hand:g", "hand:a", "fee", "mat:M2",
|
||||
"mat:M1", "sec:G"])["labels"] # fmt: skip
|
||||
add = ["위에 줄 추가", "아래에 줄 추가"]
|
||||
# 맨 위 제목 = 하위 줄 · 새 공종 · 아래로(자식 있는 맨 위 = 못 지움)
|
||||
assert out["sec:1"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "아래로 ↓"]
|
||||
# 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음)
|
||||
assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-",
|
||||
"아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip
|
||||
assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓",
|
||||
"내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑",
|
||||
"아래로 ↓", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
|
||||
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
|
||||
assert out["fee"] == ["위에 줄 추가", "새 공종 추가(1단)"] # 조달수수료 = 관급자재대 끝 고정
|
||||
assert out["mat:M2"] == [*add, "새 공종 추가(1단)"]
|
||||
assert out["mat:M1"] == [*add, "새 공종 추가(1단)"]
|
||||
assert out["sec:G"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)"]
|
||||
|
||||
|
||||
def test_칸_고침_덮개_옛_꼴_굳힘():
|
||||
out = _menu(OLD, edits=[("earth:4", "name", "절토A"), ("earth:5", "qty", 5),
|
||||
("earth:4", "qty", 10), ("earth:4", "spec", None),
|
||||
("hand:a", "qty", 7), ("sec:1.2", "name", "절토공")])["edits"] # fmt: skip
|
||||
bill = out["earth:4|name"]
|
||||
# 첫 손질 = 보이는 줄 키 · 부모 · 차례로 v2(조달수수료 빼고) · 옛 손 줄 칸 · 수량 덮개 옮김
|
||||
assert (bill["v"], bill["gone"], _order(bill)) == (2, [], ORDER)
|
||||
rows = {e["key"]: e for e in bill["rows"]}
|
||||
assert rows["earth:4"] == {"key": "earth:4", "parent": "sec:1.2", "name": "절토A"}
|
||||
assert rows["earth:5"] == {"key": "earth:5", "parent": "sec:1.2", "qty": 9}
|
||||
assert rows["hand:g"] == {"key": "hand:g", "parent": "sec:1.2", "kind": "group", "name": "g"}
|
||||
assert rows["hand:a"] == {"key": "hand:a", "parent": "hand:g", "name": "a", "qty": 2, "ref": DG}
|
||||
assert "section" not in rows["hand:b"] and "after" not in rows["hand:b"]
|
||||
# 자동 값과 같게 고침 = 덮개 지움 · 안 고친 값 그대로 = 덮개 안 생김 · null = 일부러 빈칸
|
||||
assert "qty" not in {e["key"]: e for e in out["earth:5|qty"]["rows"]}["earth:5"]
|
||||
assert "qty" not in {e["key"]: e for e in out["earth:4|qty"]["rows"]}["earth:4"]
|
||||
assert {e["key"]: e for e in out["earth:4|spec"]["rows"]}["earth:4"]["spec"] is None
|
||||
assert {e["key"]: e for e in out["hand:a|qty"]["rows"]}["hand:a"]["qty"] == 7
|
||||
assert {e["key"]: e for e in out["sec:1.2|name"]["rows"]}["sec:1.2"]["name"] == "절토공"
|
||||
|
||||
|
||||
V2 = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k, p in ORDER
|
||||
if k != "hand:b"], "gone": []}} # fmt: skip
|
||||
|
||||
|
||||
def test_지움_옮김_새_공종_자동값으로():
|
||||
user = {"bill": {**V2["bill"], "rows": [
|
||||
{**e, **({"qty": 9} if e["key"] == "earth:5" else {})} for e in V2["bill"]["rows"]]}} # fmt: skip
|
||||
runs = [("earth:4", "줄 지우기"), ("hand:g", "줄 지우기"), ("earth:5", "위로 ↑"),
|
||||
("earth:4", "아래로 ↓"), ("earth:4", "새 공종 추가(1단)"),
|
||||
("mat:M2", "새 공종 추가(1단)"), ("earth:5", "자동값으로 되돌리기"),
|
||||
("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "아래에 하위 줄 추가"),
|
||||
("earth:4", "위에 줄 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"),
|
||||
("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "위에 줄 추가"),
|
||||
("fee", "새 공종 추가(1단)")] # fmt: skip
|
||||
out = _menu(user, runs=runs)["runs"]
|
||||
keys = lambda b: [k for k, _ in _order(b)] # noqa: E731
|
||||
# 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘
|
||||
base = keys(out["earth:5|위로 ↑"])
|
||||
assert "hand:b" in base and base.index("hand:b") == base.index("hand:a") + 1
|
||||
# 지움 — 자동 줄 = gone · 새 줄 = 자식은 지운 줄 부모로
|
||||
b = out["earth:4|줄 지우기"]
|
||||
assert b["gone"] == ["earth:4"] and "earth:4" not in keys(b)
|
||||
b = out["hand:g|줄 지우기"]
|
||||
assert b["gone"] == [] and ("hand:a", "sec:1.2") in _order(b) and "hand:g" not in keys(b)
|
||||
# 위로 · 아래로 = 같은 부모 형제와 자리 바꿈
|
||||
for k in ("earth:5|위로 ↑", "earth:4|아래로 ↓"):
|
||||
got = keys(out[k])
|
||||
assert got[got.index("earth:5") + 1] == "earth:4"
|
||||
# 새 공종 = 맨 위 묶음 · cost direct · 그 맨 위 공종 뒤 / 관급자재대 앞
|
||||
b = out["earth:4|새 공종 추가(1단)"]
|
||||
new = [e for e in b["rows"] if e["key"].startswith("hand:") and e.get("cost")][0]
|
||||
assert (new["kind"], new["cost"], new["name"], "parent" in new) == (
|
||||
"group", "direct", "새 공종", False) # fmt: skip
|
||||
assert keys(b)[keys(b).index(new["key"]) + 1] == "sec:4"
|
||||
b = out["mat:M2|새 공종 추가(1단)"]
|
||||
assert keys(b)[keys(b).index("sec:G") - 1].startswith("hand:")
|
||||
# 자동값으로 = 고친 칸 지움(차례 · 부모 그대로)
|
||||
b = out["earth:5|자동값으로 되돌리기"]
|
||||
assert {e["key"]: e for e in b["rows"]}["earth:5"] == {"key": "earth:5", "parent": "sec:1.2"}
|
||||
# 들여쓰기 · 내어쓰기
|
||||
assert ("hand:b", "hand:g") in _order(out["hand:b|들여쓰기 →"])
|
||||
got = _order(out["hand:a|내어쓰기 ←"])
|
||||
assert got[got.index(("hand:g", "sec:1.2")) + 1] == ("hand:a", "sec:1.2")
|
||||
got = _order(out["earth:4|내어쓰기 ←"]) # 1.1 → 1 아래(제목 뒤)
|
||||
assert got[got.index(("sec:1.2", "sec:1")) + 1 :].index(("earth:4", "sec:1")) >= 0
|
||||
# 하위 줄 = 마지막 자식 뒤 · 위에 줄 = 같은 부모 앞 · 새 줄 = kind 없음(항목)
|
||||
got = out["sec:1.2|아래에 하위 줄 추가"]["rows"]
|
||||
kids = [e for e in got if e.get("parent") == "sec:1.2"]
|
||||
assert kids[-1]["name"] == "새 줄" and "kind" not in kids[-1]
|
||||
got = out["earth:4|위에 줄 추가"]["rows"]
|
||||
at = [e["key"] for e in got].index("earth:4")
|
||||
assert (got[at - 1]["name"], got[at - 1]["parent"]) == ("새 줄", "sec:1.2")
|
||||
a = {e["key"]: e for e in out["hand:a|묶음 줄로 바꾸기"]["rows"]}["hand:a"]
|
||||
assert (a["kind"], a["qty"], a["ref"]) == ("group", None, None)
|
||||
got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]}
|
||||
assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2"
|
||||
# 자동 줄 연결 풀기 = ref 덮개 null
|
||||
assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None
|
||||
# 조달수수료 위 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) · 새 공종 = 관급자재대 앞
|
||||
got = out["fee|위에 줄 추가"]["rows"]
|
||||
assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2")
|
||||
assert keys(out["fee|새 공종 추가(1단)"])[-3].startswith("hand:") # … 새 공종 · sec:G · mat:M2
|
||||
|
||||
|
||||
def test_관급_옮김_막힘_깊이_5():
|
||||
# 차례 = 1 · 관급자재대 · 4 자재대(별도 자재 mat:M1) · 새 공종(자재 없음)
|
||||
order = [*[(k, p) for k, p in ORDER if not k.startswith(("sec:4", "mat:M1"))],
|
||||
("sec:4", None), ("mat:M1", "sec:4"), ("hand:n", None)] # fmt: skip
|
||||
by = {r["key"]: r for r in ROWS}
|
||||
rows = [by.get(k) or _row(k, None, 1, "group") for k, _ in order]
|
||||
user = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k, p in order],
|
||||
"gone": []}} # fmt: skip
|
||||
out = _menu(user, labels=["sec:4", "hand:n"], rows=rows)["labels"]
|
||||
assert "들여쓰기 →" not in out["sec:4"] # 별도 자재가 관급자재대 안으로 → 막음
|
||||
assert "들여쓰기 →" in out["hand:n"] # 자재 없는 묶음 = 들여쓰기 됨(위 형제 4 자재대)
|
||||
# 깊이 5 — 레벨 5 묶음에 하위 줄 · 레벨 5 줄 들여쓰기 = 막음
|
||||
deep = [
|
||||
_row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"),
|
||||
_row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"),
|
||||
_row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5),
|
||||
] # fmt: skip
|
||||
out = _menu({}, labels=["hand:g3", "hand:z", "hand:g2"], rows=deep)["labels"]
|
||||
assert "아래에 하위 줄 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"]
|
||||
assert "아래에 하위 줄 추가" in out["hand:g2"]
|
||||
|
||||
|
||||
TABLE_JS = """
|
||||
let user = __USER__;
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
|
||||
get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const table = buildBill(ctx);
|
||||
const at = (key) => table.rows.findIndex((r) => r.key === key);
|
||||
const out = {
|
||||
names: ["sec:1", "sec:1.2", "earth:4"].map((k) => table.rows[at(k)].name),
|
||||
connect: ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"].map((k) => table.rows[at(k)].connect),
|
||||
cls: [["earth:5", "qty"], ["earth:5", "name"], ["earth:4", "qty"]].map(([k, c]) => table.cellClass(at(k), c)),
|
||||
tip: table.tip(at("earth:5"), "qty"),
|
||||
edit: [["sec:1", "name"], ["sec:1", "qty"], ["earth:4", "spec"], ["fee", "qty"], ["fee", "name"]]
|
||||
.map(([k, c]) => table.editable(at(k), c)),
|
||||
hint: table.hint,
|
||||
};
|
||||
table.onEdit(at("sec:1.2"), "name", "1.1 절토공");
|
||||
out.renamed = user.bill.rows.find((e) => e.key === "sec:1.2").name;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_표_칸_강조_번호_연결_칸():
|
||||
body = TABLE_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)).replace(
|
||||
"__USER__", "{}"
|
||||
)
|
||||
out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body))
|
||||
assert out["names"] == ["1 토공사", "1.1 절토", "earth:4"] # 번호 = 셈 no
|
||||
assert out["connect"] == ["연결됨 ▾", "연결 ▾", "대가3 ▾", "", "", ""]
|
||||
assert out["cls"] == ["is-edited", None, None]
|
||||
assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]"
|
||||
assert out["edit"] == [True, False, True, True, False]
|
||||
assert "엑셀 꼴 고정" not in out["hint"] and "내역 초기화" in out["hint"]
|
||||
assert out["renamed"] == "절토공" # 번호는 떼고 이름만
|
||||
|
||||
|
||||
def test_내역_초기화_배선():
|
||||
src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
block = src[src.index('label: "내역 초기화"') :]
|
||||
block = block[: block.index("b09BillReset")]
|
||||
# 확인 창 → bill 뺀 사용자 값으로 바로 PUT(덮개 빔) · 좌측 단추 줄
|
||||
assert "showConfirmDialog(" in block and "if (!ok) return;" in block
|
||||
assert "const { bill: _bill, ...rest } = user;" in block
|
||||
assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block
|
||||
assert "actions.append(save, reset);" in src
|
||||
Reference in New Issue
Block a user