diff --git a/M01_MasterData/M01_MasterData_UI_Combo.css b/M01_MasterData/M01_MasterData_UI_Combo.css new file mode 100644 index 00000000..9e31fece --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Combo.css @@ -0,0 +1,14 @@ +/* 일위대가 조합 화면 — 표는 칸 폭을 채움 · 미리 보기 금액은 오른쪽 맞춤 · + 흐린 안내 글은 보조 글색(뮤트 글색은 밝은 테마에서 대비 3.1 로 모자람) */ +.m01-combo .m01-master__grid { + width: 100%; +} + +.m01-combo [data-combo="preview"] th:not(:first-child), +.m01-combo [data-combo="preview"] td:not(:first-child) { + text-align: right; +} + +.m01-combo .m01-logic__muted { + color: var(--color-text-secondary); +} diff --git a/M01_MasterData/M01_MasterData_UI_Combo.ts b/M01_MasterData/M01_MasterData_UI_Combo.ts index 4d992d94..777d569b 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo.ts @@ -28,6 +28,7 @@ import { tc } from "./M01_MasterData_UI_Combo_Text"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import "./M01_MasterData_UI_Logic_Style.css"; import "./M01_MasterData_UI_LogicLab_Style.css"; +import "./M01_MasterData_UI_Combo.css"; const FILTER_ID = "조합|"; const blankCombo = (): Combo => ({ @@ -47,7 +48,9 @@ const blankCombo = (): Combo => ({ function reason(error: unknown): string { if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { const d = error.detail as { errors?: string[]; stale?: string[] }; - if (d.errors) return d.errors.join(" / "); + // 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림 + if (d.errors) + return d.errors.map((x) => x.replace(/^\S+\.json · (U[AM]\d{6} )?/, "")).join(" / "); if (d.stale) return tc("Stale"); } return error instanceof Error ? error.message : tc("Load_Failed"); @@ -62,6 +65,8 @@ export function mountM01Combo( host: HTMLElement, side: SideHandle, onOpenLogic: (key: string) => void, + /** 왼쪽 거름 · 찾기를 만지면 — 다른 화면이 떠 있으면 이 화면으로 */ + activate: () => void = () => {}, ): ComboHandle { let items: ComboSummary[] = []; let logics = new Map(); @@ -69,11 +74,19 @@ export function mountM01Combo( /** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */ let nav = 0; /** 열어 둔 상세 — 다른 화면에 갔다 와도 그대로 돌아오게 기억 */ - let current: { combo: Combo; version: string } | null = null; + /** `saved` = 연 때의 모양 — 다시 그려도 「저장 안 한 고침」 판정이 안 풀리게 */ + let current: { combo: Combo; version: string; saved: string } | null = null; let logicsReady: Promise = Promise.resolve(); + let q = ""; const filterHost = el("div"); - side.comboHost.replaceChildren(filterHost); - const view = el("div", { className: "m01-logic__editor" }); + const find = createInputField({ type: "search", placeholder: tc("Search") }); + find.input.addEventListener("input", () => { + q = find.input.value.trim().toLowerCase(); + activate(); + if (!current) drawList(); + }); + side.comboHost.replaceChildren(find.root, filterHost); + const view = el("div", { className: "m01-logic__editor m01-combo" }); host.replaceChildren(view); /** 목록만 받음 — 로직 이름표(1300여 개)는 따로 받아 목록을 붙잡지 않음 */ @@ -106,7 +119,7 @@ export function mountM01Combo( await load(false); if (!current) drawList(); await logicsReady; - if (current) draw(current.combo, current.version); + if (current) draw(current.combo, current.version, current.saved); }; /* --- 왼쪽 거름 --- */ @@ -130,6 +143,7 @@ export function mountM01Combo( detailLabel: tc("Detail"), onPick: (sub, detail) => { pick = { sub, detail }; + activate(); drawList(); }, }), @@ -154,7 +168,10 @@ export function mountM01Combo( nav++; current = null; const shown = items.filter( - (x) => (!pick.sub || x.구분 === pick.sub) && (!pick.detail || x.상세구분 === pick.detail), + (x) => + (!pick.sub || x.구분 === pick.sub) && + (!pick.detail || x.상세구분 === pick.detail) && + (!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)), ); const heads = [ tc("Head_Key"), @@ -177,7 +194,7 @@ export function mountM01Combo( x.단위 ?? "", String(x.count), x.본뜬키 ?? tc("Origin_Self"), - ].map((v) => el("td", { text: v })), + ].map((v) => el("td", { text: v, attrs: { title: v } })), }); tr.addEventListener("click", () => void open(x.키)); return tr; @@ -199,7 +216,7 @@ export function mountM01Combo( }), ], }) - : el("p", { className: "m01-logic__muted", text: tc("Empty") }), + : el("p", { className: "m01-logic__muted", text: tc(items.length ? "NoMatch" : "Empty") }), ); }; @@ -216,14 +233,14 @@ export function mountM01Combo( }; /* --- 상세 --- */ - const draw = (combo: Combo, version: string): void => { + const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => { nav++; - current = { combo, version }; - const saved = JSON.stringify(combo); + current = { combo, version, saved }; const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => { const f = createInputField({ type: "text", label }); f.input.value = combo[name] ?? ""; f.input.setAttribute("data-combo-field", name); + if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음 f.input.addEventListener("input", () => { const v = f.input.value; if (name === "이름") combo.이름 = v; @@ -350,7 +367,11 @@ export function mountM01Combo( label: tc("Delete"), variant: "ghost", onClick: async () => { - if (!combo.키 || !(await showConfirmDialog(tc("DeleteAsk")))) return; + if ( + !combo.키 || + !(await showConfirmDialog(`${combo.키} ${combo.이름} — ${tc("DeleteAsk")}`)) + ) + return; try { await deleteCombo(combo.키, version); showToast(tc("Deleted"), "success"); @@ -360,7 +381,18 @@ export function mountM01Combo( } }, }); - const back = createButton({ label: "←", variant: "ghost", onClick: () => drawList() }); + const back = createButton({ + label: "←", + variant: "ghost", + onClick: async () => { + if (JSON.stringify(combo) !== saved && !(await showConfirmDialog(tc("BackAsk")))) return; + drawList(); + }, + }); + const origin = createInputField({ type: "text", label: tc("Head_Origin") }); + origin.input.value = combo.본뜬키 ?? tc("Origin_Self"); + origin.input.readOnly = true; + origin.input.setAttribute("data-combo-origin", combo.본뜬키 ?? ""); view.replaceChildren( el("div", { className: "m01-logic__section-head", @@ -377,11 +409,7 @@ export function mountM01Combo( field(tc("Detail"), "상세구분"), field(tc("Head_Unit"), "단위"), field(tc("Head_Note"), "비고"), - el("p", { - className: "m01-logic__muted", - text: `${tc("Head_Origin")}: ${combo.본뜬키 ?? tc("Origin_Self")}`, - attrs: { "data-combo-origin": combo.본뜬키 ?? "" }, - }), + origin.root, ], }), ], diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts b/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts index 1cf268d1..a5e019bb 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts @@ -18,6 +18,7 @@ import { type SubBrief, } from "./M01_MasterData_UI_Logic_Api"; import { tc } from "./M01_MasterData_UI_Combo_Text"; +import "./M01_MasterData_UI_Combo.css"; const opts = (values: string[]): { value: string; text: string }[] => [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v })); @@ -76,7 +77,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void): void { }); const dialog = el("div", { - className: "m01-logic__pick", + className: "m01-logic__pick m01-combo", attrs: { role: "dialog", "aria-label": tc("Pick_Title") }, children: [ el("div", { @@ -103,6 +104,8 @@ export function openLogicPicker(done: (logic: LogicSummary) => void): void { backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); document.body.append(backdrop); + searchField.input.addEventListener("keydown", (ev) => ev.key === "Enter" && find()); + searchField.input.focus(); void Promise.all([fetchLogicSubs(), fetchLogics()]) .then(([s, l]) => { diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts index 27a4d46b..581db717 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts @@ -72,7 +72,10 @@ export function buildPreview(combo: () => Combo): PreviewHandle { const rowOf = (cells: string[], note = "", bold = false): HTMLElement => el("tr", { className: bold ? "m01lab__subtotal" : "", - children: cells.map((t, i) => el("td", { text: i === 0 && note ? `${t} — ${note}` : t })), + children: cells.map((t, i) => { + const text = i === 0 && note ? `${t} — ${note}` : t; + return el("td", { text, attrs: { title: text } }); + }), }); const run = async (): Promise => { diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts b/M01_MasterData/M01_MasterData_UI_Combo_Text.ts index 0e2dcefc..9a284d3b 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Text.ts @@ -23,6 +23,12 @@ const TEXT = { "저장 안 한 고침이 있음 — 로직 상세로 가면 이 화면에서는 그대로 두고 옮겨 감. 갈까요?", "Unsaved edits — go to the logic anyway?", ], + BackAsk: [ + "저장 안 한 고침이 있음 — 버리고 목록으로 갈까요?", + "Unsaved edits — discard and go back to the list?", + ], + Search: ["이름 · 키로 찾기", "Search by name or key"], + NoMatch: ["찾은 조합 없음 — 거름 · 찾기 글을 바꿔 봄", "No match — change the filter or search"], NeedName: ["이름을 적어야 저장됨", "A name is required"], Load_Failed: ["불러오지 못함", "Load failed"], Head_Key: ["키", "Key"], diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 211b16ff..00882927 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -217,7 +217,12 @@ function buildPage(): HTMLElement { return; } void import("./M01_MasterData_UI_Combo").then((m) => { - comboHandle = m.mountM01Combo(comboHost, side, (key) => openLogicTab(key)); + comboHandle = m.mountM01Combo( + comboHost, + side, + (key) => openLogicTab(key), + () => comboHost.hidden && openComboTab(), + ); void comboHandle.show(); }); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 30ded48a..04fe316d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -210,13 +210,19 @@ def _column_ids() -> list[str]: return [col["id"] for col in _structure_columns()] -def _pick_code(col: dict[str, Any], taken: dict[str, str], heads: dict[str, tuple]) -> str: +def _pick_code( + col: dict[str, Any], + taken: dict[str, str], + heads: dict[str, tuple], + retired: list[str] | None = None, +) -> str: """새 열 번호 — 층마다 같은 무리(그 층까지 제목행이 같은 열)가 이미 번호를 가졌으면 그 두 자리를 이어 받고 · 없으면 그 층 가장 큰 번호 + 1 · 빈 층(종류 밖)은 00 · 맨 아래 층(분류3)은 늘 다음 번호. 같은 8자리가 이미 있으면(같은 제목행 · 빈 분류3 겹침) 맨 아래 층을 그 무리 다음 번호로. - `taken` = 이미 번호 있는 열 `{열 id: 8자리}` · `heads` = 열 id → 제목행.""" + `taken` = 이미 번호 있는 열 `{열 id: 8자리}` · `heads` = 열 id → 제목행 · + `retired` = 지운 열의 번호(다시 안 줌 — 「가장 큰 번호」 · 겹침 셈에만 듦).""" parts = _heads(col) - codes = list(taken.values()) + codes = [*taken.values(), *(retired or [])] mates = [(heads[cid], code) for cid, code in taken.items() if cid in heads] def next_of(prefix: str) -> str: @@ -246,7 +252,13 @@ def _new_code(column: str) -> str: return taken[column] cols = _structure_columns() col = next((c for c in cols if c["id"] == column), {"id": column}) - return _pick_code(col, taken, {c["id"]: _heads(c) for c in cols}) + return _pick_code(col, taken, {c["id"]: _heads(c) for c in cols}, _retired()) + + +def _retired() -> list[str]: + """지운 열의 8자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌).""" + path = FOLDER / COMBO_FILE + return json.loads(path.read_bytes()).get("지운번호", []) if path.is_file() else [] def _blank(kind: str, column: str) -> dict[str, Any]: @@ -342,12 +354,20 @@ def prune_removed_structures() -> list[str]: 번호는 비워 둠(다른 열 번호를 안 당김).""" keep = set(_column_ids()) gone: list[str] = [] + codes: set[str] = set() with _LOCK: - for kind in _KEY: + for kind, key, _, _ in PARTS: for path in sorted((FOLDER / kind).glob("[!.]*.json")): if path.stem not in keep: + code = _code(json.loads(path.read_bytes()), key) + codes.update([code] if code else []) path.unlink() gone.append(f"{kind}/{path.stem}") + if codes: # 지운 번호는 장부에 남겨 새 열이 다시 안 받게 + book = FOLDER / COMBO_FILE + doc = json.loads(book.read_bytes()) if book.is_file() else {} + doc["지운번호"] = sorted(codes | set(doc.get("지운번호", []))) + atomic_write_json(book, doc) return gone @@ -387,9 +407,10 @@ def renumber() -> dict[str, list[str]]: cols = _structure_columns() heads = {c["id"]: _heads(c) for c in cols} taken = _codes() + retired = _retired() for col in cols: if col["id"] not in taken: - taken[col["id"]] = _pick_code(col, taken, heads) + taken[col["id"]] = _pick_code(col, taken, heads, retired) docs = {kind: dict(_docs(kind)) for kind in _KEY} for col in cols: cid = col["id"] @@ -467,7 +488,10 @@ def sync_combos() -> dict[str, Any]: keys[col["id"]] = f"{_COMBO_HEAD}{after:06d}" after += 1 rows.append(_combo_row(keys[col["id"]], col, numbers.get(col["id"], {}))) - doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after, "줄": rows} + doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after} + if old.get("지운번호"): + doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌 + doc["줄"] = rows if doc != old: atomic_write_json(path, doc) return doc diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index f50f0c41..dc22070e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -295,8 +295,10 @@ export function buildSide(opt: SideOptions): SideHandle { ok.disabled = false; } }); + name.input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.isComposing) ok.click(); + }); body.append(name.root, ok); - name.input.focus(); }, }); }; diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index 14d93e01..7989dcbd 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -187,6 +187,21 @@ def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(c assert numbers["pv_y"] == _num("02010101") # pv_b 무리 안 분류3 다음 번호 +def test_지운_열_번호는_다음_저장에도_다시_안_줌(client): + """무리 맨 끝 열(pv_jt 02010400)을 지워 저장한 뒤 새 열을 더해도 04 를 다시 안 받음.""" + store.renumber() + url = "/api/m02/templates/table/구조물집계표" + got = client.get(url).json() + got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "pv_jt"] + assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200 + assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["02010400"] + got = client.get(url).json() + new = {"id": "pv_z", "머리": ["콘크리트포장", "T=(두께마다)", "Z", None], "꼴": "수"} + got["문서"]["열"].append(new) + assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200 + assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("02010500") + + def test_다른_열_도면을_복사해_가져옴(client): store.renumber() src = client.get("/api/m02/templates/structure/rv").json() diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index b6c030b1..47c2868e 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -23,7 +23,7 @@ .ui-sheet__hint { margin: 0; - color: var(--color-text-muted); + color: var(--color-text-secondary); /* muted 는 작은 글에 대비 3:1 — 모자람 */ font-size: 0.8rem; } diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 0dace05e..086840de 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -23,6 +23,8 @@ import { deleteColumn, deleteRow, isBlankPick, + isBoundColumn, + isCalcColumn, KEY_PRICE, KEY_UNIT, navRows, @@ -168,7 +170,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const col = state.sel ? colOf(state.sel.c) : undefined; if (opts.mode === "master") syncLevels(!!col); colDelete.disabled = !col || state.doc.열.length <= 1; - colHand.disabled = !col; + // 바인딩 · 계산 열은 손 입력이 안 먹음(프로젝트에서 잠김) — 켜지 못하게 · 이미 켜진 것은 끌 수 있게 + colHand.disabled = !col || (!col.손 && (isBoundColumn(col) || isCalcColumn(col))); colHand.classList.toggle("is-on", !!col?.손); }; diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts index ee92cc82..da32f088 100644 --- a/ui_template/ui_template_modal.ts +++ b/ui_template/ui_template_modal.ts @@ -41,6 +41,7 @@ export function openModal(opts: ModalOptions): { close: () => void } { opts.mount(body, close); document.body.append(backdrop); dialog.tabIndex = -1; - dialog.focus(); + // 몸의 첫 입력 칸에 커서 — mount 안의 focus() 는 붙기 전이라 안 먹음 + (body.querySelector("input, textarea, select") ?? dialog).focus(); return { close }; }