Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts

307 lines
13 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_Rows.ts
* 엑셀 불러오기 줄 칸 부품 · 옮기기(PLAN 785-6 · 786-2 · 요구 641 · 642 · 644 · 설계 642-A 2-3 · 2-4 · 2-6).
* · `createRowKit` = 호표 하나의 줄 칸 부품 — [수정] 모달 「확인 — 제N호표」(`Hopyo_Check` · `Hopyo_CheckRows`)가
* 옛 확인 창 표 칸 안에 담음: 1차 판정 칩 · 후보 복수 고름 칩(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 ·
* 기계 · 율 · 고정) · 단산 줄 [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸 · 출처 꼬리표) +
* 「단가산출 통째」 칩(단산N → SK) · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [확인]
* · `createJudgeMove` = 오른쪽 판 아래 [옮기기 (n)] · `runMove` = 옮기기 한 번(모달 [사전조합 생성]도 이 길 · 이 호표만)
* · 이미 옮긴 것(409) = 물음 한 번 뒤 덮음
* · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈
* (785-6 시트 아래 줄 판 몸은 786-2 에서 모달로 옮김)
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import {
askConfirm,
createButton,
createInputField,
el,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { formatNumber } from "../common_util/common_util_money";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block";
import {
checkJudge,
moveJudge,
type JudgeRow,
type MoveBody,
type MoveBlocked,
type MoveResult,
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
import {
apartPick,
bare,
confirmable,
editLine,
lineOf,
linePicks,
missing,
moveBody,
putLine,
samePick,
togglePick,
type DraftPick,
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
import { unitView } from "./logic/M02_MasterTemplete_Hopyo_Unit";
export { createJudgeState, type JudgeState };
const VERDICT: Record<string, BadgeTone> = {
"제안 있음": "success",
"2차 필요": "info",
"후보 여럿": "warning",
"후보 없음": "danger",
};
export const show = (v: unknown): string =>
v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v);
export const bar = (kids: (HTMLElement | string)[]): HTMLElement =>
el("div", { className: "ui-toolbar m02-judge__wrap", children: kids });
export type SheetRole = "일위대가표" | "단가산출상세";
export interface RowKitOptions {
/** 파일 층 */
file: HopyoScope;
key: string;
state: JudgeState;
no: number;
/** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */
onSheet?: (행: number, role: SheetRole) => void;
readOnly?: boolean;
/** [블록 풀이] 펼침 · 접음 — 부르는 쪽이 다시 그림 */
redraw: () => void;
}
/** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */
const wholeSk = (row: JudgeRow): DraftPick => ({
갈래: "단가산출",
키: `단산${row.번}`,
이름: row.이름 ?? `단산${row.번}`,
규격: row.규격 ?? "",
단위: row.단위 ?? "",
입력값: {},
출처: {},
몫: null,
});
/** 별도계상 줄(786-1 · 서버 제안 = 고정 줄 하나 `apartPick`) — 고름 칩 없이 글만 */
export const apartRow = (row: JudgeRow): boolean => row.후보.length === 1 && apartPick(row.후보[0]);
/** 호표 하나 줄 칸 부품 — 판정 칩 · 고름 칩 · 수량(단위 환산) · [확인] · 블록 풀이 몸 */
export function createRowKit(opt: RowKitOptions) {
const no = opt.no;
const open = new Set<number>();
const bodies = new Map<number, ReturnType<typeof solvedBody>>();
const picksOf = (row: JudgeRow): DraftPick[] => linePicks(opt.state.get(), no, row);
const edit = (row: JudgeRow, list: DraftPick[]): void =>
opt.state.set(editLine(opt.state.get(), no, row, list));
function verdict(row: JudgeRow): HTMLElement {
const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral");
v.dataset.judgeVerdict = row.판정;
return v;
}
function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement {
const kids: HTMLElement[] = [];
if (apartRow(row)) return bar([createText(L("M02_CheckApart"), "muted")]);
if (row.번 !== undefined && row.풀이) {
const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); opt.redraw(); } }); // prettier-ignore
solve.root.dataset.judgeSolve = String(row.번);
const sk = wholeSk(row);
const whole = createToggle({ kind: "chip", label: `${L("M02_JudgeWholeSk")} ${sk.키}`, checked: picks.some((p) => samePick(p, sk)), disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, sk)) }); // prettier-ignore
whole.root.dataset.judgeWhole = String(sk.키);
kids.push(solve.root, whole.root);
for (const p of picks.filter((x) => x.갈래 === "기초")) {
const b = createBadge(`${p.키}`, missing(p).length ? "danger" : "accent");
b.dataset.judgePicked = String(p.키);
if (missing(p).length) b.title = `${L("M02_JudgeNeed")} ${missing(p).join(" · ")}`;
kids.push(b);
}
} else {
const seen = [...row.후보];
for (const p of picks) if (!seen.some((x) => samePick(x, p))) seen.push(p);
for (const c of seen) {
const on = picks.some((p) => samePick(p, c));
const label = `${c.갈래} ${c.이름 || c.키 || ""}${c.규격 ? ` ${c.규격}` : ""}`;
const chip = createToggle({ kind: "chip", label, checked: on, disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, { ...c })) }); // prettier-ignore
chip.root.dataset.judgeCand = `${c.갈래}:${c.키 ?? c.이름}`;
chip.root.title = c.키 ?? "";
kids.push(chip.root);
const need = picks.find((p) => samePick(p, c));
if (need && missing(need).length) {
const b = createBadge(`${L("M02_JudgeNeed")} ${missing(need).length}`, "danger");
b.dataset.judgeNeed = missing(need).join(",");
kids.push(b);
}
}
if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning"));
}
return bar(kids);
}
/** 단위 환산 칸 — 같은 단위면 null */
function unitInput(row: JudgeRow, picks: DraftPick[]): HTMLElement | null {
const line = lineOf(opt.state.get(), no, row.차례);
const 배 = line?.배 ?? row.수량.배;
const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore
if (view.mode !== "input" || opt.readOnly) return null;
const f = createInputField({ compact: true, size: 24, value: show(배), unit: view.to, placeholder: L("M02_JudgeUnitHint").replace("{from}", view.from) }); // prettier-ignore
f.input.dataset.judgeTimes = String(row.차례);
f.input.addEventListener("change", () => {
const n = Number(f.input.value.replace(/,/g, ""));
const next = f.input.value.trim() === "" || !Number.isFinite(n) ? null : n;
opt.state.set(putLine(opt.state.get(), no, row.차례, { 고른: picks, 확인: false, 배: next }));
});
if (배 === null) f.setError(L("M02_JudgeNeed"));
return bar([L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]);
}
/** 지금 배(사용자 단위 환산 › 서버) */
const timesOf = (row: JudgeRow): number | null =>
lineOf(opt.state.get(), no, row.차례)?.배 ?? row.수량.배;
function okChip(row: JudgeRow, picks: DraftPick[]): HTMLElement {
const line = lineOf(opt.state.get(), no, row.차례);
const ok = createToggle({
kind: "chip",
label: line?.확인 ? L("M02_JudgeConfirmed") : L("M02_JudgeConfirm"),
checked: !!line?.확인,
disabled: opt.readOnly,
onChange: (on) => {
if (on && !confirmable(picks)) {
ok.set(false);
const why = picks.length ? picks.flatMap(missing).join(" · ") : L("M02_JudgeNoCand");
showToast(`${L("M02_JudgeBlocked")} — ${why}`, "warning");
return;
}
opt.state.set(putLine(opt.state.get(), no, row.차례, { ...line, 고른: picks, 확인: on }));
},
});
ok.root.dataset.judgeOk = String(row.차례);
return ok.root;
}
/** 행 번호 단추 — 엑셀 보기 그 행 */
function rowJump(row: JudgeRow): HTMLElement | string {
if (row.행 === null || !opt.onSheet) return show(row.행);
const b = createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }); // prettier-ignore
b.dataset.judgeRow = String(row.행);
return b;
}
/** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */
const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출";
const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p));
/** 펼친 단산 줄의 블록 풀이 몸(접힘 = null) */
function solved(row: JudgeRow): HTMLElement | null {
if (!row.풀이 || !open.has(row.차례)) return null;
let body = bodies.get(row.차례);
if (!body) {
body = solvedBody({
solved: row.풀이,
picks: inner(row),
readOnly: opt.readOnly,
excel: row.단가,
onSheet: opt.onSheet ? (r) => opt.onSheet!(r, "단가산출상세") : undefined,
onPicks: (list) => edit(row, [...picksOf(row).filter(isSk), ...list]),
onCheck: (list, signal) =>
checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore
});
bodies.set(row.차례, body);
} else body.set(inner(row));
return body.root;
}
return { picksOf, edit, verdict, chips, unitInput, timesOf, okChip, rowJump, solved };
}
export type RowKit = ReturnType<typeof createRowKit>;
export interface JudgeMoveOptions {
file: HopyoScope;
key: string;
target: HopyoScope;
state: JudgeState;
readOnly?: boolean;
onMoved?: (r: MoveResult) => void;
}
/** 옮기기 한 번 — 409 = 물음 뒤 덮음 · 옮긴 것은 캐시에서 걷음 · 알림 · 실패 = null */
export async function runMove(
opt: Pick<JudgeMoveOptions, "target" | "key" | "state">,
body: MoveBody,
막힘: MoveBlocked[],
): Promise<MoveResult | null> {
try {
const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => {
// 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만)
if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e;
return moveJudge(opt.target, opt.key, { ...body, 덮음: true });
});
const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`));
let d = opt.state.get();
for (const [h, lines] of Object.entries(body.호표))
for (const at of Object.keys(lines))
if (!stuck.has(`${h}:${at}`)) {
const { [at]: _gone, ...rest } = d.호표[h] ?? {};
d = { ...d, 호표: { ...d.호표, [h]: rest } };
}
for (const b of Object.keys(body.블록))
if (!stuck.has(`b${b}:`)) {
const { [b]: _gone, ...rest } = d.블록;
d = { ...d, 블록: rest };
}
opt.state.set(d);
const msg = L("M02_JudgeMoved")
.replace("{a}", String(r.만듦.length))
.replace("{b}", String(r.단가산출.length))
.replace("{c}", String(r.막힘.length + 막힘.length));
showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success");
return r;
} catch (e) {
showToast((e as Error).message, "error");
return null;
}
}
/** 오른쪽 판 아래 [옮기기 (n)] — 확인한 줄 · 블록만 · 서버가 정본으로 다시 셈 · 옮긴 것은 캐시에서 걷음 */
export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; destroy: () => void } {
const btn = createButton({ label: L("M02_JudgeMove"), size: 28, variant: "filled", onClick: () => void go() }); // prettier-ignore
btn.dataset.judgeMove = "";
const root = bar([btn]);
const count = (): void => {
const n = moveBody(opt.state.get(), opt.file).수;
btn.textContent = `${L("M02_JudgeMove")} (${n})`;
btn.disabled = !!opt.readOnly || n === 0;
};
async function go(): Promise<void> {
const { body, 막힘, 수 } = moveBody(opt.state.get(), opt.file);
if (!수) {
showToast(L("M02_JudgeMoveNone"), "info");
return;
}
btn.disabled = true;
const r = await runMove(opt, body, 막힘);
count();
if (r) opt.onMoved?.(r);
}
const off = opt.state.on(count);
count();
return {
root,
destroy(): void {
off();
root.remove();
},
};
}