Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts
T
eomsangdonandClaude Opus 5.5 508e1ec34d feat(M02 기준 목록): 로직 고르기 창 = 기준 목록 표 · 줄 누름 = 계산서 보기 → [담기] (PLAN 704-1 · 704-2)
- 고르기 창 단가산출 줄 → 공용 표(품명 · 규격 · 단위 · 금액 · 출처) · 공종 거름 · 찾기 = 공용 matchLogic(규격 · 실무 낱말 별칭까지) · GF 번호 숨김 · 창 폭 960
- 기준 목록이 오기 전 · 서버 셈 도중 = 로직 줄(금액 빈칸) · 서고 · 덧줄 목록은 그대로
- 계산서 창 `M02_MasterTemplete_Labor_Sheet` — 맨 위 출처 · 노임 판 · 「이름 | 노임 × 품 | 금액」 줄 · 비목 소계 · 계(계산 줄 부품) · 곁에 원문 띠 · [담기] = 지금 담기 동작 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 13:02:07 +09:00

355 lines
14 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Labor_Pick.ts
* [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) ·
* 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} ·
* 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류)
* 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) +
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more`
* (324-4 실무 호표 · `withHopyo`)
* 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 ·
* 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 ·
* 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`)
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { createDataTable } from "@ui/ui_template_data_table";
import { createFindList } from "@ui/ui_template_find_list";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import { t as L } from "@ui/ui_template_locale";
import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
fetchLibLogics as fetchLogics,
fetchLibLogicSubs as fetchLogicSubs,
fetchLibrary,
fetchStandard,
type LibraryRow,
type StandardRow,
} from "./M02_MasterTemplete_Labor_Api";
import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet";
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import { ownOwner } from "./M02_MasterTemplete_Layer";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo";
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
import "./M02_MasterTemplete_Labor.css";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
const opts = (values: string[]): { value: string; text: string }[] =>
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v }));
type Scope = Omit<LaborRef, "키">;
/** 덧붙일 줄 하나 — kind = 종류 고르기 값 */
export interface PickExtra {
kind: string;
key: string;
tag: string;
name: string;
sub: string;
unit: string;
onPick: () => void;
}
/** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */
export interface LibraryPick {
layer: Layer;
owner: string | null;
/** `alias` = 참조 줄 별칭(324-4 실무 호표 품명 · 없으면 빈칸) */
pick: (ref: LaborRef, name: string, alias?: string | null) => void;
/** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */
libOnly?: boolean;
title?: string;
/** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */
extra?: PickExtra[];
/** 창을 연 뒤 받는 덧줄(324-4 실무 호표) — 받으면 `extra` 뒤에 붙음 */
more?: () => Promise<PickExtra[]>;
/** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */
kindFilter?: Array<{ value: string; text: string }>;
}
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
async function upper(layer: Layer, owner: string | null): Promise<Scope[]> {
if (layer === "project") return [];
const own =
layer === "system"
? null
: (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null)));
const out: Scope[] = [{ 층: "system", 주인: null }];
if (own) out.push({ 층: "company", 주인: own.split("/")[0] });
if (own && layer === "personal") out.push({ 층: "personal", 주인: own });
return out;
}
const LAYER_TEXT = {
system: "Lib_System",
company: "Lib_Company",
personal: "Lib_Personal",
} as const;
/** `pre.query` = 찾기 칸에 미리 넣을 글(338 실무자료 고르기 창 · 인용 조항 번호 · 품명 낱말) */
export function openLogicPicker(
done: (logic: LogicSummary) => void,
lib?: LibraryPick,
pre?: { query?: string },
): void {
const subs: SubBrief[] = [];
let classes: ReturnType<typeof precomboClasses> = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만
let all: LogicSummary[] = [];
let std: StandardRow[] | null = null; // 기준 목록(서버 셈) — 오기 전 = 로직 줄 그대로
let listed: StandardRow[] = [];
const work = createSelectField({ options: opts([]), compact: true, label: tc("Std_Kind") });
const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") });
const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") });
detail.select.disabled = true;
let close = (): void => {};
/* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
let scopes: Scope[] = [];
let scope: Scope | null = null;
let libRows: { kind: "Kind_Combo"; row: LibraryRow }[] = [];
// 251 종류 고르기 — 값 "" = 전체 · logic · precombo · 덧붙인 줄 kind
const kindSel = lib?.kindFilter
? createSelectField({
options: [{ value: "", text: L("M01_All") }, ...lib.kindFilter],
compact: true,
label: "종류",
})
: null;
const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k;
let loading = false;
let seq = 0;
let later: PickExtra[] = []; // lib.more 로 받은 덧줄
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
const finder = createFindList({
placeholder: tc("Pick_Search"),
filters: [...(kindSel ? [kindSel.root] : []), work.root, sub.root, detail.root],
extra: [layerBar],
});
const table = createDataTable({
caption: tc("Pick_Title"),
columns: [
{ key: "품명", label: tc("Std_Name"), width: 220 },
{ key: "규격", label: tc("Std_Spec"), width: 220 },
{ key: "단위", label: tc("Std_Unit"), width: 56 },
{ key: "금액", label: tc("Std_Money"), kind: "number", width: 96 },
{ key: "출처", label: tc("Std_Source"), width: 150 },
],
rows: [],
onRowClick: (i) => {
const row = listed[i];
const logic = all.find((x) => x.키 === row?.키);
if (row && logic)
openStandardSheet(row, () => {
close();
done(logic);
});
},
});
/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */
const plain = (x: LogicSummary): StandardRow => ({
키: x.키,
이름: x.이름,
규격: "",
단위: x.결과단위.replace(/^원\/?/, ""),
금액: null,
출처: x.출처 ?? "",
공종: "",
별칭: x.별칭 ?? "",
구분: x.구분,
상세구분: x.상세구분,
막힘: x.blocked,
바꿈: {},
});
if (pre?.query) finder.search.value = pre.query;
const find = (): void => {
const q = finder.search.value.trim().toLowerCase();
const rows = std ?? all.map(plain);
const found = rows.filter(
(x) =>
kindOk("logic") &&
(!work.select.value || x.공종 === work.select.value) &&
(!sub.select.value || x.구분 === sub.select.value) &&
(!detail.select.value || x.상세구분 === detail.select.value) &&
matchLogic(finder.search.value, { ...x, 이름: `${x.이름} ${x.규격}` }).ok, // 공용 · 208-1
);
const libFound = libRows.filter(
(x) =>
kindOk("precombo") &&
(!sub.select.value || x.row.구분 === sub.select.value) &&
(!detail.select.value || x.row.상세구분 === detail.select.value) &&
(!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)),
);
const extra = [...(lib?.extra ?? []), ...later].filter(
(x) =>
kindOk(x.kind) &&
!sub.select.value &&
(!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)),
);
const total = rows.length + libRows.length + (lib?.extra?.length ?? 0) + later.length;
const shown = found.length + libFound.length + extra.length;
finder.hint.textContent = loading
? tc("Loading")
: shown
? `${shown} / ${total}`
: tc("Pick_None");
listed = found;
table.root.hidden = !!lib?.libOnly;
table.setRows(
found.map((x) => ({
품명: [x.이름, aliasNote(matchLogic(q, x).alias)].filter(Boolean).join(" · "),
규격: x.규격,
단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`,
금액: x.금액,
출처: x.출처,
})),
);
finder.setRows([
...libFound.map((x) => {
const at = scope as Scope;
return {
key: x.row.이름,
tag: tc(x.kind),
name: x.row.제목 || x.row.이름,
sub: [x.row.이름, `${x.row.구분 || tc("Lib_Unsorted")} ${x.row.상세구분 ?? ""}`.trim()]
.filter(Boolean)
.join(" · "),
unit: x.row.단위 ?? "",
onPick: () => {
close();
lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름);
},
};
}),
...extra.map((x) => ({
...x,
onPick: () => {
close();
x.onPick();
},
})),
]);
};
const loadLib = async (): Promise<void> => {
if (!lib || !scope) return;
const mine = ++seq;
const at = scope;
loading = true;
find();
try {
const combo = await fetchLibrary(at, "precombo");
if (mine !== seq) return;
libRows = combo.map((row) => ({ kind: "Kind_Combo" as const, row }));
} catch (error) {
if (mine !== seq) return;
libRows = [];
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
loading = false;
drawSubs();
find();
};
const drawLayers = (): void =>
layerBar.replaceChildren(
...scopes.map((x) =>
createButton({
label: tc(LAYER_TEXT[x.층 as keyof typeof LAYER_TEXT]),
variant: x === scope ? "filled" : "ghost",
onClick: () => {
scope = x;
drawLayers();
void loadLib();
},
}),
),
);
/** 구분 → 상세구분 — 로직 품셈 구분 + 사전조합 새 분류 + 서고 줄 지금 값 · 같은 값은 한 칸 */
const subMap = (): Map<string, Set<string>> => {
const m = new Map([...subs, ...classes].map((s) => [s.name, new Set(s.details)]));
for (const { row } of libRows) {
if (!row.구분) continue;
if (!m.has(row.구분)) m.set(row.구분, new Set());
if (row.상세구분) m.get(row.구분)!.add(row.상세구분);
}
return m;
};
const drawDetails = (keep: string): void => {
const d = [...(subMap().get(sub.select.value) ?? [])];
detail.setOptions(opts(d), d.includes(keep) ? keep : "");
detail.select.disabled = d.length === 0;
};
const drawSubs = (): void => {
const keep = detail.select.value;
sub.setOptions(opts([...subMap().keys()]), sub.select.value);
drawDetails(keep);
};
sub.select.addEventListener("change", () => {
drawDetails("");
find();
});
detail.select.addEventListener("change", find);
work.select.addEventListener("change", find);
kindSel?.select.addEventListener("change", find);
finder.search.addEventListener("input", find);
layerBar.style.display = "none";
if (lib)
void upper(lib.layer, lib.owner).then((got) => {
scopes = got;
scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층
if (!got.length) return;
layerBar.style.display = "";
drawLayers();
void loadLib();
void fetchStructureDef().then(
(def) => {
classes = precomboClasses(def);
drawSubs();
},
() => undefined, // 못 받으면 서고 줄 지금 값만
);
});
// 모달 부품 — 제목줄 [닫기] · 바깥 누르기 · Esc · 위 고정
({ close } = openModal({
title: lib?.title ?? tc("Pick_Title"),
closeLabel: tc("Pick_Close"),
dialogClass: "m01-logic__pick m01-combo",
width: "960px", // 기준 목록 표 다섯 칸(704-1)
backdropClass: "m01-logic__backdrop",
mount: (body) =>
body.append(
el("div", {
className: "m02-std-pick",
children: [finder.root, createText(tc("Std_Hint")), table.root],
}),
),
}));
finder.search.focus();
void lib?.more?.().then(
(got) => ((later = got), find()),
(error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
if (!lib?.libOnly)
void Promise.all([fetchLogicSubs(), fetchLogics()])
.then(([s, l]) => {
subs.push(...s);
all = l;
drawSubs();
find();
return fetchStandard().then((got) => {
std = got.줄.length ? got.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로
work.setOptions(opts(got.공종), "");
find();
});
})
.catch((error) =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
}