/* ============================================================================= * 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, } from "./M02_MasterTemplete_Hopyo_Api"; import { bodyOf, draftKey, loadDraft, saveDraft, toTarget } from "./M02_MasterTemplete_Hopyo_Check"; 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; 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 { 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 { 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 => { 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, 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 => { 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 block = (line as { 참조?: { 종류?: string; 번?: number } } | undefined)?.참조; if (row.후보.length && block?.종류 === "단산" && block.번 !== undefined) { const pick = createSelectField({ options: row.후보.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); } 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; }