diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 8f740992c..65822f7be 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -53,3 +53,22 @@ export function linkCommand( if (!commands.length) return null; return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; } + +/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · + * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ +export function linkAreaCell( + book: T, + key: string, + cell: string | null, + info: LinkInfo = {}, +): T | null { + const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0]; + if (!sheet) return null; + const command = linkCommand(book, sheet.id, key, cell, info); + if (command === false) return null; + const next = structuredClone(book); + const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command]; + for (const c of list) + if (c.종류 === "시트통째") next.시트 = next.시트.map((s) => (s.id === c.시트.id ? c.시트 : s)); + return next; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts new file mode 100644 index 000000000..8012ac0e6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -0,0 +1,117 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaCellPick.ts + * 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌. + * 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어 + * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). + * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 + * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +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 { + /** 그 열 면적산출 문서(저장 안 한 것 포함) — 없으면 `layer` · `column` 저장본을 읽음 */ + doc?: Workbook | null; + layer: Layer; + /** 집계표 열 id */ + column: string; + /** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 지금 이은 칸을 보임 */ + pill: string; + /** 모달 제목에 쓸 줄 이름 */ + label: string; +} + +/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */ +export type AreaCellPick = { 칸: string | null } | null; + +export function pickAreaCell(opts: AreaCellPickOptions): Promise { + return new Promise((resolve) => { + let handle: SpreadsheetHandle | null = null; + let done = false; + let picked = "A1"; + let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름 + const finish = (value: AreaCellPick): void => { + if (done) return; + 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, + ); + const choose = (): void => (finish({ 칸: picked }), close()); + void (async () => { + try { + const [{ createSpreadsheet }, book] = 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; + const at = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; + head.textContent = at ? fill(TEXT.now, at) : TEXT.none; + unlink.hidden = !at; + handle = createSpreadsheet(host, structuredClone(book), { + 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) choose(); + }); + host.addEventListener("keydown", (e) => e.key === "Enter" && choose()); + } catch (error) { + if (done) return; + head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : ""); + } + })(); + }, + }); + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css index 9f7c0e18d..36e932845 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -62,3 +62,26 @@ flex: 1 1 auto; margin: 0; } + +/* 엑셀 칸 고르기 모달 — 머리(지금 연결 · [연결 풀기]) · 알림 · 읽기만 시트(높이를 정해야 시트가 보임) */ +.m02-cellpick { + width: min(960px, 92vw); +} + +.m02-cellpick__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.m02-cellpick__now, +.m02-cellpick__hint { + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m02-cellpick__sheet { + height: min(60vh, 520px); + min-height: 240px; +} diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index ce15d5b73..032bfb659 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -1,7 +1,7 @@ /* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; -import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; let pass = 0; @@ -89,4 +89,15 @@ eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false); eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false); eq("문서 그대로", links(b), links(book())); +// linkAreaCell — 새 문서(받은 문서 그대로) · 활성 시트 · 번호 칸 그대로 · 틀린 주소 null · 풀기 +b = { ...book(), 면적번: "SA-000001" } as Workbook & { 면적번: string }; +const before = JSON.stringify(b); +const got = linkAreaCell(b, "철근|ton", "c9", { 이름: "철근", 단위: "ton" }); +eq("받은 문서 그대로", JSON.stringify(b), before); +eq("새 문서 연결", got?.시트[0].연결?.[1], { 키: "철근|ton", 칸: "C9", 이름: "철근", 단위: "ton" }); +eq("번호 그대로", (got as { 면적번?: string } | null)?.면적번, "SA-000001"); +eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null); +eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]); +eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before); + console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index fb81c0d7a..d9a79db7c 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -88,3 +88,14 @@ def test_카드_알약_취향은_계정_받은_뒤_올림(): cards = _text("M02_MasterTemplete_ItemCards.ts") load = cards.split("async function load(")[1] assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load + + +def test_엑셀_칸_고르기_모달_부품(): + """40-3 ① — 읽기만 시트 · 칸 누름 = 주소 · 풀기 = {칸: null} · 그만둠 = null · 저장 안 함.""" + 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 and "finish({ 칸: picked })" in pick + assert "if ((e as MouseEvent).button === 0 && moved) choose();" in pick + assert "handle?.destroy();" in pick + assert "saveTemplate" not in pick diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index 13e3ba179..000430baf 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 13 + assert result["pass"] == 19 def test_손잡이_setLink_몸():