feat(B08): 거리이정표 등록 모달 항목 = 손 입력 + 분류별 제안 목록(244-14)

- 항목 칸 = 손 입력 + datalist 제안(거래처 = 견본 다섯 · 기준위치 = 기준점) · 목록 밖 글도 받음
- 분류 바꿈 = 제안만 바뀜 · 쓴 글 그대로 · 줄마다 제안 목록 따로
- 시험 test_244_14 (실제 rowField 를 node 로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 23:43:55 +09:00
co-authored by Claude Opus 5.5
parent 078e8356d0
commit 6ff34a92f2
2 changed files with 96 additions and 12 deletions
+13 -12
View File
@@ -3,7 +3,7 @@
* 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침.
*
* 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 ·
* 2열(분류(기준위치 · 거래처) · 항목(분류 따라 고르기 목록) · 거래처 이름 · 목적지) + 소재지(주소 찾기 ·
* 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 ·
* 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제]
* 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정
* 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리
@@ -56,23 +56,24 @@ export function toastKm(text: string): void {
if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success");
}
/** 항목 ▾ · 목적지 ▾ 한 줄 — 목록 밖 옛 항목 글도 보임 */
let listSeq = 0;
/** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */
function rowField(r: MilestoneRow, kind: "route" | "base") {
const item = createSelectField({ label: "항목", compact: true, options: [] });
const item = createInputField({ label: "항목", compact: true });
const list = el("datalist", { attrs: { id: `b08-milestone-items-${++listSeq}` } });
item.input.setAttribute("list", list.id);
item.input.value = r.item;
item.root.append(list);
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
const setKind = (k: "route" | "base"): void => {
const list = [...ITEMS[k]];
if (r.item && !list.includes(r.item)) list.unshift(r.item);
const keep = list.includes(item.select.value) ? item.select.value : r.item || list[0];
item.setOptions(
list.map((t) => ({ value: t, text: t })),
keep,
);
list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } })));
item.input.placeholder = `예 ${ITEMS[k][0]}`;
dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정
};
setKind(kind);
dest.select.value = r.dest;
item.select.dataset.b08MilestoneAdd = "item";
item.input.dataset.b08MilestoneAdd = "item";
return { item, dest, note: r.note, setKind };
}
@@ -169,7 +170,7 @@ export function openPlaceModal(
setKind(kind);
const rowValues = (): MilestoneRow[] =>
rows.map((r) => ({
item: r.item.select.value,
item: r.item.input.value.trim(),
dest: r.dest.select.value as Dest,
note: r.note,
}));
@@ -0,0 +1,83 @@
"""244-14 B08 거리이정표 [+] 모달 「항목」 = 손 입력 + 제안 목록(사용자 120-답 ③ 「항목: 수기 입력」).
거래처 제안 = 견본 다섯 · 기준위치 제안 = 기준점 · 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜(쓴 글 그대로)
— node 로 실제 rowField 를 돌림(가짜 DOM).
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MODAL = ROOT / "B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts"
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
STUBS = """
type N = any;
const el = (tag: string, o: any = {}): N => {
const n: N = { tag, children: [] as N[], attrs: { ...(o.attrs ?? {}) }, dataset: {}, style: {},
value: "", placeholder: "",
get id() { return n.attrs.id; },
setAttribute(k: string, v: string) { n.attrs[k] = v; },
append(...c: N[]) { n.children.push(...c); },
replaceChildren(...c: N[]) { n.children = c; } };
return n;
};
const createInputField = (o: any) => {
const input = el("input");
return { root: el("div", { children: [] }), input, label: o.label, setError: () => undefined };
};
const createSelectField = (o: any) => {
const select = el("select");
return { root: el("div"), select, setOptions: () => undefined };
};
"""
SCRIPT = """
const sug = (f: any) => f.item.root.children.find((c: N) => c.tag === "datalist")
.children.map((c: N) => c.attrs.value);
const a = rowField({ item: "", dest: "start", note: "" }, "route");
const empty = { value: a.item.input.value, list: sug(a), ph: a.item.input.placeholder,
linked: a.item.input.attrs.list === a.item.root.children[0].id };
a.item.input.value = " 레미콘(25-21-150) "; // 목록 밖 손 글
a.setKind("base");
const base = { value: a.item.input.value, list: sug(a), ph: a.item.input.placeholder };
a.setKind("route");
const old = rowField({ item: "옛 항목", dest: "mid", note: "" }, "route");
const b = rowField({ item: "", dest: "start", note: "" }, "route");
console.log(JSON.stringify({ empty, base, back: a.item.input.value, list: sug(a),
old: [old.item.input.value, old.dest.select.value, sug(old)],
ids: new Set([a, old, b].map((f) => f.item.input.attrs.list)).size,
tag: a.item.input.dataset.b08MilestoneAdd }));
"""
def test_항목_손_입력_제안_분류_바꿔도_글_유지():
src = STUBS + IMPORTS.sub("", MODAL.read_text(encoding="utf-8"))
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=src + "\n" + SCRIPT, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
got = json.loads(result.stdout)
five = ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"]
# 빈 줄 = 빈 손 입력 칸 + 거래처 제안 다섯(datalist 이어짐)
assert got["empty"] == {"value": "", "list": five, "ph": "예 장비(20톤)", "linked": True}
# 분류 → 기준위치 = 제안만 기준점 · 쓴 글 그대로
assert got["base"] == {
"value": " 레미콘(25-21-150) ",
"list": ["기준점"],
"ph": "예 기준점",
}
assert got["back"] == " 레미콘(25-21-150) " and got["list"] == five
# 옛 항목 글 = 그대로 칸에 · 목적지 그대로
assert got["old"] == ["옛 항목", "mid", five]
assert got["ids"] == 3 and got["tag"] == "item" # 줄마다 제안 목록 따로
def test_저장_값_손_글_그대로():
modal = MODAL.read_text(encoding="utf-8")
assert "item: r.item.input.value.trim()," in modal # 목록 밖 글도 받음(앞뒤 빈칸만 뺌)
assert 'createSelectField({ label: "항목"' not in modal
assert len(modal.splitlines()) <= 700