feat(804-8): M02 단가산출 수식 탭 = M01 과 같은 단추 · 저장 한 번 · 머리 단추 줄바꿈 막음(요구 689)
- 공용 줄 쌓기 화면: 페이지가 저장을 맡는 꼴(`page`)을 M02 단가산출 화면에도 — 탭 위 [고침 버림] · [수식 저장] 걷음 · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지는 그대로 · 「줄 쌓기에서 못 고치는 칸」 안내 줄 걷음(금액 줄 기호 = 율 줄이 앞 줄 금액을 부르는 속 이름 · 화면에서 고칠 값 아님 · 보존 그대로) - M02 머리 단추 = [초기화] · [삭제] · [되돌림] · [저장] — 단가산출 상세는 [새로고침] 숨김 · [되돌림] · [저장] = 고침 있을 때만(기본정보 + 수식) · [저장] 한 번 = 수식 먼저 저장(새 판 · 수식 칸 받음) → 단가산출 문서 저장 · [되돌림] · 버리고 떠남 = 수식 캐시도 비움 - 보기 고르기(수식 · 단가산출 상세) = M01 처럼 머리 줄 · [항목 추가] · [율 줄 추가] 걷음(줄 더하기 = 수식 탭 [+] 단가산출 · 일위대가 복사) · 일위대가 줄 모달 안 상세는 그대로 - 머리 단추 줄 맞춤(부품 상자 `fitButtonRow`): 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 아이콘만(글 = 풍선 도움말) · M01 단추 다섯 · M02 단추 넷 아이콘 · 단가산출 상세 긴 이름은 제목 안에서 줄바꿈 - 사본 할일 막힘 알림 글 = [저장] 또는 [되돌림] 먼저 - 시험: test_804_7 에 M02 단추 · 저장 한 번 · 줄 맞춤 지킴 더함 · test_804_4 권한 시험에 수식 저장 판 = 페이지 저장 판 이어 씀 확인 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@ const TEXT = {
|
||||
Before_After: ["계", "Sum"],
|
||||
Job_Done: ["적용함", "Applied"],
|
||||
Dirty_First: [
|
||||
"저장 안 한 고침이 있음 — [수식 저장] 또는 [고침 버림] 먼저",
|
||||
"저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저",
|
||||
"Save or discard edits first",
|
||||
],
|
||||
No_Key: [
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* =============================================================================
|
||||
* line_stack_screen.ts
|
||||
* 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈)
|
||||
* 위 = 옵션 가로(`formula_doc_options.ts`) · [고침 버림] · [수식 저장](관리자 · 층 쓰기) · M02 사본 [단가 갱신] · [재생성]
|
||||
* M01(`page` 있음 · 804-7) = 자기 단추 없음 — 페이지 [저장] · [되돌림]이 `save` · `discard` 를 부름 · 고침이 바뀌면 `page()`
|
||||
* 위 = 옵션 가로(`formula_doc_options.ts`) · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지(층 쓰기)
|
||||
* `page` 있음(M01 804-7 · M02 804-8) = 저장 · 되돌림 단추 없음 — 페이지 [저장] · [되돌림]이 `save` · `discard` 를 부름 ·
|
||||
* 고침이 바뀌면 `page()` · `page` 없음(일위대가 줄 모달 등) = 위에 [고침 버림] · [수식 저장]
|
||||
* 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침)
|
||||
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 · 화면 아래 고정 · 요구 687-3)
|
||||
* 정본 = 수식 문서(탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` · 804-6 옛 수식 문서 탭 걷음 = 이 화면이 「수식」 탭
|
||||
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
|
||||
* 고침 = 캐시(sessionStorage)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
|
||||
* 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
|
||||
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
|
||||
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
|
||||
* ========================================================================== */
|
||||
@@ -28,7 +29,6 @@ import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||||
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||||
import {
|
||||
docOf,
|
||||
notesOf,
|
||||
stackOf,
|
||||
type StackDoc,
|
||||
type StackItem,
|
||||
@@ -61,7 +61,7 @@ export interface StackScreenOptions {
|
||||
copy?: CopyOptions;
|
||||
/** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */
|
||||
scope?: { layer: Layer; owner: string | null };
|
||||
/** 페이지가 저장 · 되돌림을 맡음(M01 · 804-7) — 고침 있음이 바뀔 때 부름 · 화면 위 [고침 버림] · [수식 저장] 없음 */
|
||||
/** 페이지가 저장 · 되돌림을 맡음(M01 804-7 · M02 804-8) — 고침 있음이 바뀔 때 부름 · 화면 위 [고침 버림] · [수식 저장] 없음 */
|
||||
page?: () => void;
|
||||
}
|
||||
|
||||
@@ -80,14 +80,13 @@ const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
|
||||
export function createStackScreen(opt: StackScreenOptions) {
|
||||
const head = el("div", { className: "lstk__head" });
|
||||
const notes = el("div", { className: "lstk__notes" });
|
||||
const left = el("div", { className: "lstk__left" });
|
||||
const right = el("div", { className: "lstk__right" });
|
||||
const foot = el("div", { className: "lstk__foot" });
|
||||
const root = el("div", {
|
||||
className: "lstk",
|
||||
attrs: { "data-line-stack": "" },
|
||||
children: [head, notes, el("div", { className: "lstk__areas", children: [left, right] }), foot],
|
||||
children: [head, el("div", { className: "lstk__areas", children: [left, right] }), foot],
|
||||
});
|
||||
// 산출근거 고정 높이(요구 683) — 스크롤 조상(없으면 창) 높이 · 넓을 때만 고정(line_stack.css)
|
||||
const fit = (): void => {
|
||||
@@ -245,7 +244,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
if (from === "left") r[0]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
};
|
||||
|
||||
/** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [수식 저장] 때 서버가 다시 셈) */
|
||||
/** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [저장] 때 서버가 다시 셈) */
|
||||
const takeIn = async (src: string): Promise<void> => {
|
||||
try {
|
||||
const got = await call<Taken>(`/take?src=${encodeURIComponent(src)}`);
|
||||
@@ -263,7 +262,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
const edit = (): boolean => opt.admin && (!!key || !cp);
|
||||
const draw = (): void => {
|
||||
if (!doc || !answer) {
|
||||
[head, notes, left, right, foot].forEach((x) => x.replaceChildren());
|
||||
[head, left, right, foot].forEach((x) => x.replaceChildren());
|
||||
return;
|
||||
}
|
||||
const options = buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
|
||||
@@ -274,10 +273,10 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
draw();
|
||||
});
|
||||
const busy = dirty();
|
||||
const tools =
|
||||
edit() && key && !opt.page
|
||||
const tools = [
|
||||
...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : []),
|
||||
...(edit() && key && !opt.page
|
||||
? [
|
||||
...(cp ? copyTools(true, (job) => void runJob(job)) : []),
|
||||
createButton({
|
||||
label: ft("Discard"),
|
||||
variant: "ghost",
|
||||
@@ -293,7 +292,8 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
onClick: () => void save(),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
: []),
|
||||
];
|
||||
head.replaceChildren(
|
||||
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
|
||||
el("div", {
|
||||
@@ -310,17 +310,6 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
],
|
||||
}),
|
||||
);
|
||||
const kept = notesOf(doc);
|
||||
notes.replaceChildren(
|
||||
...(kept.length
|
||||
? [
|
||||
el("p", {
|
||||
className: "ui-text ui-text--muted",
|
||||
text: `${lt("Notes")} — ${kept.join(" · ")}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
const items = stackOf(doc);
|
||||
root.dataset.edit = edit() ? "1" : "";
|
||||
left.replaceChildren(
|
||||
@@ -468,6 +457,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
if (all[next]) doc = clone(all[next] as StackDoc);
|
||||
recalc();
|
||||
draw();
|
||||
opt.page?.(); // 캐시에서 이어 받은 고침 — 페이지 [저장] · [되돌림] 켬
|
||||
} catch (e) {
|
||||
if (mine === seq)
|
||||
head.replaceChildren(
|
||||
|
||||
@@ -60,10 +60,6 @@ const TEXT = {
|
||||
Empty_Item: ["줄 없음 — [+]", "No lines — [+]"],
|
||||
Empty: ["빈 단가산출 — [+ 그룹] 으로 시작", "Empty — start with [+ Group]"],
|
||||
Text_Ph: ["< 품셈 근거 > · ※ 메모 · 별산", "Note"],
|
||||
Notes: [
|
||||
"줄 쌓기에서 못 고치는 칸(보존됨 · 수식 탭에서 고침)",
|
||||
"Kept fields (edit in Equation tab)",
|
||||
],
|
||||
Unsaved_New: ["저장한 단가산출만 — 페이지 [저장] 뒤", "Saved unit prices only"],
|
||||
Find_Title: ["찾아 넣기", "Find and add"],
|
||||
Repick: ["찾아 바꾸기", "Find and change"],
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 제목 크기 · 자리 · 단추 차례 · 공용 단추 크기 · 찾기 칸 꼴이 한 벌로 같음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fitButtonRow } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import "./M01_MasterData_UI_Head.css";
|
||||
|
||||
@@ -19,7 +20,7 @@ export interface HeadOptions {
|
||||
buttons: HTMLElement[];
|
||||
}
|
||||
|
||||
/** 제목 줄 — 제목은 18px · 왼쪽 · 남는 자리를 차지해 단추를 오른쪽으로 밂 */
|
||||
/** 제목 줄 — 제목은 18px · 왼쪽 · 남는 자리를 차지해 단추를 오른쪽으로 밂 · 넘치면 아이콘 단추는 아이콘만(804-8) */
|
||||
export function buildHeadBar(opts: HeadOptions): { root: HTMLElement; title: HTMLElement } {
|
||||
const title = typeof opts.title === "string" ? el("h2", { text: opts.title }) : opts.title;
|
||||
title.classList.add("m01-head__title");
|
||||
@@ -27,6 +28,7 @@ export function buildHeadBar(opts: HeadOptions): { root: HTMLElement; title: HTM
|
||||
className: "m01-head",
|
||||
children: [...(opts.lead ?? []), title, ...(opts.status ?? []), ...opts.buttons],
|
||||
});
|
||||
fitButtonRow(root);
|
||||
return { root, title };
|
||||
}
|
||||
|
||||
|
||||
@@ -502,15 +502,21 @@ export async function mountM01LogicLab(
|
||||
}
|
||||
};
|
||||
|
||||
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
|
||||
const saveButton = createButton({
|
||||
label: tx("Bar_Save"),
|
||||
iconStart: "check",
|
||||
onClick: () => void onSave(),
|
||||
});
|
||||
const discardButton = createButton({
|
||||
label: tx("Bar_Discard"),
|
||||
variant: "ghost",
|
||||
iconStart: "undo",
|
||||
onClick: () => void onDiscard(),
|
||||
});
|
||||
const copyButton = createButton({
|
||||
label: tl("Copy_Open"),
|
||||
variant: "ghost",
|
||||
iconStart: "copy",
|
||||
onClick: () =>
|
||||
opened?.origKey
|
||||
? void openCopyScreen({
|
||||
@@ -529,9 +535,19 @@ export async function mountM01LogicLab(
|
||||
status: [pending],
|
||||
buttons: [
|
||||
views.root,
|
||||
createButton({ label: tl("New"), variant: "filled", onClick: () => void onNew() }),
|
||||
createButton({
|
||||
label: tl("New"),
|
||||
variant: "filled",
|
||||
iconStart: "plus",
|
||||
onClick: () => void onNew(),
|
||||
}),
|
||||
copyButton,
|
||||
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
|
||||
createButton({
|
||||
label: tx("Bar_Delete"),
|
||||
variant: "danger",
|
||||
iconStart: "trash",
|
||||
onClick: () => void onDelete(),
|
||||
}),
|
||||
discardButton,
|
||||
saveButton,
|
||||
],
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* [초기화] = 서버 `-reset` 미리 보기(걷힐 고침 수) → 물음 → 그 층에 씀 → 다시 읽음.
|
||||
* 덮개 · 뺀줄 · 더한줄 단가를 M01 키 값으로 되돌림 · 옵션(입력값) · 기본정보는 그대로(서버 740-1).
|
||||
* 둘 다 쓰기 권한이 있는 층에서만 보임(`update`) · 상세에 저장 안 한 고침이 있으면 먼저 물음.
|
||||
* 단가산출 상세(804-8 · M01 단가산출 기초와 같은 단추 규칙) = [새로고침] 숨김 · [되돌림] = 물음 → 다시 읽음(수식 캐시도 비움) ·
|
||||
* [되돌림] · [저장] = 고침 있을 때만(`dim`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import {
|
||||
@@ -41,6 +43,11 @@ const TEXT = {
|
||||
Done: ["M01 값으로 되돌렸음", "Reverted to M01 values"],
|
||||
DeleteAsk: ["「{name}」 을(를) 지울까?", "Delete 「{name}」?"],
|
||||
Deleted: ["지웠음", "Deleted"],
|
||||
Undo: ["되돌림", "Revert"],
|
||||
UndoAsk: [
|
||||
"저장 안 한 고친 것(기본정보 · 수식)을 버리고 저장 뒤 상태로 되돌릴까?",
|
||||
"Discard unsaved edits (info · formula) and revert to the saved state?",
|
||||
],
|
||||
Failed: ["못 했음 — {why}", "Failed — {why}"],
|
||||
} as const;
|
||||
const tx = (key: keyof typeof TEXT): string =>
|
||||
@@ -58,6 +65,8 @@ export interface MainActionsCtx {
|
||||
open: (sel: Selection | null) => Promise<void>;
|
||||
/** 좌측 목록을 다시 읽음 */
|
||||
changed: () => void;
|
||||
/** 페이지 머리 단추 — 단가산출 상세에서 숨김 · 흐림 */
|
||||
page: { reload: HTMLButtonElement; save: HTMLButtonElement };
|
||||
}
|
||||
|
||||
/** 문서 안 모든 `뺀줄` 길이 합(단가산출 · 일위대가 줄 안 덮개까지) */
|
||||
@@ -74,10 +83,15 @@ const why = (error: unknown): string => (error instanceof Error ? error.message
|
||||
export function createMainActions(ctx: MainActionsCtx): {
|
||||
reset: HTMLButtonElement;
|
||||
del: HTMLButtonElement;
|
||||
undo: HTMLButtonElement;
|
||||
update: (writable: boolean) => void;
|
||||
/** 고침 있음 — 단가산출 상세의 [되돌림] · [저장] 흐림 */
|
||||
dim: (dirty: boolean) => void;
|
||||
} {
|
||||
const reset = createButton({ label: tx("Reset"), variant: "ghost" });
|
||||
const del = createButton({ label: tx("Delete"), variant: "danger-ghost" });
|
||||
const reset = createButton({ label: tx("Reset"), variant: "ghost", iconStart: "rotateCcw" });
|
||||
const del = createButton({ label: tx("Delete"), variant: "danger-ghost", iconStart: "trash" });
|
||||
const undo = createButton({ label: tx("Undo"), variant: "ghost", iconStart: "undo" });
|
||||
undo.dataset.mainUndo = "";
|
||||
reset.title = tx("ResetTip");
|
||||
del.title = tx("DeleteTip");
|
||||
reset.dataset.mainReset = "";
|
||||
@@ -145,12 +159,27 @@ export function createMainActions(ctx: MainActionsCtx): {
|
||||
}
|
||||
});
|
||||
|
||||
undo.addEventListener("click", async () => {
|
||||
const at = ctx.sel();
|
||||
if (at && (await showConfirmDialog(tx("UndoAsk"), tx("Undo")))) await ctx.open(at);
|
||||
});
|
||||
|
||||
const sk = (): boolean => target()?.kind === "sangeun";
|
||||
const dim = (dirty: boolean): void => {
|
||||
undo.disabled = !dirty;
|
||||
ctx.page.save.disabled = sk() && !dirty;
|
||||
};
|
||||
return {
|
||||
reset,
|
||||
del,
|
||||
undo,
|
||||
update: (writable) => {
|
||||
const on = writable && !!target();
|
||||
reset.hidden = del.hidden = !on;
|
||||
undo.hidden = !on || !sk();
|
||||
if (sk()) ctx.page.reload.hidden = true;
|
||||
dim(false);
|
||||
},
|
||||
dim,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Detail.ts
|
||||
* M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
|
||||
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(공용 수식 문서 화면 · 802-4b-2 · 저장은 그 탭 [수식 저장] → 판 · 수식 칸 새로) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
|
||||
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(공용 수식 문서 화면 · 802-4b-2 · 저장 = 페이지 [저장] 한 번(804-8 · 수식 먼저 → 판 · 수식 칸 새로)) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
|
||||
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 기본정보 = 바탕 로직(맨 위 접힘) — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 걷음
|
||||
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
|
||||
* 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
|
||||
* 둘째 기초(727-2) = 서버 `펼침` 세부 줄 · 인력 · 재료 · 경비 상자에 함께 + 꼬리표(기초 둘 이상) · 세부 덮개 ·
|
||||
* 뺀줄 · [기초 빼기] · M01 개정 알림 · 옵션 칸 = 바탕 입력 + 더한줄 기초 입력
|
||||
* [항목 추가] 기초 = 바탕 비었으면 바탕(727-2 · 단위가 비거나 「식」이면 기초 결과단위) · 있으면 더한줄 · 바탕 없이 줄만도 됨
|
||||
* 단가산출 화면(`source` · 804-8) = M01 과 같은 꼴 — 보기 고르기는 페이지 머리 줄(`slot`) · [항목 추가] · [율 줄 추가] 걷음
|
||||
* (줄 더하기 = 수식 탭 [+] 단가산출 복사) · 수식 고침도 페이지 [저장] 한 번에(`flush` 먼저) · 고침 알림 = 기본 + 수식
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
@@ -36,6 +38,7 @@ import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
|
||||
import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
|
||||
import { createSangeunTabs } from "./M02_MasterTemplete_Sangeun_Tabs";
|
||||
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
|
||||
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
|
||||
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
|
||||
import {
|
||||
@@ -65,6 +68,8 @@ export interface SangeunDetailOptions {
|
||||
source?: boolean;
|
||||
/** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */
|
||||
onVersion?: (version: string) => void;
|
||||
/** 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에 */
|
||||
slot?: HTMLElement;
|
||||
}
|
||||
|
||||
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
|
||||
@@ -74,7 +79,13 @@ export function mountSangeunDetail(
|
||||
host: HTMLElement,
|
||||
doc0: unknown,
|
||||
opt: SangeunDetailOptions,
|
||||
): { getDoc: () => SkDoc; getValues: () => Record<string, string>; destroy: () => void } {
|
||||
): {
|
||||
getDoc: () => SkDoc;
|
||||
getValues: () => Record<string, string>;
|
||||
/** 페이지 [저장] 앞 — 수식 고침 저장 · 실패면 던짐 */
|
||||
flush: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
} {
|
||||
setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1)
|
||||
const doc = clone((doc0 ?? {}) as SkDoc);
|
||||
let saved = JSON.stringify(doc);
|
||||
@@ -100,7 +111,13 @@ export function mountSangeunDetail(
|
||||
const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } });
|
||||
const notes = el("div");
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
const tabs = createSangeunTabs(editor, { edit, scope, onSaved: (v) => void formulaSaved(v) });
|
||||
let refreshing: Promise<void> = Promise.resolve();
|
||||
const tabs = createSangeunTabs(editor, {
|
||||
edit,
|
||||
scope,
|
||||
onSaved: (v) => void (refreshing = formulaSaved(v)),
|
||||
page: opt.source ? () => report() : undefined, // 804-8 페이지 [저장] · [되돌림]
|
||||
});
|
||||
/** 수식 탭 저장 = 파일 판 · `수식` 칸이 바뀜 — 페이지 판 · 이 화면 문서 `수식` 을 새로(상세 탭 셈이 씀) */
|
||||
async function formulaSaved(version: string): Promise<void> {
|
||||
opt.onVersion?.(version);
|
||||
@@ -132,7 +149,7 @@ export function mountSangeunDetail(
|
||||
touch();
|
||||
}),
|
||||
});
|
||||
addButton.hidden = !edit;
|
||||
addButton.hidden = !edit || !!opt.source;
|
||||
// 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴)
|
||||
const rateButton = createButton({
|
||||
label: L("M02_SkRateAdd"),
|
||||
@@ -144,7 +161,7 @@ export function mountSangeunDetail(
|
||||
(line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()),
|
||||
),
|
||||
});
|
||||
rateButton.hidden = !edit;
|
||||
rateButton.hidden = !edit || !!opt.source;
|
||||
/** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */
|
||||
async function addBase(line: { 로직: string; 수량: number }): Promise<void> {
|
||||
let qty = 1;
|
||||
@@ -177,12 +194,22 @@ export function mountSangeunDetail(
|
||||
children: [
|
||||
head,
|
||||
notes,
|
||||
el("div", { className: "m02-sk__head", children: [tabs.picker, addButton, rateButton] }),
|
||||
...(opt.slot
|
||||
? []
|
||||
: [
|
||||
el("div", {
|
||||
className: "m02-sk__head",
|
||||
children: [tabs.picker, addButton, rateButton],
|
||||
}),
|
||||
]),
|
||||
tabs.host,
|
||||
editor,
|
||||
],
|
||||
});
|
||||
host.replaceChildren(side ? side.wrap(main) : main);
|
||||
opt.slot?.replaceChildren(
|
||||
el("span", { className: "ui-toolbar m02-main__tools", children: [tabs.picker] }),
|
||||
);
|
||||
|
||||
const opened = (): boolean => (doc.열린입력 ?? []).length > 0;
|
||||
// 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) · 둘째 기초 지문(727-2)
|
||||
@@ -229,8 +256,12 @@ export function mountSangeunDetail(
|
||||
.filter((i) => !seen.has(i.이름) && seen.add(i.이름));
|
||||
}
|
||||
|
||||
/** 고침 알림 — 기본(이름 · 단위 · 옵션 · 덮개) + 수식 탭 */
|
||||
function report(): void {
|
||||
if (alive) opt.onChanged(edit && (JSON.stringify(doc) !== saved || tabs.dirty()));
|
||||
}
|
||||
const touch = (again = true): void => {
|
||||
opt.onChanged(edit && JSON.stringify(doc) !== saved);
|
||||
report();
|
||||
if (again) void recalc();
|
||||
else drawHead();
|
||||
};
|
||||
@@ -516,8 +547,14 @@ export function mountSangeunDetail(
|
||||
return clone(doc);
|
||||
},
|
||||
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
|
||||
flush: async () => {
|
||||
if (!tabs.dirty()) return;
|
||||
if (!(await tabs.save())) throw new Error(tl("View_Equation"));
|
||||
await refreshing; // 새 판 · `수식` 칸 받은 뒤 페이지가 저장
|
||||
},
|
||||
destroy: () => {
|
||||
alive = false;
|
||||
if (tabs.dirty()) void tabs.discard(); // 페이지가 버리고 떠남 — 수식 캐시도 비움
|
||||
host.replaceChildren();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* M02_MasterTemplete_Sangeun_Tabs.ts
|
||||
* M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 804-2 · 804-6 · 요구 686-1) — 수식(첫 탭 · 줄 쌓기) · 단가산출 상세.
|
||||
* 수식 = 공용 줄 쌓기 화면(`A00_Common/line_stack_screen.ts` · M01 과 같은 꼴) — 길 앞머리만 M02 사본 길
|
||||
* (`/m02/layers/{층}/sangeun/{키}/doc` · 802-4a) · 층 쓰기 권한 없으면 읽기만 · 저장 = 그 탭 [수식 저장](페이지 [저장] 아님)
|
||||
* (`/m02/layers/{층}/sangeun/{키}/doc` · 802-4a) · 층 쓰기 권한 없으면 읽기만
|
||||
* 저장(804-8 · `page` 있음 = 단가산출 화면) = 페이지 [저장] · [되돌림] 한 번에 — 탭 위 저장 단추 없음(M01 과 같음)
|
||||
* 옛 수식 문서 탭(802-4b-2)은 804-6 에 걷음 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김)
|
||||
* 저장 안 한 SK(일위대가 줄 모달 · 새 SK) = 「저장한 단가산출만」 글
|
||||
* ========================================================================== */
|
||||
@@ -18,8 +19,10 @@ type View = "stack" | "ho";
|
||||
export interface SkEquationOptions {
|
||||
scope: { layer: Layer; owner: string | null };
|
||||
edit: boolean;
|
||||
/** [수식 저장] · [단가 갱신] · [재생성] 뒤 그 SK 새 판 */
|
||||
/** 수식 저장 · [단가 갱신] · [재생성] 뒤 그 SK 새 판 */
|
||||
onSaved: (version: string) => void;
|
||||
/** 페이지가 저장 · 되돌림을 맡음(804-8) — 수식 고침 있음이 바뀔 때 부름 */
|
||||
page?: () => void;
|
||||
}
|
||||
|
||||
/** SK 수식 길 — 꼬리 물음(`/table?key=…`)에 주인 물음을 이어 붙임 */
|
||||
@@ -42,6 +45,9 @@ export function createSangeunTabs(
|
||||
/** 저장한 SK 키(빈 키 = 저장 전) */
|
||||
draw: (key: string) => void;
|
||||
canLeave: () => Promise<boolean>;
|
||||
dirty: () => boolean;
|
||||
save: () => Promise<boolean>;
|
||||
discard: () => Promise<void>;
|
||||
} {
|
||||
const stack = createStackScreen({
|
||||
base: "",
|
||||
@@ -49,6 +55,7 @@ export function createSangeunTabs(
|
||||
cache: "m02_sk_doc_drafts",
|
||||
copy: { route: (k, p) => skDocPath(opt.scope, k, p), onSaved: opt.onSaved },
|
||||
scope: opt.scope, // 804-4 다른 단가산출 · 일위대가 복사
|
||||
page: opt.page,
|
||||
});
|
||||
let key = "";
|
||||
let view: View = "stack";
|
||||
@@ -81,5 +88,8 @@ export function createSangeunTabs(
|
||||
if (view === "stack") stack.draw(k);
|
||||
},
|
||||
canLeave: () => stack.canLeave(),
|
||||
dirty: () => stack.dirty(),
|
||||
save: () => stack.save(),
|
||||
discard: () => stack.discard(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* M02_MasterTemplete_Sangeun_View.ts
|
||||
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
|
||||
* 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`.
|
||||
* 804-8 = 페이지 [저장] 한 번에 수식도(`flush` 먼저 · 새 판) · 보기 고르기는 머리 줄 `slot`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
|
||||
@@ -13,12 +14,15 @@ export type SangeunViewOptions = Pick<Selection, "layer" | "owner" | "name"> & {
|
||||
onChanged: (dirty: boolean) => void;
|
||||
/** 수식 탭 저장 뒤 그 SK 새 판(802-4b-2) */
|
||||
onVersion?: (version: string) => void;
|
||||
/** 머리 줄 빈 자리(804-8) */
|
||||
slot?: HTMLElement;
|
||||
};
|
||||
|
||||
export function mountSangeunView(
|
||||
host: HTMLElement,
|
||||
doc: unknown,
|
||||
opt: SangeunViewOptions,
|
||||
): { getDoc: () => unknown; destroy: () => void } {
|
||||
return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1
|
||||
): { getDoc: () => Promise<unknown>; destroy: () => void } {
|
||||
const one = mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1
|
||||
return { getDoc: () => one.flush().then(() => one.getDoc()), destroy: one.destroy };
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { fitButtonRow } from "@ui/ui_template_button";
|
||||
import { createMainActions } from "./M02_MasterTemplete_Main_Actions";
|
||||
export interface Selection {
|
||||
layer: Layer;
|
||||
@@ -140,8 +141,8 @@ export function createMain(
|
||||
extras: MainExtras = {},
|
||||
): MainHandle {
|
||||
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost", iconStart: "refresh" });
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled", iconStart: "check" });
|
||||
// 단가산출 · 일위대가 상세 [초기화] [삭제](740-2 · 738-2) — 차례 … [초기화] [삭제] [저장]
|
||||
const actions = createMainActions({
|
||||
sel: () => sel,
|
||||
@@ -150,6 +151,7 @@ export function createMain(
|
||||
confirmLeave: () => confirmLeave(),
|
||||
open: (at) => open(at),
|
||||
changed: () => onSaved?.(),
|
||||
page: { reload, save },
|
||||
});
|
||||
// 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님)
|
||||
const back = createButton({ label: L("M02_Back") });
|
||||
@@ -183,6 +185,7 @@ export function createMain(
|
||||
reload,
|
||||
actions.reset,
|
||||
actions.del,
|
||||
actions.undo,
|
||||
save,
|
||||
...(extras.newItem ? [extras.newItem] : []),
|
||||
],
|
||||
@@ -193,6 +196,7 @@ export function createMain(
|
||||
],
|
||||
});
|
||||
const root = column;
|
||||
fitButtonRow(column.querySelector<HTMLElement>(".m02-main__head")!); // 넘치면 단추 글 대신 아이콘(804-8)
|
||||
const isCombo = (at: Selection | null): boolean =>
|
||||
!!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project";
|
||||
// 「새 항목」 · 표 접기(제목)는 「구조물 조합」일 때만
|
||||
@@ -373,10 +377,10 @@ export function createMain(
|
||||
host.replaceChildren();
|
||||
if (at.kind === "sangeun") {
|
||||
const { mountSangeunView } = await import("./M02_MasterTemplete_Sangeun_View"); // 717-2a
|
||||
const onChanged = (d: boolean): boolean => (dirty = d && !readOnly);
|
||||
const onChanged = (d: boolean): void => actions.dim((dirty = d && !readOnly));
|
||||
const onVersion = (v: string): string => (version = v); // 수식 탭 저장(802-4b-2)
|
||||
if (mine === seq)
|
||||
editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged, onVersion });
|
||||
editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged, onVersion, slot });
|
||||
return;
|
||||
}
|
||||
if (at.kind === "table") {
|
||||
@@ -601,7 +605,7 @@ export function createMain(
|
||||
);
|
||||
version = info.판;
|
||||
loaded = doc;
|
||||
dirty = false;
|
||||
actions.dim((dirty = false));
|
||||
toDrawing.drop(); // 놓기 기다림 · 보내기 취소는 이 저장으로 끝
|
||||
// 구조물집계표 — 서버가 새 열 파일 · 번호를 만듦 · 번호를 다시 받아 도면 줄 · 카드를 새로
|
||||
// (안 하면 새 열은 다시 열 때까지 「도면 없음」 · 번호 빈칸)
|
||||
|
||||
@@ -52,6 +52,15 @@ html.is-bm-route .m02-main__head {
|
||||
font-size: 18px; /* M01 머리 제목과 같음(39-5) */
|
||||
}
|
||||
|
||||
/* 단가산출 상세(804-8) — 제목이 남는 자리를 차지 · 긴 이름은 제목 안에서 줄바꿈(단추가 아래로 안 떨어짐) */
|
||||
.m02-main__head:has(> .m02-main__slot [data-sk-views]) > .m02-main__title {
|
||||
flex: 1 1 8rem;
|
||||
}
|
||||
|
||||
.m02-main__head:has(> .m02-main__slot [data-sk-views]) > .m02-main__slot {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.m02-main__head .ui-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ export function docOf(doc: StackDoc, items: StackItem[]): StackDoc {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 줄 쌓기가 못 고치는 문서 칸(보존은 됨 · 수식 탭에서 고침) */
|
||||
/** 줄 쌓기가 못 고치는 문서 칸(보존은 됨 · 시험이 봄 · 화면 안내 줄은 804-8 에 걷음) */
|
||||
export function notesOf(doc: FormulaDoc): string[] {
|
||||
const out: string[] = [];
|
||||
if (doc.단계?.length) out.push("단계");
|
||||
|
||||
@@ -182,6 +182,9 @@ def test_권한_저장_단가갱신(world, monkeypatch):
|
||||
# 페이지 [저장](옛 칸 · 이름) 뒤에도 고친 문서 그대로(빈 SK 씨로 덮지 않음)
|
||||
with store.at(me):
|
||||
file = store.read("sangeun", target)
|
||||
assert (
|
||||
file["판"] == saved["판"]
|
||||
) # 804-8 페이지 [저장] 한 번 = 수식 저장 판 그대로 이어 씀(409 없음)
|
||||
store.write("sangeun", target, file["판"], {**file["문서"], "이름": "sub3임시 이름바꿈"})
|
||||
again = _norm(path.read(target))
|
||||
assert again["셈"]["계"] == saved["셈"]["계"] and again["사본"]["따로"]
|
||||
|
||||
@@ -203,3 +203,38 @@ def test_시험_페이지_걷음_화면_차림() -> None:
|
||||
assert "html:not" in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Side.css")
|
||||
for f in (ROOT / "A00_Common").glob("line_stack*.ts"):
|
||||
assert len(f.read_text(encoding="utf-8").splitlines()) <= 700, f.name
|
||||
|
||||
|
||||
def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None:
|
||||
"""804-8 — M02 단가산출 수식 탭 = M01 과 같은 공용 화면 · 페이지 [되돌림] · [저장] 한 번 · 머리 줄 한 줄."""
|
||||
screen = _src("A00_Common/line_stack_screen.ts")
|
||||
assert "...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : [])," in screen
|
||||
assert "notesOf" not in screen and "lstk__notes" not in screen # 못 고치는 칸 안내 줄 걷음
|
||||
assert "Notes:" not in _src("A00_Common/line_stack_text.ts")
|
||||
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
|
||||
assert "page: opt.page," in tabs and "save: () => stack.save()," in tabs
|
||||
detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert "page: opt.source ? () => report() : undefined," in detail
|
||||
assert "JSON.stringify(doc) !== saved || tabs.dirty()" in detail # 고침 알림 = 기본 + 수식
|
||||
assert "addButton.hidden = !edit || !!opt.source;" in detail # [항목 추가] · [율 줄 추가] 걷음
|
||||
assert "rateButton.hidden = !edit || !!opt.source;" in detail
|
||||
assert "if (!(await tabs.save())) throw" in detail and "await refreshing;" in detail
|
||||
assert "if (tabs.dirty()) void tabs.discard();" in detail # 버리고 떠남 = 수식 캐시도
|
||||
view = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts")
|
||||
assert "getDoc: () => one.flush().then(() => one.getDoc())" in view
|
||||
acts = _src("M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts")
|
||||
assert (
|
||||
'Undo: ["되돌림", "Revert"],' in acts and "ctx.page.save.disabled = sk() && !dirty;" in acts
|
||||
)
|
||||
assert "if (sk()) ctx.page.reload.hidden = true;" in acts # 단가산출 상세 = [새로고침] 숨김
|
||||
main = _src("M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts")
|
||||
assert "actions.undo,\n save," in main and "actions.dim((dirty = false));" in main
|
||||
assert "onChanged, onVersion, slot }" in main # 보기 고르기 = 머리 줄
|
||||
assert "fitButtonRow(" in main and "fitButtonRow(root);" in _src(
|
||||
"M01_MasterData/M01_MasterData_UI_Head.ts"
|
||||
)
|
||||
lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
|
||||
for i in ('"check"', '"undo"', '"copy"', '"plus"', '"trash"'):
|
||||
assert f"iconStart: {i}" in lab, i
|
||||
css = _src("ui_template/ui_template_button.css")
|
||||
assert ".ui-btn-row--tight .ui-btn:has(> .ui-btn__icon) > .ui-btn__label" in css
|
||||
|
||||
@@ -193,3 +193,8 @@
|
||||
.ui-btn--ghost[data-tone]:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
background-color: color-mix(in srgb, var(--ui-tone) 22%, var(--color-surface-raised));
|
||||
}
|
||||
|
||||
/* 단추 줄 맞춤(`fitButtonRow`) — 넘치면 아이콘 단추는 아이콘만 */
|
||||
.ui-btn-row--tight .ui-btn:has(> .ui-btn__icon) > .ui-btn__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -115,3 +115,19 @@ export function createIconButton(opts: IconButtonOptions): HTMLButtonElement {
|
||||
if (opts.onClick) btn.addEventListener("click", opts.onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** 단추 줄 맞춤(804-8) — 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 글을 숨김(글은 `title`) · 넓어지면 다시 글.
|
||||
* `row` = 한 줄 flex(감김 허용) · 아이콘 없는 단추 · 다른 요소는 그대로 */
|
||||
export function fitButtonRow(row: HTMLElement): void {
|
||||
const fit = (): void => {
|
||||
row.classList.remove("ui-btn-row--tight");
|
||||
for (const b of row.querySelectorAll<HTMLElement>(".ui-btn:has(> .ui-btn__icon)"))
|
||||
b.title ||= b.textContent?.trim() ?? "";
|
||||
// 단추 높이보다 높음 = 단추가 떨어졌거나 제목이 여러 줄 — 글을 숨겨 자리를 냄
|
||||
const tall = Math.max(0, ...[...row.querySelectorAll("button")].map((x) => x.offsetHeight));
|
||||
if (row.clientHeight > tall + 1) row.classList.add("ui-btn-row--tight");
|
||||
};
|
||||
const watch = new ResizeObserver(fit);
|
||||
watch.observe(row);
|
||||
for (const x of row.children) watch.observe(x); // 단추 숨김 · 보임도 다시 잼
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user