feat(M02): 50-3③ 일위대가 조합 서고 화면 틀 — 좌측 컨테이너 · 단가승격 · 사전조합 목록 · 상세 서고 모드
- 구조물 조합 아래 「일위대가 조합」 컨테이너 · 층 단추(진입 open=일위대가조합) · 새로 · 복사 · 지우기 - 단가승격 새로 = 로직 고르기 → SP- 키 저장 · 사전조합 새로 = 이름만 → SC- 키 - 상세 = 일위대가 상세 서고 모드(이름 칸 · 로직 표 · 원문만 · 자재 · 면적 · 다이어그램 없음) - 낡은 글 찾기 시험 셋을 지금 코드에 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -9,8 +9,9 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor";
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) ·
|
||||
* 「단가승격」 = promote · 「사전조합」 = precombo(일위대가 조합 서고 · 50-3 · 키가 이름) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "promote" | "precombo";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
||||
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
||||
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
|
||||
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
|
||||
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
@@ -64,6 +66,8 @@ export interface LaborDetailOptions {
|
||||
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
|
||||
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
|
||||
card?: boolean;
|
||||
/** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */
|
||||
mode?: "promote" | "precombo";
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
@@ -82,6 +86,8 @@ export function mountLaborDetail(
|
||||
const cur = normalizeLabor(doc, opt.column);
|
||||
const saved = JSON.stringify(cur);
|
||||
const edit = !opt.readOnly;
|
||||
const lib = opt.mode ?? null;
|
||||
const promote = lib === "promote";
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let calc: LaborCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
@@ -135,7 +141,7 @@ export function mountLaborDetail(
|
||||
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
|
||||
|
||||
const drawDg = (): void => {
|
||||
if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음
|
||||
if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음
|
||||
const data = buildLaborDiagram({
|
||||
rows: cur.담은로직,
|
||||
logics: [...logics.values()],
|
||||
@@ -161,7 +167,7 @@ export function mountLaborDetail(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
@@ -415,6 +421,16 @@ export function mountLaborDetail(
|
||||
],
|
||||
});
|
||||
|
||||
// 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름)
|
||||
const nameField = createInputField({ type: "text", label: tc("Lib_Name") });
|
||||
nameField.root.dataset.laborField = "이름";
|
||||
nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? "");
|
||||
nameField.input.readOnly = !edit;
|
||||
nameField.input.addEventListener("input", () => {
|
||||
(cur as { 이름?: string }).이름 = nameField.input.value.trim();
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
|
||||
const editor = (body: HTMLElement): HTMLElement =>
|
||||
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
|
||||
host.replaceChildren(
|
||||
@@ -429,13 +445,17 @@ export function mountLaborDetail(
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", { className: "m01lab__info", children: [unit.root, origin.root] }),
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [...(lib ? [nameField.root] : []), unit.root, origin.root],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
|
||||
section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
|
||||
...(opt.card ? [] : [dgBox]),
|
||||
// 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 고르기는 고르기 창 탭(50-3 ④)
|
||||
section(tc("Logics_Title"), opt.card || lib ? null : addLogic, logicHost),
|
||||
...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]),
|
||||
...(opt.card || lib ? [] : [dgBox]),
|
||||
],
|
||||
}),
|
||||
),
|
||||
@@ -444,19 +464,28 @@ export function mountLaborDetail(
|
||||
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
|
||||
void Promise.all([
|
||||
loadLogics(),
|
||||
readTemplate(opt.layer, "area", opt.column, null).then(
|
||||
(got) => {
|
||||
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
|
||||
links = areaLinks(got.문서);
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
lib // 서고 모드엔 면적산출 없음
|
||||
? undefined
|
||||
: readTemplate(opt.layer, "area", opt.column, null).then(
|
||||
(got) => {
|
||||
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
|
||||
links = areaLinks(got.문서);
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
]).then(() => {
|
||||
if (alive) recalc();
|
||||
});
|
||||
|
||||
return {
|
||||
getDoc: () => cur,
|
||||
// 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음
|
||||
getDoc: () => {
|
||||
if (!lib) return cur;
|
||||
const out: Record<string, unknown> = { ...cur, 종류: promote ? "단가승격" : "사전조합" };
|
||||
delete out.열;
|
||||
delete out.자재;
|
||||
return out as unknown as LaborDoc;
|
||||
},
|
||||
saveArea: async () => {
|
||||
if (!area || !areaDirty) return false;
|
||||
const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc);
|
||||
|
||||
@@ -105,6 +105,7 @@ const TEXT = {
|
||||
In_Ok: ["확인", "OK"],
|
||||
In_Cancel: ["취소", "Cancel"],
|
||||
In_Close: ["닫기", "Close"],
|
||||
Lib_Name: ["이름", "Name"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A
|
||||
export type ManageLayer = "system" | "company" | "personal";
|
||||
/** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */
|
||||
export type Root = "master" | "company" | "personal";
|
||||
export type OpenTarget = "구조물조합" | "옵션" | "도면";
|
||||
export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합";
|
||||
|
||||
export interface Entry {
|
||||
root: Root;
|
||||
@@ -27,7 +27,7 @@ export interface Entry {
|
||||
company: string | null;
|
||||
}
|
||||
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면"];
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"];
|
||||
|
||||
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
|
||||
import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp";
|
||||
import { helpParts } from "./M02_MasterTemplete_CardShell";
|
||||
import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
export interface Selection {
|
||||
@@ -378,19 +379,24 @@ export function createMain(
|
||||
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
|
||||
return;
|
||||
}
|
||||
if (at.kind === "labor") {
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로
|
||||
if (at.kind === "labor" || isPrecombo(at.kind)) {
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 ·
|
||||
// 단가승격 · 사전조합(50-3) = 같은 상세 모드(도면 · 면적 · 자재 없음 · 이름 = 키)
|
||||
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
|
||||
const mode = isPrecombo(at.kind) ? at.kind : undefined;
|
||||
const labor = mountLaborDetail(host, doc, {
|
||||
layer: at.layer,
|
||||
column: at.name,
|
||||
readOnly,
|
||||
onChanged: (d) => (dirty = d && !readOnly),
|
||||
goArea: async () => {
|
||||
if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로
|
||||
void open({ ...at, kind: "area" });
|
||||
return true;
|
||||
},
|
||||
goArea: mode
|
||||
? null
|
||||
: async () => {
|
||||
if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로
|
||||
void open({ ...at, kind: "area" });
|
||||
return true;
|
||||
},
|
||||
...(mode ? { mode } : {}),
|
||||
});
|
||||
// [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값)
|
||||
editor = {
|
||||
|
||||
@@ -105,13 +105,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌
|
||||
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement | null, reselect: () => void): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: entry.root, owner },
|
||||
company,
|
||||
go: (next, nextOwner) => void go(next, nextOwner, open),
|
||||
add,
|
||||
...(add ? { add } : {}),
|
||||
reselect,
|
||||
});
|
||||
// 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6)
|
||||
@@ -184,6 +184,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
// 일위대가 조합(50-3) — 같은 층 단추 · 누르면 그 층 화면에서 이 컨테이너를 펼침
|
||||
precomboBar: [layerBar("일위대가조합", null, () => undefined)],
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 조합」(층 단추 · 단가승격 · 사전조합 ·
|
||||
* `UI_Side_Precombo` · 50-3) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
@@ -31,6 +32,7 @@ import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildPrecomboSection, PRECOMBO_TITLE } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
@@ -58,6 +60,8 @@ export interface SideOptions {
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 「일위대가 조합」 줄 아래 층 단추(50-3 · 페이지가 만듦) */
|
||||
precomboBar?: HTMLElement[];
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -66,7 +70,7 @@ export interface SideHandle {
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
|
||||
reveal: (target: "옵션" | "도면") => void;
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -130,6 +134,17 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인)
|
||||
const precombo = buildPrecomboSection({
|
||||
layer,
|
||||
canEdit: canEdit(layer, opt.isAdmin),
|
||||
bar: opt.precomboBar ?? [],
|
||||
confirmLeave: opt.confirmLeave,
|
||||
onOpen: opt.onOpen,
|
||||
isOpen: (kind, name) => isPicked(kind, name),
|
||||
refresh: () => refresh(),
|
||||
});
|
||||
const precomboSec = section(PRECOMBO_TITLE, precombo.body);
|
||||
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
|
||||
const extras =
|
||||
layer === "system"
|
||||
@@ -151,6 +166,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
precomboSec,
|
||||
...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []),
|
||||
...(practice ? [section(practice.title, practice.body)] : []),
|
||||
],
|
||||
@@ -205,6 +221,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
|
||||
function paint(): void {
|
||||
paintList();
|
||||
precombo.paint(items);
|
||||
paintButtons();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
@@ -228,6 +245,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
precombo.paint(items);
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -307,8 +325,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면"): void => {
|
||||
const sec = extras?.[target];
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => {
|
||||
const sec = target === "일위대가조합" ? precomboSec : extras?.[target];
|
||||
if (!sec) return; // 옵션 보이기는 시스템 관리자만
|
||||
sec.classList.remove("is-collapsed");
|
||||
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Precombo.ts
|
||||
* 좌측 「일위대가 조합」 컨테이너(50-3 ③) — 구조물 조합 아래 · 층 단추(페이지가 만듦) ·
|
||||
* 하위 둘 「단가승격」(promote · 단가산출 로직 하나를 입력값 고정해 일위대가 꼴로) ·
|
||||
* 「사전조합」(precombo · 승격 + 다른 사전조합 묶음) — 목록 · 새로 · 복사 · 지우기.
|
||||
* 상세는 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드 · 도면 · 면적 · 자재 없음).
|
||||
* 파일 이름 = 키(SP- · SC-) · 화면 이름 = 문서 `이름`(목록 `제목`) · 쓰는 곳 있으면 서버가 지우기를 막음(409 까닭).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
deleteLayerTemplate,
|
||||
deleteTemplate,
|
||||
getActiveOwner,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
export type PrecomboKind = Extract<Kind, "promote" | "precombo">;
|
||||
|
||||
/** 종류 · 키 머리 · 화면 이름(= 문서 `종류` 글) */
|
||||
const PARTS: { kind: PrecomboKind; prefix: string; label: string }[] = [
|
||||
{ kind: "promote", prefix: "SP", label: "단가승격" },
|
||||
{ kind: "precombo", prefix: "SC", label: "사전조합" },
|
||||
];
|
||||
export const PRECOMBO_TITLE = "일위대가 조합";
|
||||
export const isPrecombo = (kind: Kind): kind is PrecomboKind =>
|
||||
kind === "promote" || kind === "precombo";
|
||||
|
||||
/** 목록 줄 — 서버가 문서 이름을 `제목` 으로 실어 보냄(없으면 키만) */
|
||||
type Info = TemplateInfo & { 제목?: string };
|
||||
export const precomboLabel = (info: Info): string =>
|
||||
info.제목 ? `${info.이름} ${info.제목}` : info.이름;
|
||||
|
||||
/** 그 층 다음 키 — 지금 목록의 가장 큰 수 + 1(서버가 같은 키면 409 로 막음) */
|
||||
const nextKey = (prefix: string, items: Info[]): string => {
|
||||
const top = items.reduce((max, i) => {
|
||||
const m = new RegExp(`^${prefix}-(\\d+)$`).exec(i.이름);
|
||||
return m ? Math.max(max, Number(m[1])) : max;
|
||||
}, 0);
|
||||
return `${prefix}-${String(top + 1).padStart(6, "0")}`;
|
||||
};
|
||||
|
||||
export interface PrecomboOptions {
|
||||
layer: Layer;
|
||||
canEdit: boolean;
|
||||
/** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */
|
||||
bar: HTMLElement[];
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
isOpen: (kind: Kind, name: string) => boolean;
|
||||
/** 목록 다시 받음(좌측 전체) — 받은 뒤 `paint` 가 불림 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PrecomboHandle {
|
||||
body: HTMLElement;
|
||||
/** 좌측이 받은 층 목록 중 승격 · 사전조합만 그림 */
|
||||
paint: (items: TemplateInfo[]) => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const failed = (error: unknown): void =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
|
||||
export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
||||
let items: Info[] = [];
|
||||
const lists = new Map<PrecomboKind, HTMLElement>();
|
||||
const buttons = new Map<PrecomboKind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
|
||||
const mine = (kind: PrecomboKind): Info[] => items.filter((i) => i.종류 === kind);
|
||||
const current = (kind: PrecomboKind): Info | undefined =>
|
||||
mine(kind).find((i) => opt.isOpen(kind, i.이름));
|
||||
|
||||
const select = async (info: Info): Promise<void> => {
|
||||
if (!opt.isOpen(info.종류, info.이름) && !(await opt.confirmLeave())) return;
|
||||
opt.onOpen({
|
||||
layer: opt.layer,
|
||||
projectId: null,
|
||||
kind: info.종류,
|
||||
name: info.이름,
|
||||
label: precomboLabel(info),
|
||||
});
|
||||
};
|
||||
|
||||
/** 새로 · 복사 — 다음 키로 저장 후 엶 */
|
||||
const create = async (kind: PrecomboKind, doc: Record<string, unknown>): Promise<void> => {
|
||||
const key = nextKey(PARTS.find((p) => p.kind === kind)!.prefix, mine(kind));
|
||||
await saveTemplate(opt.layer, kind, key, null, "", doc);
|
||||
showToast(L("M02_Created"), "success");
|
||||
await opt.refresh();
|
||||
const info = mine(kind).find((i) => i.이름 === key);
|
||||
if (info) await select(info);
|
||||
};
|
||||
|
||||
const nameDialog = (title: string, start: string, onOk: (name: string) => Promise<void>) => {
|
||||
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
|
||||
name.input.value = start;
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
dialogClass: "m02-modal",
|
||||
mount: (body, close) => {
|
||||
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
|
||||
ok.addEventListener("click", async () => {
|
||||
const text = name.input.value.trim();
|
||||
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
} finally {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
name.input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.isComposing) ok.click();
|
||||
});
|
||||
body.append(name.root, ok);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 단가승격 새로 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것(입력값은 상세에서 고정) */
|
||||
const newPromote = (): void =>
|
||||
openLogicPicker((x) => {
|
||||
void opt.confirmLeave().then((go) =>
|
||||
!go
|
||||
? undefined
|
||||
: create("promote", {
|
||||
종류: "단가승격",
|
||||
이름: x.이름,
|
||||
단위: x.결과단위 || null,
|
||||
담은로직: [{ 로직: x.키, 입력값: {} }],
|
||||
}).catch(failed),
|
||||
);
|
||||
});
|
||||
/** 사전조합 새로 = 이름만 · 빈 묶음(승격 · 사전조합은 상세 [로직 추가]로) */
|
||||
const newPrecombo = (): void =>
|
||||
nameDialog(L("M02_NewTitle"), "", async (name) => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
await create("precombo", { 종류: "사전조합", 이름: name, 단위: null, 담은로직: [] });
|
||||
});
|
||||
|
||||
/** 복사 = 같은 층 새 키 · 이름 「(2)」 · 본뜬 = 원래 키 */
|
||||
const copy = (kind: PrecomboKind): void => {
|
||||
const from = current(kind);
|
||||
if (!from) return;
|
||||
nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const got = await readTemplate(opt.layer, kind, from.이름, null);
|
||||
const doc: Record<string, unknown> = { ...(got.문서 as object), 이름: name };
|
||||
doc.본뜬 = { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 };
|
||||
delete doc.판;
|
||||
await create(kind, doc);
|
||||
});
|
||||
};
|
||||
|
||||
const remove = async (kind: PrecomboKind): Promise<void> => {
|
||||
await opt.refresh(); // 저장 직후여도 최신 판으로
|
||||
const info = current(kind);
|
||||
if (!info) return;
|
||||
const name = precomboLabel(info);
|
||||
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", name)))) return;
|
||||
try {
|
||||
if (opt.layer === "system") await deleteTemplate(kind, info.이름, info.판);
|
||||
else await deleteLayerTemplate(opt.layer, kind, info.이름, info.판);
|
||||
showToast(L("M02_Deleted"), "success");
|
||||
opt.onOpen(null);
|
||||
await opt.refresh();
|
||||
} catch (error) {
|
||||
// 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로
|
||||
showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const block = (part: (typeof PARTS)[number]): HTMLElement => {
|
||||
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
|
||||
lists.set(part.kind, list);
|
||||
const row: HTMLElement[] = [];
|
||||
if (opt.canEdit) {
|
||||
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
|
||||
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
|
||||
const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
|
||||
buttons.set(part.kind, { copy: btnCopy, del });
|
||||
btnNew.addEventListener("click", part.kind === "promote" ? newPromote : newPrecombo);
|
||||
btnCopy.addEventListener("click", () => copy(part.kind));
|
||||
del.addEventListener("click", () => void remove(part.kind));
|
||||
row.push(el("div", { className: "m02-side__row", children: [btnNew, btnCopy, del] }));
|
||||
}
|
||||
return el("div", {
|
||||
className: "m02-side__sub",
|
||||
attrs: { "data-precombo": part.kind },
|
||||
children: [el("h4", { className: "m02-side__subtitle", text: part.label }), ...row, list],
|
||||
});
|
||||
};
|
||||
|
||||
const body = el("div", {
|
||||
className: "m02-side__items ui-sidebar-list",
|
||||
children: [...opt.bar, ...PARTS.map(block)],
|
||||
});
|
||||
|
||||
function paint(all: TemplateInfo[]): void {
|
||||
items = all.filter((i) => isPrecombo(i.종류));
|
||||
for (const part of PARTS) {
|
||||
const found = mine(part.kind);
|
||||
lists.get(part.kind)?.replaceChildren(
|
||||
...(found.length
|
||||
? found.map((info) => {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${opt.isOpen(part.kind, info.이름) ? " is-active" : ""}`,
|
||||
text: precomboLabel(info),
|
||||
attrs: { type: "button", title: info.수정일, "data-precombo-key": info.이름 },
|
||||
});
|
||||
b.addEventListener("click", () => void select(info));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
);
|
||||
const b = buttons.get(part.kind);
|
||||
if (b) b.copy.disabled = b.del.disabled = !current(part.kind);
|
||||
}
|
||||
}
|
||||
|
||||
return { body, paint };
|
||||
}
|
||||
@@ -56,6 +56,20 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 일위대가 조합 서고(50-3) — 하위 단가승격 · 사전조합 */
|
||||
.m02-side__sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-side__subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -79,7 +79,9 @@ def test_일위대가_카드는_상세_카드_갈래():
|
||||
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로
|
||||
assert "opt.card ? null : addLogic" in detail and "...(opt.card ? [] : [dgBox])," in detail
|
||||
# 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음
|
||||
assert "opt.card || lib ? null : addLogic" in detail
|
||||
assert "...(opt.card || lib ? [] : [dgBox])," in detail
|
||||
assert "opt.card ? null : addMat" in detail # 49-6 — 카드엔 [자재 추가] 없음 · 상세는 그대로
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
assert "const tools = !!(move || remove || option);" in rows and "compactTable" not in rows
|
||||
|
||||
@@ -46,7 +46,7 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음():
|
||||
assert "buildAllItems" not in page and "onAll" not in page and "ALL_TITLE" not in page
|
||||
assert "buildAllItems" not in _text("M02_MasterTemplete_ItemList.ts")
|
||||
api = _text("M02_MasterTemplete_Api_Fetch.ts")
|
||||
assert '| "labor";' in api and "일위대가번: string;" in api
|
||||
assert '| "labor" |' in api and "일위대가번: string;" in api
|
||||
locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8")
|
||||
for gone in ("M02_ItemsAll", "M02_KindLabor", "M02_KindStructure", "M02_KindArea"):
|
||||
assert gone not in locale, gone
|
||||
@@ -57,7 +57,9 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음():
|
||||
|
||||
def test_층_단추는_구조물_조합_한_벌():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나
|
||||
# 구조물 조합 줄 아래 하나 + 일위대가 조합 서고(50-3) 하나
|
||||
assert page.count('layerBar("') == 2
|
||||
assert 'layerBar("일위대가조합", null, () => undefined)' in page
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def test_주소_인자를_라우트에서_뗌():
|
||||
# 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로
|
||||
assert 'query.get("root") ?? query.get("layer")' in layer
|
||||
assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer
|
||||
assert '["구조물조합", "옵션", "도면"]' in layer # 일위대가 = 구조물 조합 안(44-7)
|
||||
assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3
|
||||
picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts")
|
||||
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
||||
|
||||
|
||||
Reference in New Issue
Block a user