Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Left.ts
T
eomsangdonandClaude Opus 5.5 1e6ec26dd0 feat(729-3): 불러오기 마법사 안 이은 줄 고르기 · [끝내기] 막음 · 단가산출 단계 · 일위대가표 띠 · 목록 열
- 새 Hopyo_Left: 안 만든 호표마다 /hopyo-unlinked 남은 줄 표 · 줄마다 고름(고르기 창 · 찾기 = 기초 · 노임 · 자재 · 기계 EQ · % 밑수 · 우리에 없음 · 고름 대기 후보 → SK 만들기) = 확인 창 캐시 → [저장] · 생성 몸 그대로
- 마법사: 단가산출 칸(새 Hopyo_WizardSk — 기초 · SK 차이 있음 갈래 표 · 고름 대기 후보 고름 → 고른 기초로 만들기 · 별칭 제안 + M01 별칭 장부 링크) · 안 이은 줄 띠 · [끝내기] = 다시 셈 → 남으면 막음 + 표
- 확인 창 · 엑셀 보기 창: 만들기 409 {까닭, 남은줄} → 고름 표 · [고르기 (n)] · 대상 꼴 노임 · 기계 · 비율 · % 줄 밑수 고른 것만 체크
- Api_Fetch StaleError.detail(409 몸) · 까닭 글
- 새 Labor_Unlinked: 일위대가표 「M01 연결 없음 n 줄(옛 자료)」 띠 · [연결] = 고정 줄 이음(노임 · 자재 · 기계)만 · 목록 열 「안 이은 줄」
- 시험: 새 test_729_3_hopyo_left_screen(409 → 남은 줄 · 만든 호표 안 물음 · 고름 → 몸 · 0 · 띠 줄 · 소스) · 낡은 시험 고침 — test_m02_hopyo_screen(마법사 /sangeun 부름이 단가산출 칸으로 옮김) · 헬퍼 셋(새 Hopyo_Left 를 대역 — DOM · 찾기 창)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:25:45 +09:00

379 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 참조
* · 남은 수 = 안 만든 호표만(만든 호표는 서버가 남은 줄 있으면 안 만듦) — 마법사 [끝내기] 막음 · 띠
* 계산 · 판정은 서버 — 화면은 보이기 · 고름만.
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createFindList } from "@ui/ui_template_find_list";
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 { searchElements } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
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 { openHopyoChoose } from "./M02_MasterTemplete_Hopyo_Choose";
import { openFindPicker } from "./M02_MasterTemplete_Find_Pick";
/** 파일 층 · 만들 층 · 파일 키 */
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(" + ");
}
/** M01 기계(EQ) 하나 고르기 — 이름 · 규격 낱말 */
export function pickMachine(
line: { 이름: string; 규격?: string },
done: (code: string, name: string) => void,
): void {
openModal({
title: `${L("M02_LeftMachine")} — ${line.이름} ${line.규격 ?? ""}`.trim(),
closeLabel: L("M02_Close"),
dialogClass: "m02-modal",
mount: (body, close) => {
const find = createFindList({ placeholder: L("M02_LeftMachineFind") });
let seq = 0;
const run = (q: string): void => {
const mine = ++seq;
searchElements("기계", q).then(
(got) => {
if (mine !== seq) return;
find.hint.textContent = `${got.total}`;
find.setRows(
got.items.map((x) => ({
key: x.ref,
name: `${x.이름 ?? ""} ${x.규격 ?? ""}`.trim(),
sub: x.ref,
onPick: () => {
close();
done(x.ref, `${x.이름 ?? ""} ${x.규격 ?? ""}`.trim());
},
})),
);
},
(error) => showToast(why(error), "error"),
);
};
find.search.addEventListener("input", () => run(find.search.value));
find.search.value = line.이름.split(/[\s(]/)[0] ?? "";
run(find.search.value);
body.append(find.root);
},
});
}
/** 남은 줄 창 — `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(),
logic: (x) => link(no, line.차례, { 로직: x.키 }, x.이름),
element: (e) =>
link(no, line.차례, e.종류 === "인력" ? { 노임: e.코드 } : { 자재: 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 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.갈래 === "%" && 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
if (line && row.갈래 === "기계")
box.append(
createButton({
label: L("M02_LeftMachine"),
size: 24,
variant: "ghost",
onClick: () =>
pickMachine(line, (code, name) => link(no, row.차례, { 기계: code }, name)),
}),
);
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;
}