Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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<string, LogicSummary>();
|
||||
@@ -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<void> = 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,
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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<void> => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.손);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<HTMLElement>("input, textarea, select") ?? dialog).focus();
|
||||
return { close };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user