- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」 - B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」 - 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림) - 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름 - B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」 - 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
351 lines
15 KiB
TypeScript
351 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* B08_Quantity_UI_StructureSheet_Library.ts
|
|
* 구조물도 양식 가져오기 칸 (PLAN 4장) — 로그인한 사람의 개인·회사 단 + 프로그램 기본에서
|
|
* 골라 **프로젝트 작업본에 박음**.
|
|
*
|
|
* ⛔ 표를 그릴 때는 라이브러리를 안 읽음 — [목록 보기]를 눌렀을 때만 목록을 받음(판정 Ⓑ).
|
|
* ⚠ 모양(클래스)은 옆 제원 칸(`b08-spec*`) 것을 그대로 씀 — 그 칸이 스타일을 넣음.
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
|
|
|
|
const TIER_LABELS: Record<string, string> = {
|
|
personal: "개인",
|
|
received: "받음",
|
|
company: "회사",
|
|
program: "기본",
|
|
};
|
|
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
|
|
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
|
|
/** 이름표 — 「종류 + 제원 요약」을 미리 채워 두고 고치게 함(10-A ⑫ · 코드는 난수라 이름이 흔들려도 안전). */
|
|
const NAME_TAG_HINT = "이름표(종류 + 제원 요약 · 고칠 수 있음 · 비우면 양식 이름):";
|
|
|
|
interface LibraryItem {
|
|
tier: string;
|
|
code: string;
|
|
name: string;
|
|
kind: string;
|
|
}
|
|
|
|
/** 장 머리의 양식 표시 — **어느 단에서 가져왔나** + **그 뒤 고쳤나**(지금 읽는 단이 아님). */
|
|
export function libraryLabel(
|
|
item: { name: string; imported_from?: string | null },
|
|
editedRows: number,
|
|
): string {
|
|
const from = item.imported_from
|
|
? `${TIER_LABELS[item.imported_from] ?? item.imported_from}에서 가져옴`
|
|
: "기본 · 가져오기 전";
|
|
return `양식 「${item.name}」(${from})${editedRows ? ` · 고친 식 ${editedRows}줄` : ""}`;
|
|
}
|
|
|
|
function libraryUrl(projectId: string, tail: string): string {
|
|
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library${tail}`;
|
|
}
|
|
|
|
async function readJson<T>(response: Response): Promise<T> {
|
|
const payload = (await response.json().catch(() => ({}))) as T & { message?: string };
|
|
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
|
return payload;
|
|
}
|
|
|
|
export interface LibraryPanelOptions {
|
|
projectId: string;
|
|
sheetKey: string;
|
|
typeId: string;
|
|
currentCode: string | null;
|
|
/** 거짓이면 안 가져옴 — 서버가 그 종류의 고친 식을 비우므로 부르는 쪽이 먼저 물음. */
|
|
confirmTake: () => boolean;
|
|
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
|
|
isDirty: () => boolean;
|
|
onImported: (notes: string[]) => Promise<void>;
|
|
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
|
|
originProject?: string | null;
|
|
/** 저장·발행 이름표 제안 — 장 이름(종류 + 제원 요약). */
|
|
defaultName: string;
|
|
}
|
|
|
|
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
|
|
export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|
const { projectId, sheetKey, typeId, currentCode, confirmTake, isDirty, onImported } = options;
|
|
const panel = document.createElement("div");
|
|
panel.className = "b08-spec ui-sidebar-section";
|
|
const title = document.createElement("h3");
|
|
title.className = "b08-spec__title";
|
|
title.textContent = "양식 가져오기";
|
|
const scope = document.createElement("p");
|
|
scope.className = "b08-spec__scope";
|
|
scope.textContent = "가져온 양식은 이 프로젝트에 박혀 누가 열어도 같은 값으로 섭니다.";
|
|
const status = document.createElement("p");
|
|
status.className = "b08-spec__scope";
|
|
|
|
const list = document.createElement("select");
|
|
list.className = "b08-spec__input";
|
|
list.hidden = true;
|
|
const load = document.createElement("button");
|
|
load.type = "button";
|
|
load.className = "b08-quantity__tab";
|
|
load.textContent = "목록 보기";
|
|
const take = document.createElement("button");
|
|
take.type = "button";
|
|
take.className = "b08-spec__save";
|
|
take.textContent = "가져오기";
|
|
take.hidden = true;
|
|
// 복제 — 기본·회사 항목을 개인 단에 베낌(PLAN 4장). 개인 단 것은 이미 내 것이라 안 눌림.
|
|
const clone = document.createElement("button");
|
|
clone.type = "button";
|
|
clone.className = "b08-quantity__tab";
|
|
clone.textContent = "복제해서 내 것으로";
|
|
clone.hidden = true;
|
|
/** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */
|
|
let afterLoad = "";
|
|
// 공유 — 내 개인 단 항목을 같은 회사 동료의 「받음」 단으로(브레인 판정 ①). 동료는 누를 때 받음.
|
|
const send = document.createElement("button");
|
|
send.type = "button";
|
|
send.className = "b08-quantity__tab";
|
|
send.textContent = "동료에게 보내기";
|
|
send.hidden = true;
|
|
const who = document.createElement("select");
|
|
who.className = "b08-spec__input";
|
|
who.hidden = true;
|
|
const syncClone = (): void => {
|
|
const [tier] = list.value.split("|");
|
|
clone.disabled = tier === "personal";
|
|
send.disabled = tier !== "personal";
|
|
};
|
|
send.addEventListener("click", () => {
|
|
const [tier, code] = list.value.split("|");
|
|
if (tier !== "personal" || !code) return;
|
|
void (async () => {
|
|
send.disabled = true;
|
|
try {
|
|
if (who.hidden) {
|
|
const { colleagues } = await readJson<{ colleagues: { id: number; name: string }[] }>(
|
|
await fetch(libraryUrl(projectId, "/colleagues"), { credentials: "include" }),
|
|
);
|
|
who.replaceChildren(...colleagues.map((c) => new Option(c.name, String(c.id))));
|
|
who.hidden = colleagues.length === 0;
|
|
status.textContent = colleagues.length
|
|
? "받을 동료를 고르고 한 번 더 누를 것"
|
|
: "보낼 동료가 없음";
|
|
return;
|
|
}
|
|
const label = list.selectedOptions[0]?.textContent ?? "";
|
|
const name = who.selectedOptions[0]?.textContent ?? "";
|
|
if (
|
|
!window.confirm(
|
|
`「${label}」을 ${name}에게 보냄 — 받는 쪽 목록에 「받음」으로 뜸 · 그 사람 것은 안 덮음`,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
await readJson(
|
|
await fetch(libraryUrl(projectId, "/share"), {
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ type_id: typeId, code, to_user_id: Number(who.value) }),
|
|
}),
|
|
);
|
|
who.hidden = true;
|
|
status.textContent = `${name}에게 보냄`;
|
|
} catch (error) {
|
|
status.textContent = error instanceof Error ? error.message : "보내지 못함";
|
|
} finally {
|
|
syncClone();
|
|
}
|
|
})();
|
|
});
|
|
list.addEventListener("change", syncClone);
|
|
clone.addEventListener("click", () => {
|
|
const [tier, code] = list.value.split("|");
|
|
const label = list.selectedOptions[0]?.textContent ?? "";
|
|
if (!tier || !code || tier === "personal") return;
|
|
if (
|
|
!window.confirm(
|
|
`「${label}」을 내 라이브러리로 베낌 — 같은 종류 내 것이 있으면 덮어씀 · 프로젝트 값은 안 바뀜`,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
void (async () => {
|
|
clone.disabled = true;
|
|
try {
|
|
await readJson(
|
|
await fetch(libraryUrl(projectId, "/clone"), {
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ type_id: typeId, tier, code }),
|
|
}),
|
|
);
|
|
afterLoad = "내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]";
|
|
load.click();
|
|
} catch (error) {
|
|
status.textContent = error instanceof Error ? error.message : "복제 못함";
|
|
syncClone();
|
|
}
|
|
})();
|
|
});
|
|
|
|
load.addEventListener("click", () => {
|
|
void (async () => {
|
|
load.disabled = true;
|
|
status.textContent = "목록 받는 중…";
|
|
try {
|
|
const { items, can_publish: canPublish } = await readJson<{
|
|
items: LibraryItem[];
|
|
can_publish?: { company: boolean; program: boolean };
|
|
}>(
|
|
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
|
|
credentials: "include",
|
|
}),
|
|
);
|
|
list.replaceChildren(
|
|
...items.map((item) => {
|
|
const option = document.createElement("option");
|
|
option.value = `${item.tier}|${item.code}`;
|
|
const now = item.code === currentCode ? " (지금)" : "";
|
|
const kind = KIND_LABELS[item.kind] ?? item.kind;
|
|
option.textContent = `${TIER_LABELS[item.tier] ?? item.tier} · [${kind}] ${item.name}${now}`;
|
|
return option;
|
|
}),
|
|
);
|
|
list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0;
|
|
syncClone();
|
|
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
|
|
toCompany.hidden = !canPublish?.company;
|
|
toProgram.hidden = !canPublish?.program;
|
|
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
|
|
afterLoad = "";
|
|
} catch (error) {
|
|
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
|
|
} finally {
|
|
load.disabled = false;
|
|
}
|
|
})();
|
|
});
|
|
|
|
take.addEventListener("click", () => {
|
|
const [tier, code] = list.value.split("|");
|
|
if (!tier || !code || !confirmTake()) return;
|
|
void (async () => {
|
|
take.disabled = true;
|
|
status.textContent = "가져오는 중…";
|
|
try {
|
|
const result = await readJson<{ cleared_formulas: number }>(
|
|
await fetch(libraryUrl(projectId, "/import"), {
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ type_id: typeId, tier, code }),
|
|
}),
|
|
);
|
|
const cleared = result.cleared_formulas
|
|
? ` · 고친 식 ${result.cleared_formulas}줄 비움`
|
|
: "";
|
|
// 프로젝트 작업본이 바뀌었으니 표를 **다시 받아** 그림.
|
|
await onImported([`양식을 가져왔습니다${cleared}.`]);
|
|
} catch (error) {
|
|
status.textContent = error instanceof Error ? error.message : "가져오지 못함";
|
|
take.disabled = false;
|
|
}
|
|
})();
|
|
});
|
|
|
|
// 개인 단 두 단추 — 목록을 다시 받아야 보이므로 끝나면 [목록 보기]를 한 번 누른 것처럼 갱신.
|
|
const personal = (label: string, run: () => Promise<string>): HTMLButtonElement => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "b08-quantity__tab";
|
|
button.textContent = label;
|
|
button.addEventListener("click", () => {
|
|
void (async () => {
|
|
button.disabled = true;
|
|
try {
|
|
status.textContent = await run();
|
|
if (!list.hidden) load.click();
|
|
} catch (error) {
|
|
status.textContent = error instanceof Error ? error.message : `${label} 못함`;
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
})();
|
|
});
|
|
return button;
|
|
};
|
|
const save = personal("내 라이브러리에 저장", async () => {
|
|
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
|
|
const tag = window.prompt(
|
|
`이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀\n${NAME_TAG_HINT}`,
|
|
options.defaultName,
|
|
);
|
|
if (tag === null) return "";
|
|
const result = await readJson<{ edited: number }>(
|
|
await fetch(libraryUrl(projectId, "/personal"), {
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sheet_key: sheetKey, name: tag }),
|
|
}),
|
|
);
|
|
return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`;
|
|
});
|
|
const remove = personal("내 것 지우기", async () => {
|
|
if (!window.confirm("내 라이브러리의 이 종류 양식을 지움 — 이 프로젝트 값은 안 바뀜"))
|
|
return "";
|
|
const result = await readJson<{ deleted: number }>(
|
|
await fetch(libraryUrl(projectId, `/personal?type_id=${encodeURIComponent(typeId)}`), {
|
|
method: "DELETE",
|
|
credentials: "include",
|
|
}),
|
|
);
|
|
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
|
|
});
|
|
// 발행 — [내 라이브러리에 저장]과 같은 모양으로 회사·프로그램 기본 단에(2026-09-14 브레인 승인).
|
|
const publish = (label: string, tier: "company" | "program"): HTMLButtonElement => {
|
|
const button = personal(label, async () => {
|
|
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
|
|
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
|
|
const masked =
|
|
tier === "program" && options.originProject
|
|
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
|
|
: "";
|
|
const tag = window.prompt(
|
|
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}\n${NAME_TAG_HINT}`,
|
|
options.defaultName,
|
|
);
|
|
if (tag === null) return "";
|
|
await readJson(
|
|
await fetch(libraryUrl(projectId, "/publish"), {
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sheet_key: sheetKey, tier, name: tag }),
|
|
}),
|
|
);
|
|
return `${whom}에 발행함`;
|
|
});
|
|
button.hidden = true;
|
|
return button;
|
|
};
|
|
const toCompany = publish("회사 라이브러리에 발행", "company");
|
|
const toProgram = publish("프로그램 기본으로 발행", "program");
|
|
const mine = document.createElement("div");
|
|
mine.className = "b08-sheet__actions";
|
|
mine.append(save, remove, toCompany, toProgram);
|
|
|
|
panel.append(title, scope, load, list, take, clone, send, who, mine, status);
|
|
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
|
panel.append(
|
|
buildStmatePanel({
|
|
projectId,
|
|
typeId,
|
|
onSaved: () => {
|
|
if (!list.hidden) load.click();
|
|
},
|
|
}),
|
|
);
|
|
return panel;
|
|
}
|