Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts
T
eomsangdonandClaude Sonnet 5.5 6732842a8e feat(746-2): 고르기 창 후보 블록 줄 비교 표 · 못옮김 · 안 이은 기계 줄 M01 요소 후보 단추
- 고름 대기 칸에 후보(앞 셋)마다 줄 비교 표(줄 · 엑셀 품 · 기초 품 · 꼴 배지) · 후보 고름 칸과 동기 · 칸 글에 같음 · 품 다름 수 · 판정 · 미리 고름 그대로
- 못옮김 잇기 창 · 안 이은 줄 창에 M01 요소 후보 셋 단추(누르면 이음 고름) · 안 이은 줄 창 기초 후보 칸에는 요소 후보가 안 섞이게 가름
- 새 파일 Hopyo_Compare.ts · 새 지킴 시험 test_746_2_hopyo_compare_screen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:17:48 +09:00

347 lines
14 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_Left.ts
* 일위대가 불러오기 안 이은 줄(PLAN 729-3 · 요구서 내역19 1장 8 · 2장 4) — 만들면 남을 엑셀 줄 표 · 줄마다 고름.
* · 줄 표 = 서버 `/hopyo-unlinked`(저장 없음 · 몸 = 확인 창 캐시로 지을 만들기 몸) · 409 `{까닭, 남은줄}` 도 그 표
* · 고름 = 확인 창 캐시(`Hopyo_Check` draft) 대상 · 뺌 — [저장] · [생성] 몸이 그대로 씀(새 저장 꼴 없음)
* [고르기] = 확인 창 고르기 창(후보 · 기초 · 자재 찾기 · 옵션은 서버 `guess`) · [찾기] = 기초 · 노임(LB) · 자재(MT) ·
* [기계] = M01 기계(EQ) · % 줄 = 밑수 비목 · [우리에 없음] = 엑셀 숫자 그대로(사용자가 고른 것)
* · 단가산출 고름 대기 줄 = 후보 기초 고름 → [SK 만들기](서버 `sangeun` 바탕 · 엑셀 차이 보존) → 그 줄은 SK 참조
* · 못옮김 남은 SK 를 쓰는 줄(729-4 `단산` · `SK` · `못옮김`) = [못옮김 잇기 (n)] → `Hopyo_Lost` 창
* · 남은 수 = 안 만든 호표만(만든 호표는 서버가 남은 줄 있으면 안 만듦) — 마법사 [끝내기] 막음 · 띠
* 계산 · 판정은 서버 — 화면은 보이기 · 고름만.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import {
guessHopyo,
leftHopyo,
leftOf,
makeSangeun,
readHopyoOne,
type BuildLine,
type HopyoLeft,
type HopyoLine,
type HopyoOne,
type HopyoPart,
type HopyoScope,
type LinkBase,
type SangeunCand,
isElem,
} from "./M02_MasterTemplete_Hopyo_Api";
import { bodyOf, draftKey, loadDraft, saveDraft, toTarget } from "./M02_MasterTemplete_Hopyo_Check";
import { elemChoices } from "./M02_MasterTemplete_Hopyo_Compare";
import { kindsOf, openHopyoChoose } from "./M02_MasterTemplete_Hopyo_Choose";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
import { openLost } from "./M02_MasterTemplete_Hopyo_Lost";
/** 파일 층 · 만들 층 · 파일 키 */
export interface LeftScope {
scope: HopyoScope;
target: HopyoScope;
key: string;
}
/** 호표마다 남은 줄(그 호표 읽은 것과 함께) */
export type LeftMap = Map<number, { one: HopyoOne; left: HopyoLeft[] }>;
const NONE = "우리에 없음";
const BASES: LinkBase[] = ["노무비", "재료비", "경비"];
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 남은 줄 수 · 호표 수 */
export const leftCount = (map: LeftMap): { 줄: number; 호표: number } => {
const nos = [...map.values()].filter((x) => x.left.length);
return { 줄: nos.reduce((n, x) => n + x.left.length, 0), 호표: nos.length };
};
/** 캐시로 지을 만들기 몸 — 확인 창 · [저장]과 같은 규칙(통째 호표는 줄 없음) */
export function draftLines(at: LeftScope, one: HopyoOne): BuildLine[] {
const h = one.호표;
const d = loadDraft(draftKey(at.scope, at.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
/** 호표 하나 다시 셈 — 서버 미리 보기(만든 호표 = 남은 줄 없음) */
async function recount(at: LeftScope, no: number, map: LeftMap): Promise<void> {
const one = await readHopyoOne(at.scope, at.key, no, at.target);
const from = { 층: at.scope.layer, 주인: at.scope.owner, 키: at.key, 번: no };
const made = !!one.호표.만든?.확인;
const left = made ? [] : (await leftHopyo(at.target, from, draftLines(at, one))).남은줄;
map.set(no, { one, left });
}
/** 호표 여럿 남은 줄(차례로 · `step` = 몇째) — 실패한 호표는 까닭 글로 알림 */
export async function countLeft(
at: LeftScope,
nos: number[],
step?: (done: number, all: number) => void,
): Promise<LeftMap> {
const map: LeftMap = new Map();
for (const [i, no] of nos.entries()) {
step?.(i, nos.length);
try {
await recount(at, no, map);
} catch (error) {
showToast(`제${no}호표 — ${why(error)}`, "warning");
}
}
return map;
}
/** 캐시에 한 줄 고름 — 대상 또는 뺌 「우리에 없음」(parts = null · 체크 켬) */
export function put(at: LeftScope, no: number, 차례: number, parts: HopyoPart[] | null): void {
const k = draftKey(at.scope, at.key, no);
const d = loadDraft(k);
d.체크[차례] = true;
if (parts) {
d.대상[차례] = parts;
delete d.뺌[차례];
} else {
d.뺌[차례] = NONE;
delete d.대상[차례];
}
saveDraft(k, d);
}
/** 고른 것 글 — 캐시 대상 · 뺌 */
function pickedText(at: LeftScope, no: number, 차례: number): string {
const d = loadDraft(draftKey(at.scope, at.key, no));
if (d.뺌[차례]) return d.뺌[차례];
const parts = d.대상[차례] ?? [];
return parts
.map((p) => p.이름 ?? p.로직 ?? p.자재 ?? p.노임 ?? p.기계 ?? p.비율 ?? "")
.filter(Boolean)
.join(" + ");
}
/** 남은 줄 창 — `map` 이 없으면 `nos` 를 서버에 물어 채움 · 닫을 때 `onClose(map)` */
export function openLeft(
at: LeftScope & { 파일: string; nos?: number[]; map?: LeftMap },
onClose?: (map: LeftMap) => void,
): void {
const map: LeftMap = at.map ?? new Map();
openModal({
title: `${L("M02_LeftTitle")} — ${at.파일}`,
closeLabel: L("M02_Close"),
width: "1100px",
wide: true,
dialogClass: "m02-modal",
beforeClose: () => {
onClose?.(map);
return true;
},
mount: (body) => {
const head = el("div");
const list = el("div");
body.append(createText(L("M02_LeftHint"), "hint", 14), head, list);
const again = async (no: number): Promise<void> => {
try {
await recount(at, no, map);
} catch (error) {
showToast(why(error), "error");
}
draw();
};
const choose = (no: number, line: HopyoLine): void =>
openHopyoChoose({
line,
done: (ts) => {
const parts = ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null }));
guessHopyo(at.scope, at.key, no, line.차례, parts.map(toTarget))
.then((g) => (Array.isArray(g.대상) ? g.대상 : parts))
.catch(() => parts)
.then((got) => {
put(at, no, line.차례, got);
void again(no);
});
},
});
const link = (no: number, 차례: number, part: Partial<HopyoPart>, name: string): void => {
put(at, no, 차례, [{ ...part, 이름: name, 입력값: {}, 배: 1 }]);
void again(no);
};
const find = (no: number, line: HopyoLine): void =>
openFindPicker({
layer: at.target.layer,
owner: at.target.owner,
title: `${line.이름} ${line.규격}`.trim(),
kinds: kindsOf(line), // 736-2 줄 갈래대로 탭(노임 = 인력 · 기계 = 기계 · 자재 = 재료)
logic: (x) => link(no, line.차례, { 로직: x.키 }, x.이름),
element: (e) =>
link(no, line.차례, e.종류 === "인력" ? { 노임: e.코드 } : { 자재: e.코드 }, e.이름),
machine: (e) => link(no, line.차례, { 기계: e.코드 }, e.이름),
});
const makeSk = async (no: number, block: number, 로직: string): Promise<void> => {
try {
const got = await makeSangeun(at.scope, at.key, { [block]: { 로직 } });
const sk = got.만듦.find((x) => x.번 === block);
showToast(sk ? `${sk.키} · ${sk.갈래}` : L("M02_LeftSkNone"), sk ? "success" : "warning");
} catch (error) {
showToast(why(error), "error");
}
await again(no);
};
const actions = (no: number, row: HopyoLeft, line: HopyoLine | undefined): HTMLElement => {
const box = el("div", { className: "ui-toolbar" });
const bases = row.후보.filter((c): c is SangeunCand => !isElem(c));
const block = (line as { 참조?: { 종류?: string; 번?: number } } | undefined)?.참조;
if (bases.length && block?.종류 === "단산" && block.번 !== undefined) {
const pick = createSelectField({
options: bases.map((c) => ({
value: c.로직,
text: `${c.이름} [${c.단위}] · ${c.판정}${c.글 && c.글 !== c.판정 ? ` · ${c.글}` : ""}`,
})),
compact: true,
size: 24,
ariaLabel: L("M02_LeftBase"),
});
const go = createButton({
label: L("M02_LeftMakeSk"),
size: 24,
onClick: () => void makeSk(no, block.번!, pick.select.value),
});
go.dataset.leftSk = String(block.번);
box.append(pick.root, go);
}
if (row.못옮김?.length && row.단산 !== undefined && row.SK) {
const lost = createButton({
label: `${L("M02_LostTitle")} (${row.못옮김.length})`,
size: 24,
onClick: () =>
openLost({ scope: at.scope, key: at.key, 번: row.단산!, sk: row.SK!, 이름: row.이름,
lost: row.못옮김!, done: () => void again(no) }), // prettier-ignore
});
lost.dataset.leftLost = String(row.단산);
box.append(lost);
}
const elems = elemChoices(row.후보.filter(isElem), (c) => line && link(no, row.차례, { [c.갈래]: c.키 }, c.이름)); // prettier-ignore
if (elems) box.append(elems);
if (row.갈래 === "%" && line) {
const base = createSelectField({
options: [{ value: "", text: L("M02_LeftBase") }].concat(
BASES.map((b) => ({ value: b, text: b })),
),
compact: true,
size: 24,
ariaLabel: L("M02_LeftBase"),
onChange: (v) => v && link(no, row.차례, { 비율: v as LinkBase }, v),
});
box.append(base.root);
}
if (line && row.갈래 !== "%")
box.append(
createButton({ label: L("M02_LeftChoose"), size: 24, variant: "ghost", onClick: () => choose(no, line) }),
createButton({ label: L("M02_LeftFind"), size: 24, variant: "ghost", onClick: () => find(no, line) }),
); // prettier-ignore
const none = createButton({
label: NONE,
size: 24,
variant: "ghost",
onClick: () => {
put(at, no, row.차례, null);
void again(no);
},
});
none.title = L("M02_LeftNoneTip");
box.append(none);
return box;
};
const draw = (): void => {
const n = leftCount(map);
head.replaceChildren(
n.줄
? createBanner({
tone: "warning",
text: L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표)),
})
: createBanner({ tone: "success", text: L("M02_LeftZero") }),
);
head.dataset.left = String(n.줄);
const rows: Cell[][] = [];
for (const [no, { one, left }] of [...map].sort((a, b) => a[0] - b[0]))
for (const row of left) {
const line = one.호표.줄.find((l) => l.차례 === row.차례);
rows.push([
`제${no}호표`,
row.이름,
row.규격,
`${row.수량 ?? ""} ${row.단위}`.trim(),
createBadge(row.갈래, "warning"),
row.까닭,
pickedText(at, no, row.차례),
actions(no, row, line),
]);
}
list.replaceChildren(
createTable(
[
{ label: L("M02_LeftHopyo"), width: "88px" },
{ label: L("M02_SangeunColName") },
{ label: L("M02_LeftSpec") },
{ label: L("M02_SangeunColQty"), width: "80px" },
{ label: L("M02_LeftKind"), width: "80px" },
{ label: L("M02_LeftWhy") },
{ label: L("M02_LeftPicked") },
{ label: L("M02_LeftPick") },
],
rows,
{ density: "data", empty: L("M02_LeftZero") },
),
);
};
if (at.map) draw();
else {
list.replaceChildren(createText(L("M02_LeftCounting"), "muted", 14));
void countLeft(at, at.nos ?? [], (i, all) => {
list.replaceChildren(
createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14),
);
}).then((got) => {
for (const [k, v] of got) map.set(k, v);
draw();
});
}
},
});
}
/** 만들기 실패 까닭이 남은 줄이면 그 표를 엶(참) · 아니면 거짓(부르는 쪽이 까닭 글) */
export function openLeftOf(
error: unknown,
at: LeftScope & { 파일: string; 번: number },
onClose?: (map: LeftMap) => void,
): boolean {
const left = leftOf(error);
if (!left) return false;
void readHopyoOne(at.scope, at.key, at.번, at.target).then(
(one) => openLeft({ ...at, map: new Map([[at.번, { one, left }]]) }, onClose),
(e) => showToast(why(e), "error"),
);
return true;
}
/** 결과 표 칸 단추 — 남은 줄로 막힌 호표면 [고르기 (n)] · 아니면 null */
export function leftButton(
error: unknown,
at: LeftScope & { 파일: string; 번: number },
): HTMLButtonElement | null {
const left = leftOf(error);
if (!left) return null;
const go = createButton({
label: `${L("M02_LeftChoose")} (${left.length})`,
size: 24,
onClick: () => void openLeftOf(error, at),
});
go.dataset.leftOpen = String(at.번);
return go;
}