Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4

This commit is contained in:
2026-10-05 00:53:41 +09:00
36 changed files with 1306 additions and 721 deletions
@@ -351,9 +351,11 @@ export class TableEntity implements Entity {
data.cells,
data.style
);
table.id = jsonEntity.id;
table.lineColor = jsonEntity.lineColor;
table.lineWidth = jsonEntity.lineWidth;
// 서버가 만든 표(면적산출 → 도면 · M02 41장)는 id · 선 색 · 굵기가 없을 수 있다 — 비면 새 표의
// 기본값(선 색 = 자동색 `#fff` · 바탕 따라 흰/검정)으로 채운다. 놓기 · [업데이트] · 다시 열기가 모두 여기를 지난다.
table.id = jsonEntity.id || table.id;
table.lineColor = jsonEntity.lineColor || table.lineColor;
table.lineWidth = jsonEntity.lineWidth ?? table.lineWidth;
table.lineDash = jsonEntity.lineDash;
return table;
}
@@ -77,6 +77,28 @@ describe('표 놓기(PLACETABLE)', () => {
});
});
describe('선 색 없는 표(서버 표 변환 답 · 43-6)', () => {
it('id · 선 색 · 굵기가 없으면 기본값(자동색 #fff · 1 · 새 id)으로 채워 놓고 저장 JSON 에도 남음', async () => {
const json = await table('T8').toJson();
if (!json) throw new Error('표 JSON 없음');
const { id: _id, lineColor: _color, lineWidth: _width, layerId: _layer, ...bare } = json;
await handleTableMessage(
{ type: AISLO_PLACE_TABLE_MESSAGE, entity: bare as typeof json, link: 'T8' },
vi.fn()
);
const pending = getPendingTable();
expect(pending?.lineColor).toBe('#fff');
expect(pending?.lineWidth).toBe(1);
const placed = tableAt(pending as TableEntity, new Point(0, 0), layer.id);
placed.lineColor = pending?.lineColor ?? '';
const saved = await placed.toJson();
expect(saved?.lineColor).toBe('#fff');
const back = await TableEntity.fromJson(bare as typeof json);
expect(back.id).toBeTruthy();
expect(back.lineColor).toBe('#fff');
});
});
describe('표 칸 갈기(update-table)', () => {
it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => {
const linked = table('T1');
+2 -5
View File
@@ -325,11 +325,8 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An
mats = []
for one in doc.get("자재") or []:
code = str(one.get("코드") or "")
try:
price, _ = mm.element(snapshot, code, {}, one.get("단위") or None)
except mf.FormulaError:
price = None
mats.append({"코드": code, "알약": labor_doc.material_key(code), "기준단가": price})
got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3)
mats.append({"코드": code, "알약": labor_doc.material_key(code), **got})
settled = labor_doc.settle(
to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area)
)
+28 -12
View File
@@ -5,7 +5,8 @@
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값.
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
"""
@@ -247,6 +248,24 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]:
"""자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element`
(값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만."""
row = master.get(code) or {}
prices = {slot: row.get(slot) for slot in mm.SLOTS}
try:
price, source = mm.element(master, code, {}, unit or None)
except mf.FormulaError as e:
return {"기준단가": None, "가격": prices, "기준칸": None, "까닭": str(e)}
slot = str(source).split(".")[-1] if source else None
return {
"기준단가": price,
"가격": prices,
"기준칸": slot if slot in mm.SLOTS else None,
"까닭": None if price is not None else "단가 없음",
}
def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
@@ -277,17 +296,14 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None})
continue
lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None})
mats = []
for one in doc.get("자재") or []:
try:
price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None)
except mf.FormulaError as e:
price, why = None, str(e)
else:
why = None if price is not None else "단가 없음"
mats.append(
{"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why}
)
mats = [
{
"코드": one["코드"],
"알약": material_key(one["코드"]),
**material_price(whole, one["코드"], one.get("단위")),
}
for one in doc.get("자재") or []
]
have = _area_of(doc.get("열")) if given is None else given
return settle(_json({"줄": lines, "자재": mats}), doc, have)
@@ -224,16 +224,27 @@ export const copyPartFrom = async (
column: string,
source: string,
from: { layer: Layer | "practice"; owner: string | null } | null,
pair = false,
): Promise<void> => {
const q = new URLSearchParams({ from: source });
if (from) q.set("from_layer", from.layer);
if (from?.owner) q.set("from_owner", from.owner);
if (pair) q.set("pair", "true");
const part = kind === "structure" ? "structures" : kind;
await call(owned(activeLayer, `/layers/${activeLayer}/${part}/${enc(column)}/copy?${q}`), {
method: "POST",
});
};
/** 일위대가 · 면적산출 함께 가져오기(43-9a · 카드 [가져오기]) — 원본 열의 면적산출(시트 · 알약 연결)과
* 일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 ·
* 원본에 면적산출이 없으면(실무문서) 일위대가만 · 인자는 `copyPartFrom` 과 같음 */
export const copyLaborAreaFrom = (
column: string,
source: string,
from: { layer: Layer | "practice"; owner: string | null } | null,
): Promise<void> => copyPartFrom("labor", column, source, from, true);
/** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */
export const clearPart = (
kind: "structure" | "labor" | "area",
@@ -1,15 +1,13 @@
/* =============================================================================
* M02_MasterTemplete_AreaCellPick.ts
* 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌.
* 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어
* 일위대가 [저장] 때 함께 저장(여기는 저장 안 함).
* · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에
* · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null)
* · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true` → `linkAreaCell(…, true)`) · [취소](모달 그대로)
* 면적산출 칸 고르기 시트(PLAN 40-3 · 43-1) — 그 열 면적산출 문서를 읽기만 시트로 붙이고 칸을 누르면 그 주소를 알림.
* 일위대가 수량산출 모달(sub2 `Labor_QtyModal`)이 모달 안에 바로 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에
* 이어 일위대가 [저장] 때 함께 저장(여기는 저장 안 함).
* · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 실제 값(식 칸 = 푼 값)
* · 칸 누름 · Enter = 그 칸 · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true`) · [취소](그대로)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { el } from "@ui/ui_template_elements";
import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask";
import { linksAt } from "../A00_Common/spreadsheet/spreadsheet_link_set";
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
@@ -19,118 +17,99 @@ import "./M02_MasterTemplete_AreaPanel.css";
export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";
const TEXT = {
title: "엑셀에서 고르기 — {value}",
close: "닫기",
hint: "칸을 누르면 그 칸으로 이음 · Enter 도 됨",
now: "지금 연결 {value}",
none: "지금 연결 없음",
unlink: "연결 풀기",
loading: "면적산출을 읽는 중…",
failed: "면적산출을 못 읽음 — {value}",
};
const fill = (text: string, value: string): string => text.replace("{value}", value);
export interface AreaCellPickOptions {
/** 고른 칸 — `{칸: "B6"}` = 그 칸(다른 알약과 함께) · `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */
export interface AreaCellPick {
칸: string;
바꾸기?: boolean;
}
export interface AreaSheetOptions {
/** 그 열 면적산출 문서(저장 안 한 것 포함) — 없으면 `layer` · `column` 저장본을 읽음 */
doc?: Workbook | null;
layer: Layer;
/** 집계표 열 id */
column: string;
/** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 지금 이은 칸을 보임 */
/** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 같은 칸 물음에서 자기는 뺌 */
pill: string;
/** 모달 제목에 쓸 줄 이름 */
label: string;
/** 칸을 고름(물음까지 끝난 뒤) */
onPick: (pick: AreaCellPick) => void;
/** 시트를 다 읽음(그 문서) · 못 읽으면 null */
onLoad?: (book: Workbook | null) => void;
}
/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸(다른 알약과 함께) ·
* `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */
export type AreaCellPick = { 칸: string | null; 바꾸기?: boolean } | null;
export interface AreaSheetHandle {
destroy: () => void;
}
export function pickAreaCell(opts: AreaCellPickOptions): Promise<AreaCellPick> {
return new Promise((resolve) => {
let handle: SpreadsheetHandle | null = null;
let done = false;
let picked = "A1";
let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름
const finish = (value: AreaCellPick): void => {
/** 읽기만 면적산출 시트를 `host` 에 붙임 — 칸을 눌러 뗌 · Enter = 그 칸(처음 고름 A1 · 화살표 옮김은 안 고름) */
export function mountAreaSheet(host: HTMLElement, opts: AreaSheetOptions): AreaSheetHandle {
let handle: SpreadsheetHandle | null = null;
let done = false;
let picked = "A1";
let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름
let book: Workbook | null = null;
host.replaceChildren(el("p", { className: "m02-cellpick__now", text: TEXT.loading }));
const choose = async (): Promise<void> => {
const at = picked;
const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id;
const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : [];
const choice = others.length
? await askShare(
at,
others.map((l) => l.이름 ?? l.키),
)
: "함께";
if (!choice || done) return;
opts.onPick(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });
};
void (async () => {
try {
const [{ createSpreadsheet }, got] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
opts.doc
? Promise.resolve(opts.doc)
: readTemplate(opts.layer, "area", opts.column, null).then((got) => got.문서 as Workbook),
]);
if (done) return;
book = got;
host.replaceChildren();
handle = createSpreadsheet(host, structuredClone(got), {
readOnly: true,
tabs: false,
onSelect: (s) => {
picked = s.칸;
moved = true;
},
});
const grid = host.querySelector(".ss-grid-host");
grid?.addEventListener("mousedown", () => (moved = false), true);
grid?.addEventListener("mouseup", (e) => {
if ((e as MouseEvent).button === 0 && moved) void choose();
});
host.addEventListener("keydown", (e) => e.key === "Enter" && void choose());
opts.onLoad?.(got);
} catch (error) {
if (done) return;
host.replaceChildren(
el("p", {
className: "m02-cellpick__now",
text: fill(TEXT.failed, error instanceof Error ? error.message : ""),
}),
);
opts.onLoad?.(null);
}
})();
return {
destroy: () => {
done = true;
handle?.destroy();
handle = null;
resolve(value);
};
const { close } = openModal({
title: fill(TEXT.title, opts.label),
closeLabel: TEXT.close,
dialogClass: "m02-modal m02-cellpick",
beforeClose: () => (finish(null), true),
mount: (body) => {
const head = el("p", { className: "m02-cellpick__now", text: TEXT.loading });
const unlink = createButton({
label: TEXT.unlink,
variant: "ghost",
size: "sm",
onClick: () => (finish({ 칸: null }), close()),
});
unlink.hidden = true;
const host = el("div", { className: "m02-cellpick__sheet" });
body.append(
el("div", { className: "m02-cellpick__head", children: [head, unlink] }),
el("p", { className: "m02-cellpick__hint", text: TEXT.hint }),
host,
);
let book: Workbook | null = null;
const choose = async (): Promise<void> => {
const at = picked;
const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id;
const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : [];
const choice = others.length
? await askShare(
at,
others.map((l) => l.이름 ?? l.키),
)
: "함께";
if (!choice || done) return;
finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });
close();
};
void (async () => {
try {
const [{ createSpreadsheet }, got] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
opts.doc
? Promise.resolve(opts.doc)
: readTemplate(opts.layer, "area", opts.column, null).then(
(got) => got.문서 as Workbook,
),
]);
if (done) return;
book = got;
const at = got.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸;
head.textContent = at ? fill(TEXT.now, at) : TEXT.none;
unlink.hidden = !at;
handle = createSpreadsheet(host, structuredClone(got), {
readOnly: true,
tabs: false,
onSelect: (s) => {
picked = s.칸;
moved = true;
},
});
// 칸을 눌러 뗌 = 그 칸(처음 고름 A1 · 화살표 옮김은 안 닫음)
const grid = host.querySelector(".ss-grid-host");
grid?.addEventListener("mousedown", () => (moved = false), true);
grid?.addEventListener("mouseup", (e) => {
if ((e as MouseEvent).button === 0 && moved) void choose();
});
host.addEventListener("keydown", (e) => e.key === "Enter" && void choose());
} catch (error) {
if (done) return;
head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : "");
}
})();
},
});
});
},
};
}
@@ -19,7 +19,12 @@ import type {
SpreadsheetHandle,
Workbook,
} from "../A00_Common/spreadsheet/spreadsheet";
import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch";
import {
readTemplate,
saveTemplate,
type StructureDoc,
type StructureNumbers,
} from "./M02_MasterTemplete_Api_Fetch";
import {
cardShell,
helpButton,
@@ -35,7 +40,7 @@ import {
type DrawingCardHandle,
} from "./M02_MasterTemplete_DrawingCard";
import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource";
import type { LaborViewHandle } from "./M02_MasterTemplete_Labor_View";
import type { LaborDetailHandle } from "./M02_MasterTemplete_Labor_Detail";
import "./M02_MasterTemplete_Structure.css";
/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 */
@@ -74,9 +79,6 @@ const TEXT = {
clearAsk: "「{name}」 의 {value} 을(를) 비울까? — 번호 · 열은 그대로 · 되돌릴 수 없음",
clearFailed: "비우지 못함 — {value}",
takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로",
blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴",
makeNew: "새로 만들기",
makeCopy: "복사 생성",
};
/** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */
@@ -93,11 +95,6 @@ const SHEET_KEYS: [string, string][] = [
];
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 빈 통합문서 — 값 · 식이 든 칸도 알약 연결도 하나도 없음 */
const emptyBook = (book: Workbook): boolean =>
(book.시트 ?? []).every(
(s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식),
);
const fill = (text: string, value: string): string => text.replace("{value}", value);
/** 스프레드시트는 면적산출 카드를 열 때만 받음 */
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
@@ -119,13 +116,13 @@ export interface ItemCardsOptions {
onDirty?: (dirty: boolean) => void;
/** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
sheetEdit?: SheetEditOptions;
/** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */
/** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */
onImport?: (kind: CardKind) => void;
/** 카드 [삭제](40-2) — 그 항목 비우기(번호 · 열 그대로 · 물음은 카드가 한 번) · 없으면 단추 없음 ·
* 끝나면 카드를 다시 읽음(빈 카드 = [새로 만들기] [복사 생성]) */
* 끝나면 카드를 다시 읽음(빈 카드 = 빈 몸 + 같은 단추) */
onClear?: (kind: CardKind) => Promise<void>;
/** 일위대가 카드(39-5 · 몸 = 일위대가 보기 갈래 `Labor_View`) — 도면 다음 · 지금 층에서 읽음 ·
* [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */
/** 일위대가 카드(39-5 · 몸 = 일위대가 상세 카드 갈래 `Labor_Detail` · 43-4) — 도면 다음 · 지금 층에서 읽음 ·
* [일위대가 수정] = 이것(일위대가 상세) */
labor?: () => void;
}
@@ -179,9 +176,9 @@ interface Card {
saver: SaveControl;
help: HTMLElement;
edit: HTMLButtonElement;
/** 「복사해 가져오기」 — 찬 카드만 */
/** 「가져오기」 — 빈 카드도 */
take: HTMLButtonElement;
/** [삭제] — 찬 카드만 · 쓸 수 있을 때만 */
/** [삭제] — 쓸 수 있을 때만 */
del: HTMLButtonElement;
no: HTMLElement;
name: HTMLElement;
@@ -194,8 +191,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
let docVersion: string | null = null;
let preview: DrawingCardHandle | null = null;
let seq = 0;
/** 빈 카드에서 [새로 만들기] 를 누른 칸 — 빈 문서라도 미리보기 편집을 띄움(열이 바뀌면 비움) */
const started = new Set<CardKind>();
const numberOf = (kind: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? "";
const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());
@@ -247,44 +242,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
return void card?.shell.fail(fill(TEXT.clearFailed, why(error)), reload);
}
for (const c of cards) c.saver.mark(false);
started.delete(kind);
reload();
}
/** 빈 카드(39-5) — 고칠 수 있고(가져오기 길이 있는 M02 화면) [새로 만들기] 를 안 눌렀으면 두 단추 · 아니면 미리보기 */
const blank = (c: Card, empty: boolean): boolean => {
const show = empty && opts.editable() && !!opts.onImport && !started.has(c.kind);
c.take.hidden = show || !opts.editable() || !opts.onImport;
c.del.hidden = show || !opts.editable() || !opts.onClear; // 빈 카드엔 비울 것이 없음
return show;
};
/** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */
const startBox = (c: Card, paint: () => void): HTMLElement => {
const fresh = createButton({
label: TEXT.makeNew,
variant: "ghost",
size: "sm",
onClick: () => {
started.add(c.kind);
paint();
},
});
const copy = createButton({
label: TEXT.makeCopy,
variant: "ghost",
size: "sm",
onClick: () => opts.onImport?.(c.kind),
});
copy.hidden = !opts.onImport;
return el("div", {
className: "m02-struct__start",
children: [
el("p", { className: "m02-struct__empty", text: TEXT.blank }),
el("div", { className: "m02-struct__start-row", children: [fresh, copy] }),
],
});
};
const previewHost = el("div", { className: "m02-struct__preview" });
const info = makeCard("structure", TEXT.drawingInfo, previewHost, () =>
preview ? preview.write() : Promise.resolve(),
@@ -327,35 +287,55 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
});
paintPills(readStateRaw("m02-card-pills") === "true");
sheets[0].shell.tools.prepend(pills);
// 일위대가 카드 — 보기만(고침 = [일위대가 수정] 상세) · 부품은 처음 열 때 받음
// 일위대가 카드(43-4) — 상세의 단가산출 로직 표(2행) + 자재목록 그대로(`Labor_Detail` 카드 갈래 ·
// 순서 · 로직 추가 · 다이어그램 없음) · 고침은 카드 [저장] · Ctrl+S · 부품은 처음 열 때 받음
const laborHost = el("div", { className: "m02-struct__labor" });
const laborCard = opts.labor
? makeCard("labor", PART_WORD.labor, laborHost, () => Promise.resolve())
? makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())
: null;
let laborView: LaborViewHandle | null = null;
/** 일위대가 카드 — 그 열 문서 · calc 를 받아 그림 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */
let laborEd: LaborDetailHandle | null = null;
let laborVer = "";
/** 일위대가 카드 — 그 열 문서를 읽어 카드 갈래로 붙임(빈 일위대가도 빈 몸) */
const paintLabor = async (at: string, mine: number): Promise<void> => {
if (!laborCard) return;
laborEd?.destroy();
laborEd = null;
const layer = opts.source().layer;
let mod: typeof import("./M02_MasterTemplete_Labor_Detail");
let got: Awaited<ReturnType<typeof readTemplate>>;
try {
// 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 금액 넷 · 40-2) — 자세한 것은 [수정] 상세
laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView(undefined, {
compact: true,
});
[mod, got] = await Promise.all([
import("./M02_MasterTemplete_Labor_Detail"),
readTemplate(layer, "labor", at, null),
]);
} catch (error) {
if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload);
return;
}
if (mine !== seq) return;
const view = laborView;
laborHost.replaceChildren(view.root);
await view.show(opts.source().layer, at);
if (mine !== seq) return;
const d = view.doc();
laborVer = got.판 ?? "";
const d = (got.문서 ?? {}) as { 일위대가번?: string };
laborCard.no.textContent =
d?.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
if (blank(laborCard, !!d && !d.담은로직.length && !d.자재.length))
laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.()));
d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
laborEd = mod.mountLaborDetail(laborHost, got.문서, {
layer,
column: at,
readOnly: !opts.editable(),
onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable()),
goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음
card: true,
});
};
/** 카드 [저장] — 상세와 같은 차례: 고른 칸 연결(면적 문서) 먼저 → 일위대가(판을 다시 읽어 409 막음) */
async function saveLabor(): Promise<void> {
const at = column;
if (!laborEd || !at) return;
const layer = opts.source().layer;
const areaSaved = await laborEd.saveArea();
if (areaSaved) laborVer = (await readTemplate(layer, "labor", at, null)).판 ?? "";
laborVer = (await saveTemplate(layer, "labor", at, null, laborVer, laborEd.getDoc())).판;
if (areaSaved) window.setTimeout(reload, 0); // 면적산출 카드도 새 연결로
}
const cards: Card[] = [info, ...(laborCard ? [laborCard] : []), ...sheets];
const defaults: Record<CardKind, string> = {
structure: TEXT.edit,
@@ -375,10 +355,13 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const drawEdit = drawingEditable(opts.editable(), opts.source());
const sheetEdit = !!opts.sheetEdit?.can();
for (const c of cards) {
const can = c.kind === "structure" ? drawEdit : c.kind !== "labor" && sheetEdit;
const can =
c.kind === "structure" ? drawEdit : c.kind === "labor" ? opts.editable() : sheetEdit;
c.saver.button.hidden = c.help.hidden = !can;
if (c.kind === "labor") c.help.hidden = true; // 일위대가 카드엔 단축키 상자 없음
c.edit.hidden = !opts.editable();
c.del.hidden = !opts.editable() || !opts.onClear; // 빈 카드는 그린 뒤 `blank` 가 다시 숨김
c.take.hidden = !opts.editable() || !opts.onImport;
c.del.hidden = !opts.editable() || !opts.onClear;
}
pills.hidden = !sheetEdit;
};
@@ -447,8 +430,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
* 그 사이 열이 바뀌면 버림 */
const paintSheet = async (c: SheetCard, mine: number): Promise<void> => {
if (!c.doc) return;
if (blank(c, emptyBook(c.doc)))
return void c.host.replaceChildren(startBox(c, () => void paintSheet(c, seq)));
c.host.replaceChildren();
try {
const { createSpreadsheet } = await loadSpreadsheet();
@@ -473,12 +454,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
paintDrawing();
};
/** 도면 미리보기 — 빈 도면이면 두 단추(39-5) */
/** 도면 미리보기(빈 도면도 빈 몸) */
const paintDrawing = (): void => {
if (!doc) return;
previewHost.replaceChildren();
if (blank(info, !doc.도면?.entities?.length))
return void previewHost.append(startBox(info, paintDrawing));
// 미리보기 — 고칠 수 있으면 메뉴 없이 단축키로 고치고 카드 [저장](31-4 · `DrawingCard`)
preview = mountDrawingCard(previewHost, {
doc,
@@ -514,7 +493,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
}
root.hidden = false;
paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름
if (col.id !== column) started.clear();
column = col.id;
label = col.label;
for (const c of cards) c.name.textContent = label;
@@ -533,7 +511,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
}
const source = opts.source();
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7) — 일위대가 카드는 뺌:
// 몸(Labor_View)은 따로 그려지고 제 「불러오는 중」 이 있음 · 넣으면 덮였다 지워져 빈 카드(40 D1)
// 몸(일위대가 상세 카드 갈래)은 따로 읽어 그림(금액 「…」 이 기다림 표시) · 넣으면 덮였다 지워져 빈 카드(40 D1)
const stops = cards.filter((c) => c !== laborCard).map((c) => c.shell.loading());
const [drawing, ...books] = await Promise.allSettled([
source.read("structure", col.id),
@@ -582,7 +560,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
destroy: () => {
seq += 1;
drop();
laborView?.destroy();
laborEd?.destroy();
root.remove();
},
};
@@ -225,9 +225,13 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement {
});
}
/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */
/** 창으로 고름 — 위 안내 한 줄(`note` · 있으면) · 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */
export function pickFrom(
opt: Omit<PickerOptions, "onPick"> & { title: string; ask?: (picked: Picked) => string },
opt: Omit<PickerOptions, "onPick"> & {
title: string;
note?: string;
ask?: (picked: Picked) => string;
},
): Promise<Picked | null> {
return new Promise((resolve) => {
let done = false;
@@ -250,6 +254,7 @@ export function pickFrom(
close();
})(),
});
if (opt.note) body.append(el("p", { className: "m02-itempick__note", text: opt.note }));
body.append(picker.root);
},
});
@@ -260,6 +265,7 @@ export function pickFrom(
export async function pickHere(
opt: Omit<PickerOptions, "onPick" | "user" | "company"> & {
title: string;
note?: string;
ask?: (picked: Picked) => string;
},
): Promise<Picked | null> {
+25 -99
View File
@@ -58,6 +58,13 @@
font-size: var(--text-caption);
}
/* 자재 가격 다섯 — 기준단가가 된 칸(43-3) */
.m02-labrow__price.is-base-price {
color: var(--color-accent);
font-weight: 700;
box-shadow: inset 0 -2px 0 var(--color-accent);
}
.m02-labrow__applied {
display: block;
}
@@ -229,105 +236,6 @@
}
}
/* 한 줄 표(카드용 · 40-1) — 금액 = 수량 반영 값 · 반영이 없으면 기준 값 흐리게 */
.m02-labline {
width: 100%;
font-size: var(--text-caption);
}
.m02-labline th,
.m02-labline td {
padding: var(--spacing-4) var(--spacing-6, 6px);
}
.m02-labline th:nth-child(n + 4),
.m02-labline .m02-labrow__num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.m02-labline .m02-labrow__name {
display: table-cell;
min-width: 0;
overflow-wrap: anywhere;
}
.m02-labline .m02-labrow__qty,
.m02-labline .m02-labrow__unit {
color: var(--color-text-secondary);
overflow-wrap: anywhere;
}
.m02-labline .is-base {
color: var(--color-text-secondary);
font-style: italic;
}
.m02-labline .m02-labrow__sum td {
font-weight: 600;
border-top: 2px solid var(--color-border);
}
/* 아주 좁으면(카드 3열 · 400px 이하) 결과 단위 칸을 접음 — 수량산출 글에 단위가 같이 보임 */
@container m02-labview (max-width: 400px) {
.m02-labline th:nth-child(3),
.m02-labline .m02-labrow__unit {
display: none;
}
.m02-labline th,
.m02-labline td {
padding: var(--spacing-4) 3px;
}
}
/* 더 좁으면(카드 320px 안팎 · 340px 이하) 줄마다 두 줄 — 윗줄 항목(3칸) · 수량산출(1칸) · 아랫줄 금액 넷 ·
머리 줄도 같은 꼴(글은 그대로) · 가로 넘침 없음 */
@container m02-labview (max-width: 340px) {
.m02-labline,
.m02-labline thead,
.m02-labline tbody,
.m02-labline tfoot {
display: block;
width: 100%;
}
.m02-labline tr {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-bottom: 1px solid var(--color-border);
}
.m02-labline th,
.m02-labline td {
display: block;
min-width: 0;
padding: 2px;
border: 0;
}
.m02-labline th:first-child,
.m02-labline .m02-labrow__name {
grid-column: span 3;
}
.m02-labline th:nth-child(2),
.m02-labline .m02-labrow__qty {
text-align: right;
white-space: normal;
}
.m02-labline th:nth-child(3),
.m02-labline .m02-labrow__unit {
display: none;
}
.m02-labline .m02-labrow__num {
font-size: 11px;
}
}
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
.m02-labrow__qty {
white-space: nowrap;
@@ -351,3 +259,21 @@
border-color: var(--color-primary);
background: var(--color-mist-violet);
}
/* 수량산출 모달(43-1) — 위 지금 수량 · 손 입력 · [연결 풀기] / 아래 면적산출 시트(읽기만) */
.m02-labqty {
width: min(960px, 94vw);
}
.m02-labqty__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-12);
margin-bottom: var(--spacing-4);
}
.m02-labqty__now {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@@ -28,6 +28,10 @@ export interface CalcMaterial {
코드: string;
알약?: string | null;
기준단가: number | null;
/** M01 자재품목 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 빈 값 null · 43-3) */
가격?: Record<string, number | null>;
/** 기준단가가 된 열 이름 — 없으면 null */
기준칸?: string | null;
수량?: number | null;
수량출처?: QtySource;
반영?: number | null;
@@ -19,7 +19,7 @@ import {
type ItemLogic,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import { linkAreaCell, pickAreaCell } from "./M02_MasterTemplete_AreaCellPick";
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
import { settle } from "./M02_MasterTemplete_Labor_Calc";
import {
areaLinks,
@@ -28,6 +28,7 @@ import {
normalizeLabor,
type LaborCalc,
type LaborDoc,
type LaborQty,
type LaborRow,
} from "./M02_MasterTemplete_Labor_Api";
import {
@@ -35,9 +36,10 @@ import {
diagramTitle,
renderComboDiagram,
} from "./M02_MasterTemplete_Labor_Diagram";
import { openLaborInput, openQtyModal, type GoArea } from "./M02_MasterTemplete_Labor_Input";
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { keepKnown } from "./M02_Labor_Values_Model";
import { pickMaterial } from "./M02_Material_Pick";
@@ -54,6 +56,9 @@ export interface LaborDetailOptions {
onChanged: (dirty: boolean) => void;
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */
goArea: GoArea | null;
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 올리기 · 내리기 · 로직 추가 ·
* 정보(단위 · 본뜬) · 다이어그램 없음 · 빼기 · 자재 추가 · 줄 · 수량 모달은 상세와 같음 */
card?: boolean;
}
export interface LaborDetailHandle {
@@ -103,7 +108,7 @@ export function mountLaborDetail(
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
const drawDg = (): void => {
if (!dgBox.open) return;
if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음
const data = buildLaborDiagram({
rows: cur.담은로직,
logics: [...logics.values()],
@@ -129,7 +134,9 @@ export function mountLaborDetail(
logicTable(
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
edit ? { open: openRow, qty: openQty, move, remove: removeRow } : { open: openRow },
edit
? { open: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow }
: { open: openRow },
),
);
matHost.replaceChildren(
@@ -174,37 +181,22 @@ export function mountLaborDetail(
} else recalc();
};
/** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */
const linkedText = (pill: string | null | undefined, unit: string): string | null => {
const at = pill ? links.get(pill) : undefined;
if (!at) return null;
return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`;
};
/** [엑셀에서 고르기] — 그 열 면적산출 칸을 골라 그 알약을 이음(화면 문서 · 저장은 [저장]) · 이었으면 참 */
const pickCell = async (pill: string | null | undefined, label: string): Promise<boolean> => {
if (!pill || !area) {
showToast(tc(area ? "Labor_PickWait" : "Labor_NoArea"), "error");
/** 칸 잇기 · 풀기(`cell` null) — 화면 문서만(저장은 [저장]) · 됐으면 참 */
const linkCell = (pill: string, cell: string | null, replace = false): boolean => {
if (!area) {
showToast(tc("Labor_NoArea"), "error");
return false;
}
const got = await pickAreaCell({
doc: area.doc,
layer: opt.layer,
column: opt.column,
pill,
label,
});
if (!got || !alive) return false;
const info = pills.get(pill);
const next = linkAreaCell(
area.doc,
pill,
got.칸,
cell,
{
...(info?.이름 ? { 이름: info.이름 } : {}),
...(info?.단위 ? { 단위: info.단위 } : {}),
},
got.바꾸기 === true, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 고르기 모달 안)
replace, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 시트 안)
);
if (!next) {
showToast(tc("Labor_BadCell"), "error");
@@ -217,6 +209,37 @@ export function mountLaborDetail(
return true;
};
/** 수량산출 모달(43-1) — 로직 줄 · 자재 줄 같음 · 손 · 칸 잇기 · 풀기로 `target.수량` 을 바꿈 */
const openQtyFor = (
title: string,
target: { 수량: LaborQty },
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
): void => {
const pill = line?.알약 ?? null;
openQtyModal({
title,
now: qtyShort(line, unit, cells()),
linked: !!pill && links.has(pill),
hand: target.수량?.손 ?? null,
unit,
readOnly: !edit,
sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill },
goArea: opt.goArea,
done: (r) => {
if (r.kind === "keep") return;
if (r.kind === "hand") target.수량 = { 손: r.손 };
else if (r.kind === "follow") target.수량 = null;
else {
const cell = r.kind === "cell" ? r.pick.칸 : null;
if (!pill || !linkCell(pill, cell, r.kind === "cell" && r.pick.바꾸기 === true)) return;
target.수량 = null; // 칸을 따름
}
changed(true);
},
});
};
/** 아직 안 받은 로직 입력 목록만 받음 */
const loadLogics = async (): Promise<void> => {
const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
@@ -247,85 +270,38 @@ export function mountLaborDetail(
function openRow(i: number): void {
const row: LaborRow = cur.담은로직[i];
const logic = logics.get(row.로직) ?? null;
const line = lineOf(row.로직);
openLaborInput({
title: `${row.로직} ${logic?.이름 ?? ""}`.trim(),
logic,
values: row.입력값,
memo: row.메모,
seat: {
qty: row.수량,
linked: linkedText(line?.알약, line?.분모 ?? ""),
unit: line?.분모 ?? "",
pick: () => pickRow(i),
},
readOnly: !edit,
goArea: opt.goArea,
done: (next) => {
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값);
const same = JSON.stringify(values) === JSON.stringify(row.입력값);
row.입력값 = values;
row.메모 = next.메모;
row.수량 = next.수량;
changed(qtyOnly);
changed(same); // 입력값이 그대로면(메모만) 서버 안 부름
},
});
}
/** 그 줄 알약을 칸에 이음 — 이으면 손 수량은 지움(칸을 따름) */
async function pickRow(i: number): Promise<boolean> {
const row = cur.담은로직[i];
const logic = logics.get(row.로직);
if (!(await pickCell(lineOf(row.로직)?.알약, logic?.이름 ?? row.로직))) return false;
row.수량 = null;
changed(true);
return true;
}
/** 수량산출 칸 누름 — 손 입력 · 엑셀에서 고르기 */
/** 수량산출 칸 누름 — 로직 줄 */
function openQty(i: number): void {
const row = cur.담은로직[i];
const line = lineOf(row.로직);
openQtyModal(
openQtyFor(
`${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(),
{
qty: row.수량,
linked: linkedText(line?.알약, line?.분모 ?? ""),
unit: line?.분모 ?? "",
pick: () => pickRow(i),
},
!edit,
opt.goArea,
(qty) => {
row.수량 = qty;
changed(true);
},
row,
line,
line?.분모 ?? "",
);
}
/** 자재 줄 누름 — 수량산출 모달 */
function openMat(i: number): void {
const mat = cur.자재[i];
const line = matOf(mat.코드);
openQtyModal(
`${mat.코드} ${mat.이름 ?? ""}`.trim(),
{
qty: mat.수량,
linked: linkedText(line?.알약, mat.단위 ?? ""),
unit: mat.단위 ?? "",
pick: async () => {
if (!(await pickCell(line?.알약, mat.이름 ?? mat.코드))) return false;
mat.수량 = null;
changed(true);
return true;
},
},
!edit,
opt.goArea,
(qty) => {
mat.수량 = qty;
changed(true);
},
);
openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? "");
}
const addLogic = createButton({
@@ -384,13 +360,19 @@ export function mountLaborDetail(
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
children: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })],
}),
section(tc("Logics_Title"), addLogic, logicHost),
...(opt.card
? []
: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", { className: "m01lab__info", children: [unit.root, origin.root] }),
],
}),
]),
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
section(tc("Labor_Mats"), addMat, matHost),
dgBox,
...(opt.card ? [] : [dgBox]),
],
}),
);
@@ -1,14 +1,13 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Input.ts
* 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량
* (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`).
* 일위대가 입력 모달(39-3) — 단가산출 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모.
* 수량은 이 모달에 없음(43-2) — 수량산출 칸 누름 = `Labor_QtyModal`(면적산출 시트를 모달 안에).
* [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborQty } from "./M02_MasterTemplete_Labor_Api";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { createValueControl } from "./M02_Labor_Values";
import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model";
@@ -20,102 +19,6 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
/** 면적산출 상세로 감 — 옮겨 갔으면 참(물음에서 취소 = 거짓) */
export type GoArea = () => Promise<boolean>;
/** 옮겨 갔을 때만 모달을 닫음 — 취소면 모달 · 넣던 값 그대로 */
const leaveTo =
(go: GoArea, close: () => void): (() => void) =>
() =>
void go().then((moved) => moved && close());
export interface QtySeat {
/** 지금 수량 */
qty: LaborQty;
/** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */
linked: string | null;
/** 손 입력 단위 표기(분모 · 자재 단위) */
unit: string;
/** [엑셀에서 고르기](40-1) — 그 열 면적산출 칸을 골라 이음 · 이었으면 참(모달 닫음) · 없으면 단추 없음 */
pick?: (() => Promise<boolean>) | null;
}
interface QtyPart {
root: HTMLElement;
/** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */
read: () => LaborQty | undefined;
}
/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */
function qtyPart(
seat: QtySeat,
readOnly: boolean,
goArea: (() => void) | null,
close: () => void,
): QtyPart {
const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`;
const radio = (value: "cell" | "hand"): HTMLInputElement => {
const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement;
input.disabled = readOnly;
return input;
};
const cell = radio("cell");
const hand = radio("hand");
const number = el("input", {
className: "ui-input m02-inputval__control",
attrs: { type: "number", step: "any", "data-labor-qty": "hand" },
}) as HTMLInputElement;
number.readOnly = readOnly;
if (seat.qty) number.value = String(seat.qty.손);
(seat.qty ? hand : cell).checked = true;
number.disabled = !hand.checked;
for (const r of [cell, hand])
r.addEventListener("change", () => {
number.disabled = !hand.checked;
if (hand.checked) number.focus();
});
const go = goArea
? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea })
: null;
const pickFn = seat.pick;
const pick =
pickFn && !readOnly
? createButton({
label: tc("In_QtyPick"),
variant: "ghost",
size: "sm",
onClick: () => void pickFn().then((linked) => linked && close()),
})
: null;
const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement =>
el("label", {
className: "m02-labin__qty-line",
children: [input, el("span", { text: label }), ...rest],
});
const root = el("section", {
className: "m02-labin__qty",
children: [
el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }),
line(
cell,
tc("In_QtyCell"),
el("small", {
className: "m02-labin__muted",
text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"),
}),
...(pick ? [pick] : []),
...(go ? [go] : []),
),
line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })),
],
});
return {
root,
read: () => {
if (!hand.checked) return null;
const v = Number(number.value);
return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined;
},
};
}
function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement {
return el("div", {
className: "m02-labin__foot",
@@ -131,20 +34,16 @@ function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLEleme
export interface LaborInputResult {
입력값: LaborValues;
메모: string | null;
수량: LaborQty;
}
export interface LaborInputOptions {
/** 「GF000219 메쌓기」 */
title: string;
/** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */
/** 로직 입력 목록(없으면 = 못 읽음 · 메모만) */
logic: ItemLogic | null;
values: LaborValues;
memo: string | null;
seat: QtySeat;
readOnly: boolean;
/** 「면적산출에서 잇기」 — 면적산출 상세로 감(옮겨 가면 참 · 「저장 안 한 고침」 물음에서 취소면 거짓 · 없으면 단추 없음) */
goArea: GoArea | null;
done: (next: LaborInputResult) => void;
}
@@ -192,12 +91,9 @@ export function openLaborInput(opt: LaborInputOptions): void {
}) as HTMLInputElement;
memo.value = opt.memo ?? "";
memo.readOnly = opt.readOnly;
const qty = qtyPart(opt.seat, opt.readOnly, opt.goArea && leaveTo(opt.goArea, close), close);
const ok = (): void => {
if (bad.size) return showToast(tc("In_Bad"), "error");
const 수량 = qty.read();
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 });
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null });
close();
};
body.append(
@@ -222,7 +118,6 @@ export function openLaborInput(opt: LaborInputOptions): void {
memo,
],
}),
qty.root,
],
}),
footer(opt.readOnly, ok, close),
@@ -230,28 +125,3 @@ export function openLaborInput(opt: LaborInputOptions): void {
},
});
}
/** 자재 줄 수량 모달 */
export function openQtyModal(
title: string,
seat: QtySeat,
readOnly: boolean,
goArea: GoArea | null,
done: (qty: LaborQty) => void,
): void {
openModal({
title,
closeLabel: tc("In_Close"),
dialogClass: "m02-modal m02-labin",
mount: (body, close) => {
const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, close), close);
const ok = (): void => {
const 수량 = qty.read();
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
done(수량);
close();
};
body.append(qty.root, footer(readOnly, ok, close));
},
});
}
@@ -0,0 +1,170 @@
/* =============================================================================
* M02_MasterTemplete_Labor_QtyModal.ts
* 일위대가 수량산출 모달(43-1) — 수량산출 칸 누름(로직 줄 · 자재 줄 같음) = 모달 하나.
* · 위 = 지금 수량(손 · 칸 · —) · 손 입력 칸 · [연결 풀기]
* · 아래 = 그 열 면적산출 시트를 모달 안에 바로(읽기만 · 실제 값 · 이은 칸 색 · `mountAreaSheet`) ·
* 칸 누름 = 그 칸을 고름(함께 잇기 · 바꾸기 물음 그대로)
* · [확인] = 손 값이 있으면 손 · 아니면 고른 칸으로 잇기 · 풀기 · [닫기] = 그대로
* 잇기 · 풀기는 화면 문서만(저장은 일위대가 [저장] 때 면적 문서와 함께 · `Labor_Detail`).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { mountAreaSheet, type AreaCellPick } from "./M02_MasterTemplete_AreaCellPick";
import type { GoArea } from "./M02_MasterTemplete_Labor_Input";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_Labor_Values.css";
import "./M02_MasterTemplete_Labor.css";
/** 모달 답 — 손 · 칸 따름(손 지움) · 고른 칸으로 잇기 · 연결 풀기 · 그대로 */
export type QtyResult =
| { kind: "hand"; 손: number }
| { kind: "follow" }
| { kind: "cell"; pick: AreaCellPick }
| { kind: "unlink" }
| { kind: "keep" };
export interface QtyModalOptions {
/** 「GF000219 돌쌓기 메쌓기(인력)」 */
title: string;
/** 지금 수량 글(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) */
now: string;
/** 지금 칸에 이어져 있나 — [연결 풀기] */
linked: boolean;
/** 지금 손 값(없으면 null) */
hand: number | null;
/** 손 입력 단위(분모 · 자재 단위) */
unit: string;
readOnly: boolean;
/** 그 열 면적산출 — `pill` 이 없으면(계산 전) 시트 대신 안내 */
sheet: { doc: Workbook | null; layer: Layer; column: string; pill: string | null };
/** [면적산출 상세로] — 없으면 단추 없음 */
goArea: GoArea | null;
done: (result: QtyResult) => void;
}
export function openQtyModal(opt: QtyModalOptions): void {
let pending: QtyResult = { kind: "keep" };
let sheet: { destroy: () => void } | null = null;
openModal({
title: `${tc("Labor_QtyCol")} — ${opt.title}`,
closeLabel: tc("In_Close"),
dialogClass: "m02-modal m02-labin m02-labqty",
beforeClose: () => (sheet?.destroy(), true),
mount: (body, closeNow) => {
const finish = (): void => {
sheet?.destroy();
closeNow();
};
const now = el("span", {
className: "m02-labqty__now",
text: opt.now,
attrs: { "data-labor-qty-now": "" },
});
const state = el("small", {
className: "m02-labin__muted",
attrs: { "data-labor-qty-state": "" },
});
const hand = el("input", {
className: "ui-input m02-inputval__control",
attrs: {
type: "number",
step: "any",
"data-labor-qty": "hand",
"aria-label": tc("In_QtyHand"),
},
}) as HTMLInputElement;
hand.readOnly = opt.readOnly;
if (opt.hand !== null) hand.value = String(opt.hand);
const unlink =
opt.linked && !opt.readOnly
? createButton({
label: tc("Labor_Unlink"),
variant: "ghost",
onClick: () => {
pending = { kind: "unlink" };
hand.value = "";
state.textContent = tc("Labor_UnlinkPending");
},
})
: null;
const go = opt.goArea
? createButton({
label: tc("In_QtyGoArea"),
variant: "ghost",
onClick: () =>
void opt.goArea?.().then((moved) => {
if (moved) finish();
}),
})
: null;
const host = el("div", {
className: "m02-cellpick__sheet",
attrs: { "data-labor-qty-sheet": "" },
});
if (opt.sheet.pill) {
sheet = mountAreaSheet(host, {
doc: opt.sheet.doc,
layer: opt.sheet.layer,
column: opt.sheet.column,
pill: opt.sheet.pill,
onPick: (pick) => {
if (opt.readOnly) return;
pending = { kind: "cell", pick };
hand.value = ""; // 칸을 고르면 손 값은 지움(칸을 따름)
state.textContent = tc("Labor_CellPending").replace("{value}", pick.칸);
},
});
} else
host.replaceChildren(
el("p", { className: "m02-labin__muted", text: tc("Labor_PickWait") }),
);
hand.addEventListener("input", () => {
if (hand.value.trim() !== "") state.textContent = "";
});
const ok = (): void => {
const raw = hand.value.trim();
if (raw !== "") {
const v = Number(raw);
if (!Number.isFinite(v)) return showToast(tc("In_QtyNeed"), "error");
opt.done({ kind: "hand", 손: v });
} else if (pending.kind !== "keep") opt.done(pending);
else if (opt.hand !== null) opt.done({ kind: "follow" }); // 손 값을 지움 = 칸을 따름
finish();
};
body.append(
el("div", {
className: "m02-labqty__head",
children: [
el("span", { className: "m02-inputval__name", text: tc("Labor_QtyNow") }),
now,
el("label", {
className: "m02-labin__qty-line",
children: [
el("span", { text: tc("In_QtyHand") }),
hand,
el("small", { text: opt.unit }),
],
}),
...(unlink ? [unlink] : []),
...(go ? [go] : []),
],
}),
state,
el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }),
host,
el("div", {
className: "m02-labin__foot",
children: opt.readOnly
? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: finish })]
: [
createButton({ label: tc("In_Close"), variant: "ghost", onClick: finish }),
createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }),
],
}),
);
},
});
}
@@ -3,7 +3,8 @@
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
* 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」).
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
* 금액은 calc 답(없으면 「…」).
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
* ========================================================================== */
@@ -170,7 +171,8 @@ export interface LogicTableData {
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
const wait = !data.calc && !data.fail;
const { move, remove } = on ?? {};
const tools = !!(move && remove);
// 빼기만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만
const tools = !!(move || remove);
const lineOf = (key: string): LaborCalcLine | undefined =>
data.calc?.줄.find((l) => l.로직 === key);
const trs = data.rows.map((row, i) => {
@@ -222,14 +224,15 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", { className: "m02-labrow__unit", text: unit }),
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))),
...(move && remove
...(tools
? [
el("td", {
className: "m02-labrow__move",
children: [
small(tc("Up"), () => move(i, -1)),
small(tc("Down"), () => move(i, 1)),
small(tc("Remove"), () => remove(i)),
...(move
? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))]
: []),
...(remove ? [small(tc("Remove"), () => remove(i))] : []),
],
}),
]
@@ -278,7 +281,12 @@ export interface MatTableData {
cells?: ReadonlyMap<string, string>;
}
/** 자재목록 표 — 보기 갈래는 `on` 없이 */
/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */
export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const;
/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금
* 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) ·
* 보기 갈래는 `on` 없이 */
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
const wait = !data.calc && !data.fail;
const remove = on?.remove;
@@ -286,7 +294,20 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
const trs = data.mats.map((mat, i) => {
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량);
// 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1
const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine;
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
const base = !!line?.기준칸 && line.기준칸 === slot;
return el("td", {
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
text: show(line?.가격?.[slot]),
attrs: {
"data-label": slot,
"data-price-slot": slot,
...(base ? { title: tc("Labor_BasePrice") } : {}),
},
});
};
const tr = el("tr", {
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
attrs: { "data-labor-mat": mat.코드 },
@@ -302,29 +323,14 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
],
}),
el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }),
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", {
className: "m02-labrow__unit",
text: mat.단위 ?? "",
attrs: { "data-label": tc("Labor_Unit") },
}),
el("td", {
className: "m02-labrow__num",
text: qty,
attrs: {
title: qtyText(line, mat.단위 ?? "", data.cells),
"data-label": tc("Labor_Qty"),
},
}),
el("td", {
className: "m02-labrow__num",
text: show(line?.기준단가),
attrs: { "data-label": tc("Labor_BasePrice") },
}),
el("td", {
className: "m02-labrow__num",
text: show(line?.반영),
attrs: { "data-label": tc("Labor_AppliedSum") },
}),
...PRICE_SLOTS.map(priceCell),
moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")),
...(remove
? [
el("td", {
@@ -351,125 +357,13 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
tc("Labor_Qty"),
tc("Labor_BasePrice"),
tc("Labor_AppliedSum"),
...PRICE_SLOTS,
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
...(remove ? [tc("Col_Move")] : []),
];
return data.mats.length
? table(heads, trs, [])
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
}
export interface CompactData {
rows: LaborRow[];
mats: LaborMat[];
logics: Map<string, ItemLogic>;
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
}
/** 한 줄 표(카드용 · 40-1) — 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 ·
* 반영이 없으면 기준 값 흐리게(`is-base`) · 자재 줄은 같은 표 아래(재료비 · 합계만) · 맨 아래 합계 줄 */
export function compactTable(data: CompactData): HTMLElement {
const wait = !data.calc && !data.fail;
const cell = (v: number | null | undefined, base: boolean, label: string): HTMLElement =>
el("td", {
className: `m02-labrow__num${base ? " is-base" : ""}`,
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) },
});
// 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계)
const heads = [
tc("Labor_Item"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
];
const lines = data.rows.map((row) => {
const line = data.calc?.줄.find((l) => l.로직 === row.로직);
const money = line?.반영 ?? line?.기준;
const base = !line?.반영 && !!line?.기준;
const logic = data.logics.get(row.로직);
return el("tr", {
className: "m02-labrow__row",
attrs: { "data-labor-logic": row.로직 },
children: [
el("td", {
className: "m02-labrow__name",
text: logic?.이름 ?? row.로직,
attrs: { title: `${row.로직}${line?.까닭 ? ` · ${line.까닭}` : ""}` },
}),
el("td", {
className: "m02-labrow__qty",
text: qtyShort(line, line?.분모 ?? "", data.cells),
attrs: { "data-label": tc("Labor_QtyCol") },
}),
el("td", { className: "m02-labrow__unit", text: logic?.결과단위 ?? "" }),
...COSTS.map((c) => cell(money?.[c], base, tc(HEAD[c]))),
],
});
});
const mats = data.mats.map((mat) => {
const line = data.calc?.자재.find((m) => m.코드 === mat.코드);
const has = line?.반영 !== null && line?.반영 !== undefined;
const v = has ? line?.반영 : line?.기준단가;
return el("tr", {
className: "m02-labrow__row is-mat",
attrs: { "data-labor-mat": mat.코드 },
children: [
el("td", {
className: "m02-labrow__name",
text: mat.이름 ?? mat.코드,
attrs: { title: [mat.코드, mat.규격].filter(Boolean).join(" · ") },
}),
el("td", {
className: "m02-labrow__qty",
text: qtyShort(line, mat.단위 ?? "", data.cells),
attrs: { "data-label": tc("Labor_QtyCol") },
}),
el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "" }),
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.노무비) } }),
cell(v, !has && v !== null && v !== undefined, tc(HEAD.재료비)),
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.경비) } }),
cell(v, !has && v !== null && v !== undefined, tc(HEAD.계)),
],
});
});
const applied = data.calc?.반영합;
const anyApplied = !!applied && (applied.계 ?? 0) !== 0;
const total = anyApplied ? applied : data.calc?.기준합;
const foot =
data.rows.length + data.mats.length
? [
el("tr", {
className: "m02-labrow__sum",
children: [
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
el("td", { className: "m02-labrow__qty", text: "" }),
el("td", { className: "m02-labrow__unit", text: "" }),
...COSTS.map((c) => cell(total?.[c], !anyApplied && !!total, tc(HEAD[c]))),
],
}),
]
: [];
return data.rows.length + data.mats.length
? el("div", {
className: "ui-general-block__table-wrap m02-labcompact",
children: [
el("table", {
className: "ui-general-block__table m02-labline",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: [...lines, ...mats] }),
...(foot.length ? [el("tfoot", { children: foot })] : []),
],
}),
],
})
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
}
@@ -60,10 +60,17 @@ const TEXT = {
Labor_Item: ["항목", "Item"],
Labor_Hand: ["손", "Manual"],
Labor_QtyPick: ["누르면 손 입력 · 엑셀에서 고르기", "Manual or pick a cell"],
In_QtyPick: ["엑셀에서 고르기", "Pick in sheet"],
Labor_PickWait: ["계산이 끝난 뒤 고름", "Wait for the calculation"],
Labor_NoArea: ["이 열 면적산출을 못 읽음", "Area sheet not loaded"],
Labor_BadCell: ["칸 주소가 틀림", "Invalid cell"],
Labor_QtyNow: ["지금 수량", "Now"],
Labor_Unlink: ["연결 풀기", "Unlink"],
Labor_UnlinkPending: ["연결 풀기 — [확인]을 누르면 풂", "Unlink on OK"],
Labor_CellPending: ["고른 칸 {value} — [확인]을 누르면 이음", "Cell {value} — link on OK"],
Labor_SheetHint: [
"면적산출 시트 — 칸을 누르면 그 칸을 고름 · 손 입력이 있으면 손이 앞섬",
"Area sheet — click a cell to pick it · manual value wins",
],
Labor_Base: ["기준", "Base"],
Labor_Applied: ["반영", "Applied"],
Labor_BasePrice: ["기준 단가", "Base price"],
@@ -81,12 +88,9 @@ const TEXT = {
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
In_Memo: ["메모", "Memo"],
In_Qty: ["수량", "Quantity"],
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
In_QtyEmpty: ["빈 칸", "empty cell"],
In_QtyHand: ["손 입력", "Manual"],
In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"],
In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"],
In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"],
In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"],
In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"],
@@ -1,12 +1,11 @@
/* =============================================================================
* M02_MasterTemplete_Labor_View.ts
* 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영).
* 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`).
* 면적산출 상세 오른쪽 패널(sub3)이 씀(구조물 조합 카드는 43-4 부터 `Labor_Detail` 카드 갈래). 표 모양은 상세와 한 벌(`Labor_Rows`).
* · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만)
* · `setQty({알약 키: 칸 값}, {알약 키: 칸 주소}?)` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로
* 바로 곱함 · 칸 주소를 주면 저장 전 연결(setLink · 알약 끌기)도 「수량 B6 = 20」 으로 보임(안 주면 저장본 연결)
* (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」)
* · `compact` = 한 줄 표(구조물 조합 일위대가 카드 · 40-1)
* · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음
* ========================================================================== */
@@ -21,7 +20,7 @@ import {
type LaborDoc,
} from "./M02_MasterTemplete_Labor_Api";
import { settle } from "./M02_MasterTemplete_Labor_Calc";
import { compactTable, logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
@@ -47,15 +46,7 @@ export interface LaborViewHandle {
destroy: () => void;
}
/** `compact` = 한 줄 표(카드용 · 항목 · 수량산출 · 단위 · 금액 넷 · 자재 줄 같은 표 아래 · 줄 누름 없음) */
export interface LaborViewOptions {
compact?: boolean;
}
export function createLaborView(
onRow?: (row: LaborViewRow) => void,
opts: LaborViewOptions = {},
): LaborViewHandle {
export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle {
const root = el("div", { className: "m02-labview" });
const logics = new Map<string, ItemLogic>();
let doc: LaborDoc | null = null;
@@ -93,10 +84,6 @@ export function createLaborView(
unit: mat.단위 ?? "",
})?.();
};
if (opts.compact)
return void root.replaceChildren(
compactTable({ rows: d.담은로직, mats: d.자재, logics, calc, fail, cells }),
);
root.replaceChildren(
el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }),
logicTable(
@@ -363,15 +363,22 @@ def post_copy(
owner: str | None = None,
from_layer: Literal["system", "company", "personal", "practice"] | None = None,
from_owner: str | None = None,
pair: bool = False,
session: Session = Depends(verify_session),
):
"""항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) ·
다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만)."""
다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만) ·
`pair` = 일위대가 · 면적산출 함께(43-9a · 답 `{area?, labor}` · 실무문서는 일위대가만)."""
folder = _root(session, layer, owner, write=True)
part = "structure" if kind == "structures" else kind
if from_layer == "practice":
if part != "labor" or not access.is_system_admin(session):
if part not in (("labor", "area") if pair else ("labor",)) or not access.is_system_admin(
session
):
raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만")
return _call(folder, _copy_practice, column, source)
got = _call(folder, _copy_practice, column, source)
return {"labor": got} if pair else got
origin = _root(session, from_layer, from_owner) if from_layer else None
if pair and part != "structure":
return _call(folder, store.copy_pair, column, source, origin)
return _call(folder, store.copy_from, part, column, source, origin)
@@ -397,6 +397,25 @@ def copy_from(
return info
def copy_pair(
column: str,
source: str,
source_root: Path | None = None,
labor_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""일위대가 ↔ 면적산출 함께 가져오기(43-9a) — 같은 원본 열의 면적산출(시트 · 알약 `연결`)과
일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 ·
원본에 면적산출이 없으면(실무문서 `labor_body` 포함) 일위대가만 · 면적 먼저(일위대가 계산값이
새 면적 칸 값을 읽음 · 면적 계산이 막히면 일위대가도 안 씀)."""
with at(source_root or root()):
has_area = labor_body is None and _path("area", source).is_file()
out: dict[str, Any] = {}
if has_area:
out["area"] = copy_from("area", column, source, source_root)
out["labor"] = copy_from("labor", column, source, source_root, labor_body)
return out
def prune_removed_structures() -> list[str]:
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 —
번호는 비워 둠(다른 열 번호를 안 당김) · 옛 `inputs/` 는 안 건드림."""
@@ -205,22 +205,6 @@
color: var(--color-text-secondary);
}
/* 빈 카드(39-5) — 안내 한 줄 + [새로 만들기] [복사 생성] */
.m02-struct__start {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-8);
padding-bottom: var(--spacing-16);
}
.m02-struct__start-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
padding: 0 var(--spacing-16);
}
/* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */
.m02-pick__list {
display: flex;
@@ -18,6 +18,7 @@ import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import {
clearPart,
copyLaborAreaFrom,
copyPartFrom,
fetchLaborPills,
fetchStructureNumbers,
@@ -50,6 +51,8 @@ const TEXT = {
overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로",
pickCopy: "{value} 복사해 가져오기",
copied: "복사해 가져옴",
pair: "일위대가 · 면적산출",
pairNote: "일위대가 · 면적산출 함께 가져옴 — 알약 연결 포함 · 도면은 그대로",
copyFailed: "복사 실패 — {value}",
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
pillsFailed: "일위대가 알약을 못 받음 — {value}",
@@ -263,8 +266,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
};
/** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 ·
* 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */
* 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「가져오기」가 씀 ·
* 일위대가 · 면적산출은 함께(43-9b · 알약 연결이 이어져 있음 · 실무문서면 서버가 일위대가만) · 도면은 그 칸만 */
const pickCopy = async (kind: CardKind, to: StructureColumn | undefined): Promise<void> => {
const pair = kind !== "structure";
const word = PART_WORD[kind];
const toNo = to && numbers[to.id]?.[PART_FIELD[kind]];
if (!to || !toNo) return void showToast(TEXT.saveFirst, "error");
@@ -276,22 +281,22 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
const picked = await pickHere({
title: `${fill(TEXT.pickCopy, word)} — ${target}`,
kind,
note: pair ? TEXT.pairNote : undefined,
skip: (from, row) => same(from) && row.열 === to.id,
ask: (p) =>
TEXT.overwrite
.replace("{from}", `${p.from.label} ${pickedName(p)}`)
.replace("{what}", word)
.replace("{what}", pair ? TEXT.pair : word)
.replace("{to}", target),
});
if (!picked) return;
const copy = (source: string, from: Parameters<typeof copyPartFrom>[3]) =>
pair ? copyLaborAreaFrom(to.id, source, from) : copyPartFrom(kind, to.id, source, from);
try {
// 같은 곳 = 다른 열 · 다른 층 · 사람 = 원본 층 · 실무문서 = 조합 키 → 일위대가(39-1b)
if ("combo" in picked)
await copyPartFrom(kind, to.id, picked.combo.키, { layer: "practice", owner: null });
if ("combo" in picked) await copy(picked.combo.키, { layer: "practice", owner: null });
else
await copyPartFrom(
kind,
to.id,
await copy(
picked.row.열,
same(picked.from) ? null : { layer: picked.from.layer, owner: picked.from.owner },
);
@@ -130,8 +130,8 @@ export function createMain(
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
const save = createButton({ label: L("M02_Save"), variant: "filled" });
// 구조물 도면 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채)
const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" });
// 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님)
const back = createButton({ label: L("M02_Back") });
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
const host = el("div", { className: "m02-main__host" });
@@ -8,3 +8,7 @@
4. 구조물조합에서 일위대가 미리보기에서 일위대가 상세화면의 단가산출 로직(순서와 로직추가는 제외)과 자재목록을 그대로 보여주게 하되 기능은 유지(수량산출 연결창, 옵션지정 모달창)하고 수평스크롤을 가능하게 하기
5. 일위대가의 재료도 2열구조여야 할것같은데....
6. 도면정보, 일위대가, 면적산출을 접근 후 제목의 돌아가기 버튼은 알약이 아니라 일반 버튼으로
## 추가 (10-5 새벽 · 원문 그대로)
추가 항목이 하나더 있어. 구조물 조합에서의 미리보고 3개 컨테이너에 새로만들기와 복사 생성로직이 필요없겠네. 구조물 조합의 새항목 생성을 하면 당연히 새로운 항목들이 자동으로 생성되잖아. 그후 가져오기를 하면 당연히 값을 가져와서 덮어쓰면 되겠어. 그리고 일위대가와 면적산출에서 가져오기를 하면 도면은 개별로 작성되는게 맞지만 일위대가와 면적산출은 연결되어 있는정보니 같이 가져오는게 맞겠어. 알약연결 포함해서. 이 내용도 추가해줘.
+11 -5
View File
@@ -23,7 +23,10 @@ def test_면적_길은_일위대가_패널():
area = body.split('if (kind === "area") {')[1].split("\n }\n")[0]
assert "apanel = buildAreaPanel({" in area and "host.append(apanel.root);" in area
assert "picked: () => picked," in area and "pills: () => links.항목," in area
assert "apanel?.refresh();" in body and "onSelect: (s) => (picked = s.칸)," in body
assert (
"apanel?.refresh();" in body
and "onSelect: (s) => ((picked = s.칸), toDrawing.select(s))," in body
)
assert "buildComboBar" not in main # 조합 줄 · [조합 고치기] 지움
assert body.index('if (at.layer === "project") return;') < body.index("buildAreaPanel")
assert "apanel?.destroy();\n apanel = null;" in main # drop
@@ -94,12 +97,15 @@ def test_카드_알약_취향은_계정_받은_뒤_올림():
def test_엑셀_칸_고르기_모달_부품():
"""40-3 ① — 읽기만 시트 · 칸 누름 = 주소 · 풀기 = {칸: null} · 그만둠 = null · 저장 안 함."""
"""40-3 ① · 43-1 — 읽기만 시트(수량산출 모달 안에 바로 · `mountAreaSheet`) · 칸 누름 = 주소 ·
풀기 · 손 · 확인 · 닫기는 일위대가 수량산출 모달(`Labor_QtyModal`) · 저장 안 함."""
pick = _text("M02_MasterTemplete_AreaCellPick.ts")
assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick
assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick
assert "finish({ 칸: null })" in pick
assert 'finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick
assert "export function mountAreaSheet(" in pick and "readOnly: true," in pick
assert 'opts.onPick(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick
qty = _text("M02_MasterTemplete_Labor_QtyModal.ts")
assert "sheet = mountAreaSheet(host, {" in qty and 'pending = { kind: "unlink" };' in qty
assert "엑셀에서 고르기" not in qty # 두 단계 모달 없앰(43-1)
assert "linksAt(book, sheetId, at, opts.pill)" in pick and "askShare(" in pick
assert "if ((e as MouseEvent).button === 0 && moved) void choose();" in pick
assert "handle?.destroy();" in pick
+1 -1
View File
@@ -69,7 +69,7 @@ def test_저장_안_한_카드_고침_물음():
assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show
assert "opts.keepColumn?.(from)" in show
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "const busy = (): boolean => dirty || !!panel?.dirty();" in main
assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main
assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle)
assert main.index("await panel.saveCards();") < main.index("await panel.reload();")
assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main
+19
View File
@@ -61,3 +61,22 @@ def test_실패는_카드_안_글과_다시_불러오기():
assert "const retry = (): void => void paintLabor(row, seq);" in picker
assert "inputs.set(null, why(error), retry);" in picker
assert "const stops = [labor.loading(), inputs.loading()];" in picker
def test_일위대가_카드는_상세_카드_갈래():
"""43-4 — 일위대가 카드 몸 = Labor_Detail 카드 갈래(단가산출 로직 2행 표 + 자재목록 · 순서 ·
로직 추가 · 다이어그램 없음 · 빼기 그대로) · 카드 [저장] = 면적 먼저 → 일위대가 ·
한 줄 표 지움."""
cards = _text("M02_MasterTemplete_ItemCards.ts")
assert "mod.mountLaborDetail(laborHost, got.문서, {" in cards and "card: true," in cards
assert 'makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())' in cards
save = cards[cards.index("async function saveLabor()") :]
assert save.index("laborEd.saveArea()") < save.index('saveTemplate(layer, "labor"')
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로
assert "opt.card ? null : addLogic" in detail and "...(opt.card ? [] : [dgBox])," in detail
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
assert "const tools = !!(move || remove);" in rows and "compactTable" not in rows
assert "compact" not in _text("M02_MasterTemplete_Labor_View.ts").split("*/", 1)[1]
+1 -1
View File
@@ -51,5 +51,5 @@ def test_저장_안_한_카드_고침은_물음():
assert "dirty: anyDirty," in cards
assert "dirty: () => cards.dirty()," in _text("M02_MasterTemplete_Structure.ts")
main = _text("M02_MasterTemplete_UI_Main.ts")
assert "const busy = (): boolean => dirty || !!panel?.dirty();" in main
assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main
assert main.count("!busy()") >= 2 # 떠날 때 물음 · 창 닫기 막음
+3
View File
@@ -181,6 +181,9 @@ def test_계산_반영_합은_손셈_원본과_같음(world):
(mat,) = got["자재"]
assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"]
assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY)
assert (
mat["가격"][mat["기준칸"]] == mat["기준단가"]
) # 복사본 단가 묶음으로도 가격 다섯 · 기준칸(43-3)
total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"]
assert got["반영합"]["계"] == pytest.approx(total)
# 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것
+3
View File
@@ -98,6 +98,9 @@ def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
assert (thousand["수량"], thousand["수량출처"]) == (1, "손")
mat = got["자재"][0]
assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000)
# 43-3 — M01 값 열 다섯(빈 것 null) · 기준단가가 된 열 이름
assert list(mat["가격"]) == ["물가자료", "유통물가", "물가정보", "거래가격", "관급"]
assert mat["기준칸"] == "물가정보" and mat["가격"]["물가정보"] == 83000
nothing = labor.calc(doc, {})["줄"][0]
assert (nothing["수량출처"], nothing["반영"]) == ("없음", None)
+92
View File
@@ -0,0 +1,92 @@
"""일위대가 ↔ 면적산출 함께 가져오기(PLAN 43-9a) · 새 항목 = 칸 셋 자동 생성.
시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage (`test_m02_labor_copy` 의 world 를 씀).
"""
from __future__ import annotations
import json
from pathlib import Path
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_labor_copy import (
TEN,
URL,
_as,
world, # noqa: F401 — 고정물
)
def _doc(folder: Path, kind: str, cid: str) -> dict:
return json.loads((folder / kind / f"{cid}.json").read_bytes())
def _link_rv(system: Path) -> None:
"""원본 rv — 일위대가 수량은 알약 연결 · 면적산출 B6 = 5 에 그 알약."""
with store.at(system):
got = store.read("labor", "rv")
rows = [{"로직": TEN, "입력값": {}, "수량": None}]
store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": rows})
pill = labor.pills(store.read("labor", "rv")["문서"])[0]["키"]
area = store.read("area", "rv")
sheet = area["문서"]["시트"][0]
sheet["칸"] = {"B6": {"값": "5"}}
sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}]
store.write("area", "rv", area["판"], area["문서"])
def test_새_항목_칸_셋_빈_문서_자동_같은_뒷자리(world): # noqa: F811
personal = world["personal"]
numbers = [_doc(personal, k, "a")[key] for k, key in store._KEY.items()]
assert [n[:2] for n in numbers] == ["SD", "SU", "SA"]
assert len({n[2:] for n in numbers}) == 1
assert _doc(personal, "labor", "a")["담은로직"] == []
def test_일위대가_면적산출_함께_덮어씀_도면은_그대로(world): # noqa: F811
system, personal = world["system"], world["personal"]
_link_rv(system)
before = {k: _doc(personal, k, "a") for k in ("structure", "labor", "area")}
for kind in ("labor", "area"): # 어느 카드에서 불러도 같음
url = URL.format("personal", kind, "a") + "?from=rv&from_layer=system&pair=true"
got = _as(world).post(url)
assert got.status_code == 200 and set(got.json()) == {"area", "labor"}
area, doc = _doc(personal, "area", "a"), _doc(personal, "labor", "a")
assert area["시트"][0]["연결"][0]["칸"] == "B6" and area["면적번"] == before["area"]["면적번"]
assert (doc["담은로직"][0]["로직"], doc["일위대가번"]) == (TEN, before["labor"]["일위대가번"])
line = doc["계산값"]["줄"][0]
assert (line["수량"], line["수량출처"]) == (5, "칸") # 새 면적 칸 값으로 셈
assert _doc(personal, "structure", "a") == before["structure"]
def test_pair_없으면_예전처럼_한_칸만(world): # noqa: F811
system, personal = world["system"], world["personal"]
_link_rv(system)
url = URL.format("personal", "labor", "a") + "?from=rv&from_layer=system"
assert _as(world).post(url).status_code == 200
assert "연결" not in _doc(personal, "area", "a")["시트"][0]
def test_실무문서는_일위대가만_면적_그대로(world): # noqa: F811
system = world["system"]
_link_rv(system)
area = _doc(system, "area", "rv")
for kind in ("labor", "area"):
url = URL.format("system", kind, "rv") + "?from=UA000008&from_layer=practice&pair=true"
assert _as(world).post(url).status_code == 403 # 시스템 관리자만
got = _as(world, "SYSTEM_ADMIN").post(url)
assert got.status_code == 200 and set(got.json()) == {"labor"}
assert len(_doc(system, "labor", "rv")["담은로직"]) == 14
assert _doc(system, "area", "rv") == area
def test_원본에_면적산출_없으면_일위대가만(world): # noqa: F811
system, personal = world["system"], world["personal"]
(system / "area" / "rv.json").unlink()
area = _doc(personal, "area", "a")
url = URL.format("personal", "area", "a") + "?from=rv&from_layer=system&pair=true"
got = _as(world).post(url)
assert got.status_code == 200 and set(got.json()) == {"labor"}
assert _doc(personal, "area", "a") == area
assert _doc(personal, "labor", "a")["담은로직"][0]["로직"] == TEN
@@ -238,3 +238,10 @@ def test_상세_화면_돌아가기는_집계표로_그_열_고른_채():
body = main.split('back.addEventListener("click"')[1]
assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body
assert "sheet?.selectColumn(column)" in body
def test_상세_머리_돌아가기는_일반_단추():
"""43-5 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] = 공용 기본 단추(filled · 같은 높이 ·
알약 · ghost · sm 아님) · 대비는 ui_checks/43-5 시나리오(어두움 8.01)."""
main = _text(MAIN)
assert 'const back = createButton({ label: L("M02_Back") });' in main
@@ -0,0 +1,134 @@
/* 43-1 · 43-2 일위대가 수량산출 모달 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌.
*
* 시작 화면: M02 마스터 템플릿(시스템 관리자) · 좌측 일위대가 줄 「SU-000003」 이 보이는 상태 ·
* 그 열 면적산출(SA-000003) B6 = 12.5(검증 전 넣고 끝나면 마스터 사본으로 되씀) · 저장은 안 함(끝에 다시 열기 → 버림).
* 기대 수치:
* qtyModal.sheetCells > 0 · qtyModal.twoStepButton = false(「엑셀에서 고르기」 없음) · qtyModal.hasHand = true
* hand.applied = round(기준 × 5) · hand.qty = "손 5 ㎡"
* cell.qty = "B6 = 12.5" · cell.applied = round(기준 × 12.5)(배 1)
* follow.qty = "B6 = 12.5"(손 지움 = 칸 따름 · 연결은 남음) · unlink.qty = "—"
* matRow.opens = true(자재 줄이 있으면 같은 모달) · inputModal.hasQty = false(43-2 · 입력값 · 메모만)
* discarded = true(다시 열기 → 확인 → 처음 수량 글과 같음)
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const btn = (root, text) => $$("button", root).find((b) => b.innerText.trim() === text);
const num = (t) => Number(String(t).replace(/[^0-9.-]/g, ""));
const LOGIC = "GF000219";
const row = () => $(`[data-labor-logic="${LOGIC}"]`);
const qtyText = () =>
row().querySelector(".m02-labrow__qty .m02-labrow__applied").innerText.trim();
const money = () =>
$$(".m02-labrow__money", row()).map((c) => [
num(c.querySelector(".m02-labrow__base").innerText),
c.querySelector(".m02-labrow__applied").innerText.trim(),
]);
const out = {};
const side = $$(".m02-side__item").find((b) => b.innerText.startsWith("SU-000003"));
if (!side) return JSON.stringify({ error: "SU-000003 줄 없음" });
side.click();
await W(5000);
const base = money()[3][0]; // 합계 기준
out.initial = qtyText();
const openQty = async () => {
row().querySelector("[data-labor-qty-open]").click();
await W(2500);
return $(".m02-labqty");
};
// ① 모달 모양 + 손 5
let m = await openQty();
out.qtyModal = {
open: !!m,
sheetCells: $$("[data-labor-qty-sheet] .aislo-cell", m).length,
twoStepButton: !!btn(m, "엑셀에서 고르기"),
hasHand: !!$("[data-labor-qty]", m),
now: $("[data-labor-qty-now]", m)?.innerText,
};
const hand = $("[data-labor-qty]", m);
hand.value = "5";
hand.dispatchEvent(new Event("input"));
btn(m, "확인").click();
await W(600);
out.hand = { qty: qtyText(), applied: num(money()[3][1]), expect: Math.round(base * 5) };
out.hand.ok = Math.abs(out.hand.applied - out.hand.expect) <= 5; // 기준은 화면 반올림 값
// ② 칸 B6 고름 → 확인
m = await openQty();
const c = $('[data-labor-qty-sheet] [data-addr="B6"]', m);
const r = c.getBoundingClientRect();
const o = {
bubbles: true,
cancelable: true,
clientX: r.x + 10,
clientY: r.y + 8,
button: 0,
pointerId: 1,
pointerType: "mouse",
};
c.dispatchEvent(new PointerEvent("pointerdown", o));
c.dispatchEvent(new MouseEvent("mousedown", o));
await W(80);
c.dispatchEvent(new PointerEvent("pointerup", o));
c.dispatchEvent(new MouseEvent("mouseup", o));
c.dispatchEvent(new MouseEvent("click", o));
await W(600);
const share = btn(document, "함께 잇기"); // 그 칸에 다른 알약이 있으면 물음
if (share) {
share.click();
await W(300);
}
out.cell = {
state: $("[data-labor-qty-state]", m)?.innerText,
handCleared: $("[data-labor-qty]", m).value === "",
};
btn(m, "확인").click();
await W(800);
Object.assign(out.cell, {
qty: qtyText(),
applied: num(money()[3][1]),
expect: Math.round(base * 12.5),
});
// ③ 손 넣었다 지우고 확인 = 칸 따름 · ④ 연결 풀기
m = await openQty();
$("[data-labor-qty]", m).value = "";
btn(m, "확인").click();
await W(500);
out.follow = { qty: qtyText() };
m = await openQty();
const unlink = btn(m, "연결 풀기");
out.unlink = { button: !!unlink };
if (unlink) {
unlink.click();
btn(m, "확인").click();
await W(500);
out.unlink.qty = qtyText();
} else btn(m, "닫기").click();
// ⑤ 자재 줄 · ⑥ 입력 모달(43-2)
const mat = $("[data-labor-mat]");
if (mat) {
mat.click();
await W(2500);
out.matRow = { opens: !!$(".m02-labqty") };
btn($(".m02-labqty"), "닫기")?.click();
await W(300);
} else out.matRow = { opens: "자재 없음" };
row().querySelector(".m02-labrow__name").click();
await W(400);
const im = $(".m02-labin:not(.m02-labqty)");
out.inputModal = {
open: !!im,
hasQty: !!$("[data-labor-qty]", im) || !!btn(im, "엑셀에서 고르기"),
hasMemo: !!$("[data-labor-memo]", im),
};
btn(im, "취소")?.click();
await W(300);
// ⑦ 버림
btn($(".m02-main__head"), "다시 열기").click();
await W(600);
btn($(".ui-confirm"), "확인")?.click();
await W(4500);
out.afterDiscard = qtyText();
out.discarded = out.afterDiscard === out.initial;
return JSON.stringify(out);
})();
@@ -0,0 +1,70 @@
// 43-3 자재 줄 2행 · 가격 다섯 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎(async 식 · JSON 돌려줌).
// 시작 화면 = M02 마스터 템플릿(#/m02-master-template · 시스템 관리자 · 마스터 층).
// 하는 일 = 좌측 일위대가 SU-000001 상세 → [자재 추가] → MT004177(시멘트, 전국) 고름 → 줄 수치 읽음 → [빼기]로 되돌림(저장 안 함).
// 기대 = heads 에 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · prices = 6,545 · 6,727 · 6,090 · 6,181 · — ·
// base = 물가정보(굵게 · 강조) · money.base = 6,090 · qty.base = 「1 포」 · after = 0(빼기 뒤 줄 없음).
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 6000) => {
for (let t = 0; t < ms; t += 150) {
const got = fn();
if (got) return got;
await wait(150);
}
return null;
};
const CODE = "MT004177";
const group = [...document.querySelectorAll(".m02-side__group")].find((s) =>
s.querySelector(".ui-collapsible__title")?.textContent.trim().startsWith("일위대가"),
);
if (!group) return JSON.stringify({ error: "좌측 일위대가 없음" });
if (group.classList.contains("is-collapsed"))
group.querySelector(".ui-collapsible__title").click();
const su = [...group.querySelectorAll("button")].find((b) =>
b.textContent.startsWith("SU-000001"),
);
su?.click();
const add = await until(() =>
[...document.querySelectorAll("button")].find((b) => b.textContent.trim() === "자재 추가"),
);
if (!add) return JSON.stringify({ error: "자재 추가 단추 없음" });
add.click();
const box = await until(() => document.querySelector(".m02-pick input"));
let item = null;
for (let tries = 0; tries < 3 && !item; tries += 1) {
// 서버가 막 다시 떴으면 첫 찾기가 늦음 — 다시 침(찾기 칸 글 = 코드)
box.value = tries ? `${CODE} ` : CODE;
box.dispatchEvent(new Event("input", { bubbles: true }));
item = await until(() => {
const items = [...document.querySelectorAll(".m02-pick__item")];
return items.length === 1 && items[0].dataset.material === CODE ? items[0] : null;
}, 4000);
}
if (!item) return JSON.stringify({ error: "고르기 창에 코드 줄 없음" });
item.click();
const row = await until(() => {
const tr = document.querySelector(`tr[data-labor-mat="${CODE}"]`);
return tr && !tr.textContent.includes("…") ? tr : null;
}, 8000);
if (!row) return JSON.stringify({ error: "자재 줄이 안 섬(계산 대기)" });
const table = row.closest("table");
const out = {
heads: [...table.querySelectorAll("thead th")].map((th) => th.textContent.trim()),
prices: [...row.querySelectorAll("[data-price-slot]")].map((td) => td.textContent.trim()),
base: [...row.querySelectorAll(".is-base-price")].map((td) => td.dataset.priceSlot),
baseWeight: getComputedStyle(row.querySelector(".is-base-price") ?? row).fontWeight,
qty: {
base: row.querySelector(".m02-labrow__qty .m02-labrow__base")?.textContent.trim(),
applied: row.querySelector(".m02-labrow__qty .m02-labrow__applied")?.textContent.trim(),
},
money: {
base: row.querySelector(".m02-labrow__money .m02-labrow__base")?.textContent.trim(),
applied: row.querySelector(".m02-labrow__money .m02-labrow__applied")?.textContent.trim(),
},
};
// 되돌림 — 그 줄 [빼기](저장 안 함)
[...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "빼기")?.click();
await wait(400);
out.after = document.querySelectorAll(`tr[data-labor-mat="${CODE}"]`).length;
return JSON.stringify(out);
})();
@@ -0,0 +1,96 @@
/* 43-4 — 구조물 조합 일위대가 카드 = 일위대가 상세 카드 갈래(단가산출 로직 2행 표 + 자재목록)
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
* 나무에 item1(일위대가 담은 로직 있는 열)이 있음
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안 — 길면 ORCA 가 끊음)
* 기대 수치:
* - sections = ["단가산출 로직", "자재목록"] · diagram = 0 · info = 0(단위 · 본뜬 칸 없음)
* - addLogic = 0 · up = down = 0 · remove = 로직 줄 수 · addMat = 1 · rows = 담은 로직 수(item1 = 14)
* - money2 = true(금액 칸이 두 줄 — 위 기준 · 아래 반영)
* - scroll.overflowX = "auto" · scroll.wide = 참(item1 표 ≈ 870px > 카드 ≈ 310px · 표 감싸개 안 가로 스크롤)
* - rowModal = 「넣을 값 — …」 · qtyModal = 모달 뜸(수량 · 43-1) · 둘 다 닫기 뒤 dirty = false
* - 빼기 하나 → dirty = true · saveEnabled = true · 다른 항목(item3) → 버림 물음 「확인」 → item1 다시(되돌림)
* → rowsAfter = rows · dirtyAfter = false
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 6000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await w(150);
}
return null;
};
const vis = (b) => b && !b.hidden && b.offsetParent !== null;
const pick = async () => {
document.querySelector('[data-node="i|item1"]').click();
return until(() => {
const ed = document.querySelector(".m02-struct__labor .m02-labor__edit");
return ed && ed.querySelector("tbody tr[data-labor-logic]") ? ed : null;
});
};
const ed = await pick();
if (!ed) return JSON.stringify({ error: "카드 몸 없음" });
const card = ed.closest(".m02-struct > *");
const btn = (t) =>
[...card.querySelectorAll("button")].filter((b) => vis(b) && b.textContent === t);
const save = [...card.querySelectorAll(".ui-collapsible__title button")].find(
(b) => b.textContent === "저장",
);
const rows = () => ed.querySelectorAll("tbody tr[data-labor-logic]").length;
const money = ed.querySelector("tbody tr[data-labor-logic] td:nth-child(5)");
const out = {
sections: [...ed.querySelectorAll(".m01-logic__section-head h3")].map((h) => h.textContent),
diagram: ed.querySelectorAll(".m02-labor__dg").length,
info: ed.querySelectorAll("[data-labor-field]").length,
addLogic: btn("로직 추가").length,
up: btn("올리기").length,
down: btn("내리기").length,
remove: btn("빼기").length,
addMat: btn("자재 추가").length,
rows: rows(),
money2: !!money && money.children.length >= 2,
// 표마다 공용 감싸개(ui-general-block__table-wrap)가 가로 스크롤 — 카드보다 넓으면 그 안에서
scroll: (() => {
const wrap = ed.querySelector("table")?.parentElement;
return wrap
? {
overflowX: getComputedStyle(wrap).overflowX,
wide: wrap.scrollWidth > wrap.clientWidth,
sw: wrap.scrollWidth,
cw: wrap.clientWidth,
}
: null;
})(),
};
// 줄 누름 = 넣을 값 모달 · 수량산출 칸 = 수량 모달 — 닫기만(값 안 바꿈)
const modal = async (open) => {
open();
const m = await until(() => document.querySelector(".ui-modal"), 2000);
const t = m?.querySelector("h3")?.textContent ?? null;
[...(m?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "닫기")?.click();
await until(() => !document.querySelector(".ui-modal"), 1500);
return t;
};
const first = () => ed.querySelector("tbody tr[data-labor-logic]");
out.rowModal = await modal(() => first().querySelector(".m02-labrow__name").click());
out.qtyModal = await modal(() =>
(
first().querySelector("[data-labor-qty-open]") ?? first().querySelector(".m02-labrow__qty")
).click(),
);
out.dirtyAfterModals = !save.disabled;
// 빼기 하나 → 고침 표시 · 다른 항목으로 갔다 버림 확인 → item1 다시(서버 판으로 되돌림)
btn("빼기").pop().click();
await w(300);
out.dirty = card.classList.contains("is-dirty") || !save.disabled;
out.saveEnabled = !save.disabled;
document.querySelector('[data-node="i|item3"]').click();
const ask = await until(() => document.querySelector(".ui-confirm"), 2000);
[...(ask?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "확인")?.click();
await w(600);
const again = await pick();
out.rowsAfter = again ? again.querySelectorAll("tbody tr[data-labor-logic]").length : null;
out.dirtyAfter = !save.disabled;
return JSON.stringify(out);
})();
@@ -0,0 +1,96 @@
/* 43-5 · E1 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] 꼴 · 머리 단추 글자 대비(밝음 · 어두움)
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 기대 수치:
* - 세 상세 모두 back.cls = "ui-btn ui-btn--filled"(ghost · sm 없음) · back.h = 머리 다른 단추 높이(±1px)
* - 밝음 · 어두움(앱 ☾ 단추) 모두 머리 보이는 단추 대비 ≥ 4.5
* - 끝나면 처음 테마로 되돌림(theme.end = theme.start)
* ⚠ 단추 color 전환(120ms)이 ORCA 탭에선 0ms 에 멈춰 옛 색이 읽힘(1.11 착오 · E1) — 재는 동안 전환을 끔
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const rgb = (s) => (s.match(/[\d.]+/g) || []).map(Number);
const lum = ([r, g, b]) => {
const f = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
/** 실제로 보이는 바탕 — 투명이면 조상으로 */
const bgOf = (el) => {
for (let e = el; e; e = e.parentElement) {
const c = rgb(getComputedStyle(e).backgroundColor);
if (c.length >= 3 && (c.length < 4 || c[3] > 0.5)) return c.slice(0, 3);
}
return [255, 255, 255];
};
const ratio = (el) => {
const a = lum(rgb(getComputedStyle(el).color).slice(0, 3));
const b = lum(bgOf(el));
return +((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)).toFixed(2);
};
const theme = () => document.documentElement.getAttribute("data-theme") || "auto";
const moon = () =>
[...document.querySelectorAll("button")].find((b) => /^[☾☀]$/.test(b.textContent.trim()));
const headButtons = () =>
[...document.querySelectorAll(".m02-main__head button")].filter(
(b) => !b.hidden && b.offsetParent,
);
const openRow = async (sectionTitle, prefix) => {
const sec = [...document.querySelectorAll(".m02-side__summary > section")].find(
(s) => s.querySelector("h3").textContent.trim() === sectionTitle,
);
sec.classList.remove("is-collapsed");
[...sec.querySelectorAll("button")].find((b) => b.textContent.startsWith(prefix)).click();
await w(5000);
};
const measure = () => {
const back = [...document.querySelectorAll(".m02-main__head button")].find(
(b) => b.textContent.trim() === "돌아가기",
);
const others = headButtons().filter((b) => b !== back);
return {
title: document.querySelector(".m02-main__title")?.textContent,
back: back && {
cls: back.className,
h: Math.round(back.getBoundingClientRect().height),
radius: getComputedStyle(back).borderRadius,
},
otherH: others.map((b) => Math.round(b.getBoundingClientRect().height)),
contrast: Object.fromEntries(
headButtons().map((b) => [b.textContent.trim() || b.title, ratio(b)]),
),
};
};
const still = document.createElement("style");
still.textContent = "*,*::before,*::after{transition:none!important}";
document.head.append(still);
const start = theme();
const out = { theme: { start }, pages: {} };
for (const [name, sec, prefix] of [
["도면", "구조물 도면", "SD-000001"],
["일위대가", "일위대가", "SU-000001"],
["면적산출", "면적산출", "SA-000001"],
]) {
await openRow(sec, prefix);
const light = measure();
moon()?.click();
await w(400);
const dark = { theme: theme(), ...measure() };
moon()?.click();
await w(400);
out.pages[name] = { light, dark };
}
if (theme() !== start) moon()?.click();
out.theme.end = theme();
still.remove();
const all = Object.values(out.pages).flatMap((p) => [
...Object.values(p.light.contrast),
...Object.values(p.dark.contrast),
]);
out.minContrast = Math.min(...all);
out.backOk = Object.values(out.pages).every(
(p) =>
p.light.back?.cls === "ui-btn ui-btn--filled" &&
p.light.otherH.every((h) => Math.abs(h - p.light.back.h) <= 1),
);
return JSON.stringify(out);
})();
@@ -0,0 +1,67 @@
/* 43-6 도면 표 선 색(흠 E2) — orca eval 한 번에 도는 시나리오(읽기만 · 저장 안 함).
* 시작 화면: M02 · 시스템 관리자 · 구조물 도면 상세(마스터 item1 · CAD 열림 · 보기 전용 아님).
* 하는 일: 면적산출 범위 → 표 변환(POST …/area/item1/table · 답에 lineColor 없음) → CAD 에 place-table →
* 커서 옮김 → 누름(놓기) → save-request 로 CAD 도면 JSON 받음 → Ctrl+Z 로 되돌림.
* 기대: 변환 답 lineColor 없음(answerHasColor false) · 놓인 표 1 · 저장 JSON 표 lineColor = "#fff" ·
* lineWidth 1 · id 있음 · 되돌린 뒤 표 수 = 시작 수.
*/
(async () => {
const W = (t) => new Promise((r) => setTimeout(r, t));
const f = document.querySelector("iframe");
const w = f.contentWindow;
const cad = w.__aisloCad;
const before = cad.entities().filter((e) => e.type === "Table").length;
const area = await (
await fetch("/api/m02/layers/system/templates/area/item1", { credentials: "include" })
).json();
const sheet = area.문서.시트[0].id;
const got = await (
await fetch("/api/m02/layers/system/area/item1/table", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ 시트: sheet, 범위: "A1:B2", 글자크기: 2.5 }),
})
).json();
const entity = got.표;
w.postMessage({ type: "aislo:b08:place-table", entity, link: "T43" }, location.origin);
await W(600);
const c = f.contentDocument.querySelector("canvas");
const r = c.getBoundingClientRect();
const at = { bubbles: true, button: 0, clientX: r.left + 320, clientY: r.top + 260 };
c.dispatchEvent(new MouseEvent("mousemove", at));
await W(window.__hold ?? 300); // 사진 찍을 때는 __hold 를 늘림
c.dispatchEvent(new MouseEvent("mousedown", at));
c.dispatchEvent(new MouseEvent("mouseup", at));
await W(500);
const placed = cad.entities().filter((e) => e.type === "Table").length - before;
let saved = null;
const take = (e) => {
if (e.data && e.data.type === "aislo:b08:save-response") saved = e.data.drawing;
};
window.addEventListener("message", take);
w.postMessage({ type: "aislo:b08:save-request" }, location.origin);
await W(1000);
window.removeEventListener("message", take);
const table = (saved?.entities ?? []).find(
(e) => e.type === "Table" && e.shapeData?.style?.연결 === "T43",
);
f.contentDocument.body.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
code: "KeyZ",
ctrlKey: true,
bubbles: true,
cancelable: true,
}),
);
await W(400);
return JSON.stringify({
answerHasColor: "lineColor" in entity,
placed,
lineColor: table?.lineColor ?? null,
lineWidth: table?.lineWidth ?? null,
hasId: !!table?.id,
afterUndo: cad.entities().filter((e) => e.type === "Table").length === before,
});
})();
@@ -0,0 +1,125 @@
/* 43-9b — 구조물 조합 카드 셋: 빈 카드 [새로 만들기] · [복사 생성] 없음 · 일위대가 [가져오기] = 일위대가 · 면적산출 함께
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
* 나무에 item1(SU-000001 · 담은 로직 있음 · 면적산출 있음)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글 · 20초 안)
* 쓰는 것: 마스터 층에 새 항목 하나(「검증43 9b」) + 그 일위대가 · 면적산출 — 끝나면 resources/master_template 사본으로 되씀
* 기대 수치:
* - made = 새 열 id · twoButtons = 0(카드 안 「새로 만들기」 · 「복사 생성」) · tools = 카드 셋 모두 가져오기 · 수정 · 삭제
* - note.labor = 「일위대가 · 면적산출 함께 가져옴 …」 · note.area = 같은 글 · note.drawing = null
* - after.logic = src.logic(14) · after.links = src.links(item1 알약 연결 3) · after.areaSame = true(시트 통째 = 원본)
* - drawing.before = drawing.after = 0(도면 그대로) · card.rows = src.logic(카드 다시 읽음)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 6000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await w(150);
}
return null;
};
const vis = (b) => b && !b.hidden && b.offsetParent !== null;
const btns = (root, t) =>
[...root.querySelectorAll("button")].filter((b) => vis(b) && b.innerText.trim() === t);
const api = (kind, col) =>
fetch(`/api/m02/layers/system/templates/${kind}/${col}`, { credentials: "include" }).then((r) =>
r.json(),
);
const cells = (book) =>
(book.시트 ?? []).flatMap((s) =>
Object.entries(s.칸 ?? {}).filter(([, c]) => (c.값 ?? "") !== "" || c.식),
);
const links = (book) => (book.시트 ?? []).reduce((n, s) => n + (s.연결?.length ?? 0), 0);
const sheets = (book) => JSON.stringify(book.시트 ?? []);
const items = () =>
[...document.querySelectorAll('[data-node^="i|"]')].map((n) => n.dataset.node);
const cards = () => [...document.querySelectorAll(".m02-struct > *")];
const out = {};
// ① 새 항목 — 제목 줄 「새 항목」 → [새로 만들기] → 종류 · 이름 → 확인
const before = items();
btns(document, "새 항목")[0].click();
const two = await until(() => [...document.querySelectorAll(".m02-itempick__two")].find(vis));
two.querySelector(".m02-itempick__buttons button").click();
await w(500);
const ins = [...document.querySelectorAll("input")].filter(vis);
const set = (i, v) => {
i.value = v;
i.dispatchEvent(new Event("input", { bubbles: true }));
i.dispatchEvent(new Event("change", { bubbles: true }));
};
set(ins[0], "검증43");
set(ins[1], `9b-${Date.now() % 100000}`); // 같은 이름이면 안 만들어짐
btns(document, "확인").pop().click();
const node = await until(() => items().find((n) => !before.includes(n)));
if (!node) return JSON.stringify({ error: "새 항목 안 생김" });
const col = node.slice(2);
out.made = col;
await until(() => cards()[1]?.querySelector(".m02-labor__edit"), 8000);
out.twoButtons = cards()
.flatMap((c) => [...c.querySelectorAll("button")])
.filter((b) => ["새로 만들기", "복사 생성"].includes(b.innerText.trim())).length;
out.tools = cards().map((c) =>
["가져오기", "수정", "삭제"].every((t) => btns(c, t).length === 1),
);
// ② 가져오기 창 안내 — 도면(없음) · 면적산출(있음) 은 열어 보고 닫음
const note = async (i) => {
btns(cards()[i], "가져오기")[0].click();
const d = await until(() => document.querySelector(".m02-itempick__dialog"));
await w(300);
const text = d.querySelector(".m02-itempick__note")?.innerText ?? null;
return { d, text };
};
out.note = {};
for (const [i, k] of [
[0, "drawing"],
[2, "area"],
]) {
const { d, text } = await note(i);
out.note[k] = text;
btns(d, "닫기")[0].click();
await until(() => !document.querySelector(".m02-itempick__dialog"));
}
// ③ 일위대가 카드 가져오기 ← item1(같은 마스터 층)
const [srcLabor, srcArea, dstDrawing] = await Promise.all([
api("labor", "item1"),
api("area", "item1"),
api("structure", col),
]);
out.src = {
logic: srcLabor.문서.담은로직?.length ?? 0,
links: links(srcArea.문서),
areaCells: cells(srcArea.문서).length,
};
out.drawing = { before: dstDrawing.문서.도면?.entities?.length ?? 0 };
const { d, text } = await note(1);
out.note.labor = text;
const row = await until(() =>
[...d.querySelectorAll("tr")].find((r) => r.innerText.startsWith("SU-000001")),
);
row.click();
await w(400);
btns(document, "확인").pop().click();
const rows = await until(() => {
const ed = cards()[1]?.querySelector(".m02-labor__edit");
const n = ed?.querySelectorAll("tbody tr[data-labor-logic]").length ?? 0;
return n ? n : null;
}, 8000);
const [labor, area, drawing] = await Promise.all([
api("labor", col),
api("area", col),
api("structure", col),
]);
out.after = {
logic: labor.문서.담은로직?.length ?? 0,
links: links(area.문서),
areaCells: cells(area.문서).length,
areaSame: sheets(area.문서) === sheets(srcArea.문서),
};
out.drawing.after = drawing.문서.도면?.entities?.length ?? 0;
out.card = { rows };
return JSON.stringify(out);
})();