feat(b08): 라이브러리 칸 [STmate 엑셀 읽기] → 호표 고르기 → [내 라이브러리에 넣기] — 읽은 호표 수·구성 줄 수를 보이고 넣기 전에 다시 물음(덮어씀·원문 시점 수량·단가 안 가져옴) · 넣기는 파일을 다시 보냄

배정 프로젝트 화면: 돌쌓기(찰) 장에서 봉화 엑셀 읽기 → 「호표 45개 · 구성 줄 209줄」 → 제6호표 기슭막이(깬잡석,찰쌓기) H=2.0m 구성 9줄 넣기 → 목록 「개인 · [고정형] …」 · [내 것 지우기]로 되돌림(목록 기본 한 줄)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 16:01:50 +09:00
co-authored by Claude Opus 5
parent 38a5e785f2
commit 91317295f5
3 changed files with 181 additions and 0 deletions
@@ -8,6 +8,7 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
@@ -191,5 +192,15 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
mine.append(save, remove);
panel.append(title, scope, load, list, take, mine, status);
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
panel.append(
buildStmatePanel({
projectId,
typeId,
onSaved: () => {
if (!list.hidden) load.click();
},
}),
);
return panel;
}
@@ -0,0 +1,144 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_Stmate.ts
* 라이브러리 칸 — [STmate 엑셀에서 뽑기] (PLAN 4장 · 2026-09-14 브레인 판정).
*
* 두 걸음: [읽기] → 호표 수·구성 줄 수·못 읽은 사유를 보임(아무것도 안 씀) → 호표를 고르고
* [내 라이브러리에 넣기] → 수를 다시 묻고 **파일을 다시 보냄**(서버가 다시 읽어 씀 — 화면은 줄을 안 보냄).
* ⚠ 종류는 지금 보고 있는 장의 종류 — 이름으로 자동으로 안 붙임(판정 Ⓒ).
* ⚠ 모양은 옆 제원 칸(`b08-spec*`) 클래스를 그대로 씀.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
interface ReadHopyo {
no: number;
name: string;
spec: string;
unit: string;
rows: number;
percent_rows: number;
contract_rows: number;
}
interface ReadResult {
project: string;
hopyo: ReadHopyo[];
counts: { hopyo: number; rows: number };
problems: string[];
}
async function postForm<T>(url: string, form: FormData): Promise<T> {
const response = await fetch(url, { method: "POST", credentials: "include", body: form });
const payload = (await response.json().catch(() => ({}))) as T & { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function buildStmatePanel(options: {
projectId: string;
typeId: string;
/** 넣은 뒤 — 목록을 다시 받게 함. */
onSaved: () => void;
}): HTMLElement {
const { projectId, typeId, onSaved } = options;
const base = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library`;
const box = document.createElement("div");
box.className = "b08-sheet__actions";
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx";
input.className = "b08-spec__input";
input.title = "STmate 가 내보낸 내역 엑셀(일위대가표 시트)";
const read = document.createElement("button");
read.type = "button";
read.className = "b08-quantity__tab";
read.textContent = "STmate 엑셀 읽기";
const list = document.createElement("select");
list.className = "b08-spec__input";
list.hidden = true;
const put = document.createElement("button");
put.type = "button";
put.className = "b08-spec__save";
put.textContent = "내 라이브러리에 넣기";
put.hidden = true;
const status = document.createElement("p");
status.className = "b08-spec__scope";
let result: ReadResult | null = null;
input.addEventListener("change", () => {
result = null;
list.hidden = put.hidden = true;
status.textContent = "";
});
read.addEventListener("click", () => {
const file = input.files?.[0];
if (!file) {
status.textContent = "엑셀 파일을 먼저 고르세요";
return;
}
void (async () => {
read.disabled = true;
status.textContent = "읽는 중…";
try {
const form = new FormData();
form.append("file", file);
result = await postForm<ReadResult>(`${base}/stmate/read`, form);
list.replaceChildren(
...result.hopyo.map((h) => {
const option = document.createElement("option");
option.value = String(h.no);
const extra = h.percent_rows ? ` · 가산 행 ${h.percent_rows}` : "";
option.textContent = `${h.no}호표 ${h.name} ${h.spec} / ${h.unit} · 구성 ${h.rows}${extra}`;
return option;
}),
);
list.hidden = put.hidden = result.hopyo.length === 0;
const missed = result.problems.length
? ` · 못 읽은 것 ${result.problems.length}건 — ${result.problems.slice(0, 3).join(" / ")}`
: "";
status.textContent = `${result.project}」 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄 읽음${missed}`;
} catch (error) {
status.textContent = error instanceof Error ? error.message : "읽지 못함";
} finally {
read.disabled = false;
}
})();
});
put.addEventListener("click", () => {
const file = input.files?.[0];
const picked = result?.hopyo.find((h) => String(h.no) === list.value);
if (!file || !result || !picked) return;
const question =
`읽은 것: 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}\n` +
`넣을 것: 「${picked.name} ${picked.spec}」 구성 ${picked.rows}줄 → 이 장 종류로 내 라이브러리\n` +
"· 같은 종류 내 것이 있으면 덮어씀\n· 수량은 원문 시점값(현행 품셈 대조 전) · 단가는 안 가져옴";
if (!window.confirm(question)) return;
void (async () => {
put.disabled = true;
status.textContent = "넣는 중…";
try {
const form = new FormData();
form.append("file", file);
form.append("hopyo_no", String(picked.no));
form.append("type_id", typeId);
const saved = await postForm<{ code: string; name: string; note: string }>(
`${base}/stmate/save`,
form,
);
status.textContent = `내 라이브러리에 넣음 — 「${saved.name}」 [고정형] · ${saved.note}`;
onSaved();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "넣지 못함";
} finally {
put.disabled = false;
}
})();
});
const wrap = document.createElement("div");
box.append(read, put);
wrap.append(input, box, list, status);
return wrap;
}
@@ -0,0 +1,26 @@
"""라이브러리 칸의 [STmate 엑셀에서 뽑기] — 읽은 수를 보이고 묻고 넣는 두 걸음(2026-09-14 브레인 판정).
⚠ 읽기 전에 넣기가 열리면 안 됨 · 넣기 전에 호표 수·구성 줄 수·덮어씀을 묻는 확인이 있어야 함 ·
넣기는 **파일을 다시 보냄**(서버가 다시 읽음 — 화면이 읽은 줄을 보내지 않음).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
UI = ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Stmate.ts"
PANEL = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
def test_라이브러리_칸에_뽑기_칸이_붙는다() -> None:
assert "buildStmatePanel(" in PANEL
def test_읽고_수를_보이고_묻고_파일을_다시_보내_넣는다() -> None:
ui = UI.read_text(encoding="utf-8")
assert "/stmate/read" in ui and "/stmate/save" in ui
assert 'form.append("file"' in ui and 'form.append("hopyo_no"' in ui
assert "window.confirm(" in ui and "counts.hopyo" in ui and "counts.rows" in ui
assert "put.hidden = true" in ui # 읽기 전에는 넣기 단추가 숨음
assert "rows:" not in ui.split("/stmate/save")[1][:400] # 줄을 보내지 않음