307 lines
13 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|