Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Paste.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

214 lines
8.5 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Labor_Paste.ts
* 엑셀 붙여넣기(PLAN 705-2) — 일위대가 격자 Ctrl+V → 붙인 글 그대로 서버(`/hopyo-paste` · 숫자는 서버가 글에서 읽음).
* 열 짝 = 처음 한 번 묻고 기억(이 브라우저 `localStorage` · 사용자 설정) · 엑셀 머리 글이 붙어 오면 묻지 않음
* 답 「줄」(호표 머리 없음) = 열린 문서에 고정 줄로 더함 → [저장] · 알림을 누르면 「이 공사 단가로 다시 잇기」
* 답 「만듦」(호표 머리 있음) = 호표마다 사전조합(실무자료 「붙여넣기 …」) · 결과 창 [이 공사 단가로 다시 잇기]
* `askCols` · `withCols` = B09 펼친 일위대가 붙여넣기도 씀
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createSelectField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import { createDataTable } from "@ui/ui_template_data_table";
import { createText } from "@ui/ui_template_text";
import { hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_toast";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import {
pageScope,
pasteHopyo,
type HopyoBuiltAll,
type PasteCols,
} from "./M02_MasterTemplete_Hopyo_Api";
import { openRelink, relinkDoc, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink";
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
const COLS_KEY = "aislo:hopyo-paste-cols";
const FIELDS = ["품명", "규격", "수량", "단위", "금액", "노", "재", "경"] as const;
const LABEL: Record<(typeof FIELDS)[number], string> = {
품명: "품명",
규격: "규격",
수량: "수량",
단위: "단위",
금액: "합계 금액",
노: "노무비 단가",
재: "재료비 단가",
경: "경비 단가",
};
const NEED = new Set(["품명", "수량", "단위"]);
const letter = (n: number): string =>
n < 26 ? String.fromCharCode(65 + n) : `${letter(Math.floor(n / 26) - 1)}${letter(n % 26)}`;
const won = (v: number | null | undefined): string =>
v == null ? "-" : Math.round(v).toLocaleString("ko-KR");
export function rememberedCols(): PasteCols | null {
try {
const got = JSON.parse(localStorage.getItem(COLS_KEY) ?? "null") as PasteCols | null;
return got && typeof got === "object" ? got : null;
} catch {
return null;
}
}
/** 열 짝 묻기 — 짐작 값이 미리 골라짐 · 보기 = 붙인 첫 줄들(칸 머리 A · B · C …) · 취소 = null */
export function askCols(guess: PasteCols, look: string[][]): Promise<PasteCols | null> {
return new Promise((done) => {
const width = Math.max(0, ...look.map((r) => r.length));
const options = [
{ value: "", text: "없음" },
...Array.from({ length: width }, (_, n) => ({
value: String(n),
text: `${letter(n)} 칸 · ${look.find((r) => r[n])?.[n] ?? ""}`.slice(0, 40),
})),
];
const picks = FIELDS.map((f) =>
createSelectField({
label: `${LABEL[f]}${NEED.has(f) ? " *" : ""}`,
options,
value: guess[f] != null ? String(guess[f]) : "",
compact: true,
}),
);
const table = createDataTable({
columns: Array.from({ length: width }, (_, n) => ({ key: `c${n}`, label: letter(n) })),
rows: look.map((r, i) => ({
key: String(i),
...Object.fromEntries(r.map((c, n) => [`c${n}`, c])),
})),
empty: "",
});
let answered = false;
const { close } = openModal({
title: "붙인 엑셀 열 짝 — 한 번만 묻고 기억",
closeLabel: "취소",
width: "960px",
beforeClose: () => {
if (!answered) done(null);
return true;
},
mount: (body, _close, actions) => {
const ok = createButton({
label: "확인",
onClick: () => {
const cols: PasteCols = {};
FIELDS.forEach((f, i) => {
const v = picks[i].select.value;
if (v !== "") cols[f] = Number(v);
});
if ([...NEED].some((f) => cols[f as keyof PasteCols] == null))
return showToast("품명 · 수량 · 단위 칸은 꼭 고름", "warning");
localStorage.setItem(COLS_KEY, JSON.stringify(cols));
answered = true;
done(cols);
close();
},
});
actions.append(ok);
body.append(
createText(
"엑셀 머리 글 없이 붙여 열 짝을 모름 — 칸마다 무엇인지 고름(짐작 값이 골라져 있음) · 다음부터 묻지 않음",
"muted",
),
el("div", { className: "ui-toolbar", children: picks.map((p) => p.root) }),
table.root,
);
},
});
});
}
/** 열 짝 고리 — 기억한 짝으로 부름 · 「물음」이면 묻고 다시 부름 · 취소 = null */
export async function withCols<T extends { 갈래: string }>(
run: (cols: PasteCols | null) => Promise<T | { 갈래: "물음"; 열: PasteCols; 보기: string[][] }>,
): Promise<T | null> {
let got = await run(rememberedCols());
if (got.갈래 === "물음") {
const ask = got as { 열: PasteCols; 보기: string[][] };
hideLoadingOverlay();
const cols = await askCols(ask.열, ask.보기);
if (!cols) return null;
showLoadingOverlay();
got = await run(cols);
}
return got as T;
}
/** 만든 결과 창 — 엑셀 합계 = 우리 합계 · 다음 걸음 [이 공사 단가로 다시 잇기] */
export function builtNotice(got: HopyoBuiltAll, keys: string[]): void {
const text =
`사전조합 ${got.만듦?.length ?? 0} 만듦(호표 ${got.호표} · 줄 ${got.줄} · 고정 줄 ${got.고정} · ` +
`하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐}) · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` +
` · 저절로 이음 ${got.이음} · 이음 후보 ${got.이음후보}`;
const rows = got.다름.map((x) => ({
key: String(x.번),
name: `제${x.번}호표 ${x.이름}`,
excel: x.엑셀,
ours: x.우리,
gap: x.차이,
}));
openModal({
title: "엑셀 붙여넣기 — 만듦",
closeLabel: "닫기",
mount: (body, close, actions) => {
actions.append(
createButton({
label: "다음 — 이 공사 단가로 다시 잇기",
onClick: () => {
close();
void openRelink(relinkKeys(keys));
},
}),
);
body.append(createText(text, got.빠짐 || got.실패.length ? "error" : "muted"));
if (rows.length)
body.append(
createDataTable({
columns: [
{ key: "name", label: "합계 다른 호표", width: 260 },
{ key: "excel", label: "엑셀", kind: "number" },
{ key: "ours", label: "우리", kind: "number" },
{ key: "gap", label: "차이", kind: "number", decimals: 1 },
],
rows,
}).root,
);
},
});
}
/** 격자 Ctrl+V(M02 일위대가 상세 · 사전조합) — 줄 = 문서에 더함(`changed`) · 만듦 = 사전조합 여럿 */
export async function pasteInto(doc: LaborDoc, text: string, changed: () => void): Promise<void> {
const scope = pageScope();
const have = doc.담은로직.map((r) => lineId(r) ?? "").filter((id) => id.startsWith("고정|"));
showLoadingOverlay();
try {
const got = await withCols((cols) => pasteHopyo(scope, text, cols, { 있음: have }));
if (!got || got.갈래 === "물음") return;
if (got.갈래 === "만듦") {
document.dispatchEvent(new Event(HOPYO_BUILT));
builtNotice(
got,
(got.만듦 ?? []).map((x) => x.키),
);
return;
}
for (const one of got.줄)
doc.담은로직.push({ 로직: "", 고정: one.고정, 별칭: one.별칭, 메모: one.메모, 입력값: {},
수량: one.수량.손 == null ? null : { 손: one.수량.손 } }); // prettier-ignore
changed();
showToast(
`고정 줄 ${got.줄.length} 붙임 · 엑셀 합계 ${won(got.엑셀)} · 이음 ${got.이음} · 후보 ${got.이음후보}` +
" — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기",
"success",
9000,
() => void openRelink(relinkDoc(doc, changed)),
);
} catch (error) {
showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error");
} finally {
hideLoadingOverlay();
}
}