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,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));
},
});
}