- 일위대가 격자(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>
235 lines
9.6 KiB
TypeScript
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();
|
|
},
|
|
});
|
|
}
|