- to-base 몸 환산? · 환산 없이 단위 성격 다름 = 후 None · 필요 ["환산"](막지 않음 · 화면 붉은 칸) - 화면 bind = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) + 블록 검산 입력 필요 · 줄 검산(빈 입력값)은 옵션을 못 읽었음 - 가져온 식 SK = M01 짝 없음 — 시트 M01바뀜 · 짝잃음 안 띄움(저장 M01판이 바탕 줄과 달라 늘 떴음) - 시험 test_793_3b 8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
361 lines
15 KiB
TypeScript
361 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Hopyo_Check.ts
|
|
* 불러오기 [수정] 모달 「확인 — 제N호표」(PLAN 786-2 · 요구 644 · 645 · 785 전 옛 확인 창 꼴 되살림) —
|
|
* 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶.
|
|
* · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 사전조합 ·
|
|
* 단추 [호표 제안 모두 확인](새) · [사전조합 생성] · [구조물 조합으로] · [엑셀 보기]
|
|
* · 몸 = 로직 표 + 자재 표(구조물 조합 일위대가 상세 꼴 · `Hopyo_CheckRows`) · 줄 칸 부품 = `Hopyo_Rows.createRowKit`
|
|
* · 우리 금액 = 서버 `/hopyo/{키}/price`(옮기기와 같은 길로 한 줄 문서 → `Labor_Doc.calc` · 저장 없음 ·
|
|
* 고름이 바뀐 줄만 · 묶어 한 번 · 줄마다 최신 응답만)
|
|
* · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음
|
|
* · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈
|
|
* · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`)
|
|
* · 단산 줄 [가져온 식으로 열기](793-3b) = `Hopyo_Imported` 모달(가져온 식 SK) · [이 줄에 잇기] = 그 줄 고른 것 = 그 SK
|
|
* · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로)
|
|
* · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음
|
|
* ========================================================================== */
|
|
|
|
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
|
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import { openModal } from "@ui/ui_template_modal";
|
|
import { createText } from "@ui/ui_template_text";
|
|
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
|
import { formatPrice } from "../common_util/common_util_money";
|
|
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
|
import { at, HOPYO_BUILT, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
|
|
import {
|
|
checkJudge,
|
|
readJudgeBlock,
|
|
readJudgeOne,
|
|
type JudgeOne,
|
|
type JudgeRow,
|
|
type Pick,
|
|
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
|
|
import {
|
|
checkTables,
|
|
newBadge,
|
|
NO_LOGIC,
|
|
type LinePrice,
|
|
type PriceLine,
|
|
} from "./M02_MasterTemplete_Hopyo_CheckRows";
|
|
import {
|
|
createRowKit,
|
|
runMove,
|
|
type JudgeState,
|
|
type SheetRole,
|
|
} from "./M02_MasterTemplete_Hopyo_Rows";
|
|
import { openImported } from "./M02_MasterTemplete_Hopyo_Imported";
|
|
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
|
|
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
|
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
|
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
|
import {
|
|
bare,
|
|
confirmSuggested,
|
|
lineOf,
|
|
moveBody,
|
|
samePick,
|
|
setInput,
|
|
type DraftPick,
|
|
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
|
import "./M02_MasterTemplete_Hopyo.css";
|
|
|
|
const enc = encodeURIComponent;
|
|
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
const note = (text: string): HTMLElement => createText(text, "muted");
|
|
const won = (v: number | null | undefined): string => (v === null || v === undefined ? "—" : formatPrice(v)); // prettier-ignore
|
|
|
|
/** 우리 금액 미리 셈 — 경로 층 = 만들 층 · 몸 층 = 파일 층(저장 없음) */
|
|
const priceJudge = (
|
|
target: HopyoScope,
|
|
file: HopyoScope,
|
|
key: string,
|
|
no: number,
|
|
줄: Record<string, Pick[]>,
|
|
): Promise<{ 줄: Record<string, PriceLine> }> =>
|
|
call(at(target, `/hopyo/${enc(key)}/price`), {
|
|
method: "POST",
|
|
body: JSON.stringify({ 층: file.layer, ...(file.owner ? { 주인: file.owner } : {}), 호표: no, 줄 }), // prettier-ignore
|
|
});
|
|
|
|
export interface CheckOptions {
|
|
/** 실무자료 파일 층 */
|
|
file: HopyoScope;
|
|
key: string;
|
|
/** 사전조합 · 구조물 조합을 만들 층 */
|
|
target: HopyoScope;
|
|
state: JudgeState;
|
|
no: number;
|
|
/** 목록 한 줄(엑셀 단가 · 제안 n / 줄 m) */
|
|
brief?: HopyoBrief;
|
|
canWrite: boolean;
|
|
/** 모달 닫은 뒤 엑셀 보기 그 행 · 그 호표 */
|
|
onSheet: (행: number, role: SheetRole) => void;
|
|
onHopyo: () => void;
|
|
/** 옮긴 뒤(목록 · 칠 다시 읽음) */
|
|
onMoved: () => void;
|
|
}
|
|
|
|
/** [수정] 모달 */
|
|
export function openHopyoCheck(opt: CheckOptions): { close: () => void } {
|
|
const readOnly = !opt.canWrite;
|
|
const no = opt.no;
|
|
const root = el("div", { className: "m02-hopyo m02-hopyo-check", attrs: { "data-hopyo-check": `${opt.key}|${no}` } }); // prettier-ignore
|
|
let one: JudgeOne | null = null;
|
|
let shut: () => void = () => {};
|
|
let off: () => void = () => {};
|
|
let busy = false;
|
|
const leave = (): void => {
|
|
off();
|
|
shut();
|
|
};
|
|
|
|
// ── 우리 금액(줄마다 최신 응답만 · 바뀐 줄만 묶어 한 번) ──
|
|
const priced = new Map<number, { sig: string; seq: number; val: LinePrice }>();
|
|
let queued: Record<string, Pick[]> = {};
|
|
let seq = 0;
|
|
let timer = 0;
|
|
let pending = false;
|
|
function soon(): void {
|
|
if (pending) return;
|
|
pending = true;
|
|
window.setTimeout(() => {
|
|
pending = false;
|
|
if (root.isConnected) draw();
|
|
}, 0);
|
|
}
|
|
function flush(): void {
|
|
const ask = queued;
|
|
queued = {};
|
|
const my = ++seq;
|
|
for (const k of Object.keys(ask)) {
|
|
const slot = priced.get(Number(k));
|
|
if (slot) slot.seq = my;
|
|
}
|
|
priceJudge(opt.target, opt.file, opt.key, no, ask).then(
|
|
(got) => {
|
|
for (const k of Object.keys(ask)) {
|
|
const slot = priced.get(Number(k));
|
|
const line = got.줄[k];
|
|
if (slot?.seq === my) slot.val = line ? { got: line } : { fail: L("M02_CheckNoCalc") };
|
|
}
|
|
soon();
|
|
},
|
|
(e: unknown) => {
|
|
for (const k of Object.keys(ask)) {
|
|
const slot = priced.get(Number(k));
|
|
if (slot?.seq === my) slot.val = { fail: why(e) };
|
|
}
|
|
soon();
|
|
},
|
|
);
|
|
}
|
|
function price(row: JudgeRow): LinePrice | null {
|
|
const picks = kit.picksOf(row);
|
|
if (!picks.length) return null;
|
|
const 배 = lineOf(opt.state.get(), no, row.차례)?.배;
|
|
const body = picks.map((p) => bare(p, 배));
|
|
const sig = JSON.stringify(body);
|
|
const have = priced.get(row.차례);
|
|
if (have?.sig === sig) return have.val;
|
|
priced.set(row.차례, { sig, seq: -1, val: { wait: true } });
|
|
queued[String(row.차례)] = body;
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(flush, 250);
|
|
return { wait: true };
|
|
}
|
|
|
|
const kit = createRowKit({
|
|
file: opt.file,
|
|
key: opt.key,
|
|
state: opt.state,
|
|
no,
|
|
readOnly,
|
|
redraw: () => draw(),
|
|
onSheet: (r, role) => {
|
|
leave();
|
|
opt.onSheet(r, role);
|
|
},
|
|
});
|
|
|
|
/** [원문 보기] — 엑셀 인용 조항(단산 줄 = 블록 근거) · 우리 로직 출처 */
|
|
async function showSource(row: JudgeRow): Promise<void> {
|
|
const blk = row.번 !== undefined ? await readJudgeBlock(opt.file, opt.key, row.번, opt.target).catch(() => null) : null; // prettier-ignore
|
|
const excel = (blk?.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean);
|
|
const codes = kit.picksOf(row).flatMap((p) => (p.갈래 === "기초" && p.키 ? [p.키] : []));
|
|
const ours = (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) =>
|
|
x.출처 ? [`${x.로직} — ${x.출처}`] : [],
|
|
);
|
|
openModal({
|
|
title: `${L("M02_CheckSource")} — ${row.이름 ?? ""}`,
|
|
closeLabel: L("Common_Btn_Close"),
|
|
wide: true,
|
|
dialogClass: "m02-modal m02-hopyo__src",
|
|
mount: (body) => {
|
|
if (row.근거) body.append(note(row.근거));
|
|
const part = (title: string, list: string[], cut: (s: string) => string): void => {
|
|
body.append(el("h4", { text: title }));
|
|
if (!list.length) body.append(note(L("M02_CheckNone")));
|
|
for (const s of list) {
|
|
const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf" }); // prettier-ignore
|
|
view.show(cut(s));
|
|
body.append(note(s), view.root);
|
|
}
|
|
};
|
|
part(L("M02_CheckSourceExcel"), excel, (s) => s);
|
|
part(L("M02_CheckSourceOurs"), ours, (s) => s.split(" — ").slice(1).join(" — "));
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 기초 하나 바꿔 넣음(같은 것 자리) */
|
|
const swap = (row: JudgeRow, from: DraftPick, to: DraftPick): void =>
|
|
kit.edit(
|
|
row,
|
|
kit.picksOf(row).map((x) => (samePick(x, from) ? to : x)),
|
|
);
|
|
|
|
/** [옵션] — 그 기초 입력값 모달(구조물 조합 상세와 같은 창) · 바뀐 칸 = 출처 「사용자」 */
|
|
function options(row: JudgeRow, p: DraftPick): void {
|
|
fetchLogicInputs([String(p.키)]).then(
|
|
(res) =>
|
|
openLaborInput({
|
|
title: `${p.키} ${p.이름}`.trim(),
|
|
logic: res.로직[0] ?? null,
|
|
values: p.입력값 as never,
|
|
memo: null,
|
|
readOnly,
|
|
done: (r) => {
|
|
let next = p;
|
|
const got = r.입력값 as Record<string, unknown>;
|
|
for (const k of new Set([...Object.keys(got), ...Object.keys(p.입력값)]))
|
|
if (got[k] !== p.입력값[k]) next = setInput(next, k, got[k] ?? "");
|
|
swap(row, p, next);
|
|
},
|
|
}),
|
|
(e: unknown) => showToast(why(e), "error"),
|
|
);
|
|
}
|
|
|
|
/** [고르기] — 로직 찾기 창(구조물 조합 [로직 추가]와 같은 창) → 검산으로 입력값 · 입력 필요 받아 더함 */
|
|
function pick(row: JudgeRow): void {
|
|
openLogicPicker((logic) => {
|
|
const raw: DraftPick = { 갈래: "기초", 키: logic.키, 이름: logic.이름, 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore
|
|
checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: [bare(raw)] }).then(
|
|
(res) => {
|
|
const c = res.고른[0];
|
|
const p: DraftPick = { ...raw, 입력값: c?.입력값 ?? {}, 필요: c?.입력필요 ?? [] };
|
|
kit.edit(row, [...kit.picksOf(row).filter((x) => !samePick(x, p)), p]);
|
|
},
|
|
(e: unknown) => showToast(why(e), "error"),
|
|
);
|
|
});
|
|
}
|
|
|
|
/** [가져온 식으로 열기](793-3b) — 단산 블록 → 가져온 식 SK 모달 · [이 줄에 잇기] = 이 줄 고른 것 */
|
|
const imported = (row: JudgeRow): void =>
|
|
openImported({
|
|
...{ file: opt.file, key: opt.key, no: row.번!, target: opt.target, canWrite: opt.canWrite },
|
|
onLink: (p) => kit.edit(row, [p]),
|
|
});
|
|
|
|
/** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */
|
|
function out(row: JudgeRow, value: string): void {
|
|
if (value === NO_LOGIC) {
|
|
kit.edit(row, [{ 갈래: "고정", 키: null, 이름: row.이름 ?? "", 입력값: {}, 출처: {}, 몫: null }]); // prettier-ignore
|
|
return;
|
|
}
|
|
const d = opt.state.get();
|
|
const lines = { ...(d.호표[String(no)] ?? {}) };
|
|
delete lines[String(row.차례)];
|
|
opt.state.set({ ...d, 호표: { ...d.호표, [String(no)]: lines } });
|
|
}
|
|
|
|
/** [사전조합 생성] — 이 호표의 확인한 줄만 옮김 */
|
|
async function make(): Promise<void> {
|
|
const all = moveBody(opt.state.get(), opt.file);
|
|
const mine = all.body.호표[String(no)] ?? {};
|
|
const n = Object.keys(mine).length;
|
|
if (!n) return void showToast(L("M02_JudgeMoveNone"), "info");
|
|
const ask = L("M02_CheckMakeAsk").replace("{a}", String(n)).replace("{b}", String((one?.줄.length ?? 0) - n)); // prettier-ignore
|
|
if (!(await showConfirmDialog(ask, L("M02_CheckMake")))) return;
|
|
busy = true;
|
|
draw();
|
|
const stuck = all.막힘.filter((x) => x.호표 === no);
|
|
const r = await runMove(opt, { ...all.body, 호표: { [String(no)]: mine }, 블록: {} }, stuck);
|
|
busy = false;
|
|
if (r) {
|
|
document.dispatchEvent(new Event(HOPYO_BUILT));
|
|
opt.onMoved();
|
|
await load(true);
|
|
} else draw();
|
|
}
|
|
|
|
function draw(): void {
|
|
if (!one) return;
|
|
const h = one;
|
|
const done = h.줄.filter((r) => lineOf(opt.state.get(), no, r.차례)?.확인).length;
|
|
const [a, m] = opt.brief?.맞춤 ?? [h.줄.filter((r) => r.판정 === "제안 있음").length, h.줄.length]; // prettier-ignore
|
|
const all = createButton({ label: L("M02_JudgeConfirmAll"), size: "sm", variant: "ghost", disabled: readOnly, onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, h.줄)) }); // prettier-ignore
|
|
all.dataset.judgeAll = String(no);
|
|
const go = createButton({ label: L("M02_CheckMake"), size: "sm", variant: "filled", disabled: readOnly || busy || !done, onClick: () => void make() }); // prettier-ignore
|
|
go.dataset.hopyoMake = "";
|
|
const item = createButton({ label: L("M02_CheckToItem"), size: "sm", disabled: readOnly || !h.만든, onClick: () => void toItem({ target: opt.target, sc: h.만든!, name: [h.이름, h.규격].filter(Boolean).join(" "), unit: h.단위 }) }); // prettier-ignore
|
|
item.dataset.hopyoToItem = "";
|
|
item.title = h.만든 ? "" : L("M02_CheckToItemHint");
|
|
const sheet = createButton({ label: L("M02_CheckSheet"), size: "sm", onClick: () => { leave(); opt.onHopyo(); } }); // prettier-ignore
|
|
sheet.dataset.hopyoSheet = "";
|
|
const head = el("div", {
|
|
className: "ui-toolbar m02-hopyo__bar m02-judge__wrap",
|
|
children: [
|
|
el("strong", { text: `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${h.이름}` }),
|
|
note(h.규격),
|
|
note(h.단위),
|
|
note(`${L("M02_CheckExcelPrice")} ${won(opt.brief?.금액.계)}`),
|
|
note(L("M02_CheckMoneyHint")),
|
|
note(L("M02_CheckCount").replace("{a}", String(a)).replace("{m}", String(m)).replace("{c}", String(done))), // prettier-ignore
|
|
...(h.만든 ? [note(`${L("M02_JudgeMade")} ${L("M02_PrecomboTitle")} ${h.만든}`)] : []),
|
|
newBadge(),
|
|
all,
|
|
go,
|
|
item,
|
|
sheet,
|
|
],
|
|
});
|
|
const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore
|
|
const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out, imported }; // prettier-ignore
|
|
root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore
|
|
}
|
|
|
|
async function load(quiet = false): Promise<void> {
|
|
const stop = quiet ? () => {} : showLoading(root);
|
|
try {
|
|
one = await readJudgeOne(opt.file, opt.key, no, opt.target);
|
|
draw();
|
|
} catch (e) {
|
|
if (!quiet) root.replaceChildren(loadingError(why(e), () => void load()));
|
|
} finally {
|
|
stop();
|
|
}
|
|
}
|
|
|
|
openModal({
|
|
title: L("M02_CheckTitle").replace("{n}", String(no)),
|
|
closeLabel: L("Common_Btn_Close"),
|
|
wide: true,
|
|
dialogClass: "m02-modal m02-hopyo__check-modal",
|
|
beforeClose: () => {
|
|
off();
|
|
return true;
|
|
},
|
|
mount: (body, close) => {
|
|
shut = close;
|
|
body.append(root);
|
|
},
|
|
});
|
|
off = opt.state.on(() => draw());
|
|
void load();
|
|
return { close: leave };
|
|
}
|