feat(M02 붙여넣기 화면 · 다시 잇기): 격자 Ctrl+V · 열 짝 한 번 묻고 기억 · 이 공사 단가로 다시 잇기 (PLAN 705-2)

- 일위대가 격자(ui_template_hopyo_grid onPaste) 누르고 Ctrl+V → 서버 /hopyo-paste · 열 짝 = 처음 한 번 묻고 이 브라우저에 기억 · 엑셀 머리 글이면 묻지 않음
- 호표 머리 없음 = 열린 문서에 고정 줄(알림 누름 = 다시 잇기) · 머리 있음 = 호표마다 사전조합 + 결과 창 [다음 — 이 공사 단가로 다시 잇기]
- 다시 잇기 창(EST 신 · 구 단가 비교 꼴) — 붙인 단가 · 최신 판 단가 · 후보(705-3 이음 짝) · 금액 차이 · 줄마다 · 모두 고름 · [바꿈] · [되돌리기]
- 서버 M02_Hopyo_Relink — 후보 표 · 이음 칸만 고쳐 씀(숫자 안 받음) · 전후 1 단위 값 · 되돌림
- 실무자료 [모두 고정으로 들여오기] 결과에도 다음 걸음 단추
- M02 상세가 고정 줄을 읽을 때 떨구던 흠 — normalizeLabor 가 고정 칸 지킴 · 로직 표 · 격자에 고정 줄 이름 · 규격 · 단위 · 「고정」 꼬리표 · 이음 / 손 단가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:57:39 +09:00
co-authored by Claude Opus 5.5
parent 137b8a3a45
commit 32de488401
14 changed files with 831 additions and 14 deletions
+148
View File
@@ -0,0 +1,148 @@
"""「이 공사 단가로 다시 잇기」(PLAN 705-2 · 요구서 4장 2 · EST 「신 · 구 단가 비교」 꼴) — 고정 줄마다 붙인 단가 ↔
단가 책(M02 = M01 최신 판 노임 「값」 · 자재 기준단가) · 이음 후보 · 고른 줄만 이음 바꿈 · 되돌리기.
후보 = 705-3 이음과 같은 짝(`M02_Hopyo_Fixed.Linker`) — 노임 = 이름이 우리 직종(단위 인) ·
자재 = 이름 · 규격 같은 품목 + 단위 배 1 · 단가가 달라도 후보(바꿀지는 사람이 고름) · 기계 = 없음(B09 중기 몫).
줄 = 비율 줄 뺀 고정 줄 · 줄 가름 = `fixed_id` · 붙인 이름 끝 「 (2)」(줄 가름)은 떼고 찾음.
바꿈 = `{키, 줄, 노임?, 자재?}` — 키 = 이 층 사전조합(SC) · 값 null = 이음 풂(붙인 단가로) ·
서버가 문서를 읽어 이음 칸만 고쳐 씀(숫자 안 받음) · 전후 1 단위 값(`Labor_Ref.Expand`) · `되돌림` = 옛 이음.
열린 문서(저장 전) = `doc` 로 줄 · 후보만 — 바꿈은 화면이 문서에 넣고 [저장].
"""
from __future__ import annotations
import re
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
KINDS = ("노임", "자재")
CHANGES_MAX = 2000
_SUFFIX = re.compile(r"\s*\(\d+\)$")
def _num(raw: Any) -> float | None:
got = fixed._num(raw)
return float(got) if got is not None else None
class Finder:
"""후보 찾기 — 단가 책(M01 최신 판) 한 번 읽음."""
def __init__(self) -> None:
self.linker = hfix.Linker()
self.whole = self.linker.whole
def _name(self, code: str) -> dict[str, str]:
try:
row = self.whole.get(code) or {}
except fixed.mf.FormulaError:
row = {}
return {k: str(row.get(k) or "") for k in ("이름", "규격", "단위")}
def price(self, kind: str, code: str | None, unit: str | None) -> float | None:
if not code:
return None
try:
if kind == "노임":
return _num((self.whole.get(code) or {}).get("값"))
return _num(fixed.mm.element(self.whole, code, {}, unit or None)[0])
except fixed.mf.FormulaError:
return None
def candidates(self, fx: dict[str, Any]) -> list[dict[str, Any]]:
name = _SUFFIX.sub("", str(fx.get("이름") or ""))
line = {"이름": name, "규격": fx.get("규격") or "", "단위": fx.get("단위") or ""}
out = []
trade = shares.trade(name) if line["단위"].strip() == "인" else None
for code, value in self.linker.labor.get(trade or "", []):
out.append({"비목": "노임", "키": code, **self._name(code), "단가": _num(value)})
price = fx.get("단가") or {}
if not trade and (_num(price.get("재료비")) or not any(_num(v) for v in price.values())):
for code, value in self.linker._mats({**line, "갈래": "자재"}):
out.append({"비목": "자재", "키": code, **self._name(code), "단가": _num(value)})
return out
def row(self, key: str | None, one: dict[str, Any]) -> dict[str, Any]:
fx = one[fixed.FIXED]
link = fx.get("이음") or {}
qty = _num((one.get("수량") or {}).get("손"))
now = {
k: self.price(k, link.get(k), fx.get("단위"))
for k in KINDS
if link.get(k) # 이은 비목 지금 단가
}
return {
"키": key,
"줄": fixed.fixed_id(fx),
**{k: fx.get(k) or "" for k in ("이름", "규격", "단위")},
"수량": qty,
"붙인": {k: _num((fx.get("단가") or {}).get(k)) or 0 for k in fixed.COSTS},
"이음": {k: link.get(k) for k in KINDS},
"지금": now,
"후보": self.candidates(fx),
}
def _lines(doc: dict[str, Any]) -> list[dict[str, Any]]:
return [
one
for one in doc.get("담은로직") or []
if fixed.is_fixed(one) and not one[fixed.FIXED].get("비율")
]
def rows(keys: list[str] | None = None, doc: dict[str, Any] | None = None) -> dict[str, Any]:
"""고정 줄 표 — `keys` = 이 층 사전조합들 · `doc` = 열린 문서 → `{줄 [...], 문서 [{키, 이름}]}`."""
finder, out, docs = Finder(), [], []
if doc is not None:
out = [finder.row(None, one) for one in _lines(doc)]
for key in keys or []:
refs.check_key("precombo", key)
got = store.read("precombo", key)["문서"]
docs.append({"키": key, "이름": got.get("이름") or key})
out += [finder.row(key, one) for one in _lines(got)]
return {"줄": out, "문서": docs}
def _total(key: str) -> float | None:
at = refs.here()
got = refs.Expand(m01.loaded()[1])({"층": at[0], "주인": at[1], "키": key})
return _num((got.get("기준") or {}).get("계"))
def apply(changes: list[dict[str, Any]]) -> dict[str, Any]:
"""바꿈 → 이 층 사전조합 이음 칸만 고쳐 씀 · M01 에 없는 키 400(`Labor_Fixed.clean`) →
`{바뀜, 문서 [{키, 이름, 전, 후}], 되돌림 [{키, 줄, 노임, 자재}]}`."""
if len(changes) > CHANGES_MAX:
raise store.StoreError(400, f"바꿈은 {CHANGES_MAX} 줄 안")
by_key: dict[str, list[dict[str, Any]]] = {}
for one in changes:
if not isinstance(one, dict) or not isinstance(one.get("줄"), str):
raise store.StoreError(400, "바꿈 줄은 {키, 줄, 노임?, 자재?}")
refs.check_key("precombo", str(one.get("키") or ""))
by_key.setdefault(str(one["키"]), []).append(one)
whole, done, back, docs = m01.loaded()[1], 0, [], []
for key, wants in by_key.items():
got = store.read("precombo", key)
doc, before = got["문서"], _total(key)
lines = {fixed.fixed_id(x[fixed.FIXED]): x for x in _lines(doc)}
for want in wants:
one = lines.get(want["줄"])
if one is None:
raise store.StoreError(404, f"없는 고정 줄 「{want['줄']}」 ({key})")
link = dict(one[fixed.FIXED].get("이음") or {})
back.append({"키": key, "줄": want["줄"], **{k: link.get(k) for k in KINDS}})
for k in KINDS:
if k in want:
link[k] = want[k] or None
one[fixed.FIXED] = fixed.clean({**one[fixed.FIXED], "이음": link}, want["줄"], whole)
done += 1
store.write("precombo", key, got["판"], doc)
docs.append({"키": key, "이름": doc.get("이름") or key, "전": before, "후": _total(key)})
return {"바뀜": done, "문서": docs, "되돌림": back}
+38
View File
@@ -28,6 +28,7 @@ from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
@@ -467,3 +468,40 @@ def hopyo_paste_run(
folder = _root(session, layer, owner, write=True)
args = (folder, body.글, body.열, _who(session), body.줄만, body.있음)
return _call(folder, hopyo_paste.paste, *args)
class RelinkRowsBody(BaseModel):
"""다시 잇기 표(705-2) — `키` = 이 층 사전조합들 · `문서` = 열린 문서(저장 전 · 줄 · 후보만)."""
키: list[str] = Field(default_factory=list, max_length=500)
문서: dict[str, Any] | None = None
@router.post("/layers/{layer}/hopyo-relink/rows")
def hopyo_relink_rows(
layer: Layer,
body: RelinkRowsBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""「이 공사 단가로 다시 잇기」 표 — 고정 줄마다 붙인 단가 · 이은 단가 · 후보(층 읽기)."""
folder = _root(session, layer, owner)
return _call(folder, hopyo_relink.rows, body.키, body.문서)
class RelinkBody(_Strict):
"""바꿈 `[{키, 줄, 노임?, 자재?}]` — 이음 키만(숫자 칸 없음 · 값 null = 풂)."""
바꿈: list[dict[str, Any]] = Field(max_length=hopyo_relink.CHANGES_MAX)
@router.post("/layers/{layer}/hopyo-relink")
def hopyo_relink_apply(
layer: Layer,
body: RelinkBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""[바꿈] · [되돌리기] — 이 층 사전조합 이음 칸 고쳐 씀(층 쓰기) → `{바뀜, 문서, 되돌림}`."""
folder = _root(session, layer, owner, write=True)
return _call(folder, hopyo_relink.apply, body.바꿈)
@@ -8,7 +8,7 @@
import { API_BASE_URL } from "@config/config_frontend";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import type { MatMarks } from "./M02_MasterTemplete_Labor_Api";
/** 층 · 주인(null = 자기 층) */
@@ -275,6 +275,8 @@ export interface HopyoBuiltAll {
차이: number | null;
}[];
실패: { 번: number; 까닭: string }[];
/** 만든 · 그대로 둔 사전조합(705-2 · 다시 잇기) */
만듦?: { 번: number; 이름: string; 키: string }[];
}
/** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */
@@ -411,3 +413,82 @@ export async function uploadHopyo(scope: HopyoScope, file: File): Promise<HopyoF
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body;
}
/** 엑셀 붙여넣기 열 짝(705-2) — 칸 차례(0부터) · 노 · 재 · 경 = 단가 칸 · 금액 = 합계 금액 칸 */
export type PasteCols = Partial<
Record<"품명" | "규격" | "수량" | "단위" | "금액" | "노" | "재" | "경", number>
>;
/** 붙여넣기 답 — 물음(열 짝 짐작 + 보기) · 줄(고정 줄 · 문서에 넣음) · 만듦(호표마다 사전조합) */
export type PasteResult =
| { 갈래: "물음"; 열: PasteCols; 짐작: boolean; 보기: string[][] }
| {
갈래: "줄";
열: PasteCols;
줄: { 고정: LaborFixed; 별칭: string | null; 메모: string; 수량: { 손: number | null } }[];
엑셀: number;
이음: number;
이음후보: number;
}
| ({ 갈래: "만듦"; 열: PasteCols; 키: string } & HopyoBuiltAll);
/** [Ctrl+V](705-2) — 붙인 글 그대로(숫자는 서버가 글에서) */
export const pasteHopyo = (
scope: HopyoScope,
글: string,
열: PasteCols | null,
opt: { 줄만?: boolean; 있음?: string[] } = {},
): Promise<PasteResult> =>
call(at(scope, "/hopyo-paste"), {
method: "POST",
body: JSON.stringify({ 글, ...(열 ? { 열 } : {}), ...opt }),
});
/** 다시 잇기 표 한 줄(705-2) — 붙인 단가 · 이은 단가(지금) · 후보 */
export interface RelinkRow {
키: string | null;
줄: string;
이름: string;
규격: string;
단위: string;
수량: number | null;
붙인: Record<"노무비" | "재료비" | "경비", number>;
이음: Record<"노임" | "자재", string | null>;
지금: Partial<Record<"노임" | "자재", number | null>>;
후보: {
비목: "노임" | "자재";
키: string;
이름: string;
규격: string;
단위: string;
단가: number | null;
}[];
}
/** 바꿈 한 줄 — 값 null = 이음 풂 */
export interface RelinkChange {
키: string;
줄: string;
노임?: string | null;
자재?: string | null;
}
export const relinkRows = async (
scope: HopyoScope,
body: { 키?: string[]; 문서?: unknown },
): Promise<RelinkRow[]> =>
(
await call<{ 줄: RelinkRow[] }>(at(scope, "/hopyo-relink/rows"), {
method: "POST",
body: JSON.stringify(body),
})
).줄;
export const relinkApply = (
scope: HopyoScope,
바꿈: RelinkChange[],
): Promise<{
바뀜: number;
문서: { 키: string; 이름: string; 전: number | null; 후: number | null }[];
되돌림: RelinkChange[];
}> => call(at(scope, "/hopyo-relink"), { method: "POST", body: JSON.stringify({ 바꿈 }) });
@@ -0,0 +1,234 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Relink.ts
* 「이 공사 단가로 다시 잇기」(PLAN 705-2 · EST 「신 · 구 단가 비교」 꼴) — 고정 줄마다 붙인 단가 · 지금 이은 단가 ·
* 이음 후보(이름 · 규격 · 단위 맞춤 · 705-3 이음과 같은 짝) · 차이 → 줄마다 · 모두 골라 [바꿈] · [되돌리기].
* 후보 칸 누름 = 다음 후보(끝 = 그대로 · 이은 줄은 「풂」) · 고름 칸 누름 = 켬 · 끔
* 출처 셋 — 사전조합 여럿(`relinkKeys` · 서버가 이음 칸만 고쳐 씀 · 전후 1 단위 값) ·
* 열린 문서(`relinkDoc` · 문서에 넣고 [저장]) · B09 지금 호표(부르는 쪽 · 이 공사 단가 책)
* 숫자는 서버 단가 책 값 — 화면은 이음 키만 고름
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createDataTable, type DataRow } from "@ui/ui_template_data_table";
import { createText } from "@ui/ui_template_text";
import {
pageScope,
relinkApply,
relinkRows,
type RelinkChange,
type RelinkRow,
} from "./M02_MasterTemplete_Hopyo_Api";
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
type Kind = "노임" | "자재";
const COST: Record<Kind, "노무비" | "재료비"> = { 노임: "노무비", 자재: "재료비" };
export interface RelinkSource {
title: string;
/** 이은 단가 칸 머리 — 「최신 판 단가」 · 「이 공사 단가」 */
priceLabel: string;
rows: () => Promise<RelinkRow[]>;
/** 바꿈 → 되돌릴 바꿈 · 알림 글 */
apply: (changes: RelinkChange[]) => Promise<{ back: RelinkChange[]; note: string }>;
/** 단가 덮기(B09 = 이 공사 단가 책) — 없으면 서버 값 */
price?: (kind: Kind, key: string) => number | null | undefined;
}
const won = (v: number | null | undefined): string =>
v == null ? "-" : Math.round(v).toLocaleString("ko-KR");
/** 사전조합 여럿(붙여넣기 · 실무자료 들여오기 뒤) — 서버가 쓰고 전후 1 단위 값 */
export function relinkKeys(keys: string[]): RelinkSource {
const scope = pageScope();
return {
title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`,
priceLabel: "최신 판 단가",
rows: () => relinkRows(scope, { 키: keys }),
apply: async (changes) => {
const got = await relinkApply(scope, changes);
const sum = (k: "전" | "후"): number => got.문서.reduce((a, d) => a + (d[k] ?? 0), 0);
return {
back: got.되돌림,
note: `바뀐 줄 ${got.바뀜} · 사전조합 ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`,
};
},
};
}
/** 열린 문서(저장 전) — 이음을 문서에 넣고 `changed`(서버 셈 · [저장]) */
export function relinkDoc(doc: LaborDoc, changed: () => void): RelinkSource {
const scope = pageScope();
return {
title: "이 공사 단가로 다시 잇기 — 이 일위대가",
priceLabel: "최신 판 단가",
rows: () => relinkRows(scope, { 문서: doc }),
apply: async (changes) => {
const back: RelinkChange[] = [];
for (const c of changes) {
const row = doc.담은로직.find((r) => r.고정 && lineId(r) === c.줄);
if (!row?.고정) continue;
const link = { 노임: null, 자재: null, 기계: null, ...(row.고정.이음 ?? {}) };
back.push({ 키: c.키, 줄: c.줄, 노임: link.노임, 자재: link.자재 });
if ("노임" in c) link.노임 = c.노임 ?? null;
if ("자재" in c) link.자재 = c.자재 ?? null;
row.고정 = { ...row.고정, 이음: link };
}
changed();
return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` };
},
};
}
/** 다시 잇기 창 */
export async function openRelink(src: RelinkSource): Promise<void> {
let rows: RelinkRow[] = [];
/** 줄마다 고른 후보 차례(후보 수 = 그대로 · 이은 줄의 +1 = 풂) · 켬 */
let pick: number[] = [];
let on: boolean[] = [];
let back: RelinkChange[] = [];
const host = document.createElement("div");
const info = createText("", "muted");
const linked = (r: RelinkRow): boolean => !!(r.이음.노임 || r.이음.자재);
const priceOf = (kind: Kind, key: string, given: number | null | undefined): number | null =>
src.price?.(kind, key) ?? given ?? null;
const load = async (): Promise<void> => {
rows = await src.rows();
pick = rows.map((r) => (r.후보.length && !linked(r) ? 0 : r.후보.length));
on = rows.map((r) => !!r.후보.length && !linked(r));
};
const choice = (i: number): RelinkRow["후보"][number] | "keep" | "free" => {
const r = rows[i];
if (pick[i] < r.후보.length) return r.후보[pick[i]];
return pick[i] === r.후보.length ? "keep" : "free";
};
const changes = (): RelinkChange[] =>
rows.flatMap((r, i): RelinkChange[] => {
const c = choice(i);
if (!on[i] || c === "keep") return [];
if (c === "free") return [{ 키: r.키 ?? "", 줄: r.줄, 노임: null, 자재: null }];
return [{ 키: r.키 ?? "", 줄: r.줄, [c.비목]: c.키 }];
});
const nowPrice = (r: RelinkRow): string =>
(["노임", "자재"] as Kind[])
.filter((k) => r.이음[k])
.map((k) => won(priceOf(k, r.이음[k] as string, r.지금[k])))
.join(" · ") || "손 단가";
const draw = (): void => {
let gapSum = 0;
const data: DataRow[] = rows.map((r, i) => {
const c = choice(i);
const cand = typeof c === "object" ? c : null;
const price = cand ? priceOf(cand.비목, cand.키, cand.단가) : null;
const gap =
cand && price != null && r.수량 != null ? (price - r.붙인[COST[cand.비목]]) * r.수량 : null;
if (on[i] && gap != null) gapSum += gap;
return {
key: `${r.키 ?? ""}|${r.줄}`,
name: [r.이름, r.규격].filter(Boolean).join(" · "),
unit: r.단위,
qty: r.수량,
pasted: r.붙인.노무비 + r.붙인.재료비 + r.붙인.경비,
now: nowPrice(r),
cand: cand
? `${cand.비목} ${cand.이름} ${cand.규격} (${pick[i] + 1}/${r.후보.length})`
: c === "free"
? "이음 풂 — 붙인 단가로"
: r.후보.length
? `그대로 (후보 ${r.후보.length})`
: "후보 없음",
price,
gap,
pick: on[i] ? "✓" : "",
};
});
const table = createDataTable({
columns: [
{ key: "name", label: "품명 · 규격", width: 220 },
{ key: "unit", label: "단위", width: 48 },
{ key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 70 },
{ key: "pasted", label: "붙인 단가", kind: "number", decimals: 1, width: 90 },
{ key: "now", label: src.priceLabel, width: 100 },
{ key: "cand", label: "이음 후보 (누름 = 다음)", width: 260 },
{ key: "price", label: "후보 단가", kind: "number", decimals: 1, width: 90 },
{ key: "gap", label: "금액 차이", kind: "number", decimals: 1, width: 90 },
{ key: "pick", label: "고름", width: 48 },
],
rows: data,
frozen: 1,
empty: "고정 줄 없음",
onCellClick: (i, key) => {
const r = rows[i];
if (key === "cand") {
pick[i] = (pick[i] + 1) % (r.후보.length + (linked(r) ? 2 : 1));
on[i] = choice(i) !== "keep";
} else if (key === "pick") on[i] = !on[i];
else return;
draw();
},
cellClass: (i, key) => (key === "pick" && on[i] ? "is-edited" : null),
});
const n = changes().length;
info.textContent =
`고정 줄 ${rows.length} · 후보 있는 줄 ${rows.filter((r) => r.후보.length).length} · ` +
`이은 줄 ${rows.filter(linked).length} · 고른 줄 ${n} · 금액 차이 합 ${won(gapSum)}`;
apply.disabled = !n;
undo.disabled = !back.length;
host.replaceChildren(table.root);
};
const run = async (list: RelinkChange[], undoing: boolean): Promise<void> => {
try {
const got = await src.apply(list);
back = undoing ? [] : got.back;
showToast(undoing ? `되돌림 — ${got.note}` : got.note, "success", 6000);
await load();
draw();
} catch (error) {
showToast(`실패 — ${error instanceof Error ? error.message : String(error)}`, "error");
}
};
const all = createButton({
label: "모두 고름",
variant: "ghost",
onClick: () => {
const want = !rows.every((r, i) => !r.후보.length || on[i]);
on = rows.map((r, i) => (want ? !!r.후보.length && choice(i) !== "keep" : false));
draw();
},
});
const apply = createButton({ label: "바꿈", onClick: () => void run(changes(), false) });
const undo = createButton({
label: "되돌리기",
variant: "ghost",
onClick: () => void run(back, true),
});
try {
await load();
} catch (error) {
showToast(
`다시 잇기 표 실패 — ${error instanceof Error ? error.message : String(error)}`,
"error",
);
return;
}
openModal({
title: src.title,
closeLabel: "닫기",
wide: true,
width: "1100px",
actions: [all, undo, apply],
mount: (body) => {
body.append(
createText(
"붙인 단가 ↔ 단가 책 — 후보 칸을 눌러 고르고 [바꿈] · 품(수량)은 그대로 · 단가만 이음 · [되돌리기] = 바로 앞 바꿈",
"muted",
),
info,
host,
);
draw();
},
});
}
@@ -55,6 +55,7 @@ import {
toTarget,
} from "./M02_MasterTemplete_Hopyo_Check";
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink";
import "./M02_MasterTemplete_Hopyo.css";
export interface HopyoSheetOptions {
@@ -537,7 +538,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
const next =
"다음 — 호표 [수정]에서 [품셈으로 바꾸기]" +
(got.이음후보
? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 「이 공사 단가로 다시 잇기」에서 바꿈`
? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 아래 [다음 — 이 공사 단가로 다시 잇기]에서 바꿈`
: "");
const rows = [
...got.다름.map((x) =>
@@ -556,9 +557,15 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 })] }),
),
];
const relink = createButton({
label: "다음 — 이 공사 단가로 다시 잇기",
size: "sm",
onClick: () => void openRelink(relinkKeys((got.만듦 ?? []).map((x) => x.키))),
}); // 705-2
result.replaceChildren(
note(allText(got)),
note(next),
relink,
...(rows.length ? [table(["호표", "합계 다름 · 실패", "차이"], rows)] : []),
);
showToast(allText(got), got.빠짐 || got.실패.length ? "warning" : "success");
@@ -11,7 +11,7 @@ import {
type LogicSummary,
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc";
import type { LaborCalc, LaborFixed, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc";
import {
call,
getActiveLayer,
@@ -182,6 +182,8 @@ export interface LaborRow {
승격?: boolean;
/** 로직 자재 세 칸(148-4) — {호표 차례: 세 칸} · 금액 무관 */
자재표시?: Record<string, MatMarks>;
/** 고정 줄(705-1 · 705-2 붙여넣기) — 숫자 그대로 · 로직 · 참조 없음 */
고정?: LaborFixed;
}
/** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 빈 칸 = 읽을 때 기본값 · 금액 무관 */
export const MAT_MARKS = {
@@ -260,7 +262,8 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}),
...(r.승격 === true && !r.참조 ? { 승격: true } : {}),
...(r.참조 ? {} : cleanShown(r.자재표시)),
...(r.참조 || r.고정 ? {} : cleanShown(r.자재표시)),
...(r.고정 ? { 고정: r.고정 } : {}), // 705-1 — 읽을 때 떨구면 [저장]에서 줄이 사라짐
})),
자재: (d.자재 ?? []).map((m) => ({
코드: m.코드,
@@ -218,7 +218,7 @@ export function mountLaborDetail(
),
);
const draw = (): void => {
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed));
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed, !lib));
drawLogic();
matHost.replaceChildren(
matTable(
@@ -22,6 +22,7 @@ import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { valueLine } from "./M02_Labor_Values_Model";
import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec";
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
m
@@ -46,6 +47,20 @@ export function laborBlock(
const layer =
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
const qty = line?.수량 ?? null;
const fx = row.고정; // 705-1 고정 줄 — 이름 · 규격 · 단위 = 줄 것 · 비고 = 이음 · 손 단가
if (fx)
return {
name: fx.이름,
spec: fx.규격 ?? "",
qty,
unit: fx.비율 ? "%" : (fx.단위 ?? ""),
prices: money(line?.기준, line?.배 || 1),
amounts: money(line?.반영),
note:
line?.까닭 ??
(Object.values(fx.이음 ?? {}).some(Boolean) ? tc("Grid_Linked") : tc("Grid_Hand")),
tag: HOPYO_TAG.fix,
};
return {
name: row.별칭 || ((row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직),
spec: [valueLine(logic?.입력 ?? [], row.입력값).text, layer].filter(Boolean).join(" · "),
@@ -90,16 +105,19 @@ export function laborBlock(
return withSpecs(block, doc, calc);
}
/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */
/** 격자 하나 — `edit` 있으면 Ctrl+V 붙여넣기(705-2 · `Labor_Paste`) · `spec` 이면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */
export const laborGrid = (
doc: LaborDoc,
calc: LaborCalc | null,
logics: ReadonlyMap<string, ItemLogic>,
edit?: SpecEdit | false,
spec = true,
): HTMLElement => {
const block = laborBlock(doc, calc, logics);
if (!edit) return createHopyoGrid([block]).root;
const grid = createHopyoGrid([block], specCells(block, doc, edit)).root;
const paste = (text: string): void => void pasteInto(doc, text, () => edit(false));
if (!spec) return createHopyoGrid([block], {}, paste).root;
const grid = createHopyoGrid([block], specCells(block, doc, edit), paste).root;
const box = document.createElement("div");
box.append(specBar(doc, calc, edit), grid);
return box;
@@ -0,0 +1,213 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Paste.ts
* 엑셀 붙여넣기(PLAN 705-2) — 일위대가 격자 Ctrl+V → 붙인 글 그대로 서버(`/hopyo-paste` · 숫자는 서버가 글에서 읽음).
* 열 짝 = 처음 한 번 묻고 기억(이 브라우저 `localStorage` · 사용자 설정) · 엑셀 머리 글이 붙어 오면 묻지 않음
* 답 「줄」(호표 머리 없음) = 열린 문서에 고정 줄로 더함 → [저장] · 알림을 누르면 「이 공사 단가로 다시 잇기」
* 답 「만듦」(호표 머리 있음) = 호표마다 사전조합(실무자료 「붙여넣기 …」) · 결과 창 [이 공사 단가로 다시 잇기]
* `askCols` · `withCols` = B09 펼친 일위대가 붙여넣기도 씀
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createSelectField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import { createDataTable } from "@ui/ui_template_data_table";
import { createText } from "@ui/ui_template_text";
import { hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_toast";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import {
pageScope,
pasteHopyo,
type HopyoBuiltAll,
type PasteCols,
} from "./M02_MasterTemplete_Hopyo_Api";
import { openRelink, relinkDoc, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink";
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
const COLS_KEY = "aislo:hopyo-paste-cols";
const FIELDS = ["품명", "규격", "수량", "단위", "금액", "노", "재", "경"] as const;
const LABEL: Record<(typeof FIELDS)[number], string> = {
품명: "품명",
규격: "규격",
수량: "수량",
단위: "단위",
금액: "합계 금액",
노: "노무비 단가",
재: "재료비 단가",
경: "경비 단가",
};
const NEED = new Set(["품명", "수량", "단위"]);
const letter = (n: number): string =>
n < 26 ? String.fromCharCode(65 + n) : `${letter(Math.floor(n / 26) - 1)}${letter(n % 26)}`;
const won = (v: number | null | undefined): string =>
v == null ? "-" : Math.round(v).toLocaleString("ko-KR");
export function rememberedCols(): PasteCols | null {
try {
const got = JSON.parse(localStorage.getItem(COLS_KEY) ?? "null") as PasteCols | null;
return got && typeof got === "object" ? got : null;
} catch {
return null;
}
}
/** 열 짝 묻기 — 짐작 값이 미리 골라짐 · 보기 = 붙인 첫 줄들(칸 머리 A · B · C …) · 취소 = null */
export function askCols(guess: PasteCols, look: string[][]): Promise<PasteCols | null> {
return new Promise((done) => {
const width = Math.max(0, ...look.map((r) => r.length));
const options = [
{ value: "", text: "없음" },
...Array.from({ length: width }, (_, n) => ({
value: String(n),
text: `${letter(n)} 칸 · ${look.find((r) => r[n])?.[n] ?? ""}`.slice(0, 40),
})),
];
const picks = FIELDS.map((f) =>
createSelectField({
label: `${LABEL[f]}${NEED.has(f) ? " *" : ""}`,
options,
value: guess[f] != null ? String(guess[f]) : "",
compact: true,
}),
);
const table = createDataTable({
columns: Array.from({ length: width }, (_, n) => ({ key: `c${n}`, label: letter(n) })),
rows: look.map((r, i) => ({
key: String(i),
...Object.fromEntries(r.map((c, n) => [`c${n}`, c])),
})),
empty: "",
});
let answered = false;
const { close } = openModal({
title: "붙인 엑셀 열 짝 — 한 번만 묻고 기억",
closeLabel: "취소",
width: "960px",
beforeClose: () => {
if (!answered) done(null);
return true;
},
mount: (body, _close, actions) => {
const ok = createButton({
label: "확인",
onClick: () => {
const cols: PasteCols = {};
FIELDS.forEach((f, i) => {
const v = picks[i].select.value;
if (v !== "") cols[f] = Number(v);
});
if ([...NEED].some((f) => cols[f as keyof PasteCols] == null))
return showToast("품명 · 수량 · 단위 칸은 꼭 고름", "warning");
localStorage.setItem(COLS_KEY, JSON.stringify(cols));
answered = true;
done(cols);
close();
},
});
actions.append(ok);
body.append(
createText(
"엑셀 머리 글 없이 붙여 열 짝을 모름 — 칸마다 무엇인지 고름(짐작 값이 골라져 있음) · 다음부터 묻지 않음",
"muted",
),
el("div", { className: "ui-toolbar", children: picks.map((p) => p.root) }),
table.root,
);
},
});
});
}
/** 열 짝 고리 — 기억한 짝으로 부름 · 「물음」이면 묻고 다시 부름 · 취소 = null */
export async function withCols<T extends { 갈래: string }>(
run: (cols: PasteCols | null) => Promise<T | { 갈래: "물음"; 열: PasteCols; 보기: string[][] }>,
): Promise<T | null> {
let got = await run(rememberedCols());
if (got.갈래 === "물음") {
const ask = got as { 열: PasteCols; 보기: string[][] };
hideLoadingOverlay();
const cols = await askCols(ask.열, ask.보기);
if (!cols) return null;
showLoadingOverlay();
got = await run(cols);
}
return got as T;
}
/** 만든 결과 창 — 엑셀 합계 = 우리 합계 · 다음 걸음 [이 공사 단가로 다시 잇기] */
export function builtNotice(got: HopyoBuiltAll, keys: string[]): void {
const text =
`사전조합 ${got.만듦?.length ?? 0} 만듦(호표 ${got.호표} · 줄 ${got.줄} · 고정 줄 ${got.고정} · ` +
`하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐}) · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` +
` · 저절로 이음 ${got.이음} · 이음 후보 ${got.이음후보}`;
const rows = got.다름.map((x) => ({
key: String(x.번),
name: `제${x.번}호표 ${x.이름}`,
excel: x.엑셀,
ours: x.우리,
gap: x.차이,
}));
openModal({
title: "엑셀 붙여넣기 — 만듦",
closeLabel: "닫기",
mount: (body, close, actions) => {
actions.append(
createButton({
label: "다음 — 이 공사 단가로 다시 잇기",
onClick: () => {
close();
void openRelink(relinkKeys(keys));
},
}),
);
body.append(createText(text, got.빠짐 || got.실패.length ? "error" : "muted"));
if (rows.length)
body.append(
createDataTable({
columns: [
{ key: "name", label: "합계 다른 호표", width: 260 },
{ key: "excel", label: "엑셀", kind: "number" },
{ key: "ours", label: "우리", kind: "number" },
{ key: "gap", label: "차이", kind: "number", decimals: 1 },
],
rows,
}).root,
);
},
});
}
/** 격자 Ctrl+V(M02 일위대가 상세 · 사전조합) — 줄 = 문서에 더함(`changed`) · 만듦 = 사전조합 여럿 */
export async function pasteInto(doc: LaborDoc, text: string, changed: () => void): Promise<void> {
const scope = pageScope();
const have = doc.담은로직.map((r) => lineId(r) ?? "").filter((id) => id.startsWith("고정|"));
showLoadingOverlay();
try {
const got = await withCols((cols) => pasteHopyo(scope, text, cols, { 있음: have }));
if (!got || got.갈래 === "물음") return;
if (got.갈래 === "만듦") {
document.dispatchEvent(new Event(HOPYO_BUILT));
builtNotice(
got,
(got.만듦 ?? []).map((x) => x.키),
);
return;
}
for (const one of got.줄)
doc.담은로직.push({ 로직: "", 고정: one.고정, 별칭: one.별칭, 메모: one.메모, 입력값: {},
수량: one.수량.손 == null ? null : { 손: one.수량.손 } }); // prettier-ignore
changed();
showToast(
`고정 줄 ${got.줄.length} 붙임 · 엑셀 합계 ${won(got.엑셀)} · 이음 ${got.이음} · 후보 ${got.이음후보}` +
" — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기",
"success",
9000,
() => void openRelink(relinkDoc(doc, changed)),
);
} catch (error) {
showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error");
} finally {
hideLoadingOverlay();
}
}
@@ -13,6 +13,7 @@
* ========================================================================== */
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { HOPYO_TAG } from "@ui/ui_template_hopyo_grid";
import { pctView } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
@@ -46,7 +47,8 @@ const SPAN = 3;
/** 구분(50-4) — 로직 줄 = `승격`(134-1 · 단가승격 · 없음 = 단가산출) · 참조 줄 = 키 머리(SC- = 일위조합 ·
* 옛 SP- = 단가승격 글 고정 · 134-2) */
export const kindOf = (row: Pick<LaborRow, "로직" | "참조" | "승격">): string => {
export const kindOf = (row: Pick<LaborRow, "로직" | "참조" | "승격" | "고정">): string => {
if (row.고정) return HOPYO_TAG.fix; // 705-1 고정 줄
const key = row.참조?.키 ?? row.로직;
return tc(
row.승격 || key.startsWith("SP-")
@@ -343,7 +345,8 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const logic = data.logics.get(row.로직);
const line = lineOf(id);
// 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정)
const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? "");
const fx = row.고정; // 705-1 고정 줄 — 이름 · 단위 = 줄 것 · [옵션] 없음
const unit = fx ? (fx.단위 ?? "") : row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? "");
const values = valueLine(logic?.입력 ?? [], row.입력값);
const layer =
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
@@ -393,7 +396,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
el("td", {
className: "m02-labrow__kind",
children: [
on?.promote && !row.참조
on?.promote && !row.참조 && !fx
? kindPick(!!row.승격, (v) => on.promote?.(i, v))
: kindOf(row),
// 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말)
@@ -404,9 +407,14 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
el("td", {
className: "m02-labrow__name",
children: [
el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }),
el("span", {
className: "m02-labrow__key",
text: fx
? [fx.규격, fx.이음?.노임 ?? fx.이음?.자재].filter(Boolean).join(" · ")
: (row.참조?.키 ?? row.로직),
}),
el("strong", {
text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직,
text: fx?.이름 ?? (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직,
}),
...(values.empty
? [
@@ -456,7 +464,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
? ref && !row.참조.키.startsWith("SP-")
? [small(tc("Labor_Edit"), () => ref(i))]
: []
: option
: option && !fx
? [small(tc("Labor_Option"), () => option(i))]
: []),
...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []),
@@ -177,6 +177,8 @@ const TEXT = {
Grid_Title: ["일위대가표", "Unit-price sheet"],
Grid_NoQty: ["수량 없음", "No quantity"],
Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"],
Grid_Linked: ["이 공사 단가에 이음", "Linked to project price"],
Grid_Hand: ["손 단가(붙인 값)", "Hand price (pasted)"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
@@ -170,3 +170,47 @@ def test_틀린_글_400(client, text, cols, word):
def test_쉼표_빈칸_수():
assert hx._num("1,415,169") == Decimal("1415169") and hx._num("") is None
# ── 이 공사 단가로 다시 잇기 ──────────────────────────────────────────────────
RELINK = "/api/m02/layers/personal/hopyo-relink"
def test_다시_잇기_후보_바꿈_되돌리기(client):
from resources.tester.test_m02_hopyo_excel import _post
assert _post(client).status_code == 200 # 북부청 — 이음 13 · 이음 후보 31(705-3)
made = client.post(
"/api/m02/layers/personal/hopyo-build-all", json={"층": "personal", "키": "HP-000001"}
).json()
keys = [x["키"] for x in made["만듦"]]
assert len(keys) == 68
rows = client.post(f"{RELINK}/rows", json={"키": keys}).json()["줄"]
near = [r for r in rows if r["후보"] and not any(r["이음"].values())]
assert near, "단가 다른 이음 후보 줄"
linked = [r for r in rows if any(r["이음"].values())]
assert len(linked) >= 13 and all(r["지금"] for r in linked)
one = near[0]
pick = one["후보"][0]
change = {"키": one["키"], "줄": one["줄"], pick["비목"]: pick["키"]}
got = client.post(RELINK, json={"바꿈": [change]})
assert got.status_code == 200, got.text
done = got.json()
(doc,) = done["문서"]
assert done["바뀜"] == 1 and doc["전"] != doc["후"] # 붙인 단가 → 단가 책 값
again = client.post(f"{RELINK}/rows", json={"키": [one["키"]]}).json()["줄"]
assert next(r for r in again if r["줄"] == one["줄"])["이음"][pick["비목"]] == pick["키"]
back = client.post(RELINK, json={"바꿈": done["되돌림"]}).json() # 되돌리기
assert back["문서"][0]["후"] == doc["전"]
bad = {"키": one["키"], "줄": one["줄"], "노임": "LB999999"}
assert client.post(RELINK, json={"바꿈": [bad]}).status_code == 400
assert client.post(RELINK, json={"바꿈": [{**change, "줄": "고정|없음||"}]}).status_code == 404
num = client.post(RELINK, json={"바꿈": [change], "금액": 1})
assert num.status_code == 422 # 숫자 칸 안 받음
def test_다시_잇기_열린_문서_후보만(client):
fx = {"이름": "보통인부", "규격": "", "단위": "인", "단가": {"노무비": 1}, "이음": {}}
doc = {"담은로직": [{"고정": fx, "수량": {"손": 0.5}}]}
(row,) = client.post(f"{RELINK}/rows", json={"문서": doc}).json()["줄"]
assert row["키"] is None and row["후보"][0]["비목"] == "노임" and row["후보"][0]["단가"] > 1
+7
View File
@@ -19,3 +19,10 @@
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
/* 붙여넣기 받는 격자(705-2) — 눌러 고르면 테두리 · Ctrl+V 자리 */
.ui-hopyo-grid[data-paste]:focus {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
+15 -1
View File
@@ -13,6 +13,7 @@
* 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음
*
* const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]);
* 붙여넣기(705-2) = `createHopyoGrid(blocks, {}, (text) => …)` — 격자 누른 뒤 Ctrl+V 글을 넘김
* B09 처럼 탭 표가 따로 그리면 `hopyoGrid(blocks)` 의 열 · 줄 · 제목 줄만 씀
* ========================================================================== */
@@ -248,13 +249,26 @@ export interface HopyoGridHandle {
set: (blocks: HopyoBlock[]) => void;
}
/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 */
/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 · `onPaste`(705-2) = 격자를 누르고 Ctrl+V → 붙인 글 그대로
* (엑셀 칸 = 탭 · 줄 = 줄바꿈 · 해석은 부르는 쪽 · 서버) */
export function createHopyoGrid(
blocks: HopyoBlock[],
extra: Partial<DataTableOptions> = {},
onPaste?: (text: string) => void,
): HopyoGridHandle {
const root = document.createElement("div");
root.className = "ui-hopyo-grid";
if (onPaste) {
root.tabIndex = 0; // 눌러 고르면 붙여넣기를 받음
root.dataset.paste = "";
root.addEventListener("paste", (ev) => {
if ((ev.target as Element | null)?.closest("input, textarea")) return; // 칸 고침 중은 그 칸 몫
const text = ev.clipboardData?.getData("text/plain") ?? "";
if (!text.trim()) return;
ev.preventDefault();
onPaste(text);
});
}
const set = (next: HopyoBlock[]): void => {
root.replaceChildren(createDataTable(hopyoTableOptions(next, extra)).root);
};