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