- 설계내역서 표 오른쪽 서랍(공용 side_panels · 접고 펴기 · 좁은 화면 = 아래) — 찾기 하나 + 출처 탭 이 공사 · 내 것 · 회사 · 품셈(704-1 기준 목록 · 우클릭 계산서 보기) · 지난 공사 - 두 번 누름 · Enter = 고른 내역 줄에 단가연결 · 끌어 놓기 = 놓은 줄 · 연결 = 단가연결 모달과 같은 길(702-3 즉시 금액) - 품셈 = 넣을 값 창에 계산서 입력 전부(목록 바꿈은 기본과 다른 것만 — 석축헐기 뒷길이 60 이 0 으로 열리던 것) - 서버 B09_Estimation_Past + GET/POST /estimation/past — 같은 회사 다른 공사 구조물 사본 · 서고 사본 → 이 공사 서고 사본(폴더 통째 · 가져온곳 · 덮개째 · 단가 = 이 공사 책) · 남의 회사 403 - 표 높이 셈(spaceBelow)이 뒤 형제만 보므로 서랍 = DOM 앞 · CSS order 로 오른쪽 - 시험 test_703_1 6 · 셸 꼴 글 시험 200-2 · 202-1 = 표 칸 + 서랍 꼴로 고침(낡은 글) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Store.ts
|
|
* B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사).
|
|
*
|
|
* 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 ·
|
|
* 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결)
|
|
* 연결 = 단가연결 모달과 같은 길(`…_Bill_Link` · 702-3 즉시 금액) — 호표 = 연결 · 조합 · 사전조합 = 사본 들여옴 ·
|
|
* 품셈 = 넣을 값 창(대표 규격 채움) · 지난 공사 = 서버가 이 공사 서고 사본으로(`…_Past` · 덮개째 ·
|
|
* 단가 = 이 공사 단가 책) → 받은 덮개를 그 cover 가 비었을 때만 화면 캐시에 얹음
|
|
* 틀 = 공용 오른쪽 서랍(`ui_template_side_panels` · 접고 펴기 · 폭 끌기 · 좁은 화면 = 아래로)
|
|
* 창고 하나 = 페이지 한 벌(다시 그려도 찾기 글 · 탭 · 받은 목록 남음) · 이 공사 = 그릴 때마다 새로
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import type { DataRow } from "@ui/ui_template_data_table";
|
|
import { el, showToast } from "@ui/ui_template_elements";
|
|
import { createFindList, type FindRow } from "@ui/ui_template_find_list";
|
|
import { loadRows } from "@ui/ui_template_item_picker";
|
|
import { itemName } from "@ui/ui_template_item_picker_data";
|
|
import { matchLogic } from "@ui/ui_template_logic_find";
|
|
import { createSidePanels } from "@ui/ui_template_side_panels";
|
|
import { createTabs } from "@ui/ui_template_tabs";
|
|
import { createText } from "@ui/ui_template_text";
|
|
import { currentProjectId } from "../A00_Common/b_project_switch";
|
|
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
|
import {
|
|
fetchLibrary,
|
|
fetchStandard,
|
|
fetchStandardSheet,
|
|
type StandardRow,
|
|
} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
|
import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet";
|
|
import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer";
|
|
import type { Row } from "./B09_Estimation_Calc_Base";
|
|
import type { TabContext } from "./B09_Estimation_UI_Tabs";
|
|
import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
|
import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
|
|
import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level";
|
|
|
|
type Source = "here" | "mine" | "company" | "poomsem" | "past";
|
|
const SOURCES: Array<[Source, string]> = [
|
|
["here", "이 공사"],
|
|
["mine", "내 것"],
|
|
["company", "회사"],
|
|
["poomsem", "품셈"],
|
|
["past", "지난 공사"],
|
|
];
|
|
/** 한 번에 그리는 줄 — 품셈 1만 줄 · 찾기로 좁힘 */
|
|
const SHOWN = 200;
|
|
const DRAG = "text/x-b09-store";
|
|
|
|
interface Item {
|
|
tag: string;
|
|
name: string;
|
|
sub: string;
|
|
unit: string;
|
|
hit: (q: string) => boolean;
|
|
go: (ctx: TabContext, apply: ApplyRef) => void;
|
|
sheet?: (put: () => void) => void;
|
|
}
|
|
|
|
const words = (q: string): string[] => q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
const has = (text: string) => (q: string) => words(q).every((w) => text.toLowerCase().includes(w));
|
|
const onPayload = (ctx: TabContext) => (p: Row) => Object.assign(ctx.payload, p);
|
|
|
|
function hereItems(ctx: TabContext): Item[] {
|
|
const of = (tab: "dg" | "sg", list: Row[] | undefined): Item[] =>
|
|
(list || []).map((r) => {
|
|
const tag = tab === "dg" ? `일위대가 ${r.no}호` : `단가산출 ${r.no}호`;
|
|
const name = String(r.name || "");
|
|
return {
|
|
tag,
|
|
name,
|
|
sub: String(r.spec || ""),
|
|
unit: String(r.unit || ""),
|
|
hit: has(`${tag} ${name} ${r.spec || ""}`),
|
|
go: (_c, apply) => apply({ tab, key: r.key }, r),
|
|
};
|
|
});
|
|
return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)];
|
|
}
|
|
|
|
/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */
|
|
async function layerItems(layer: "personal" | "company"): Promise<Item[]> {
|
|
const owner = ownOwner(layer, await fetchSessionUser().catch(() => null));
|
|
const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer);
|
|
const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : [];
|
|
return [
|
|
...combos.map((r) => {
|
|
const name = itemName(r);
|
|
const pick = { kind: "combo" as const, layer, owner: r.owner ?? null, key: r.key };
|
|
return {
|
|
tag: "구조물 조합",
|
|
name,
|
|
sub: "",
|
|
unit: String(r.단위 || ""),
|
|
hit: has(`구조물 조합 ${name}`),
|
|
go: (ctx: TabContext, apply: ApplyRef) =>
|
|
void importLib({ apply, onPayload: onPayload(ctx) }, pick),
|
|
};
|
|
}),
|
|
...pres.map((r) => {
|
|
const name = r.제목 || r.이름;
|
|
const pick = { kind: "precombo" as const, layer, owner, key: r.이름 };
|
|
return {
|
|
tag: "일위대가",
|
|
name,
|
|
sub: [r.구분, r.상세구분].filter(Boolean).join(" "),
|
|
unit: String(r.단위 || ""),
|
|
hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`),
|
|
go: (ctx: TabContext, apply: ApplyRef) =>
|
|
void importLib({ apply, onPayload: onPayload(ctx) }, pick),
|
|
};
|
|
}),
|
|
];
|
|
}
|
|
|
|
/** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 ·
|
|
* 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */
|
|
function askStandard(apply: ApplyRef, r: StandardRow): void {
|
|
const open = (given: Row): void => askInputs(apply, r.키, r.이름, { given });
|
|
fetchStandardSheet(r).then(
|
|
(sheet) => open((sheet as { 입력?: Row }).입력 ?? r.바꿈),
|
|
() => open(r.바꿈),
|
|
);
|
|
}
|
|
|
|
/** 품셈 = 704-1 기준 목록(품명 · 규격 · 금액 · 실무 낱말 찾기) · 연결 = 넣을 값 창(대표 규격 채움) */
|
|
async function poomsemItems(): Promise<Item[]> {
|
|
const list = await fetchStandard();
|
|
return list.줄.map((r) => ({
|
|
tag: r.공종 || "품셈",
|
|
name: r.이름,
|
|
sub: [r.규격, r.금액 === null ? "" : `${Math.round(r.금액).toLocaleString("ko-KR")}원`]
|
|
.filter(Boolean)
|
|
.join(" · "),
|
|
unit: r.단위,
|
|
hit: (q) => !q || matchLogic(q, { ...r, 이름: `${r.이름} ${r.규격}` }).ok,
|
|
go: (_c, apply) => askStandard(apply, r),
|
|
sheet: (put) => openStandardSheet(r, put),
|
|
}));
|
|
}
|
|
|
|
async function call(path: string, body?: Row): Promise<Row> {
|
|
const projectId = currentProjectId();
|
|
if (!projectId) throw new Error("프로젝트 없음");
|
|
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, {
|
|
method: body ? "POST" : "GET",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
...(body && { body: JSON.stringify(body) }),
|
|
});
|
|
const got = (await response.json().catch(() => null)) as Row | null;
|
|
if (!response.ok || !got) {
|
|
const detail = got?.detail;
|
|
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
|
}
|
|
return got;
|
|
}
|
|
|
|
/** 지난 공사 가져옴 — 이 공사 서고 사본 · 덮개 = 그 cover 가 비었을 때만 얹음 */
|
|
async function importPast(ctx: TabContext, apply: ApplyRef, r: Row): Promise<void> {
|
|
showToast("지난 공사에서 가져오는 중…", "info");
|
|
try {
|
|
const got = await call("past", { project_id: r.project_id, cover: r.cover });
|
|
if (!got.key) throw new Error("사본은 들여왔으나 일위대가를 못 만듦");
|
|
onPayload(ctx)(got.payload);
|
|
const from = ((got.payload.sheets?.daega as Row[]) || []).find((x) => x.key === got.key);
|
|
const cells: Row = got.덮개 || {};
|
|
const n = Object.keys(cells).length;
|
|
let note = "";
|
|
if (n && got.cover) {
|
|
const all: Row = { ...(ctx.user.daega_cover || {}) };
|
|
if (Object.keys(all[got.cover] || {}).length) note = " · 이 공사에 고친 칸이 있어 그대로 둠";
|
|
else {
|
|
all[got.cover] = cells;
|
|
ctx.user = { ...ctx.user, daega_cover: all }; // 아래 연결이 같은 캐시에 씀
|
|
note = ` · 고친 칸 ${n} 함께 옮김`;
|
|
}
|
|
}
|
|
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
|
|
showToast(`「${r.project}」 일위대가로 연결함${note} — [저장]해야 남음`, "success");
|
|
} catch (error) {
|
|
showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error");
|
|
}
|
|
}
|
|
|
|
async function pastItems(): Promise<Item[]> {
|
|
const got = await call("past");
|
|
return ((got.rows as Row[]) || []).map((r) => ({
|
|
tag: String(r.project || ""),
|
|
name: String(r.name || ""),
|
|
sub: [r.kind, r.covers ? `고친 칸 ${r.covers}` : ""].filter(Boolean).join(" · "),
|
|
unit: String(r.unit || ""),
|
|
hit: has(`${r.project} ${r.name} ${r.spec} ${r.kind}`),
|
|
go: (ctx, apply) => void importPast(ctx, apply, r),
|
|
}));
|
|
}
|
|
|
|
const LOAD: Record<Exclude<Source, "here">, () => Promise<Item[]>> = {
|
|
mine: () => layerItems("personal"),
|
|
company: () => layerItems("company"),
|
|
poomsem: poomsemItems,
|
|
past: pastItems,
|
|
};
|
|
|
|
/** 페이지 한 벌 창고 */
|
|
let store: {
|
|
root: HTMLElement;
|
|
bind: (ctx: TabContext, raw: DataRow[], table: HTMLElement) => void;
|
|
} | null = null;
|
|
|
|
function makeStore(): NonNullable<typeof store> {
|
|
let ctx: TabContext | null = null;
|
|
let raw: DataRow[] = [];
|
|
let source: Source = "here";
|
|
const got: Partial<Record<Source, Item[] | "loading" | Error>> = {};
|
|
let shown: Item[] = [];
|
|
const finder = createFindList({ placeholder: "품명 · 규격 · 실무 낱말로 찾기" });
|
|
const tabs = createTabs({
|
|
tabs: SOURCES.map(([key, label]) => ({ key, label })),
|
|
value: source,
|
|
kind: "pill",
|
|
onChange: (key) => {
|
|
source = key as Source;
|
|
draw();
|
|
},
|
|
});
|
|
const guide = createText(
|
|
"두 번 누름 = 고른 내역 줄에 단가연결 · 끌어서 내역 줄에 놓기 = 그 줄에 연결 · 우클릭 = 계산서 보기(품셈)",
|
|
);
|
|
|
|
/** 그 줄에 연결 — 줄 = 고른 내역 줄(index 없음) · 연결 못 하는 줄이면 알림 */
|
|
const linkTo = (item: Item, at?: number): void => {
|
|
if (!ctx) return;
|
|
const key = pickedKey();
|
|
const i = at ?? raw.findIndex((r) => String(r.key) === key);
|
|
const apply = i >= 0 ? applyAt(ctx, raw, i) : null;
|
|
if (!apply)
|
|
return void showToast("내역 항목 줄을 먼저 누름(자재 · 묶음 줄은 연결 없음)", "info");
|
|
item.go(ctx, apply);
|
|
};
|
|
|
|
const draw = (): void => {
|
|
const now = source === "here" ? (ctx ? hereItems(ctx) : []) : got[source];
|
|
if (source !== "here" && now === undefined) void load(source);
|
|
if (!Array.isArray(now)) {
|
|
finder.hint.textContent = now instanceof Error ? `못 읽음 — ${now.message}` : "읽는 중…";
|
|
return finder.setRows([]);
|
|
}
|
|
const q = finder.search.value.trim();
|
|
const found = now.filter((x) => x.hit(q));
|
|
shown = found.slice(0, SHOWN);
|
|
const more = found.length > SHOWN ? ` · 앞 ${SHOWN} 줄만 — 찾기로 좁힘` : "";
|
|
finder.hint.textContent = found.length
|
|
? `${found.length} / ${now.length}${more}`
|
|
: now.length
|
|
? "맞는 것 없음"
|
|
: source === "past"
|
|
? "가져올 지난 공사 일위대가 없음(같은 회사 공사만)"
|
|
: "비었음";
|
|
finder.setRows(
|
|
shown.map<FindRow>((x, i) => ({
|
|
key: String(i),
|
|
tag: x.tag,
|
|
name: x.name,
|
|
sub: x.sub,
|
|
unit: x.unit,
|
|
onPick: () => linkTo(x),
|
|
})),
|
|
);
|
|
for (const row of finder.root.querySelectorAll<HTMLElement>("[data-pick]")) {
|
|
row.draggable = true;
|
|
row.addEventListener("dragstart", (ev) => ev.dataTransfer?.setData(DRAG, row.dataset.pick!));
|
|
}
|
|
for (const [key] of SOURCES) {
|
|
const list = key === "here" ? (ctx ? hereItems(ctx) : []) : got[key];
|
|
tabs.setBadge(key, Array.isArray(list) ? list.length : 0);
|
|
}
|
|
};
|
|
|
|
const load = async (key: Exclude<Source, "here">): Promise<void> => {
|
|
got[key] = "loading";
|
|
try {
|
|
got[key] = await LOAD[key]();
|
|
} catch (error) {
|
|
got[key] = error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
draw();
|
|
};
|
|
|
|
finder.search.addEventListener("input", draw);
|
|
// 한 번 누름 = 고름만(두 번째 누름 · 키보드 = 연결)
|
|
finder.root.addEventListener(
|
|
"click",
|
|
(ev) => {
|
|
if ((ev.target as Element).closest("[data-pick]") && (ev as MouseEvent).detail === 1)
|
|
ev.stopPropagation();
|
|
},
|
|
true,
|
|
);
|
|
finder.root.addEventListener("contextmenu", (ev) => {
|
|
const row = (ev.target as Element).closest<HTMLElement>("[data-pick]");
|
|
const item = row ? shown[Number(row.dataset.pick)] : undefined;
|
|
if (!item) return;
|
|
ev.preventDefault();
|
|
openMenuAt(ev.clientX, ev.clientY, [
|
|
["고른 내역 줄에 연결", () => linkTo(item)],
|
|
...(item.sheet
|
|
? [["계산서 보기", () => item.sheet?.(() => linkTo(item))] as [string, () => void]]
|
|
: []),
|
|
]);
|
|
});
|
|
|
|
const body = el("div", {
|
|
className: "b09-store",
|
|
attrs: { "data-b09-store": "" },
|
|
children: [tabs.root, finder.root, guide],
|
|
});
|
|
const side = createSidePanels({
|
|
prefKey: "b09-store",
|
|
panels: [{ id: "store", title: "재료 창고", body }],
|
|
defaultWidth: 360,
|
|
});
|
|
|
|
/** 내역 표 줄에 놓기 — 표를 다시 그리면 새 표에 다시 붙임 */
|
|
const dropOn = (table: HTMLElement): void => {
|
|
let over: HTMLElement | null = null;
|
|
const mark = (tr: HTMLElement | null): void => {
|
|
over?.classList.remove("is-focus");
|
|
over = tr;
|
|
over?.classList.add("is-focus");
|
|
};
|
|
table.addEventListener("dragover", (ev) => {
|
|
if (!ev.dataTransfer?.types.includes(DRAG)) return;
|
|
ev.preventDefault();
|
|
mark((ev.target as Element).closest<HTMLElement>("tr[data-i]"));
|
|
});
|
|
table.addEventListener("dragleave", (ev) => {
|
|
if (!table.contains(ev.relatedTarget as Node)) mark(null);
|
|
});
|
|
table.addEventListener("drop", (ev) => {
|
|
const at = ev.dataTransfer?.getData(DRAG);
|
|
const tr = (ev.target as Element).closest<HTMLElement>("tr[data-i]");
|
|
mark(null);
|
|
if (!at || !tr) return;
|
|
ev.preventDefault();
|
|
const item = shown[Number(at)];
|
|
if (item) linkTo(item, Number(tr.dataset.i));
|
|
});
|
|
};
|
|
|
|
side.root.classList.add("b09-store__side");
|
|
return {
|
|
root: side.root,
|
|
bind: (next, rows, table) => {
|
|
ctx = next;
|
|
raw = rows;
|
|
dropOn(table);
|
|
draw();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 설계내역서 표 오른쪽 창고 — 표(root)에 놓기 붙이고 창고 서랍을 돌려줌 */
|
|
export function attachBillStore(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
|
|
store ??= makeStore();
|
|
store.bind(ctx, raw, table);
|
|
return store.root;
|
|
}
|