Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_5

This commit is contained in:
2026-10-09 19:09:06 +09:00
20 changed files with 220 additions and 179 deletions
@@ -52,8 +52,7 @@
gap: var(--spacing-8);
}
.m02-cellpick__now,
.m02-cellpick__hint {
.m02-cellpick__now {
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
}
@@ -63,7 +63,7 @@ export const CARD_HELP: Record<"structure" | "labor" | "area", CardHelp> = {
["수정", "일위대가 상세를 엶 — 로직 추가 · 순서 바꾸기 · 다이어그램은 여기"],
clear("일위대가"),
["로직 줄 누름", "넣을 값 창 — 그 로직의 넣을 값 고침"],
["수량산출 칸", "손 입력 · 면적산출 칸 고르기"],
["수량산출 칸", "수기 입력 · 면적산출 칸 고르기"],
["별칭 칸", "줄에 따로 이름 붙임"],
["줄 끝 단추", "그 줄을 뺌"],
["자재 추가", "자재를 골라 자재목록에 넣음"],
@@ -72,7 +72,7 @@ export const CARD_HELP: Record<"structure" | "labor" | "area", CardHelp> = {
steps: [
"왼쪽 표에서 구조물 항목을 고름",
"로직 줄을 눌러 넣을 값을 고침",
"수량산출 칸에서 손 입력 또는 면적산출 칸을 고름",
"수량산출 칸에서 수기 입력 또는 면적산출 칸을 고름",
"[저장] — 로직을 더하거나 순서를 바꿀 때는 [수정]",
],
},
@@ -319,7 +319,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
}
: {}),
onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable()),
goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음
card: true,
});
};
@@ -301,13 +301,6 @@
font-size: var(--text-caption);
}
.m02-labin__foot {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
margin-top: var(--spacing-12);
}
/* 보기 갈래(면적산출 오른쪽 패널 · 일위대가 카드) */
.m02-labview {
display: flex;
@@ -55,7 +55,7 @@ import {
diagramTitle,
renderComboDiagram,
} from "./M02_MasterTemplete_Labor_Diagram";
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
@@ -81,8 +81,6 @@ export interface LaborDetailOptions {
readOnly: boolean;
/** 저장 안 한 고침이 생기거나 사라짐 */
onChanged: (dirty: boolean) => void;
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */
goArea: GoArea | null;
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
card?: boolean;
@@ -328,7 +326,6 @@ export function mountLaborDetail(
unit,
readOnly: !edit,
sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill },
goArea: opt.goArea,
...(lib ? { handOnly: true } : {}),
done: (r) => {
if (r.kind === "keep") return;
@@ -16,26 +16,6 @@ import "./M02_MasterTemplete_Labor.css";
const fill = (text: string, value: string): string => text.replace("{value}", value);
/** 면적산출 상세로 감 — 옮겨 갔으면 참(물음에서 취소 = 거짓) */
export type GoArea = () => Promise<boolean>;
function footer(
readOnly: boolean,
ok: () => void,
close: () => void,
okLabel = tc("In_Ok"),
): HTMLElement {
return el("div", {
className: "m02-labin__foot",
children: readOnly
? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })]
: [
createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }),
createButton({ label: okLabel, variant: "filled", onClick: ok }),
],
});
}
export interface LaborInputResult {
입력값: LaborValues;
메모: string | null;
@@ -79,7 +59,7 @@ export function openLaborInput(opt: LaborInputOptions): void {
title: fill(tc("In_Title"), opt.title),
closeLabel: tc("In_Close"),
dialogClass: "m02-modal m02-labin",
mount: (body, close) => {
mount: (body, close, head) => {
const rows = inputs.map((input) => {
const field = createValueControl(input, draft[input.이름] ?? null, (value, isBad) => {
if (isBad) bad.add(input.이름);
@@ -184,8 +164,12 @@ export function openLaborInput(opt: LaborInputOptions): void {
}),
],
}),
footer(opt.readOnly, ok, close, opt.okLabel),
);
// [확인] = 머리 [닫기] 왼쪽 · 아래 단추 없음(머리 [닫기] = 취소 · 147-3) · 읽기만 = [닫기]만
if (!opt.readOnly)
head.append(
createButton({ label: opt.okLabel ?? tc("In_Ok"), variant: "filled", onClick: ok }),
);
},
});
}
@@ -1,10 +1,10 @@
/* =============================================================================
* M02_MasterTemplete_Labor_QtyModal.ts
* 일위대가 수량산출 모달(43-1) — 수량산출 칸 누름(로직 줄 · 자재 줄 같음) = 모달 하나.
* · 위 = 지금 수량(손 · 칸 · —) · 손 입력 칸 · [연결 풀기]
* · 위 = 지금 수량(수기 · 칸 · —) · 수기 입력 칸 · [연결 풀기]
* · 아래 = 그 열 면적산출 시트를 모달 안에 바로(읽기만 · 실제 값 · 이은 칸 색 · `mountAreaSheet`) ·
* 칸 누름 = 그 칸을 고름(함께 잇기 · 바꾸기 물음 그대로)
* · [확인] = 손 값이 있으면 손 · 아니면 고른 칸으로 잇기 · 풀기 · [닫기] = 그대로
* · 머리 [확인] = 수기 값이 있으면 수기 · 아니면 고른 칸으로 잇기 · 풀기 · 머리 [닫기] = 그대로
* 잇기 · 풀기는 화면 문서만(저장은 일위대가 [저장] 때 면적 문서와 함께 · `Labor_Detail`).
* ========================================================================== */
@@ -13,7 +13,6 @@ 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";
@@ -29,20 +28,18 @@ export type QtyResult =
export interface QtyModalOptions {
/** 「GF000219 돌쌓기 메쌓기(인력)」 */
title: string;
/** 지금 수량 글(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) */
/** 지금 수량 글(「수기 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;
/** 손 입력만(서고 사전조합 줄 · 51-6) — 면적산출 시트 없음 */
/** 수기 입력만(서고 사전조합 줄 · 51-6) — 면적산출 시트 없음 */
handOnly?: boolean;
done: (result: QtyResult) => void;
}
@@ -55,7 +52,7 @@ export function openQtyModal(opt: QtyModalOptions): void {
closeLabel: tc("In_Close"),
dialogClass: "m02-modal m02-labin m02-labqty",
beforeClose: () => (sheet?.destroy(), true),
mount: (body, closeNow) => {
mount: (body, closeNow, head) => {
const finish = (): void => {
sheet?.destroy();
closeNow();
@@ -65,10 +62,6 @@ export function openQtyModal(opt: QtyModalOptions): void {
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: {
@@ -80,7 +73,8 @@ export function openQtyModal(opt: QtyModalOptions): void {
}) as HTMLInputElement;
hand.readOnly = opt.readOnly;
if (opt.hand !== null) hand.value = String(opt.hand);
const unlink =
// 시트 위 상태 · 안내 글 없음(147-5) — [연결 풀기] 누르면 그 단추가 꺼짐 · 칸을 고르면 다시 켜짐
const unlink: HTMLButtonElement | null =
opt.linked && !opt.readOnly
? createButton({
label: tc("Labor_Unlink"),
@@ -88,20 +82,10 @@ export function openQtyModal(opt: QtyModalOptions): void {
onClick: () => {
pending = { kind: "unlink" };
hand.value = "";
state.textContent = tc("Labor_UnlinkPending");
if (unlink) unlink.disabled = true;
},
})
: 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": "" },
@@ -117,17 +101,14 @@ export function openQtyModal(opt: QtyModalOptions): void {
onPick: (pick) => {
if (opt.readOnly) return;
pending = { kind: "cell", pick };
hand.value = ""; // 칸을 고르면 손 값은 지움(칸을 따름)
state.textContent = tc("Labor_CellPending").replace("{value}", pick.칸);
hand.value = ""; // 칸을 고르면 수기 값은 지움(칸을 따름)
if (unlink) unlink.disabled = false;
},
});
} 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 !== "") {
@@ -153,23 +134,13 @@ export function openQtyModal(opt: QtyModalOptions): void {
],
}),
...(unlink ? [unlink] : []),
...(go ? [go] : []),
],
}),
state,
...(opt.handOnly
? []
: [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 }),
],
}),
...(opt.handOnly ? [] : [host]),
);
// [확인] = 머리 [닫기] 왼쪽 · 아래 [닫기] 없음(147-3) · 읽기만 = 머리 [닫기]만
if (!opt.readOnly)
head.append(createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }));
},
});
}
@@ -59,26 +59,16 @@ export async function openRefEdit(opt: RefEditOptions): Promise<void> {
finish();
return true;
},
mount: (body, close) => {
mount: (body, close, head) => {
const host = el("div", { attrs: { "data-labor-ref": opt.ref.키 } });
handle = mountLaborDetail(host, doc, {
layer,
column: opt.ref.키,
readOnly: opt.readOnly,
onChanged: (d) => (dirty = d && !opt.readOnly),
goArea: null,
mode: "precombo",
owner: opt.ref.주인,
});
const cancel = createButton({
label: tc(opt.readOnly ? "In_Close" : "In_Cancel"),
variant: "ghost",
onClick: async () => {
if (dirty && !(await showConfirmDialog(tc("Ref_Discard")))) return;
close();
finish();
},
});
const ok = createButton({
label: tc("Save"),
variant: "filled",
@@ -98,11 +88,9 @@ export async function openRefEdit(opt: RefEditOptions): Promise<void> {
attrs: { "data-labor-notice": "" },
}),
host,
el("div", {
className: "m02-labin__foot",
children: opt.readOnly ? [cancel] : [cancel, ok],
}),
);
// [저장] = 머리 [닫기] 왼쪽 · 아래 단추 없음(머리 [닫기] = 취소 · 고친 것 있으면 물음 · 147-3)
if (!opt.readOnly) head.append(ok);
},
});
}
@@ -79,7 +79,7 @@ const BOOK = /^(건설|산림)품셈/;
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) ·
/** 수량 글 — 「수량 수기 입력 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) ·
* 이은 칸이 비었으면 「수량 빈 칸(A1)」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
export function qtyText(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
@@ -105,7 +105,7 @@ export function baseQtyText(
return `${num(line.배 || 1)} ${line.분모}`;
}
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(B6)」 · 없으면 「—」 */
/** 지금 수량 짧은 글 — 「수기 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(B6)」 · 없으면 「—」 */
export function qtyShort(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
@@ -119,7 +119,7 @@ export function qtyShort(
return `${num(line.수량)}(${at ?? tc("In_QtyCell")})`;
}
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「수기 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
* `open` 이 있으면 단추(줄 누름과 따로) */
function qtyCell(
line: LaborCalcLine | undefined,
@@ -206,7 +206,7 @@ export interface RowHandlers {
option?: (i: number) => void;
/** 참조 줄 단추(51-14) — 일위조합 [수정] = 서고 원본 모달 · 없으면 단추 없음 */
ref?: (i: number) => void;
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
/** 수량산출 칸 누름(수기 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
qty?: (i: number) => void;
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 줄 끌기 102-2 = 놓은 줄 자리) */
move?: (from: number, to: number) => void;
@@ -70,19 +70,13 @@ const TEXT = {
Labor_Qty: ["수량", "Quantity"],
Labor_QtyCol: ["수량산출", "Quantity"],
Labor_Item: ["항목", "Item"],
Labor_Hand: ["손", "Manual"],
Labor_QtyPick: ["누르면 손 입력 · 엑셀에서 고르기", "Manual or pick a cell"],
Labor_Hand: ["수기", "Manual"],
Labor_QtyPick: ["누르면 수기 입력 · 엑셀에서 고르기", "Manual or pick a cell"],
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"],
@@ -141,12 +135,10 @@ const TEXT = {
Labor_Layer: ["켜쌓기 {n}%", "Layered {n}%"],
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
In_QtyEmpty: ["빈 칸", "empty cell"],
In_QtyHand: ["손 입력", "Manual"],
In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"],
In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"],
In_QtyHand: ["수기 입력", "Manual"],
In_QtyNeed: ["수기 입력 수량을 수로 적음", "Enter a number"],
In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"],
In_Ok: ["확인", "OK"],
In_Cancel: ["취소", "Cancel"],
In_Close: ["닫기", "Close"],
Lib_Name: ["이름", "Name"],
// 서고 목록표(51-4)
@@ -423,13 +423,6 @@ export function createMain(
column: at.name,
readOnly,
onChanged: (d) => (dirty = d && !readOnly),
goArea: mode
? null
: async () => {
if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로
void open({ ...at, kind: "area" });
return true;
},
...(mode ? { mode, owner: at.owner ?? null } : {}),
});
// [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값)