feat(M02): 일위대가 사전조합 — 층 단추 따로 · 명시 owner · 구분 › 상세구분 2단 나무(134-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-09 15:01:16 +09:00
co-authored by Claude Opus 5.5
parent 15f2e7c4f5
commit 66efaf2834
16 changed files with 466 additions and 94 deletions
@@ -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 = <T>(path: string, init: RequestInit = {}): Promise<T> =>
call<T>(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<TemplateInfo[]> => {
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
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<TemplateDoc> => call(item(layer, kind, name, projectId));
owner?: string | null,
): Promise<TemplateDoc> => 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<TemplateInfo> =>
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<TemplateInfo> =>
call(owned(layer, `/layers/${layer}/library/${kind}`), {
call(owned(layer, `/layers/${layer}/library/${kind}`, owner), {
method: "POST",
body: JSON.stringify(body),
});
@@ -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<string, number>,
owner?: string | null,
): Promise<LaborCalc & { 알약: unknown[] }> =>
request(laborAt(layer, column, "/calc"), "POST", {
request(laborAt(layer, column, "/calc", owner), "POST", {
...(doc ? { doc } : {}),
...(수량 ? { 수량 } : {}),
});
@@ -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))
@@ -30,14 +30,14 @@ export async function openRefEdit(opt: RefEditOptions): Promise<void> {
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<unknown> =>
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<void> {
onChanged: (d) => (dirty = d && !opt.readOnly),
goArea: null,
mode: "precombo",
owner: opt.ref.주인,
});
const cancel = createButton({
label: tc(opt.readOnly ? "In_Close" : "In_Cancel"),
@@ -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"],
+12 -3
View File
@@ -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<string, string> => ({
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 {
@@ -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<void> => {
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<void> =>
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) {
@@ -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<void> {
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<string, unknown>),
}) // + 저장 전 [보내기 취소] 연결(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;
@@ -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<Mounte
kind: "table",
name: STRUCTURE_TABLE,
};
// 일위대가 사전조합 층(134-3) — 페이지 층(해시 root)과 따로 · 마스터 · 볼 회사 · 내 개인(사람 나무 없음) ·
// 못 가는 층(회사 없음) = 마스터 · 고른 층 = 주소 `pre`(페이지를 다시 세우지 않음 · replaceState)
const preScope = (want: Root): PrecomboScope => {
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<Mounte
layerBar("구조물조합", tree.add, () => 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" });
@@ -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<PrecomboOptions, "scope" | "bar">;
}
export interface SideHandle {
@@ -71,6 +75,8 @@ export interface SideHandle {
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
/** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */
refreshPrecombo: () => Promise<void>;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
@@ -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 };
}
@@ -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<Kind, "precombo">;
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<boolean>;
onOpen: (sel: Selection | null) => void;
/** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */
openKind: () => Kind | null;
getOpen: () => Selection | null;
}
export interface PrecomboHandle {
body: HTMLElement;
/** 고름 표시만 */
paint: () => void;
/** 층 단추 · 나무를 그 층 목록으로 다시 그림 */
refresh: () => Promise<void>;
}
interface Row {
이름: string;
종류: string;
제목?: string | null;
구분?: string | null;
상세구분?: string | null;
}
/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) */
export function groupRows(
rows: Row[],
unsorted: string,
): { 구분: string; 상세: Map<string, Row[]> }[] {
const out = new Map<string, Map<string, Row[]>>();
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<string, HTMLElement>();
const closed = new Set<string>(); // 접은 폴더 — 다시 그려도 그대로
let rows: Row[] = [];
let seq = 0;
const open = async (row: Row): Promise<void> => {
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<void> => {
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 };
}
@@ -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 }));
})();
+1 -1
View File
@@ -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")
+3 -3
View File
@@ -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
+5 -2
View File
@@ -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 # 도면 양식은 시스템 층에만
@@ -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