Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts
T
eomsangdonandClaude Opus 5.5 32de488401 feat(M02 붙여넣기 화면 · 다시 잇기): 격자 Ctrl+V · 열 짝 한 번 묻고 기억 · 이 공사 단가로 다시 잇기 (PLAN 705-2)
- 일위대가 격자(ui_template_hopyo_grid onPaste) 누르고 Ctrl+V → 서버 /hopyo-paste · 열 짝 = 처음 한 번 묻고 이 브라우저에 기억 · 엑셀 머리 글이면 묻지 않음
- 호표 머리 없음 = 열린 문서에 고정 줄(알림 누름 = 다시 잇기) · 머리 있음 = 호표마다 사전조합 + 결과 창 [다음 — 이 공사 단가로 다시 잇기]
- 다시 잇기 창(EST 신 · 구 단가 비교 꼴) — 붙인 단가 · 최신 판 단가 · 후보(705-3 이음 짝) · 금액 차이 · 줄마다 · 모두 고름 · [바꿈] · [되돌리기]
- 서버 M02_Hopyo_Relink — 후보 표 · 이음 칸만 고쳐 씀(숫자 안 받음) · 전후 1 단위 값 · 되돌림
- 실무자료 [모두 고정으로 들여오기] 결과에도 다음 걸음 단추
- M02 상세가 고정 줄을 읽을 때 떨구던 흠 — normalizeLabor 가 고정 칸 지킴 · 로직 표 · 격자에 고정 줄 이름 · 규격 · 단위 · 「고정」 꼬리표 · 이음 / 손 단가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:57:39 +09:00

235 lines
9.6 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_Relink.ts
* 「이 공사 단가로 다시 잇기」(PLAN 705-2 · EST 「신 · 구 단가 비교」 꼴) — 고정 줄마다 붙인 단가 · 지금 이은 단가 ·
* 이음 후보(이름 · 규격 · 단위 맞춤 · 705-3 이음과 같은 짝) · 차이 → 줄마다 · 모두 골라 [바꿈] · [되돌리기].
* 후보 칸 누름 = 다음 후보(끝 = 그대로 · 이은 줄은 「풂」) · 고름 칸 누름 = 켬 · 끔
* 출처 셋 — 사전조합 여럿(`relinkKeys` · 서버가 이음 칸만 고쳐 씀 · 전후 1 단위 값) ·
* 열린 문서(`relinkDoc` · 문서에 넣고 [저장]) · B09 지금 호표(부르는 쪽 · 이 공사 단가 책)
* 숫자는 서버 단가 책 값 — 화면은 이음 키만 고름
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createDataTable, type DataRow } from "@ui/ui_template_data_table";
import { createText } from "@ui/ui_template_text";
import {
pageScope,
relinkApply,
relinkRows,
type RelinkChange,
type RelinkRow,
} from "./M02_MasterTemplete_Hopyo_Api";
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
type Kind = "노임" | "자재";
const COST: Record<Kind, "노무비" | "재료비"> = { 노임: "노무비", 자재: "재료비" };
export interface RelinkSource {
title: string;
/** 이은 단가 칸 머리 — 「최신 판 단가」 · 「이 공사 단가」 */
priceLabel: string;
rows: () => Promise<RelinkRow[]>;
/** 바꿈 → 되돌릴 바꿈 · 알림 글 */
apply: (changes: RelinkChange[]) => Promise<{ back: RelinkChange[]; note: string }>;
/** 단가 덮기(B09 = 이 공사 단가 책) — 없으면 서버 값 */
price?: (kind: Kind, key: string) => number | null | undefined;
}
const won = (v: number | null | undefined): string =>
v == null ? "-" : Math.round(v).toLocaleString("ko-KR");
/** 사전조합 여럿(붙여넣기 · 실무자료 들여오기 뒤) — 서버가 쓰고 전후 1 단위 값 */
export function relinkKeys(keys: string[]): RelinkSource {
const scope = pageScope();
return {
title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`,
priceLabel: "최신 판 단가",
rows: () => relinkRows(scope, { 키: keys }),
apply: async (changes) => {
const got = await relinkApply(scope, changes);
const sum = (k: "전" | "후"): number => got.문서.reduce((a, d) => a + (d[k] ?? 0), 0);
return {
back: got.되돌림,
note: `바뀐 줄 ${got.바뀜} · 사전조합 ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`,
};
},
};
}
/** 열린 문서(저장 전) — 이음을 문서에 넣고 `changed`(서버 셈 · [저장]) */
export function relinkDoc(doc: LaborDoc, changed: () => void): RelinkSource {
const scope = pageScope();
return {
title: "이 공사 단가로 다시 잇기 — 이 일위대가",
priceLabel: "최신 판 단가",
rows: () => relinkRows(scope, { 문서: doc }),
apply: async (changes) => {
const back: RelinkChange[] = [];
for (const c of changes) {
const row = doc.담은로직.find((r) => r.고정 && lineId(r) === c.줄);
if (!row?.고정) continue;
const link = { 노임: null, 자재: null, 기계: null, ...(row.고정.이음 ?? {}) };
back.push({ 키: c.키, 줄: c.줄, 노임: link.노임, 자재: link.자재 });
if ("노임" in c) link.노임 = c.노임 ?? null;
if ("자재" in c) link.자재 = c.자재 ?? null;
row.고정 = { ...row.고정, 이음: link };
}
changed();
return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` };
},
};
}
/** 다시 잇기 창 */
export async function openRelink(src: RelinkSource): Promise<void> {
let rows: RelinkRow[] = [];
/** 줄마다 고른 후보 차례(후보 수 = 그대로 · 이은 줄의 +1 = 풂) · 켬 */
let pick: number[] = [];
let on: boolean[] = [];
let back: RelinkChange[] = [];
const host = document.createElement("div");
const info = createText("", "muted");
const linked = (r: RelinkRow): boolean => !!(r.이음.노임 || r.이음.자재);
const priceOf = (kind: Kind, key: string, given: number | null | undefined): number | null =>
src.price?.(kind, key) ?? given ?? null;
const load = async (): Promise<void> => {
rows = await src.rows();
pick = rows.map((r) => (r.후보.length && !linked(r) ? 0 : r.후보.length));
on = rows.map((r) => !!r.후보.length && !linked(r));
};
const choice = (i: number): RelinkRow["후보"][number] | "keep" | "free" => {
const r = rows[i];
if (pick[i] < r.후보.length) return r.후보[pick[i]];
return pick[i] === r.후보.length ? "keep" : "free";
};
const changes = (): RelinkChange[] =>
rows.flatMap((r, i): RelinkChange[] => {
const c = choice(i);
if (!on[i] || c === "keep") return [];
if (c === "free") return [{ 키: r.키 ?? "", 줄: r.줄, 노임: null, 자재: null }];
return [{ 키: r.키 ?? "", 줄: r.줄, [c.비목]: c.키 }];
});
const nowPrice = (r: RelinkRow): string =>
(["노임", "자재"] as Kind[])
.filter((k) => r.이음[k])
.map((k) => won(priceOf(k, r.이음[k] as string, r.지금[k])))
.join(" · ") || "손 단가";
const draw = (): void => {
let gapSum = 0;
const data: DataRow[] = rows.map((r, i) => {
const c = choice(i);
const cand = typeof c === "object" ? c : null;
const price = cand ? priceOf(cand.비목, cand.키, cand.단가) : null;
const gap =
cand && price != null && r.수량 != null ? (price - r.붙인[COST[cand.비목]]) * r.수량 : null;
if (on[i] && gap != null) gapSum += gap;
return {
key: `${r.키 ?? ""}|${r.줄}`,
name: [r.이름, r.규격].filter(Boolean).join(" · "),
unit: r.단위,
qty: r.수량,
pasted: r.붙인.노무비 + r.붙인.재료비 + r.붙인.경비,
now: nowPrice(r),
cand: cand
? `${cand.비목} ${cand.이름} ${cand.규격} (${pick[i] + 1}/${r.후보.length})`
: c === "free"
? "이음 풂 — 붙인 단가로"
: r.후보.length
? `그대로 (후보 ${r.후보.length})`
: "후보 없음",
price,
gap,
pick: on[i] ? "✓" : "",
};
});
const table = createDataTable({
columns: [
{ key: "name", label: "품명 · 규격", width: 220 },
{ key: "unit", label: "단위", width: 48 },
{ key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 70 },
{ key: "pasted", label: "붙인 단가", kind: "number", decimals: 1, width: 90 },
{ key: "now", label: src.priceLabel, width: 100 },
{ key: "cand", label: "이음 후보 (누름 = 다음)", width: 260 },
{ key: "price", label: "후보 단가", kind: "number", decimals: 1, width: 90 },
{ key: "gap", label: "금액 차이", kind: "number", decimals: 1, width: 90 },
{ key: "pick", label: "고름", width: 48 },
],
rows: data,
frozen: 1,
empty: "고정 줄 없음",
onCellClick: (i, key) => {
const r = rows[i];
if (key === "cand") {
pick[i] = (pick[i] + 1) % (r.후보.length + (linked(r) ? 2 : 1));
on[i] = choice(i) !== "keep";
} else if (key === "pick") on[i] = !on[i];
else return;
draw();
},
cellClass: (i, key) => (key === "pick" && on[i] ? "is-edited" : null),
});
const n = changes().length;
info.textContent =
`고정 줄 ${rows.length} · 후보 있는 줄 ${rows.filter((r) => r.후보.length).length} · ` +
`이은 줄 ${rows.filter(linked).length} · 고른 줄 ${n} · 금액 차이 합 ${won(gapSum)}`;
apply.disabled = !n;
undo.disabled = !back.length;
host.replaceChildren(table.root);
};
const run = async (list: RelinkChange[], undoing: boolean): Promise<void> => {
try {
const got = await src.apply(list);
back = undoing ? [] : got.back;
showToast(undoing ? `되돌림 — ${got.note}` : got.note, "success", 6000);
await load();
draw();
} catch (error) {
showToast(`실패 — ${error instanceof Error ? error.message : String(error)}`, "error");
}
};
const all = createButton({
label: "모두 고름",
variant: "ghost",
onClick: () => {
const want = !rows.every((r, i) => !r.후보.length || on[i]);
on = rows.map((r, i) => (want ? !!r.후보.length && choice(i) !== "keep" : false));
draw();
},
});
const apply = createButton({ label: "바꿈", onClick: () => void run(changes(), false) });
const undo = createButton({
label: "되돌리기",
variant: "ghost",
onClick: () => void run(back, true),
});
try {
await load();
} catch (error) {
showToast(
`다시 잇기 표 실패 — ${error instanceof Error ? error.message : String(error)}`,
"error",
);
return;
}
openModal({
title: src.title,
closeLabel: "닫기",
wide: true,
width: "1100px",
actions: [all, undo, apply],
mount: (body) => {
body.append(
createText(
"붙인 단가 ↔ 단가 책 — 후보 칸을 눌러 고르고 [바꿈] · 품(수량)은 그대로 · 단가만 이음 · [되돌리기] = 바로 앞 바꿈",
"muted",
),
info,
host,
);
draw();
},
});
}