feat(M02): 일위대가 수량산출 모달 하나 — 지금 수량 · 손 입력 · 연결 풀기 + 면적산출 시트를 모달 안에 바로(칸 누름 = 이음) · 넣을 값 모달에서 수량 칸 뺌(43-1 · 43-2)

- 새 Labor_QtyModal.ts(로직 줄 수량산출 칸 · 자재 줄 같음 · [확인] = 손 · 고른 칸 · 풀기 · [닫기] = 그대로) · 두 단계 모달([엑셀에서 고르기]) 없앰
- AreaCellPick: 시트 부분을 mountAreaSheet 로 뗌(읽기만 · 실제 값 · 이은 칸 색 · 함께 잇기 · 바꾸기 물음 그대로) · 모달 pickAreaCell 지움
- Labor_Detail: openQty · openMat → openQtyFor · linkCell · Labor_Input: 수량 칸 · openQtyModal 지움(입력값 · 메모만)
- 화면 판정 시나리오 resources/tester/ui_checks/43-1_수량산출_모달.js · 시험 test_m02_area_panel 부품 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-05 00:35:03 +09:00
co-authored by Claude Opus 5.5
parent 31a753b7fd
commit ef24de8b63
8 changed files with 474 additions and 325 deletions
@@ -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 : "");
}
})();
},
});
});
},
};
}