- 새 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>
379 lines
14 KiB
TypeScript
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;
|
|
}
|