- 새 M02_MasterTemplete_Sangeun_Pick — listSangeun = 위 층 SK 목록(마스터 › 회사 › 개인 · 금액 = 층 목록 기준금액) · openSangeunLinePicker = SK 더한줄 고르기(M01 로직 · 일위대가 · 단가산출 · M01 재료 → 715 더한줄 꼴)
- Labor_Pick — lib.sangeun 이면 탭 [기준 목록 | 단가산출] · 단가산출 탭 = 기준 목록 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = 참조 {층, 주인, SK 키}
- withHopyo(일위대가 [로직 추가]) 에 sangeun 켬 · B09 단가연결 창은 그대로(SK 공사 연결 = 719-1 뒤)
- B09 재료 창고 출처 탭 「단가산출」 — SK 목록 · 연결은 알림만
- locale_m2 키 넷 · 시험 test_717_4_sangeun_pick
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
431 lines
17 KiB
TypeScript
431 lines
17 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(로직)`)
|
||
* 717-4 `lib.sangeun` = 탭 [기준 목록 | 단가산출] · 단가산출 = 위 층 SK(마스터 › 회사 › 개인 · `Sangeun_Pick`)를
|
||
* 같은 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = `lib.pick` 참조(SK 키)
|
||
* ========================================================================== */
|
||
|
||
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";
|
||
import { createTabs } from "@ui/ui_template_tabs";
|
||
import { listSangeun, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick";
|
||
|
||
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
|
||
|
||
/** 구분 보이는 글(707 낱말) — 값(저장 글)은 그대로 */
|
||
const CLASS_TEXT: Record<string, string> = { 실무자료: "실무 엑셀 일위대가" };
|
||
const opts = (values: string[]): { value: string; text: string }[] =>
|
||
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: CLASS_TEXT[v] ?? 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 }>;
|
||
/** 717-4 「단가산출」 탭(M02 SK · 위 층 모두) — 고르면 `pick` 참조 */
|
||
sangeun?: boolean;
|
||
}
|
||
|
||
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
|
||
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 => {};
|
||
let rowPick: Array<() => void> = []; // 표 줄 누름 — 탭마다 다시 셈
|
||
let tab: "std" | "sk" = "std";
|
||
let skRows: SangeunRow[] | null = null; // 717-4 — 오기 전 = null
|
||
|
||
/* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */
|
||
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) => rowPick[i]?.(),
|
||
});
|
||
/** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */
|
||
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();
|
||
if (tab === "sk") return findSangeun(q);
|
||
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;
|
||
rowPick = listed.map((row) => () => {
|
||
const logic = all.find((x) => x.키 === row.키);
|
||
if (logic)
|
||
openStandardSheet(row, () => {
|
||
close();
|
||
done(logic);
|
||
});
|
||
});
|
||
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.이름,
|
||
// 707 번호(SC · SP 키) 숨김 — 제목이 없을 때만 이름 자리에 씀
|
||
sub: [
|
||
x.row.제목 ? "" : x.row.이름,
|
||
`${CLASS_TEXT[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();
|
||
},
|
||
})),
|
||
]);
|
||
};
|
||
/** 717-4 단가산출 탭 — 같은 표 꼴 · 찾기 = 이름 · 제목 · 구분 */
|
||
const findSangeun = (q: string): void => {
|
||
const rows = (skRows ?? []).filter(
|
||
(x) => !q || [x.이름, x.제목, x.구분, x.상세구분].join(" ").toLowerCase().includes(q),
|
||
);
|
||
finder.hint.textContent = !skRows
|
||
? tc("Loading")
|
||
: rows.length
|
||
? `${rows.length} / ${skRows.length}`
|
||
: tc("Pick_None");
|
||
rowPick = rows.map((x) => () => {
|
||
close();
|
||
lib?.pick(x.ref, x.제목 || x.이름);
|
||
});
|
||
table.root.hidden = false;
|
||
table.setRows(
|
||
rows.map((x) => ({
|
||
품명: x.제목 || x.이름,
|
||
규격: [x.구분, x.상세구분].filter(Boolean).join(" "),
|
||
단위: x.단위 ?? "",
|
||
금액: x.기준금액,
|
||
출처: `${L("M02_PickSangeun")} · ${tc(LAYER_TEXT[x.ref.층 as keyof typeof LAYER_TEXT])}`,
|
||
})),
|
||
);
|
||
finder.setRows([]);
|
||
};
|
||
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";
|
||
const hint = createText(tc("Std_Hint"));
|
||
/** 탭 바꿈 — 단가산출 탭은 찾기 칸만(거름 · 층 단추 · 계산서 안내 숨김) */
|
||
const tabs = lib?.sangeun
|
||
? createTabs({
|
||
tabs: [
|
||
{ key: "std", label: L("M02_PickStd") },
|
||
{ key: "sk", label: L("M02_PickSangeun") },
|
||
],
|
||
value: tab,
|
||
kind: "line",
|
||
onChange: (key) => {
|
||
tab = key === "sk" ? "sk" : "std";
|
||
const std = tab === "std";
|
||
for (const f of [kindSel?.root, work.root, sub.root, detail.root])
|
||
if (f) f.style.display = std ? "" : "none";
|
||
layerBar.style.display = std && scopes.length ? "" : "none";
|
||
hint.hidden = !std;
|
||
find();
|
||
},
|
||
})
|
||
: null;
|
||
if (lib)
|
||
void upper(lib.layer, lib.owner).then((got) => {
|
||
scopes = got;
|
||
if (tabs)
|
||
listSangeun(got).then(
|
||
(rows) => {
|
||
skRows = rows;
|
||
tabs.setBadge("sk", rows.length);
|
||
find();
|
||
},
|
||
(error) => {
|
||
skRows = [];
|
||
find();
|
||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
|
||
},
|
||
);
|
||
scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층
|
||
if (!got.length) return;
|
||
layerBar.style.display = tab === "std" ? "" : "none";
|
||
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: [...(tabs ? [tabs.root] : []), finder.root, 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"),
|
||
);
|
||
}
|