Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts
T
eomsangdon 73f113ba34 feat(785-4): 불러오기 화면 껍데기 — 가운데 상세 페이지(마법사) 없앰 · 왼쪽 [불러오기] → 바로 본 칸 엑셀 + 오른쪽 목록
- 새 Hopyo_Import.ts(머리 막대 층 · 안 이은 줄 배지 · [끝내기] · 떠남 지움) · Hopyo_Wizard.ts 지움
- Hopyo_Side.ts = 올림 · 층 작은 고름(둘 이상일 때만) · 작업 중 줄 · 다른 엑셀 물음
- Hopyo_Sheet.ts = 몸(buildHopyoSheet) + 모달 껍데기 · 만들기 셋 → 새 Hopyo_SheetMake.ts · [삭제] 걷음 · 찾기 · 체크 부품으로
- 흠 셋: 안 이은 줄 0 오표시(빈 호표 번) · 맞춤 셈 중 멈춤(3 초 저절로 다시) · 좁은 판 줄 짓눌림(시트 아래 줄 판 자리)
- 시험: 785-4 지킴 시험 새로 · 낡은 마법사 · 모달 [삭제] · 좁은 꼴 인자 대조를 새 파일 · 새 길로 고침(Wizard 삭제 · SheetMake 이동 · narrow 걷음)
2026-11-04 23:08:39 +09:00

353 lines
14 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_WizardSk.ts
* 단가산출 목록 판(PLAN 778-5 D-5 · 780-2 · 785-4 — 엑셀 보기 오른쪽 · 요구 621 · 622 · 623 · 624 · 627) — 모두 줄로만 · 자동 없음.
* · 열면 서버 줄 판정(`POST …/sangeun` 빈 확인 = 안 만듦 · D-3) → 블록 표: 단산N · 부름 칸 · 판정(「옮길 수 있음」 ·
* 「고를 줄 n」 — 캐시 고름 겹침) · 만든 SK · [확인] 칩(고를 줄 0 일 때만 켜짐) · [줄 n] = 시트 아래 줄 판(`lines` 자리) 에
* 줄 표(`Hopyo_SkLines` · 넓은 꼴) · 금액 · 근거는 줄 title
* · 맞춤 셈 중(409)이면 3 초마다 다시 판정(끝나면 저절로 표)
* · 고름 · 확인 = 캐시 `m02-hopyo-sk:{층}:{주인}:{키}`(`SkPick` 꼴) — 누를 때 서버에 안 감 · 자동 저장 없음
* · [확인한 것 옮기기 (n)] = 몸 `{확인: [{번, 고름}]}` → 서버가 M01 지금 값으로 다시 셈 · 단위 SK(확인 안 한 블록 안 만듦) ·
* 만든 블록은 확인에서 뺌 · 못만듦 = 띠(까닭 그대로) · 「모두 확인」 단추 없음
* · `count()` = 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄([끝내기] 글)
* · 부름 칸(「일위대가 n 곳」 · 「일위대가 밖」) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · 블록 줄 누름 = `onRow` ·
* `mark(번)` 강조 · `readOnly` = 옮기기 숨김 · 알림 `SK_SYNC`(같은 캐시 열쇠 · 고름 바뀜 → 다시 그림) · 옮김 뒤 `HOPYO_BUILT`
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { createSegment } from "@ui/ui_template_segment";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import { HOPYO_BUILT, POLL_MS } from "./M02_MasterTemplete_Hopyo_Check";
import { formatPrice } from "../common_util/common_util_money";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { judgeSangeun } from "./M02_MasterTemplete_Hopyo_SkApi";
import { skLineTable } from "./M02_MasterTemplete_Hopyo_SkLines";
import {
GOOD,
canConfirm,
filterBlocks,
outside,
picksOf,
readDraft,
setPick,
setSure,
sureBody,
tidy,
verdictOf,
waiting,
type SkBlock,
type SkDraft,
type SkFilter,
type SkJudged,
} from "./logic/M02_MasterTemplete_Hopyo_SkPick";
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "—" : formatPrice(v);
export const skDraftKey = (scope: HopyoScope, key: string): string =>
`m02-hopyo-sk:${scope.layer}:${scope.owner ?? ""}:${key}`;
export interface SkStep {
root: HTMLElement;
/** 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄 — 판정 전 null */
count: () => { 블록: number; 밖: number; 줄: number } | null;
/** 그 블록 펼치고 화면에 보임(안 이은 줄 창 [단가산출 걸음에서 확인]) */
show: (번: number) => void;
/** 그 블록 줄 강조 · 보이게(펼치지 않음 · 거른 줄이면 거르기 풂) — null = 강조 지움 */
mark: (번: number | null) => void;
}
export interface SkStepOptions {
/** 줄 판 자리(시트 아래) — [줄 n] 이 여기에 그 블록 줄 표를 그림 · 없으면 [줄 n] 없음 */
lines?: HTMLElement;
/** 블록 줄 누름(단추 밖) */
onRow?: (번: number) => void;
/** 옮기기 숨김(만들 층 ≠ 파일 층 · 쓰기 권한 없음) */
readOnly?: boolean;
}
/** 맞춤 알림 — detail `{dk, from, got?}` */
export const SK_SYNC = "m02-hopyo-sk";
type SyncDetail = { dk: string; from: object; got?: SkJudged };
/** ② 단가산출 칸 — `파일` = 엑셀 보기 창 머리 · `onChange` = 판정 · 옮김 뒤(끝내기 글) */
export function sangeunStep(
scope: HopyoScope,
key: string,
파일: string,
onChange?: () => void,
o: SkStepOptions = {},
): SkStep {
const dk = skDraftKey(scope, key);
const me = {};
const load = (): SkDraft => readDraft(sessionStorage.getItem(dk));
const save = (d: SkDraft): void => sessionStorage.setItem(dk, JSON.stringify(d));
const tell = (more: Partial<SyncDetail> = {}): void =>
void document.dispatchEvent(new CustomEvent<SyncDetail>(SK_SYNC, { detail: { dk, from: me, ...more } })); // prettier-ignore
let got: SkJudged | null = null;
let fails: SkJudged["못만듦"] = [];
let current: number | null = null; // 강조 블록
let filter: SkFilter = "all";
let shown: number | null = null; // 줄 판에 보인 블록
const out = el("div", { className: "m02-hopyo", attrs: { "data-wiz-sk": "" } });
const seg = createSegment({
options: [
{ value: "all", label: L("M02_SkFilterAll") },
{ value: "in", label: L("M02_SkFilterIn") },
{ value: "out", label: L("M02_SkFilterOut") },
{ value: "open", label: L("M02_SkFilterOpen") },
],
value: filter,
size: 24,
ariaLabel: L("M02_SkFilter"),
onChange: (v) => {
filter = v as SkFilter;
draw();
},
});
seg.root.dataset.skFilter = "";
const again = createButton({ label: L("M02_SkJudge"), variant: "ghost", size: 28, onClick: () => void judge() }); // prettier-ignore
again.dataset.skJudge = "";
const move = createButton({ label: L("M02_SkMove").replace("{n}", "0"), variant: "filled", size: 28, disabled: true, onClick: () => void run() }); // prettier-ignore
move.title = L("M02_SkMoveTip");
move.dataset.skMove = "";
const root = el("div", {
className: "m02-hopyo",
children: [
seg.root,
out,
el("div", { className: "ui-toolbar", children: o.readOnly ? [again] : [move, again] }),
],
});
/** 다른 벌이 고름 · 옮김을 알림 — 떨어진 벌은 듣기 그침 */
const onSync = (e: Event): void => {
const x = (e as CustomEvent<SyncDetail>).detail;
if (!root.isConnected) return document.removeEventListener(SK_SYNC, onSync);
if (x.dk !== dk || x.from === me || !got) return;
if (x.got) {
got = x.got;
fails = [];
}
draw();
};
document.addEventListener(SK_SYNC, onSync);
const sheet = (행: number): void =>
void import("./M02_MasterTemplete_Hopyo_Sheet")
.then((m) =>
m.openHopyoSheet({ scope, key, 파일, canWrite: true, target: scope, 단산행: 행 }),
)
.catch((error: unknown) => showToast(why(error), "error"));
async function judge(): Promise<void> {
again.disabled = true;
out.replaceChildren(createText(L("M02_SkJudging"), "muted", 14));
try {
got = await judgeSangeun(scope, key);
fails = [];
draw();
} catch (error) {
out.replaceChildren(createBanner({ tone: "warning", text: why(error) }));
if (error instanceof StaleError)
window.setTimeout(() => root.isConnected && void judge(), POLL_MS); // 맞춤 셈 중 — 끝나면 저절로
} finally {
again.disabled = false;
onChange?.();
}
}
async function run(): Promise<void> {
if (!got) return;
const body = sureBody(load(), got.판정);
if (!body.length) return showToast(L("M02_SkSureFirst"), "warning");
move.disabled = true;
try {
const next = await judgeSangeun(scope, key, body);
const made = new Set(next.만듦.map((x) => x.번));
const d = load();
save({ ...d, 확인: d.확인.filter((n) => !made.has(n)) });
got = next;
fails = next.못만듦;
tell({ got: next });
if (made.size) document.dispatchEvent(new Event(HOPYO_BUILT)); // 열린 엑셀 보기 칠 · 좌측 나무 다시
showToast(
L("M02_SkMoved")
.replace("{made}", String(made.size))
.replace("{bad}", String(fails.length)),
fails.length ? "warning" : "success",
);
draw();
} catch (error) {
showToast(why(error), "error");
draw();
} finally {
onChange?.();
}
}
const cols: Column[] = [
{ label: L("M02_SkColBlock") },
{ label: L("M02_SkColCall"), width: "80px" },
{ label: L("M02_SkColVerdict"), width: "84px" },
{ label: L("M02_SkColMade"), width: "72px" },
{ label: L("M02_SkSure"), width: "56px" },
{ label: "", width: "56px" },
];
/** 부름 칸 — 「일위대가 n 곳」(title 호표 번) · 「일위대가 밖」 · 옛 서버(칸 없음) = — */
function callCell(b: SkBlock): HTMLElement | string {
if (!b.부름) return "—";
const out = outside(b);
const word = out ? L("M02_SkCallOut") : L("M02_SkCallIn").replace("{n}", String(b.부름.length)); // prettier-ignore
const badge = createBadge(word, out ? "neutral" : "info");
badge.title = b.부름.map((n) => `제${n}호표`).join(" · ");
badge.dataset.skCall = out ? "out" : "in";
return badge;
}
/** 블록 한 줄 + 펼친 줄 표 */
function blockRows(b: SkBlock, d: SkDraft): HTMLTableRowElement[] {
const picks = picksOf(d, b.번);
const word = verdictOf(b, picks);
const verdict = createBadge(word, word === GOOD ? "success" : "info");
verdict.dataset.skVerdict = String(b.번);
const basis = b.근거 ? createBadge(`${L("M02_SkBasisTag")} ${b.근거.이름 ?? b.근거.로직}`, "neutral") : "—"; // prettier-ignore
if (basis instanceof HTMLElement)
basis.title = [b.근거!.로직, b.근거!.판정].filter(Boolean).join(" · ");
const made = b.만든 ? createBadge(b.만든, "success") : "—";
const ok = canConfirm(b, picks);
const sure = createToggle({
label: L("M02_SkSure"),
kind: "chip",
size: 24,
checked: d.확인.includes(b.번),
disabled: !ok,
onChange: (on) => {
save(setSure(load(), b, on));
draw();
tell();
},
});
sure.root.dataset.skSure = String(b.번);
sure.root.title = ok
? L("M02_SkSureTip")
: L(b.줄.length ? "M02_SkSureOff" : "M02_SkSureEmpty");
const lines = createButton({
label: `${L("M02_SkOpen")} ${b.줄.length}`,
size: 24,
variant: shown === b.번 ? "filled" : "ghost",
onClick: () => showLines(shown === b.번 ? null : b.번),
});
lines.dataset.skOpen = String(b.번);
lines.hidden = !o.lines;
const name = `단산${b.번} ${b.이름 ?? ""}${b.단위 ? ` [${b.단위}]` : ""}`;
const head = tableRow(cols, [name, callCell(b), verdict, made, sure.root, lines]);
head.dataset.skBlock = String(b.번);
if (b.번 === current) head.classList.add("is-current");
head.title = `${name} · ${L("M02_SkColOurs")} ${won(b.우리금액)} · ${L("M02_SkColExcel")} ${won(b.엑셀금액)}`; // prettier-ignore
head.addEventListener("click", (e) => {
if (!(e.target as Element).closest("button, label, input")) o.onRow?.(b.번);
});
return [head];
}
/** 줄 판 — 그 블록 줄 표(넓은 꼴) · 고름은 같은 캐시 · null = 닫음 */
function showLines(번: number | null): void {
shown = 번;
draw(); // 줄 판 · 목록 단추 칠을 한 번에
}
function drawLines(): void {
const host = o.lines;
if (!host) return;
const b = shown === null ? null : got?.판정.find((x) => x.번 === shown);
host.hidden = !b;
if (!b) return host.replaceChildren();
const close = createButton({ label: L("M02_Close"), size: 24, variant: "ghost", onClick: () => showLines(null) }); // prettier-ignore
host.replaceChildren(
createPanelHead({ title: `단산${b.번} ${b.이름 ?? ""}`, level: 4, divider: true, actions: [close] }).root, // prettier-ignore
skLineTable({
scope,
block: b,
picks: picksOf(load(), b.번),
onPick: (at, p) => {
save(tidy(setPick(load(), b.번, at, p), got!.판정));
draw();
tell();
},
onSheet: sheet,
}),
);
}
function draw(): void {
if (!got) return;
const d = tidy(load(), got.판정);
save(d);
const blocks = got.판정;
const good = blocks.filter((b) => canConfirm(b, picksOf(d, b.번))).length;
const made = blocks.filter((b) => b.만든).length;
const sum = L("M02_SkSummary")
.replace("{b}", String(blocks.length))
.replace("{ok}", String(good))
.replace("{wait}", String(blocks.length - good))
.replace("{made}", String(made))
.replace("{sure}", String(d.확인.length));
const { root: table, tbody } = tableShell(cols, { density: "data" });
const shown = filterBlocks(blocks, d, filter);
for (const b of shown) tbody.append(...blockRows(b, d));
const band = fails.length
? [
createBanner({
tone: "warning",
text: L("M02_SkFailed").replace("{n}", String(fails.length)),
details: fails.map((x) => `단산${x.번} ${x.이름 ?? ""} — ${x.까닭}`).join("\n"),
}),
]
: [];
const far = blocks.filter(outside).length;
const where = blocks.some((b) => b.부름) ? ` · ${L("M02_SkSummaryIn").replace("{in}", String(blocks.length - far)).replace("{out}", String(far))}` : ""; // prettier-ignore
out.replaceChildren(
createText(sum + where, "muted", 14),
...band,
shown.length ? table : createText(L("M02_SkNone"), "muted", 14),
);
move.disabled = !d.확인.length;
move.querySelector(".ui-btn__label")!.textContent = L("M02_SkMove").replace(
"{n}",
String(d.확인.length),
);
drawLines();
onChange?.();
}
void judge();
return {
root,
count: () => (got ? waiting(load(), got.판정) : null),
show: (번) => {
showLines(번);
out.querySelector(`[data-sk-block="${번}"]`)?.scrollIntoView({ block: "center" });
},
mark: (번) => {
out.querySelector("tr.is-current")?.classList.remove("is-current");
current = 번;
if (번 === null || !got) return;
if (!out.querySelector(`[data-sk-block="${번}"]`) && filter !== "all") {
filter = "all";
seg.set("all");
draw();
}
const row = out.querySelector(`[data-sk-block="${번}"]`);
row?.classList.add("is-current");
row?.scrollIntoView({ block: "nearest" });
},
};
}