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

This commit is contained in:
2026-10-09 19:05:57 +09:00
19 changed files with 198 additions and 156 deletions
@@ -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,
});
};
@@ -294,13 +294,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;
@@ -54,7 +54,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";
@@ -79,8 +79,6 @@ export interface LaborDetailOptions {
readOnly: boolean;
/** 저장 안 한 고침이 생기거나 사라짐 */
onChanged: (dirty: boolean) => void;
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */
goArea: GoArea | null;
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
card?: boolean;
@@ -318,7 +316,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();
@@ -92,16 +89,6 @@ export function openQtyModal(opt: QtyModalOptions): void {
},
})
: 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": "" },
@@ -153,23 +140,16 @@ 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 }),
],
}),
);
// [확인] = 머리 [닫기] 왼쪽 · 아래 [닫기] 없음(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);
},
});
}
@@ -77,7 +77,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,
@@ -103,7 +103,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,
@@ -117,7 +117,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,
@@ -204,7 +204,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,8 +70,8 @@ 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"],
@@ -131,12 +131,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 } : {}),
});
// [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값)
+2 -2
View File
@@ -184,11 +184,11 @@ def test_수량_글은_이은_칸_주소를_보임() -> None:
assert out.returncode == 0, out.stderr
got = json.loads(out.stdout)
# 수량산출 열(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 짧은 글
assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "12.5(B6)", "빈 칸(B6)", "—"]
assert got[:7] == ["10 ㎡", "1 ㎥", "", "수기 5 ㎥", "12.5(B6)", "빈 칸(B6)", "—"]
assert got[7:] == [
"수량 20 ㎡(B6)", # 44-6 — 수량(칸 주소)
"수량 20 ㎡(면적산출 칸)",
"수량 손 입력 3 ㎡",
"수량 수기 입력 3 ㎡",
"수량 빈 칸(B6)", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음)
"",
]
@@ -0,0 +1,95 @@
"""공용 모달 머리 단추 · 큰 창 · M02 수량산출 창(PLAN 147) — 소스 검사 · 화면 판정은 사용자.
· 147-1 곁값 `actions`(머리 [닫기] 바로 왼쪽 · 넘긴 창만) · `wide`(96vw × 94vh · 몸만 스크롤)
· 147-2 연결창 단추 넷 → 머리 · 큰 창 · 글 묻는 창 [확인] 머리 · [취소] 없음
· 147-3 M02 입력 · 수량산출 · 참조 창 — 아래 단추 줄 없음 · [확인] 머리 · 읽기만이면 없음
· 147-4 「손 입력」 → 「수기 입력」 · 짧은 글 「손」 → 「수기」 · [면적산출에서 잇기] · goArea 없음
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _text(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
found = re.search(rf"^{re.escape(selector)} \{{([^}}]*)\}}", css, re.M)
assert found, selector
return found.group(1)
def test_147_1_곁값_actions_wide():
ts = _text("ui_template/ui_template_modal.ts")
assert "actions?: HTMLElement[];" in ts
assert "wide?: boolean;" in ts
# 몸에서 만든 단추도 넣게 mount 셋째 인자 = 머리 자리
assert "opts.mount(body, close, actions);" in ts
# 단추가 있을 때만 [닫기] 바로 앞에 — 없으면 지금 꼴(h3 · [닫기])
assert "if (actions.childElementCount) closeButton.before(actions);" in ts
assert 'children: [el("h3", { text: opts.title }), closeButton]' in ts
assert '"ui-modal--wide "' in ts
def test_147_1_머리_자리_큰_창_css():
css = _text("ui_template/ui_template_modal.css")
assert "margin-right: auto;" in _block(css, ".ui-modal__top > h3")
assert "justify-content: space-between;" in _block(css, ".ui-modal__top")
wide = _block(css, ".ui-modal.ui-modal--wide")
for rule in ("width: 96vw;", "height: 94vh;", "--modal-max: 94vh;", "overflow: hidden;"):
assert rule in wide, rule
body = _block(css, ".ui-modal--wide > .ui-modal__body")
assert "overflow: auto;" in body and "min-height: 0;" in body
def test_147_2_연결창_머리_단추_큰_창():
ts = _text("ui_template/ui_template_item_picker.ts")
assert "children: [copyButton, manageButton, clearButton, applyButton]," in ts
assert "actions: [headButtons]," in ts and "wide: true," in ts
# 편집 화면 = 머리 단추도 숨김(편집 중 [적용] 막음) · 돌아오면 다시
assert "headButtons.hidden = true;" in ts and "headButtons.hidden = false;" in ts
# 안내 글(구조물 [저장]) 은 아래 남김
assert "ui-item-picker__saveline" in ts
css = _text("ui_template/ui_template_item_picker.css")
assert ".ui-item-picker .ui-modal__actions[hidden]" in css
assert "width: min(1320px, 96vw);" not in css
def test_147_2_글_묻는_창_확인만_머리():
ask = _text("ui_template/ui_template_item_picker_ask.ts")
assert "head.append(createButton({ label: opts.okLabel" in ask
assert "cancelLabel" not in ask
for rel in (
"ui_template/ui_template_item_picker.ts",
"ui_template/ui_template_item_picker_detail.ts",
):
assert "cancelLabel" not in _text(rel), rel
def test_147_3_m02_창_셋_머리_확인():
m02 = "M02_MasterTemplete/M02_MasterTemplete_"
for name in ("Labor_Input", "Labor_QtyModal", "Labor_RefEdit"):
ts = _text(f"{m02}{name}.ts")
assert "m02-labin__foot" not in ts, name
assert "mount: (body, " in ts and ", head) => {" in ts, name
# 읽기만 = 머리 [닫기]만
assert "if (!opt.readOnly)" in ts and "head.append(" in ts, name
assert "In_Cancel" not in _text(f"{m02}Labor_Text.ts")
assert ".m02-labin__foot" not in _text(f"{m02}Labor.css")
def test_147_4_수기_입력_잇기_없음():
m02 = ROOT / "M02_MasterTemplete"
text = _text("M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts")
assert 'In_QtyHand: ["수기 입력", "Manual"],' in text
assert 'Labor_Hand: ["수기", "Manual"],' in text
assert "In_QtyGoArea" not in text
assert "손 입력" not in _text("M02_MasterTemplete/M02_MasterTemplete_CardHelp.ts")
for path in m02.glob("*.ts"):
src = path.read_text(encoding="utf-8")
assert "goArea" not in src and "GoArea" not in src, path.name
@@ -30,9 +30,9 @@ def test_공용_모달_위_고정():
assert "max-height: var(--modal-max, 80vh);" in modal
assert "margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);" in modal
# 큰 창은 max-height 대신 --modal-max(위 거리가 함께 맞음)
assert "--modal-max: 94vh;" in _block(
_text("ui_template/ui_template_item_picker.css"), ".ui-modal.ui-item-picker"
)
# 고르기 창 = 공용 큰 창 곁값(147-2)
assert "--modal-max: 94vh;" in _block(css, ".ui-modal.ui-modal--wide")
assert "wide: true," in _text("ui_template/ui_template_item_picker.ts")
assert "--modal-max: 92vh;" in _block(
_text("B05_Profile/B05_Profile_UI_Style_RouteRecommend.css"), ".ui-modal.b05-routerec"
)
+3 -10
View File
@@ -1,13 +1,5 @@
/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */
/* 공용 모달(.ui-modal)의 폭 · 높이보다 앞서게 — 불러오는 차례에 따라 뒤집히지 않도록 두 클래스 */
.ui-modal.ui-item-picker {
width: min(1320px, 96vw);
--modal-max: 94vh;
}
.ui-item-picker .ui-modal__body {
height: min(80vh, 800px);
}
/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 머리 단추(147-2) */
/* 크기 = 공용 큰 창(곁값 wide · 96vw × 94vh · 147-2) */
.ui-item-picker__cols {
display: grid;
@@ -51,6 +43,7 @@
.ui-item-picker__cols[hidden],
.ui-item-picker__actions[hidden],
.ui-item-picker .ui-modal__actions[hidden],
.ui-item-picker__edit-host[hidden] {
display: none;
}
+13 -12
View File
@@ -103,7 +103,6 @@ const NEW_TEXT = {
name: "이름",
unit: "단위",
ok: "만들기",
cancel: "취소",
empty: "{label} 을(를) 넣을 것",
made: "새 항목을 만들어 지정했음 — 구조물 [저장]을 눌러야 이 구조물 복사본이 남음",
failed: "새 항목을 만들지 못함 — {value}",
@@ -289,7 +288,6 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
{ key: "unit", label: NEW_TEXT.unit, placeholder: "m · ㎥ · 개소", required: true },
],
okLabel: NEW_TEXT.ok,
cancelLabel: NEW_TEXT.cancel,
closeLabel: t("M02_Close"),
emptyText: NEW_TEXT.empty,
});
@@ -308,17 +306,16 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
}
}
// 단추 넷 = 머리 [닫기] 왼쪽(147-2) · 안내 글은 아래 남김
const headButtons = el("div", {
className: "ui-modal__actions",
children: [copyButton, manageButton, clearButton, applyButton],
});
const actions = el("div", {
className: "ui-item-picker__actions",
children: [
...(opts.projectId
? [el("span", { className: "ui-item-picker__saveline", text: NEW_TEXT.saveLine })]
: []),
copyButton,
manageButton,
clearButton,
applyButton,
],
children: opts.projectId
? [el("span", { className: "ui-item-picker__saveline", text: NEW_TEXT.saveLine })]
: [],
});
/** 카드 [수정] — 모달 안에서 편집 화면으로 바뀜(나무 · 카드 · 단추는 숨김) */
@@ -328,6 +325,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
editHost.replaceChildren(view.root);
cols.hidden = true;
actions.hidden = true;
headButtons.hidden = true;
editHost.hidden = false;
view.start();
}
@@ -338,6 +336,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
editHost.hidden = true;
cols.hidden = false;
actions.hidden = false;
headButtons.hidden = false;
if (saved) detailView.markEdited();
detailView.reload();
}
@@ -346,6 +345,8 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
title: t("M02_PickerTitle"),
closeLabel: t("M02_Close"),
dialogClass: "ui-item-picker",
wide: true,
actions: [headButtons],
// 닫기 단추 · Esc · 바깥 누르기 = 취소 — 편집 중 저장 안 한 고침이 있으면 물음
beforeClose: async () => {
if (editView && !(await editView.leave())) return false;
@@ -354,7 +355,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
return true;
},
mount: (body) => {
body.append(cols, editHost, actions);
body.append(cols, editHost, ...(opts.projectId ? [actions] : []));
paintDetail();
void load(null);
},
+4 -17
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* ui_template_item_picker_ask.ts
* 고르기 모달에서 글을 물을 때 쓰는 작은 입력 창(계획서 26-3b) — [개인에 저장](경로 · 이름) ·
* 나무 오른쪽 클릭 「새 항목」(이름 · 단위). 확인 = 넣은 글들 · 취소 · 닫기 = null.
* 나무 오른쪽 클릭 「새 항목」(이름 · 단위). 확인 = 넣은 글들 · 닫기 = null.
* [확인] = 머리 [닫기] 왼쪽 · [취소] 없음(머리 [닫기] = 취소 · 147-2).
* ========================================================================== */
import { createButton, createInputField, el } from "./ui_template_elements";
@@ -22,7 +23,6 @@ export interface AskOptions {
note?: string;
fields: AskField[];
okLabel: string;
cancelLabel: string;
closeLabel: string;
/** 빈 값 알림 — `{label}` 자리에 칸 이름 */
emptyText: string;
@@ -45,7 +45,7 @@ export function askFields(opts: AskOptions): Promise<Record<string, string> | nu
finish(null);
return true;
},
mount: (body, close) => {
mount: (body, close, head) => {
const inputs = opts.fields.map((field) => ({
field,
input: createInputField({
@@ -76,21 +76,8 @@ export function askFields(opts: AskOptions): Promise<Record<string, string> | nu
body.append(
...(opts.note ? [el("p", { className: "ui-item-picker__where", text: opts.note })] : []),
...inputs.map(({ input }) => input.root),
el("div", {
className: "ui-item-picker__actions",
children: [
createButton({
label: opts.cancelLabel,
variant: "ghost",
onClick: () => {
finish(null);
close();
},
}),
createButton({ label: opts.okLabel, variant: "filled", onClick: submit }),
],
}),
);
head.append(createButton({ label: opts.okLabel, variant: "filled", onClick: submit }));
},
});
});
@@ -98,7 +98,6 @@ const TEXT = {
saved: "개인 층에 저장했음",
saveFailed: "개인에 저장하지 못함 — {value}",
ok: "확인",
cancel: "취소",
close: "닫기",
empty: "{label} 을(를) 넣을 것",
importFailed: "이 구조물로 가져오지 못함 — {value}",
@@ -267,7 +266,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
{ key: "name", label: TEXT.nameLabel, value: itemName(row), required: true },
],
okLabel: TEXT.ok,
cancelLabel: TEXT.cancel,
closeLabel: TEXT.close,
emptyText: TEXT.empty,
});
+26
View File
@@ -33,6 +33,32 @@
gap: var(--spacing-8);
}
/* 머리 단추 자리(곁값 actions) — 제목이 남는 폭을 먹고 단추는 [닫기] 바로 왼쪽 */
.ui-modal__top > h3 {
margin-right: auto;
}
.ui-modal__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 큰 창(곁값 wide) — 화면 거의 꽉 · 몸 넘침만 스크롤. 화면별 클래스보다 앞서게 두 클래스 */
.ui-modal.ui-modal--wide {
width: 96vw;
height: 94vh;
--modal-max: 94vh;
overflow: hidden;
}
.ui-modal--wide > .ui-modal__body {
flex: 1;
min-height: 0;
overflow: auto;
}
.ui-modal__body {
display: flex;
flex-direction: column;
+23 -13
View File
@@ -2,6 +2,8 @@
* ui_template_modal.ts
* 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움.
* M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임.
* 곁값 — `actions`(머리 [닫기] 바로 왼쪽 단추 · mount 셋째 인자 자리에 넣어도 됨) · `wide`(큰 창 96vw × 94vh).
* 안 넘긴 창은 지금 꼴 그대로.
* ========================================================================== */
import "./ui_template_modal.css";
@@ -10,8 +12,13 @@ import { createButton, el } from "./ui_template_elements";
export interface ModalOptions {
title: string;
closeLabel: string;
mount: (body: HTMLElement, close: () => void) => void;
/** `actions` = 머리 단추 자리 — 몸에서 만든 단추([확인] 등)를 넣음 */
mount: (body: HTMLElement, close: () => void, actions: HTMLElement) => void;
dialogClass?: string;
/** 머리 [닫기] 바로 왼쪽에 둘 단추(넘긴 창만) */
actions?: HTMLElement[];
/** 큰 창 — 96vw × 94vh · 몸 넘침만 스크롤 */
wide?: boolean;
backdropClass?: string;
/** 닫기 단추 · Esc · 바깥 누르기 전에 물음 — false 면 안 닫음(몸의 `close` 는 묻지 않고 닫음) */
beforeClose?: () => boolean | Promise<boolean>;
@@ -19,23 +26,24 @@ export interface ModalOptions {
export function openModal(opts: ModalOptions): { close: () => void } {
const body = el("div", { className: "ui-modal__body" });
const actions = el("div", { className: "ui-modal__actions", children: opts.actions ?? [] });
const closeButton = createButton({
label: opts.closeLabel,
variant: "ghost",
onClick: () => void ask(),
});
const top = el("div", {
className: "ui-modal__top",
children: [el("h3", { text: opts.title }), closeButton],
});
const close = (): void => backdrop.remove();
const ask = async (): Promise<void> => {
if (!opts.beforeClose || (await opts.beforeClose())) close();
};
const dialog = el("div", {
className: `ui-modal ${opts.dialogClass ?? ""}`.trim(),
className: `ui-modal ${opts.wide ? "ui-modal--wide " : ""}${opts.dialogClass ?? ""}`.trim(),
attrs: { role: "dialog", "aria-label": opts.title },
children: [
el("div", {
className: "ui-modal__top",
children: [
el("h3", { text: opts.title }),
createButton({ label: opts.closeLabel, variant: "ghost", onClick: () => void ask() }),
],
}),
body,
],
children: [top, body],
});
const backdrop = el("div", {
className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(),
@@ -43,7 +51,9 @@ export function openModal(opts: ModalOptions): { close: () => void } {
});
backdrop.addEventListener("click", (ev) => ev.target === backdrop && void ask());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask());
opts.mount(body, close);
opts.mount(body, close, actions);
// 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기)
if (actions.childElementCount) closeButton.before(actions);
document.body.append(backdrop);
dialog.tabIndex = -1;
// 몸의 첫 입력 칸에 커서 — mount 안의 focus() 는 붙기 전이라 안 먹음