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:
@@ -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 : "");
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,3 +351,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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -174,37 +176,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 +204,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 +265,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({
|
||||
|
||||
@@ -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 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user