From eb64bf64ac146f9a92e8621f584a8d29d9f7d120 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 14:56:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(804-8):=20M02=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=88=98=EC=8B=9D=20=ED=83=AD=20=3D=20M01?= =?UTF-8?q?=20=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EB=8B=A8=EC=B6=94=20=C2=B7?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=EB=8B=A8=EC=B6=94=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=EB=A7=89=EC=9D=8C(=EC=9A=94=EA=B5=AC=20689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 줄 쌓기 화면: 페이지가 저장을 맡는 꼴(`page`)을 M02 단가산출 화면에도 — 탭 위 [고침 버림] · [수식 저장] 걷음 · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지는 그대로 · 「줄 쌓기에서 못 고치는 칸」 안내 줄 걷음(금액 줄 기호 = 율 줄이 앞 줄 금액을 부르는 속 이름 · 화면에서 고칠 값 아님 · 보존 그대로) - M02 머리 단추 = [초기화] · [삭제] · [되돌림] · [저장] — 단가산출 상세는 [새로고침] 숨김 · [되돌림] · [저장] = 고침 있을 때만(기본정보 + 수식) · [저장] 한 번 = 수식 먼저 저장(새 판 · 수식 칸 받음) → 단가산출 문서 저장 · [되돌림] · 버리고 떠남 = 수식 캐시도 비움 - 보기 고르기(수식 · 단가산출 상세) = M01 처럼 머리 줄 · [항목 추가] · [율 줄 추가] 걷음(줄 더하기 = 수식 탭 [+] 단가산출 · 일위대가 복사) · 일위대가 줄 모달 안 상세는 그대로 - 머리 단추 줄 맞춤(부품 상자 `fitButtonRow`): 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 아이콘만(글 = 풍선 도움말) · M01 단추 다섯 · M02 단추 넷 아이콘 · 단가산출 상세 긴 이름은 제목 안에서 줄바꿈 - 사본 할일 막힘 알림 글 = [저장] 또는 [되돌림] 먼저 - 시험: test_804_7 에 M02 단추 · 저장 한 번 · 줄 맞춤 지킴 더함 · test_804_4 권한 시험에 수식 저장 판 = 페이지 저장 판 이어 씀 확인 더함 Co-Authored-By: Claude Opus 5.5 --- A00_Common/formula_doc_text.ts | 2 +- A00_Common/line_stack_screen.ts | 38 +++++--------- A00_Common/line_stack_text.ts | 4 -- M01_MasterData/M01_MasterData_UI_Head.ts | 4 +- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 22 ++++++-- .../M02_MasterTemplete_Main_Actions.ts | 35 +++++++++++-- .../M02_MasterTemplete_Sangeun_Detail.ts | 51 ++++++++++++++++--- .../M02_MasterTemplete_Sangeun_Tabs.ts | 14 ++++- .../M02_MasterTemplete_Sangeun_View.ts | 8 ++- .../M02_MasterTemplete_UI_Main.ts | 14 +++-- .../M02_MasterTemplete_UI_Style.css | 9 ++++ common_util/common_util_line_stack.ts | 2 +- .../tester/test_804_4_line_stack_take.py | 3 ++ resources/tester/test_804_7_line_stack_fix.py | 35 +++++++++++++ ui_template/ui_template_button.css | 5 ++ ui_template/ui_template_button.ts | 16 ++++++ 16 files changed, 209 insertions(+), 53 deletions(-) diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts index 9e4006b27..2ed7e96b1 100644 --- a/A00_Common/formula_doc_text.ts +++ b/A00_Common/formula_doc_text.ts @@ -73,7 +73,7 @@ const TEXT = { Before_After: ["계", "Sum"], Job_Done: ["적용함", "Applied"], Dirty_First: [ - "저장 안 한 고침이 있음 — [수식 저장] 또는 [고침 버림] 먼저", + "저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저", "Save or discard edits first", ], No_Key: [ diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index 37ad0e6cf..e91568663 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -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 = (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 => { try { const got = await call(`/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( diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index 299be2bdb..cb8b97d79 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -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"], diff --git a/M01_MasterData/M01_MasterData_UI_Head.ts b/M01_MasterData/M01_MasterData_UI_Head.ts index 9587effa7..5643ca722 100644 --- a/M01_MasterData/M01_MasterData_UI_Head.ts +++ b/M01_MasterData/M01_MasterData_UI_Head.ts @@ -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 }; } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 08256219a..6780f3a45 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -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, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts b/M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts index 4f02b668f..df56bf19c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts @@ -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; /** 좌측 목록을 다시 읽음 */ 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, }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 283be09d7..c3262d5e9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -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; destroy: () => void } { +): { + getDoc: () => SkDoc; + getValues: () => Record; + /** 페이지 [저장] 앞 — 수식 고침 저장 · 실패면 던짐 */ + flush: () => Promise; + 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 = 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 { 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 { 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(); }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts index 9d645f1b0..8e66583fd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts @@ -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; + dirty: () => boolean; + save: () => Promise; + discard: () => Promise; } { 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(), }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts index 555a73fbf..66e131f31 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -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 & { 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; 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 }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 90da9d89d..a6b102bfc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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(".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(); // 놓기 기다림 · 보내기 취소는 이 저장으로 끝 // 구조물집계표 — 서버가 새 열 파일 · 번호를 만듦 · 번호를 다시 받아 도면 줄 · 카드를 새로 // (안 하면 새 열은 다시 열 때까지 「도면 없음」 · 번호 빈칸) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 277727da2..9ae1c3acc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -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; } diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts index f2bf87b14..14d222a48 100644 --- a/common_util/common_util_line_stack.ts +++ b/common_util/common_util_line_stack.ts @@ -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("단계"); diff --git a/resources/tester/test_804_4_line_stack_take.py b/resources/tester/test_804_4_line_stack_take.py index 8cf2802fa..4c3422b0c 100644 --- a/resources/tester/test_804_4_line_stack_take.py +++ b/resources/tester/test_804_4_line_stack_take.py @@ -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["사본"]["따로"] diff --git a/resources/tester/test_804_7_line_stack_fix.py b/resources/tester/test_804_7_line_stack_fix.py index d30650e78..b1cca9a09 100644 --- a/resources/tester/test_804_7_line_stack_fix.py +++ b/resources/tester/test_804_7_line_stack_fix.py @@ -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 diff --git a/ui_template/ui_template_button.css b/ui_template/ui_template_button.css index 6c4237571..f1d64122d 100644 --- a/ui_template/ui_template_button.css +++ b/ui_template/ui_template_button.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; +} diff --git a/ui_template/ui_template_button.ts b/ui_template/ui_template_button.ts index 6c2faf113..0329b7853 100644 --- a/ui_template/ui_template_button.ts +++ b/ui_template/ui_template_button.ts @@ -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(".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); // 단추 숨김 · 보임도 다시 잼 +}