feat(일위대가실무): 324-4 화면 — 좌측 「일위대가 실무자료」 판 · 본 화면(호표 표 · 줄 맞춤 · 확정 · 빠진 줄 · 값 비교) · [로직 추가] 「실무 호표」 줄
- 좌측 판(Hopyo_Side): 일위대가 사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 [엑셀 올리기] 꺼짐 · 숨은 file 입력 .xls,.xlsx · 파일 줄 → 본 화면
- 본 화면(Hopyo_View): 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · [지우기] / 호표 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산) · [고르기](후보 5 + 다른 로직 · 자재 찾기 → 미리 채움) · [확정] · [빼기] · [되살림] · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기](확인 뒤 덮음)
- [로직 추가](Hopyo_Pick · Labor_Pick 늦은 덧줄 `more` · 종류 고르기): 문서 층 · 위 층 호표 줄(맞춤 n/m) → 고르면 서버가 문서 층 사전조합을 만들고 참조 줄(별칭 = 호표 품명) · 빠진 줄 알림 · 프로젝트 층 없음
- Labor_Detail: 제자리 넷 줄(가져오기 · pick 별칭 받음 · withHopyo) · 693 줄 그대로
- 서버: GET /layers/{층}/hopyo-picks(이 층 · 위 층 · 이 층 기억)
- 색 = 공용 토큰(밝음 · 어두움) · 시험 test_m02_hopyo_screen 4 + hopyo-picks 1 · typecheck 0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_View.ts
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
|
||||
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기].
|
||||
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
buildHopyo,
|
||||
deleteHopyo,
|
||||
guessHopyo,
|
||||
readHopyo,
|
||||
readHopyoOne,
|
||||
saveHopyoMemory,
|
||||
type HopyoBuilt,
|
||||
type HopyoGuess,
|
||||
type HopyoLine,
|
||||
type HopyoMemory,
|
||||
type HopyoOne,
|
||||
type HopyoScope,
|
||||
type HopyoTarget,
|
||||
type HopyoView,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoViewOptions {
|
||||
scope: HopyoScope;
|
||||
/** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */
|
||||
canWrite: boolean;
|
||||
/** 파일을 지웠음 — 좌측 목록 다시 */
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const STATE: Record<string, string> = {
|
||||
기억: "기억",
|
||||
같음: "같음",
|
||||
후보: "후보",
|
||||
없음: "없음",
|
||||
빼기: "뺌",
|
||||
호표: "호표",
|
||||
"%": "% 줄",
|
||||
};
|
||||
const MATERIAL = new Set(["자재", "별도계상"]);
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
|
||||
|
||||
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(),
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
|
||||
grid(heads, el("tbody", { children: rows }));
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
|
||||
export const targetText = (t: HopyoTarget | null | undefined): string => {
|
||||
if (!t) return "";
|
||||
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
|
||||
const spec = t.규격 ? ` ${t.규격}` : "";
|
||||
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
|
||||
};
|
||||
|
||||
/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */
|
||||
export const inputsText = (values: Record<string, unknown>, unread: string[]): string =>
|
||||
[
|
||||
Object.entries(values)
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(", "),
|
||||
unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */
|
||||
export function memoryOf(
|
||||
target: HopyoTarget | null,
|
||||
values: Record<string, unknown>,
|
||||
times: number | null,
|
||||
): HopyoMemory {
|
||||
if (!target || times === null || !(times > 0)) return null;
|
||||
if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times };
|
||||
if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
|
||||
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
|
||||
let nav = 0; // 늦게 온 응답 버림
|
||||
let query = "";
|
||||
|
||||
async function load<T>(get: () => Promise<T>, retry: () => void): Promise<T | null> {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view);
|
||||
try {
|
||||
const got = await get();
|
||||
return my === nav ? got : null;
|
||||
} catch (error) {
|
||||
if (my === nav) view.replaceChildren(loadingError(why(error), retry));
|
||||
return null;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */
|
||||
async function drawFile(): Promise<void> {
|
||||
const file = await load(
|
||||
() => readHopyo(opt.scope, key),
|
||||
() => void drawFile(),
|
||||
);
|
||||
if (!file) return;
|
||||
const search = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__search",
|
||||
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query },
|
||||
}) as HTMLInputElement;
|
||||
const body = el("tbody");
|
||||
const paint = (): void => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
query = search.value;
|
||||
body.replaceChildren(
|
||||
...file.호표
|
||||
.filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q))
|
||||
.map((h) => {
|
||||
const tr = el("tr", {
|
||||
className: "m02-hopyo__row",
|
||||
attrs: { "data-hopyo-no": String(h.번) },
|
||||
children: [
|
||||
td(`제${h.번}호표`),
|
||||
td(h.이름),
|
||||
td(h.규격),
|
||||
td(h.단위),
|
||||
td(won(h.금액.계), "m02-hopyo__num"),
|
||||
td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => void drawOne(h.번));
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
};
|
||||
search.addEventListener("input", paint);
|
||||
paint();
|
||||
const wrong = file.검산.어긋남;
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: file.파일 }),
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
|
||||
...(opt.canWrite ? [dropButton(file)] : []),
|
||||
],
|
||||
}),
|
||||
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
|
||||
search,
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body),
|
||||
);
|
||||
}
|
||||
|
||||
const dropButton = (file: HopyoView): HTMLButtonElement =>
|
||||
createButton({
|
||||
label: "지우기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "지우기"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, key);
|
||||
showToast("지움", "success");
|
||||
opt.onDeleted();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */
|
||||
async function drawOne(no: number, built?: HopyoBuilt): Promise<void> {
|
||||
const got = await load(
|
||||
() => readHopyoOne(opt.scope, key, no),
|
||||
() => void drawOne(no),
|
||||
);
|
||||
if (!got) return;
|
||||
const h = got.호표;
|
||||
const picked = new Map<number, HopyoGuess>(); // 고른 대상(확정 전)
|
||||
const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" });
|
||||
back.addEventListener("click", () => void drawFile());
|
||||
const rows = el("tbody");
|
||||
const paintRows = (): void => {
|
||||
rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows)));
|
||||
};
|
||||
paintRows();
|
||||
const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null);
|
||||
const tools: HTMLElement[] = [];
|
||||
if (opt.canWrite) {
|
||||
const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" });
|
||||
all.disabled = !same.length;
|
||||
all.addEventListener("click", () => void confirmAll(no, same));
|
||||
const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" });
|
||||
make.addEventListener("click", () => void build(no, false));
|
||||
tools.push(all, make);
|
||||
}
|
||||
const missing = h.줄.filter((x) => !x.맞춤.들어감);
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
back,
|
||||
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
|
||||
...tools,
|
||||
],
|
||||
}),
|
||||
h.줄.length
|
||||
? grid(
|
||||
[
|
||||
"갈래",
|
||||
"품명",
|
||||
"규격",
|
||||
"수량",
|
||||
"단위",
|
||||
"엑셀 단가",
|
||||
"맞춤",
|
||||
"대상",
|
||||
"입력값",
|
||||
"환산",
|
||||
"",
|
||||
],
|
||||
rows,
|
||||
"m02-hopyo__lines",
|
||||
)
|
||||
: el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }),
|
||||
...(missing.length ? [missingBlock(missing)] : []),
|
||||
...(built ? [builtBlock(no, built)] : []),
|
||||
);
|
||||
}
|
||||
|
||||
function lineRow(
|
||||
got: HopyoOne,
|
||||
line: HopyoLine,
|
||||
picked: Map<number, HopyoGuess>,
|
||||
repaint: () => void,
|
||||
): HTMLElement {
|
||||
const m = line.맞춤;
|
||||
const mine = picked.get(line.차례);
|
||||
const target = mine?.대상 ?? m.대상;
|
||||
const values = mine?.입력값 ?? m.입력값;
|
||||
const conv = mine?.환산 ?? m.환산;
|
||||
const fixed = ["%", "호표", "빼기"].includes(m.상태);
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: {
|
||||
type: "number",
|
||||
step: "any",
|
||||
min: "0",
|
||||
value: conv?.배 != null ? String(conv.배) : "",
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
times.disabled = !opt.canWrite || fixed || !target;
|
||||
const state = mine ? "고름" : (STATE[m.상태] ?? m.상태);
|
||||
const actions: HTMLElement[] = [];
|
||||
if (opt.canWrite && m.상태 === "빼기") {
|
||||
actions.push(
|
||||
createButton({
|
||||
label: "되살림",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void remember(got.호표.번, { [m.열쇠]: null }),
|
||||
}),
|
||||
);
|
||||
} else if (opt.canWrite && !["%", "호표"].includes(m.상태)) {
|
||||
actions.push(
|
||||
createButton({
|
||||
label: "고르기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
choose(line, (t) =>
|
||||
guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then(
|
||||
(g) => (picked.set(line.차례, g), repaint()),
|
||||
fail,
|
||||
),
|
||||
),
|
||||
}),
|
||||
createButton({
|
||||
label: "확정",
|
||||
size: "sm",
|
||||
disabled: !target,
|
||||
onClick: () => {
|
||||
const body = memoryOf(target, values, times.value === "" ? null : Number(times.value));
|
||||
if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error");
|
||||
void remember(got.호표.번, { [m.열쇠]: body });
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: "빼기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("tr", {
|
||||
className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`,
|
||||
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
|
||||
children: [
|
||||
td(line.갈래),
|
||||
td(line.이름),
|
||||
td(line.규격),
|
||||
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
|
||||
td(line.단위),
|
||||
td(won(line.단가.계), "m02-hopyo__num"),
|
||||
el("td", {
|
||||
children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })],
|
||||
}),
|
||||
td(targetText(target)),
|
||||
td(inputsText(values, mine?.못읽음 ?? m.못읽음)),
|
||||
el("td", {
|
||||
children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")],
|
||||
}),
|
||||
el("td", { className: "m02-hopyo__acts", children: actions }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */
|
||||
function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void {
|
||||
const material = MATERIAL.has(line.갈래);
|
||||
openModal({
|
||||
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "m02-modal m02-hopyo__choose",
|
||||
mount: (body, close) => {
|
||||
const pick = (t: HopyoTarget): void => {
|
||||
close();
|
||||
done(t);
|
||||
};
|
||||
const list = (line.맞춤.후보 ?? []).map((c) =>
|
||||
createButton({
|
||||
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => pick(c),
|
||||
}),
|
||||
);
|
||||
const more = createButton({
|
||||
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
if (material) pickMaterial((x) => done({ 자재: x.코드 }));
|
||||
else openLogicPicker((x) => done({ 로직: x.키 }));
|
||||
},
|
||||
});
|
||||
body.append(
|
||||
el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "후보 없음" }),
|
||||
el("div", { className: "m02-hopyo__cands", children: list }),
|
||||
more,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function remember(no: number, 줄: Record<string, HopyoMemory>): Promise<void> {
|
||||
try {
|
||||
await saveHopyoMemory(opt.scope, 줄);
|
||||
await drawOne(no);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAll(no: number, lines: HopyoLine[]): Promise<void> {
|
||||
const body: Record<string, HopyoMemory> = {};
|
||||
for (const x of lines) {
|
||||
const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null);
|
||||
if (one) body[x.맞춤.열쇠] = one;
|
||||
}
|
||||
await remember(no, body);
|
||||
}
|
||||
|
||||
async function build(no: number, fresh: boolean): Promise<void> {
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
|
||||
const got = await buildHopyo(opt.scope, from, fresh);
|
||||
showToast(
|
||||
got.빠진줄.length ? `만듦 — 빠진 줄 ${got.빠진줄.length}` : "만듦",
|
||||
got.빠진줄.length ? "warning" : "success",
|
||||
);
|
||||
await drawOne(no, got);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
const missingBlock = (lines: HopyoLine[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-hopyo__block",
|
||||
children: [
|
||||
el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }),
|
||||
table(
|
||||
["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"],
|
||||
lines.map((x) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
td(x.이름),
|
||||
td(x.규격),
|
||||
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
|
||||
td(x.단위),
|
||||
td(won(x.금액.계), "m02-hopyo__num"),
|
||||
td(STATE[x.맞춤.상태] ?? x.맞춤.상태),
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
function builtBlock(no: number, got: HopyoBuilt): HTMLElement {
|
||||
const c = got.비교;
|
||||
const again = got.다시씀
|
||||
? [
|
||||
note(`있던 사전조합 ${got.참조.키} 를 다시 씀`),
|
||||
createButton({
|
||||
label: "새로 만들기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`;
|
||||
if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [note(`새 사전조합 ${got.참조.키}`)];
|
||||
return el("div", {
|
||||
className: "m02-hopyo__block m02-hopyo__built",
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
el("div", { className: "m02-hopyo__bar", children: again }),
|
||||
note(
|
||||
`값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
|
||||
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
void drawFile();
|
||||
return view;
|
||||
}
|
||||
Reference in New Issue
Block a user