diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index d16f16a9e..455daa988 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -70,26 +70,39 @@ export const getActiveLayer = (): Layer => activeLayer; export const getActiveOwner = (): string | null => activeOwner; const enc = encodeURIComponent; -/** 지금 층 길이면 `owner` 인자를 붙임 */ -const owned = (layer: Layer, path: string): string => - layer === activeLayer && activeOwner && layer !== "system" && layer !== "project" - ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}` +/** 지금 층 길이면 `owner` 인자를 붙임 · `owner` 를 넘기면(134-3 · 사전조합 층) 그 주인만(null = 자기 층) */ +const owned = (layer: Layer, path: string, owner?: string | null): string => { + const who = owner === undefined ? (layer === activeLayer ? activeOwner : null) : owner; + return who && layer !== "system" && layer !== "project" + ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(who)}` : path; +}; const scoped = (path: string, init: RequestInit = {}): Promise => call(owned(activeLayer, `/layers/${activeLayer}${path}`), init); const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : ""); const head = (layer: Layer): string => `/layers/${layer}/templates`; -const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => - owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`); +const item = ( + layer: Layer, + kind: Kind, + name: string, + projectId: string | null, + owner?: string | null, +): string => + owned( + layer, + `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`, + owner, + ); /** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */ export const listTemplates = async ( layer: Layer, projectId: string | null, + owner?: string | null, ): Promise => { const got = await call( - owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`), + owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`, owner), ); return Array.isArray(got) ? got : got.양식; }; @@ -99,7 +112,8 @@ export const readTemplate = ( kind: Kind, name: string, projectId: string | null, -): Promise => call(item(layer, kind, name, projectId)); + owner?: string | null, +): Promise => call(item(layer, kind, name, projectId, owner)); export const saveTemplate = ( layer: Layer, @@ -108,8 +122,12 @@ export const saveTemplate = ( projectId: string | null, 판: string, 문서: unknown, + owner?: string | null, ): Promise => - call(item(layer, kind, name, projectId), { method: "PUT", body: JSON.stringify({ 판, 문서 }) }); + call(item(layer, kind, name, projectId, owner), { + method: "PUT", + body: JSON.stringify({ 판, 문서 }), + }); /** 시스템 층만 지움 */ export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => @@ -121,8 +139,9 @@ export const deleteLayerTemplate = ( kind: Kind, name: string, 판: string, + owner?: string | null, ): Promise<{ ok: boolean }> => - call(owned(layer, `/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`), { + call(owned(layer, `/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, owner), { method: "DELETE", }); @@ -133,8 +152,9 @@ export const newLibrary = ( layer: Layer, kind: "precombo", body: LibraryBody, + owner?: string | null, ): Promise => - call(owned(layer, `/layers/${layer}/library/${kind}`), { + call(owned(layer, `/layers/${layer}/library/${kind}`, owner), { method: "POST", body: JSON.stringify(body), }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index a27945e4c..6f1fa2a76 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -218,9 +218,9 @@ export type { Money as LaborCost, } from "./M02_MasterTemplete_Labor_Calc"; -/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */ -const laborAt = (layer: Layer, column: string, tail: string): string => { - const owner = layer === getActiveLayer() ? getActiveOwner() : null; +/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) · `who` 를 넘기면 그 주인(134-3) */ +const laborAt = (layer: Layer, column: string, tail: string, who?: string | null): string => { + const owner = who !== undefined ? who : layer === getActiveLayer() ? getActiveOwner() : null; const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; return `/layers/${layer}/labor/${enc(column)}${tail}${q}`; }; @@ -231,8 +231,9 @@ export const calcLabor = ( column: string, doc: LaborDoc | null, 수량?: Record, + owner?: string | null, ): Promise => - request(laborAt(layer, column, "/calc"), "POST", { + request(laborAt(layer, column, "/calc", owner), "POST", { ...(doc ? { doc } : {}), ...(수량 ? { 수량 } : {}), }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 946f1cb8c..878146694 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -86,6 +86,8 @@ export interface LaborDetailOptions { card?: boolean; /** 일위대가 사전조합 서고(50-3) — 사전조합 문서(`column` = 키) */ mode?: "precombo"; + /** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */ + owner?: string | null; } export interface LaborDetailHandle { @@ -243,7 +245,13 @@ export function mountLaborDetail( draw(); try { // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) - const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined); + const got = await calcLabor( + opt.layer, + opt.column, + cur, + areaDirty ? given() : undefined, + opt.owner, + ); if (!alive || mine !== seq) return; calc = last = got; pills = new Map( @@ -452,7 +460,12 @@ export function mountLaborDetail( /** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */ const library = { layer: opt.layer, - owner: opt.layer === getActiveLayer() ? getActiveOwner() : null, + owner: + opt.owner !== undefined + ? opt.owner + : opt.layer === getActiveLayer() + ? getActiveOwner() + : null, pick: (ref: LaborRef, name: string) => { const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts index 422309fea..d8edd56ab 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts @@ -30,14 +30,14 @@ export async function openRefEdit(opt: RefEditOptions): Promise { let doc: { 담은로직?: LaborRow[] }; let 판: string; try { - const got = await readTemplate(layer, kind, opt.ref.키, null); + const got = await readTemplate(layer, kind, opt.ref.키, null, opt.ref.주인); // 134-3 — 참조 주인 그대로 doc = got.문서 as typeof doc; 판 = got.판 ?? ""; } catch (error) { return fail(error, "Load_Failed"); } const save = (body: unknown): Promise => - saveTemplate(layer, kind, opt.ref.키, null, 판, body).then(() => + saveTemplate(layer, kind, opt.ref.키, null, 판, body, opt.ref.주인).then(() => showToast(tc("Saved"), "success"), ); const notice = opt.readOnly ? `${tc("Ref_Shared")} · ${tc("Ref_ReadOnly")}` : tc("Ref_Shared"); @@ -68,6 +68,7 @@ export async function openRefEdit(opt: RefEditOptions): Promise { onChanged: (d) => (dirty = d && !opt.readOnly), goArea: null, mode: "precombo", + owner: opt.ref.주인, }); const cancel = createButton({ label: tc(opt.readOnly ? "In_Close" : "In_Cancel"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 6ad1d8ed9..8088e75fb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -141,6 +141,8 @@ const TEXT = { Lib_Name: ["이름", "Name"], // 서고 목록표(51-4) Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"], + Lib_None: ["사전조합 없음", "No pre-combination"], + Lib_Unsorted: ["미분류", "Unsorted"], Lib_Create: ["생성", "Create"], Lib_Copy: ["복사", "Copy"], Lib_Delete: ["삭제", "Delete"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 43a7b6b86..496792475 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Layer.ts * M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시 - * `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…` - * (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · + * `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…` + * (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · * 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대. * ========================================================================== */ @@ -25,9 +25,12 @@ export interface Entry { open: OpenTarget; /** 시스템 관리자가 보는 회사(PLAN 29 · 없으면 sessionStorage `aislo.adminCompany` · 자기 회사) */ company: string | null; + /** 일위대가 사전조합 층(134-3 · 페이지 층과 따로) — 없으면 기본(`defaultRoot`) */ + pre: Root | null; } const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"]; +const ROOTS: Root[] = ["master", "company", "personal"]; export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root); @@ -38,7 +41,8 @@ export function entryFromHash(): Entry { const root: Root = raw === "company" || raw === "personal" ? raw : "master"; const open = OPENS.find((one) => one === query.get("open")) ?? "구조물조합"; const owner = root === "master" ? null : query.get("owner") || null; - return { root, owner, open, company: query.get("company") || null }; + const pre = ROOTS.find((one) => one === query.get("pre")) ?? null; + return { root, owner, open, company: query.get("company") || null, pre }; } /** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */ @@ -47,8 +51,13 @@ export const entryQuery = (entry: Entry): Record => ({ root: entry.root, ...(entry.owner ? { owner: entry.owner } : {}), ...(entry.company ? { company: entry.company } : {}), + ...(entry.pre ? { pre: entry.pre } : {}), }); +/** 기본 층(134-4) — 내 개인 층이 있으면 개인 · 회사 없으면 마스터 */ +export const defaultRoot = (user: SessionUser | null): Root => + ownOwner("personal", user) ? "personal" : "master"; + /** 볼 회사 — 시스템 관리자 = 해시 `company` · sessionStorage `aislo.adminCompany`(대시보드가 넣음) · * 자기 회사 차례 · 그 밖 = 자기 회사 · 없으면 null */ export function viewCompany(entry: Entry, user: SessionUser | null): string | null { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 6fa883238..fa4dd69c6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -21,7 +21,6 @@ import "../M01_MasterData/M01_MasterData_UI_Style.css"; import { deleteLayerTemplate, deleteTemplate, - getActiveOwner, listTemplates, newLibrary, readTemplate, @@ -45,10 +44,14 @@ type Row = TemplateInfo & { export interface LibraryListOptions { layer: Layer; + /** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) */ + owner: string | null; kind: PrecomboKind; canEdit: boolean; /** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */ open: (key: string, label: string) => void; + /** 생성 · 복사 · 삭제 뒤 — 좌측 나무를 다시 그림 */ + changed: () => void; } const SIZE = 30; @@ -130,7 +133,9 @@ export function mountLibraryList( const load = async (): Promise => { try { - rows = (await listTemplates(opt.layer, null)).filter((r) => r.종류 === opt.kind) as Row[]; + rows = (await listTemplates(opt.layer, null, opt.owner)).filter( + (r) => r.종류 === opt.kind, + ) as Row[]; } catch (error) { rows = []; showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); @@ -171,10 +176,11 @@ export function mountLibraryList( /** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */ const made = (key: string, label: string): void => { showToast(L("M02_Created"), "success"); + opt.changed(); opt.open(key, label); }; const create = async (body: LibraryBody, label: string): Promise => - made((await newLibrary(opt.layer, opt.kind, body)).이름, label); + made((await newLibrary(opt.layer, opt.kind, body, opt.owner)).이름, label); /** 사전조합 = 이름 + 구분 · 상세구분 처음 값 = 로직 구분 목록 첫 줄(51-12 · 상세에서 바꿈) · 빈 묶음 */ btnNew.addEventListener("click", () => { @@ -196,14 +202,22 @@ export function mountLibraryList( const from = rows.find((r) => r.이름 === picked); if (!from) return; nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => { - const got = await newLibrary(opt.layer, opt.kind, { - 본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }, - }); - const doc = await readTemplate(opt.layer, opt.kind, got.이름, null); - await saveTemplate(opt.layer, opt.kind, got.이름, null, got.판, { - ...(doc.문서 as object), - 이름: name, - }); + const got = await newLibrary( + opt.layer, + opt.kind, + { 본뜬: { 층: opt.layer, 주인: opt.owner, 키: from.이름 } }, + opt.owner, + ); + const doc = await readTemplate(opt.layer, opt.kind, got.이름, null, opt.owner); + await saveTemplate( + opt.layer, + opt.kind, + got.이름, + null, + got.판, + { ...(doc.문서 as object), 이름: name }, + opt.owner, + ); made(got.이름, name); }); }); @@ -216,8 +230,9 @@ export function mountLibraryList( if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", label)))) return; try { if (opt.layer === "system") await deleteTemplate(opt.kind, info.이름, info.판); - else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판); + else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판, opt.owner); showToast(L("M02_Deleted"), "success"); + opt.changed(); picked = null; await load(); } catch (error) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index d98c88b00..31b05e1f4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -46,6 +46,10 @@ export interface Selection { name: string; /** 화면 제목에 쓸 글 — 없으면 name 그대로(구조물 도면은 열 머리글) */ label?: string; + /** 사전조합 층(134-3 · 페이지 층과 따로) — 서버 길에 붙일 주인(null = 자기 층) · 고칠 권한 · + * 없으면 페이지 층 것 */ + owner?: string | null; + canWrite?: boolean; } interface EditorHandle { @@ -243,9 +247,11 @@ export function createMain( const empty = (text: string): void => { host.replaceChildren(el("p", { className: "m02-main__empty", text })); }; + /** 고를 것마다 고칠 권한 — 사전조합 층은 고름에 실림(134-3) · 그 밖 페이지 층 */ + const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin); const bar = (): void => { reload.hidden = !sel; - save.hidden = !sel || !canEdit(sel.layer, isAdmin); + save.hidden = !sel || !writable(sel); back.hidden = !sel || !(sel.kind in NUMBER_FIELD); nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음 const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null; @@ -345,7 +351,7 @@ export function createMain( } async function mount(doc: unknown, at: Selection): Promise { - const readOnly = !canEdit(at.layer, isAdmin); + const readOnly = !writable(at); host.replaceChildren(); if (at.kind === "table") { const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( @@ -424,7 +430,7 @@ export function createMain( void open({ ...at, kind: "area" }); return true; }, - ...(mode ? { mode } : {}), + ...(mode ? { mode, owner: at.owner ?? null } : {}), }); // [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값) editor = { @@ -508,14 +514,16 @@ export function createMain( editor = mountLibraryList(host, slot, { layer: next.layer, kind: next.kind, - canEdit: canEdit(next.layer, isAdmin), + owner: next.owner ?? null, + canEdit: writable(next), open: (name, label) => void open({ ...next, name, label }), + changed: () => onSaved?.(), }); return; } const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6) try { - const got = await readTemplate(next.layer, next.kind, next.name, next.projectId); + const got = await readTemplate(next.layer, next.kind, next.name, next.projectId, next.owner); if (mine !== seq) return; version = got.판 ?? ""; loaded = got.문서; @@ -563,7 +571,15 @@ export function createMain( ...(edited as Record), }) // + 저장 전 [보내기 취소] 연결(112-2) : edited; - const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc); + const info = await saveTemplate( + at.layer, + at.kind, + at.name, + at.projectId, + version, + doc, + at.owner, + ); version = info.판; loaded = doc; dirty = false; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 7eebb2002..8547d25ff 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -19,6 +19,7 @@ import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildReadOnlyBar, + defaultRoot, entryFromHash, entryQuery, layerOfRoot, @@ -41,6 +42,7 @@ import { } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo"; import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; @@ -173,6 +175,35 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const at = + (want === "company" && !company) || (want === "personal" && !ownOwner("personal", user)) + ? "master" + : want; + const to = layerOfRoot(at); + const who = at === "company" ? companyOwner(company, user) : null; + return { root: at, layer: to, owner: who, canWrite: accessFor(to, who, user).canWrite }; + }; + let pre = preScope(entry.pre ?? defaultRoot(user)); + const setPre = (want: Root): void => { + pre = preScope(want); + entry.pre = pre.root; + const query = new URLSearchParams(entryQuery(entry)); + history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`); + void side.refreshPrecombo(); + }; + const preBar = (): HTMLElement => + createLayerBar({ + user, + active: { root: pre.root, owner: pre.owner }, + company, + go: (next) => setPre(next), + // 켜진 단추 다시 누름 = 그 층 목록표(생성 · 복사 · 삭제) + reselect: () => + void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))), + }); const side = buildSide({ layer, isAdmin, @@ -184,8 +215,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - // 일위대가 조합(50-3) — 같은 층 단추 · 누르면 그 층 화면에서 이 컨테이너를 펼침 - precomboBar: [layerBar("일위대가조합", null, () => undefined)], + precombo: { scope: () => pre, bar: preBar }, }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 7ceefa0f5..f0e73ad40 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 조합」(층 단추 · 단가승격 · 사전조합 · - * `UI_Side_Precombo` · 50-3) · 「도면 양식」(새로 · 본떠 · 지우기). + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 사전조합」(층 단추 따로 · 2단 폴더 나무 · + * `UI_Side_Precombo` · 50-3 · 134-3) · 「도면 양식」(새로 · 본떠 · 지우기). * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. @@ -32,7 +32,11 @@ 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"; +import { + buildPrecomboSection, + PRECOMBO_TITLE, + type PrecomboOptions, +} from "./M02_MasterTemplete_UI_Side_Precombo"; const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, @@ -60,8 +64,8 @@ export interface SideOptions { showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; - /** 「일위대가 조합」 줄 아래 층 단추(50-3 · 페이지가 만듦) */ - precomboBar?: HTMLElement[]; + /** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */ + precombo: Pick; } export interface SideHandle { @@ -71,6 +75,8 @@ export interface SideHandle { paint: () => void; /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */ reveal: (target: "옵션" | "도면" | "일위대가조합") => void; + /** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */ + refreshPrecombo: () => Promise; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -134,13 +140,12 @@ export function buildSide(opt: SideOptions): SideHandle { openOnExpand(summary, async () => { if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null); }); - // 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인) + // 일위대가 사전조합 서고(50-3 · 134-3) — 구조물 조합 바로 아래 · 층은 페이지 층과 따로 const precombo = buildPrecomboSection({ - layer, - bar: opt.precomboBar ?? [], + ...opt.precombo, confirmLeave: opt.confirmLeave, onOpen: opt.onOpen, - openKind: () => (opt.getOpen()?.layer === layer ? (opt.getOpen()?.kind ?? null) : null), + getOpen: opt.getOpen, }); const precomboSec = section(PRECOMBO_TITLE, precombo.body); // 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2) @@ -243,7 +248,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); - precombo.paint(); + await precombo.refresh(); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ @@ -331,5 +336,5 @@ export function buildSide(opt: SideOptions): SideHandle { }; void refresh(); - return { root, refresh, paint, reveal }; + return { root, refresh, paint, reveal, refreshPrecombo: precombo.refresh }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 20ac94507..67a77f931 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -1,64 +1,182 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts - * 좌측 「일위대가 조합」 컨테이너(50-3 ③ · 51-4) — 구조물 조합 아래 · 층 단추(페이지가 만듦) · - * 항목 둘 「단가산출 승격」(promote) · 「일위대가 사전조합」(precombo)만. - * 항목 누름 = 상세 칸에 그 종류 목록표(`Library_List` · 생성 · 복사 · 삭제는 거기) · - * 표 줄 누름 = 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드). + * 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 · + * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무(구분 › 상세구분 2단 폴더 · 빈 구분 = 미분류). + * 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표 + * (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄. * ========================================================================== */ import { el } from "@ui/ui_template_elements"; -import type { Kind, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { createTreeRow, renderTree, type TreeNode } from "@ui/ui_template_tree"; +import { listTemplates, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { Root } from "./M02_MasterTemplete_Layer"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; export type PrecomboKind = Extract; -const PARTS = [{ kind: "precombo", label: "Lib_PrecomboAll" }] as const; -export const PRECOMBO_TITLE = "일위대가 조합"; +export const PRECOMBO_TITLE = "일위대가 사전조합"; export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "precombo"; -export interface PrecomboOptions { +/** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ +export interface PrecomboScope { + root: Root; layer: Layer; - /** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */ - bar: HTMLElement[]; + owner: string | null; + canWrite: boolean; +} + +/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */ +export const precomboList = (scope: PrecomboScope): Selection => ({ + layer: scope.layer, + owner: scope.owner, + canWrite: scope.canWrite, + projectId: null, + kind: "precombo", + name: "", + label: tc("Lib_PrecomboAll"), +}); + +export interface PrecomboOptions { + scope: () => PrecomboScope; + /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ + bar: () => HTMLElement; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; - /** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */ - openKind: () => Kind | null; + getOpen: () => Selection | null; } export interface PrecomboHandle { body: HTMLElement; + /** 고름 표시만 */ paint: () => void; + /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ + refresh: () => Promise; +} + +interface Row { + 이름: string; + 종류: string; + 제목?: string | null; + 구분?: string | null; + 상세구분?: string | null; +} + +/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) */ +export function groupRows( + rows: Row[], + unsorted: string, +): { 구분: string; 상세: Map }[] { + const out = new Map>(); + for (const row of rows) { + const top = row.구분 || ""; + const sub = row.구분 ? row.상세구분 || "" : ""; + if (!out.has(top)) out.set(top, new Map()); + const subs = out.get(top)!; + subs.set(sub, [...(subs.get(sub) ?? []), row]); + } + const order = (a: string, b: string): number => + !a ? 1 : !b ? -1 : a.localeCompare(b, "ko-KR", { numeric: true }); + return [...out.keys()].sort(order).map((top) => ({ + 구분: top || unsorted, + 상세: new Map([...out.get(top)!].sort(([a], [b]) => order(a, b))), + })); } export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { - const items = PARTS.map((part) => { - const b = el("button", { - className: "m02-side__item ui-sidebar-row", - text: tc(part.label), - attrs: { type: "button", "data-precombo": part.kind }, + const barHost = el("div"); + const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } }); + const leaves = new Map(); + const closed = new Set(); // 접은 폴더 — 다시 그려도 그대로 + let rows: Row[] = []; + let seq = 0; + + const open = async (row: Row): Promise => { + const at = opt.scope(); + if (!(await opt.confirmLeave())) return; + opt.onOpen({ + ...precomboList(at), + name: row.이름, + label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, }); - // 이름 "" = 목록표(UI_Main) - b.addEventListener("click", async () => { - if (!(await opt.confirmLeave())) return; - opt.onOpen({ - layer: opt.layer, - projectId: null, - kind: part.kind, - name: "", - label: tc(part.label), - }); - }); - return b; + }; + const leaf = (row: Row): TreeNode => ({ + id: `doc:${row.이름}`, + label: row.제목 || row.이름, + tip: row.이름, + count: null, + run: () => void open(row), }); + const folder = (id: string, label: string, children: TreeNode[]): TreeNode => ({ + id, + label, + count: children.length, + folder: true, + open: !closed.has(id), + children, + }); + + const draw = (): void => { + leaves.clear(); + if (!rows.length) + return void treeHost.replaceChildren( + el("p", { className: "m02-side__empty", text: tc("Lib_None") }), + ); + const nodes = groupRows(rows, tc("Lib_Unsorted")).map((g) => + folder( + `sub:${g.구분}`, + g.구분, + [...g.상세].flatMap(([sub, list]) => + sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf))] : list.map(leaf), + ), + ), + ); + treeHost.replaceChildren( + ...renderTree(nodes, (node, caret, depth, onClick) => { + const row = createTreeRow(node, caret, depth, () => { + onClick(); + if (!node.children?.length) return; + if (row.querySelector(".ui-tree__caret")?.textContent === "▸") closed.add(node.id); + else closed.delete(node.id); + }); + if (node.run) { + row.dataset.precomboKey = node.id.slice(4); + leaves.set(node.id.slice(4), row); + } + return row; + }), + ); + paint(); + }; + + const paint = (): void => { + const cur = opt.getOpen(); + const at = opt.scope(); + const mine = + cur && isPrecombo(cur.kind) && cur.layer === at.layer && (cur.owner ?? null) === at.owner; + for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); + }; + + const refresh = async (): Promise => { + const mine = ++seq; + const at = opt.scope(); + barHost.replaceChildren(opt.bar()); + treeHost.replaceChildren(el("p", { className: "m02-side__empty", text: tc("Loading") })); + let got: Row[] = []; + try { + got = (await listTemplates(at.layer, null, at.owner)).filter((r) => r.종류 === "precombo"); + } catch { + got = []; // 못 받으면 빈 나무(목록표에서 까닭) + } + if (mine !== seq) return; + rows = got; + draw(); + }; + const body = el("div", { className: "m02-side__items ui-sidebar-list", - children: [...opt.bar, ...items], + attrs: { "data-precombo": "" }, + children: [barHost, treeHost], }); - const paint = (): void => { - const on = opt.openKind(); - items.forEach((b, i) => b.classList.toggle("is-active", PARTS[i].kind === on)); - }; - return { body, paint }; + return { body, paint, refresh }; } diff --git a/resources/tester/helper_m02_precombo_scope.cjs b/resources/tester/helper_m02_precombo_scope.cjs new file mode 100644 index 000000000..aa455bbe2 --- /dev/null +++ b/resources/tester/helper_m02_precombo_scope.cjs @@ -0,0 +1,52 @@ +/* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 · + * `M02_MasterTemplete_UI_Side_Precombo.ts` `groupRows`(구분 › 상세구분 2단) 를 트랜스파일해 Node 로 돌림 · + * fetch 대역이 부른 주소를 모음 · stdin = {rows} · stdout = {urls, groups} JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const urls = []; +global.fetch = async (url) => { + urls.push(url); + return { status: 200, ok: true, json: async () => ({ 양식: [] }) }; +}; + +(async () => { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + const api = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Api_Fetch.ts")); + const side = loadTs( + path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Precombo.ts"), + ); + api.setActiveLayer("personal", "7/99"); // 페이지 = 남의 개인을 보는 중 + await api.readTemplate("personal", "labor", "x1", null); // 페이지 층 = 그 사람 + await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인 + await api.listTemplates("company", null, "8"); // 사전조합 = 볼 회사 + await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null); + const groups = side.groupRows(input.rows, "미분류").map((g) => ({ + 구분: g.구분, + 상세: [...g.상세].map(([k, v]) => [k, v.map((r) => r.이름)]), + })); + process.stdout.write(JSON.stringify({ urls, groups })); +})(); diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 86b732761..fbd950f94 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -161,7 +161,7 @@ def test_순서칸_2행_참조줄_옵션_수정(): assert "readOnly: !edit || ref.층 !== opt.layer," in detail assert "closed: () => alive && recalc()," in detail ref = _text("M02_MasterTemplete_Labor_RefEdit.ts") - assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body)" in ref + assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body, opt.ref.주인)" in ref assert 'mode: "precombo",' in ref and "showConfirmDialog" in ref assert 'Labor_Edit: ["수정"' in _text("M02_MasterTemplete_Labor_Text.ts") diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index ff2f7bb97..c8b7aa5ce 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -57,9 +57,9 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음(): def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") - # 구조물 조합 줄 아래 하나 + 일위대가 조합 서고(50-3) 하나 - assert page.count('layerBar("') == 2 - assert 'layerBar("일위대가조합", null, () => undefined)' in page + # 구조물 조합 줄 아래 하나 · 일위대가 사전조합은 층 따로(134-3 · `preBar`) + assert page.count('layerBar("') == 1 + assert "const preBar = (): HTMLElement =>" in page assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index a4e027391..a1f9b249c 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -201,7 +201,7 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert "...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {})" in panel assert "const got = opt.canEdit && api.paste ? clipOf(clipKind) : null;" in panel api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") - assert "owner=${enc(activeOwner)}" in api and "/owners" in api + assert "owner=${enc(who)}" in api and "/owners" in api # 134-3 — 명시 owner 도 같은 자리 def test_층_길_접두어와_고정_풀림(): @@ -215,7 +215,10 @@ def test_층_길_접두어와_고정_풀림(): assert '"system"' not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 열 조합 읽기(옛 Combo_Api · 39-4 D3 지움) 자리 = 열마다 일위대가 문서 — 층을 받아 부름 labor = _text(M02 / "M02_MasterTemplete_Labor_Api.ts") - assert "const laborAt = (layer: Layer, column: string, tail: string): string =>" in labor + assert ( + "const laborAt = (layer: Layer, column: string, tail: string, who?: string | null): string =>" + in labor + ) side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") assert "const layer = opt.layer;" in side assert 'layer === "system"' in side # 도면 양식은 시스템 층에만 diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py new file mode 100644 index 000000000..11a893db8 --- /dev/null +++ b/resources/tester/test_m02_precombo_scope.py @@ -0,0 +1,87 @@ +"""일위대가 사전조합 층 따로(PLAN 134-3) — 페이지 층과 따로 · 명시 owner · 2단 폴더 나무. + +- 서버 길: 명시 owner 를 넘기면 그 주인만(null = 자기 층) — 남의 개인을 보는 중에도 사전조합 길에 안 섞임 · + 안 넘기면 지금처럼 페이지 층 주인(Node · fetch 대역) +- 나무: 구분 › 상세구분 2단 · 빈 구분 = 미분류 맨 뒤 · 빈 상세구분 = 구분 바로 아래 +- 사전조합 층 단추 = 해시를 바꿔 페이지를 다시 세우지 않음(replaceState · 그 컨테이너만 다시 그림) · + 구조물 조합 층 단추는 그대로 해시 이동 · 주소 `pre` 에 남음 · 고름에 owner · canWrite +""" + +import json +import os +import subprocess +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" + + +def _text(name: str) -> str: + return (M02 / name).read_text(encoding="utf-8") + + +def _run(rows): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_precombo_scope.cjs")], + input=json.dumps({"rows": rows}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def test_명시_owner_길은_페이지_층_주인과_안_섞임(): + urls = _run([])["urls"] + assert urls[0] == "/api/m02/layers/personal/templates/labor/x1?owner=7%2F99" + assert urls[1] == "/api/m02/layers/personal/templates/precombo/SC-000001" + assert urls[2] == "/api/m02/layers/company/templates?owner=8" + assert urls[3] == "/api/m02/layers/system/templates/precombo/SC-000002" + + +def test_나무는_구분_상세구분_2단_미분류는_맨_뒤(): + rows = [ + {"이름": "SC-000003", "종류": "precombo", "구분": "C 사면안정", "상세구분": "옹벽"}, + {"이름": "SC-000001", "종류": "precombo", "구분": "", "상세구분": "07장"}, + {"이름": "SC-000002", "종류": "precombo", "구분": "A 횡단배수", "상세구분": ""}, + {"이름": "SC-000004", "종류": "precombo", "구분": "C 사면안정", "상세구분": "돌쌓기"}, + {"이름": "SC-000005", "종류": "precombo", "구분": "A 횡단배수", "상세구분": "공통"}, + ] + groups = _run(rows)["groups"] + assert [g["구분"] for g in groups] == ["A 횡단배수", "C 사면안정", "미분류"] + assert groups[0]["상세"] == [["공통", ["SC-000005"]], ["", ["SC-000002"]]] + assert groups[1]["상세"] == [["돌쌓기", ["SC-000004"]], ["옹벽", ["SC-000003"]]] + assert groups[2]["상세"] == [["", ["SC-000001"]]] # 구분 없으면 상세구분도 안 봄 + + +def test_사전조합_층_단추는_페이지를_다시_안_세움(): + page = _text("M02_MasterTemplete_UI_Page.ts") + pre = page.split("const setPre = (want: Root): void => {")[1].split("};")[0] + assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre + assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음 + assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음 + bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0] + assert "go: (next) => setPre(next)," in bar and "main.open(precomboList(pre))" in bar + # 구조물 조합 층 단추는 그대로 해시 이동 한 벌 + assert page.count('layerBar("') == 1 + assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page + assert "precombo: { scope: () => pre, bar: preBar }," in page + + +def test_고름에_owner_권한_서고_길에_명시_owner(): + side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + assert 'export const PRECOMBO_TITLE = "일위대가 사전조합";' in side + assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side + assert "listTemplates(at.layer, null, at.owner)" in side + main = _text("M02_MasterTemplete_UI_Main.ts") + assert ( + "const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);" + in main + ) + assert "next.owner,\n );" in main and "at.owner,\n );" in main + assert "owner: next.owner ?? null," in main and "mode, owner: at.owner ?? null" in main + lib = _text("M02_MasterTemplete_Library_List.ts") + assert "getActiveOwner" not in lib and lib.count("opt.owner") >= 6 + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert "opt.owner !== undefined" in detail