Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_1
This commit is contained in:
@@ -62,6 +62,8 @@ export const STATE_REGISTRY = {
|
||||
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */
|
||||
"m02-card-pills": { bucket: "pref", scope: "global" },
|
||||
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import type { ConditionOptions } from "./spreadsheet_condition";
|
||||
import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
|
||||
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
|
||||
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
|
||||
export type { LinkInfo } from "./spreadsheet_link_set";
|
||||
|
||||
export interface SpreadsheetOptions {
|
||||
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
|
||||
@@ -76,6 +78,9 @@ export interface SpreadsheetHandle {
|
||||
recalc(): CalcValues;
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 0) */
|
||||
getLinkedValues(): Record<string, CalcValue>;
|
||||
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
|
||||
* 주소가 틀리거나 읽기만이면 false */
|
||||
setLink(key: string, cell: string | null, info?: LinkInfo): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -288,6 +293,14 @@ export function createSpreadsheet(
|
||||
}
|
||||
return out;
|
||||
},
|
||||
setLink(key, cell, info) {
|
||||
if (readOnly) return false;
|
||||
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info);
|
||||
if (command === false) return false;
|
||||
if (command) dispatch(command);
|
||||
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_set.ts (주인 D)
|
||||
* 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`).
|
||||
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 ·
|
||||
* 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
|
||||
* ========================================================================== */
|
||||
|
||||
import { parseA1, toA1 } from "./spreadsheet_address";
|
||||
import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types";
|
||||
|
||||
/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */
|
||||
export interface LinkInfo {
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
자재?: LinkMaterial;
|
||||
}
|
||||
|
||||
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */
|
||||
export function linkCommand(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
key: string,
|
||||
cell: string | null,
|
||||
info: LinkInfo = {},
|
||||
): Command | null | false {
|
||||
const at = cell === null ? null : parseA1(cell.trim().toUpperCase());
|
||||
if (cell !== null && !at) return false;
|
||||
const a1 = at ? toA1(at.r, at.c) : null;
|
||||
const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key);
|
||||
const home = book.시트.find((s) => s.id === sheetId);
|
||||
if (a1 && !home) return false;
|
||||
if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
|
||||
const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null;
|
||||
if (one) {
|
||||
const 이름 = info.이름 ?? old?.이름;
|
||||
const 단위 = info.단위 ?? old?.단위;
|
||||
const 자재 = info.자재 ?? old?.자재;
|
||||
if (이름) one.이름 = 이름;
|
||||
if (단위) one.단위 = 단위;
|
||||
if (자재) one.자재 = 자재;
|
||||
}
|
||||
const commands: Command[] = [];
|
||||
for (const s of book.시트) {
|
||||
const was = s.연결 ?? [];
|
||||
const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1));
|
||||
const next = s === home && one ? [...rest, one] : rest;
|
||||
if (next.length === was.length && next.every((l, i) => l === was[i])) continue;
|
||||
const copy = structuredClone(s);
|
||||
if (next.length) copy.연결 = next;
|
||||
else delete copy.연결;
|
||||
commands.push({ 종류: "시트통째", 시트: copy });
|
||||
}
|
||||
if (!commands.length) return null;
|
||||
return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_AreaLink.ts
|
||||
* 면적산출 상세 — 칸 지정 모달(PLAN 39-4 D5). 오른쪽 패널의 담은 로직 · 자재 줄을 누르면 엶.
|
||||
* 그 줄 알약 키(로직 물량 「이름|단위」 · 자재 「자재|MT키」)를 면적산출 칸에 잇기 · 옮기기 · 풀기.
|
||||
* 정본 = 면적산출 문서 `연결` 한 곳 — 스프레드시트 손잡이 `setLink`(알약 누름과 같은 규칙 · 되돌리기 됨)라
|
||||
* 아래 알약 줄 · 칸 세모 · 패널 수량이 같이 따라감. 손 입력 수량은 일위대가 몫(여기 없음) · 저장은 [저장].
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
|
||||
const TEXT = {
|
||||
title: "칸 지정 — {value}",
|
||||
close: "닫기",
|
||||
now: "지금 연결",
|
||||
none: "연결 없음",
|
||||
picked: "지금 고른 칸으로 ({value})",
|
||||
address: "칸 주소",
|
||||
hint: "예 B6",
|
||||
link: "잇기",
|
||||
unlink: "풀기",
|
||||
bad: "칸 주소가 틀림 — 예 B6",
|
||||
readOnly: "읽기만 — 고칠 수 없음",
|
||||
};
|
||||
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
export interface AreaLinkOptions {
|
||||
/** 줄 이름(모달 제목) */
|
||||
label: string;
|
||||
/** 알약 키 */
|
||||
key: string;
|
||||
/** 연결에 같이 적을 이름 · 단위 · 자재 */
|
||||
info: LinkInfo;
|
||||
sheet: SpreadsheetHandle;
|
||||
/** 시트에서 지금 고른 칸(A1) */
|
||||
picked: () => string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */
|
||||
function linkedText(opts: AreaLinkOptions): string | null {
|
||||
const link = opts.sheet
|
||||
.getDoc()
|
||||
.시트.flatMap((s) => s.연결 ?? [])
|
||||
.find((l) => l.키 === opts.key);
|
||||
if (!link) return null;
|
||||
const value = opts.sheet.getLinkedValues()[opts.key];
|
||||
return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim();
|
||||
}
|
||||
|
||||
export function openAreaLink(opts: AreaLinkOptions): void {
|
||||
const { close } = openModal({
|
||||
title: fill(TEXT.title, opts.label),
|
||||
closeLabel: TEXT.close,
|
||||
dialogClass: "m02-modal m02-area-link",
|
||||
mount: (body) => {
|
||||
const now = el("p", { className: "m02-area-link__now" });
|
||||
const paint = (): void => {
|
||||
const text = linkedText(opts);
|
||||
now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`;
|
||||
unlink.disabled = opts.readOnly || !text;
|
||||
};
|
||||
const done = (cell: string | null): boolean => {
|
||||
if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false;
|
||||
close();
|
||||
return true;
|
||||
};
|
||||
const here = opts.picked();
|
||||
const pick = createButton({
|
||||
label: fill(TEXT.picked, here),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => void done(here),
|
||||
});
|
||||
const address = createInputField({ label: TEXT.address, placeholder: TEXT.hint });
|
||||
const link = createButton({
|
||||
label: TEXT.link,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
if (!done(address.input.value)) address.setError(TEXT.bad);
|
||||
},
|
||||
});
|
||||
const unlink = createButton({
|
||||
label: TEXT.unlink,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void done(null),
|
||||
});
|
||||
address.input.addEventListener("input", () => address.setError(undefined));
|
||||
address.input.addEventListener("keydown", (ev) => ev.key === "Enter" && link.click());
|
||||
pick.disabled = link.disabled = address.input.disabled = opts.readOnly;
|
||||
paint();
|
||||
body.append(
|
||||
now,
|
||||
...(opts.readOnly
|
||||
? [el("p", { className: "m02-struct__empty", text: TEXT.readOnly })]
|
||||
: []),
|
||||
el("div", { className: "m02-area-link__row", children: [pick, unlink] }),
|
||||
el("div", { className: "m02-area-link__row", children: [address.root, link] }),
|
||||
);
|
||||
if (!opts.readOnly) address.input.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type {
|
||||
LinkOptions,
|
||||
SpreadsheetHandle,
|
||||
@@ -62,6 +63,8 @@ const TEXT = {
|
||||
readFailed: "{value} 파일을 못 읽음 — {why}",
|
||||
discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?",
|
||||
sheetHelp: "단축키 — 칸을 고르고 바로 침",
|
||||
pills: "알약",
|
||||
pillsTip: "아래 알약 줄 보임 · 숨김",
|
||||
};
|
||||
|
||||
/** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */
|
||||
@@ -221,6 +224,20 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
return card;
|
||||
};
|
||||
const sheets = [sheetCard("area"), sheetCard("material")];
|
||||
// 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임)
|
||||
const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" });
|
||||
pills.title = TEXT.pillsTip;
|
||||
const paintPills = (shown: boolean): void => {
|
||||
pills.setAttribute("aria-pressed", String(shown));
|
||||
sheets[0].host.classList.toggle("is-pills-off", !shown);
|
||||
};
|
||||
pills.addEventListener("click", () => {
|
||||
const shown = pills.getAttribute("aria-pressed") !== "true";
|
||||
writeStateRaw("m02-card-pills", String(shown));
|
||||
paintPills(shown);
|
||||
});
|
||||
paintPills(readStateRaw("m02-card-pills") === "true");
|
||||
sheets[0].shell.tools.prepend(pills);
|
||||
const cards: Card[] = [info, ...sheets];
|
||||
const defaults: Record<EditKind, string> = {
|
||||
structure: TEXT.edit,
|
||||
@@ -244,6 +261,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
c.saver.button.hidden = c.help.hidden = !can;
|
||||
c.edit.hidden = !opts.editable();
|
||||
}
|
||||
pills.hidden = !sheetEdit;
|
||||
};
|
||||
|
||||
const drop = (): void => {
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor.ts
|
||||
* 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추
|
||||
* (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` ·
|
||||
* 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`Labor_Edit`).
|
||||
* 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
editComboTree,
|
||||
editComboTreeMany,
|
||||
fetchComboList,
|
||||
pasteCombos,
|
||||
type ComboList,
|
||||
type ComboTreeEdit,
|
||||
type LaborScope,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit";
|
||||
import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
|
||||
|
||||
/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */
|
||||
function treeError(error: unknown): Error {
|
||||
if (!(error instanceof ApiError))
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
const d = error.detail as { errors?: string[]; stale?: unknown } | string | null;
|
||||
const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`;
|
||||
return error.status === 409 ? new StaleError(text) : new Error(text);
|
||||
}
|
||||
|
||||
export interface LaborLibraryOptions {
|
||||
scope: LaborScope;
|
||||
canEdit: boolean;
|
||||
/** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */
|
||||
copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[];
|
||||
/** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */
|
||||
reveal: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LaborLibrary {
|
||||
/** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */
|
||||
root: HTMLElement;
|
||||
/** 좌측 패널 나무 */
|
||||
tree: HTMLElement;
|
||||
/** 나무 맨 위 더하기 「+」(층 단추 옆) */
|
||||
add: HTMLElement;
|
||||
/** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */
|
||||
back: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
/** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */
|
||||
tools: HTMLElement | null;
|
||||
/** 메인 칸 「저장 안 한 고침」 손잡이 */
|
||||
bind: (setDirty: (dirty: boolean) => void) => void;
|
||||
}
|
||||
|
||||
export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
|
||||
const { scope } = opt;
|
||||
let setDirty: (dirty: boolean) => void = () => {};
|
||||
let keys = new Set<string>(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나
|
||||
const took = (list: ComboList): ComboList => {
|
||||
keys = new Set(list.줄.map((c) => c.키));
|
||||
editor.setList(list.줄);
|
||||
return list;
|
||||
};
|
||||
const editor = createLaborEditor({
|
||||
scope,
|
||||
canEdit: opt.canEdit,
|
||||
onDirty: (dirty) => setDirty(dirty),
|
||||
onSaved: async (key) => {
|
||||
await tree.refresh();
|
||||
tree.select(key);
|
||||
},
|
||||
});
|
||||
const tree = createTreePanel({
|
||||
layer: scope.layer,
|
||||
owner: scope.owner,
|
||||
canEdit: opt.canEdit,
|
||||
maxDepth: 2,
|
||||
item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) },
|
||||
api: {
|
||||
fetch: async () => {
|
||||
return toView(took(await fetchComboList(scope)));
|
||||
},
|
||||
edit: async (_layer, edit, 판) => {
|
||||
const body = { ...edit, 판 } as ComboTreeEdit;
|
||||
const got = await editComboTree(body, scope).catch((error: unknown) => {
|
||||
throw treeError(error);
|
||||
});
|
||||
return toView(took(got), noteOf(edit, got.지운조합));
|
||||
},
|
||||
// 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장
|
||||
many: async (_layer, body, 판) => {
|
||||
const ops = body.대상.map((ref) => ({
|
||||
op: body.op,
|
||||
...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }),
|
||||
...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}),
|
||||
}));
|
||||
const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => {
|
||||
throw treeError(error);
|
||||
});
|
||||
return toView(took(got), { op: body.op, 지운항목: got.지운조합 });
|
||||
},
|
||||
// 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로
|
||||
paste: async (layer, owner, clip, dest) => {
|
||||
const to = { layer, owner } as LaborScope;
|
||||
const pick = {
|
||||
키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])),
|
||||
폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])),
|
||||
};
|
||||
const got = await fetchComboList(to)
|
||||
.then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to))
|
||||
.catch((error: unknown) => {
|
||||
throw treeError(error);
|
||||
});
|
||||
const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null);
|
||||
return toView(took(here ? got : await fetchComboList(scope)));
|
||||
},
|
||||
},
|
||||
clipKind: "일위대가",
|
||||
copyTo: opt.copyTo,
|
||||
onPick: async (item) => {
|
||||
await opt.reveal();
|
||||
tree.select(item.key);
|
||||
void editor.open(item.key);
|
||||
},
|
||||
// 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로)
|
||||
settle: async () => {
|
||||
if (!editor.isDirty()) return "clean";
|
||||
if (!(await showConfirmDialog(tc("BackAsk")))) return false;
|
||||
editor.showList();
|
||||
return "changed";
|
||||
},
|
||||
// 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록
|
||||
onChanged: async () => {
|
||||
const open = editor.current();
|
||||
if (!open) return;
|
||||
if (keys.has(open)) await editor.open(open);
|
||||
else editor.showList();
|
||||
},
|
||||
});
|
||||
// 찾기 칸 하나(PLAN 29-6) — 나무와 조합 목록을 같은 글 · 같은 규칙으로 거름
|
||||
tree.search
|
||||
.querySelector("input")
|
||||
?.addEventListener("input", (event) =>
|
||||
editor.setQuery((event.target as HTMLInputElement).value),
|
||||
);
|
||||
return {
|
||||
root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }),
|
||||
tree: tree.root,
|
||||
add: tree.add,
|
||||
back: async () => {
|
||||
await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움
|
||||
if (editor.current() === null) return;
|
||||
if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
|
||||
editor.showList();
|
||||
},
|
||||
refresh: async () => {
|
||||
const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6)
|
||||
try {
|
||||
await tree.refresh();
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
tools: editor.newButton,
|
||||
bind: (fn) => (setDirty = fn),
|
||||
};
|
||||
}
|
||||
|
||||
export interface LaborSectionOptions {
|
||||
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
|
||||
show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
|
||||
onOpen: () => void;
|
||||
/** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */
|
||||
make: (reveal: () => Promise<void>) => LaborLibrary;
|
||||
/** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */
|
||||
bar?: (add: HTMLElement, back: () => void) => HTMLElement;
|
||||
/** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */
|
||||
wrap?: (tree: HTMLElement) => HTMLElement;
|
||||
}
|
||||
|
||||
export interface LaborSection {
|
||||
title: string;
|
||||
body: HTMLElement;
|
||||
open: () => Promise<void>;
|
||||
/** 다른 화면을 열었음 — 고름 표시만 지움 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */
|
||||
export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
|
||||
let opened = false;
|
||||
const mark = (on: boolean): void => {
|
||||
opened = on;
|
||||
};
|
||||
|
||||
async function open(): Promise<void> {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
library.bind(opt.show(tc("Library"), library.root, library.tools));
|
||||
opt.onOpen();
|
||||
mark(true);
|
||||
await library.refresh();
|
||||
}
|
||||
// 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음
|
||||
const library = opt.make(open);
|
||||
void library.refresh();
|
||||
const body = el("div", {
|
||||
className: "m02-side__items ui-sidebar-list",
|
||||
children: [
|
||||
...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []),
|
||||
opt.wrap ? opt.wrap(library.tree) : library.tree,
|
||||
],
|
||||
});
|
||||
return { title: tc("Title"), body, open, clear: () => mark(false) };
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Tree.ts
|
||||
* 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림.
|
||||
* 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api";
|
||||
|
||||
/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */
|
||||
export const pathOf = (c: ComboSummary): string[] =>
|
||||
c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : [];
|
||||
|
||||
/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */
|
||||
export const toView = (list: ComboList, note?: TreeNote): TreeView => ({
|
||||
판: list.판,
|
||||
폴더: list.폴더,
|
||||
items: list.줄.map((c) => ({
|
||||
key: c.키,
|
||||
열: c.키,
|
||||
경로: [...pathOf(c), c.이름],
|
||||
이름: c.이름,
|
||||
단위: c.단위,
|
||||
})),
|
||||
...(note ? { 알림: note } : {}),
|
||||
});
|
||||
|
||||
/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */
|
||||
export function noteOf(edit: TreeEdit, gone: string[]): TreeNote {
|
||||
const at = edit.경로 ?? [];
|
||||
const last = at[at.length - 1] ?? "";
|
||||
if (edit.키 && edit.op !== "delete") {
|
||||
const where = edit.op === "move" ? (edit.새경로 ?? []) : at;
|
||||
return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone };
|
||||
}
|
||||
const path =
|
||||
edit.op === "add-folder"
|
||||
? [...at, edit.이름 ?? ""]
|
||||
: edit.op === "rename"
|
||||
? [...at.slice(0, -1), edit.이름 ?? ""]
|
||||
: edit.op === "move"
|
||||
? [...(edit.새경로 ?? []), last]
|
||||
: undefined;
|
||||
return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone };
|
||||
}
|
||||
@@ -93,6 +93,78 @@ export function accessFor(
|
||||
return { canWrite: layer === "personal" || companyAdmin, needCompany: false };
|
||||
}
|
||||
|
||||
/** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */
|
||||
export function manages(company: string | null, user: SessionUser | null): boolean {
|
||||
if (!company || !user) return false;
|
||||
if (user.role === "SYSTEM_ADMIN") return true;
|
||||
const companyAdmin = user.role === "ADMIN" || !!user.is_master;
|
||||
return companyAdmin && company === ownOwner("company", user);
|
||||
}
|
||||
|
||||
/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */
|
||||
export interface Source {
|
||||
id: "master" | "company" | "personal" | "people" | "practice";
|
||||
layer: ManageLayer;
|
||||
/** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */
|
||||
owner: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭.
|
||||
* 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */
|
||||
export function readableSources(
|
||||
user: SessionUser | null,
|
||||
company: string | null,
|
||||
opt: { practice?: boolean } = {},
|
||||
): { tabs: Source[]; first: Source["id"] } {
|
||||
const isSystem = user?.role === "SYSTEM_ADMIN";
|
||||
const own = ownOwner("personal", user);
|
||||
const tabs: Source[] = [
|
||||
{ id: "master", layer: "system", owner: null, label: L("M02_PickerSystem") },
|
||||
...(company
|
||||
? [
|
||||
{
|
||||
id: "company" as const,
|
||||
layer: "company" as const,
|
||||
owner: company === ownOwner("company", user) ? null : company,
|
||||
label: L("M02_PickerCompany"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(own
|
||||
? [
|
||||
{
|
||||
id: "personal" as const,
|
||||
layer: "personal" as const,
|
||||
owner: null,
|
||||
label: L("M02_PickerPersonal"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(manages(company, user)
|
||||
? [
|
||||
{
|
||||
id: "people" as const,
|
||||
layer: "personal" as const,
|
||||
owner: null,
|
||||
label: L("M02_SourcePeople"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(opt.practice && isSystem
|
||||
? [
|
||||
{
|
||||
id: "practice" as const,
|
||||
layer: "system" as const,
|
||||
owner: null,
|
||||
label: L("M02_SourcePractice"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return { tabs, first: isSystem || !own ? "master" : "personal" };
|
||||
}
|
||||
|
||||
/** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */
|
||||
export function buildNeedCompany(): HTMLElement {
|
||||
const go = createButton({ label: L("M02_NeedCompanyGo"), variant: "filled" });
|
||||
|
||||
@@ -5,15 +5,7 @@
|
||||
* (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import {
|
||||
@@ -22,6 +14,7 @@ import {
|
||||
type MaterialCombo,
|
||||
type MaterialItem,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo";
|
||||
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
|
||||
|
||||
@@ -51,12 +44,6 @@ const TEXT = {
|
||||
askAlone:
|
||||
"이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀",
|
||||
failed: "자재 조합을 못 고침 — {value}",
|
||||
pickTitle: "자재 추가",
|
||||
pickClose: "닫기",
|
||||
search: "자재 이름 · 규격 · 코드",
|
||||
searching: "찾는 중…",
|
||||
noMatch: "맞는 자재 없음",
|
||||
searchFailed: "자재 목록을 못 받음",
|
||||
};
|
||||
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
@@ -122,57 +109,6 @@ export interface MaterialPanelOptions {
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
/** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(좌측 자재 조합 상세도 씀) */
|
||||
export function pickMaterial(done: (item: MaterialItem) => void): void {
|
||||
openModal({
|
||||
title: TEXT.pickTitle,
|
||||
closeLabel: TEXT.pickClose,
|
||||
dialogClass: "m02-modal m02-pick",
|
||||
mount: (body, close) => {
|
||||
const search = createInputField({ placeholder: TEXT.search, type: "search" });
|
||||
const list = el("div", { className: "m02-pick__list" });
|
||||
let seq = 0;
|
||||
const paint = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching }));
|
||||
try {
|
||||
const hits = await searchMaterials(search.input.value.trim());
|
||||
if (mine !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(hits.length
|
||||
? hits.map((m) => {
|
||||
const row = el("button", {
|
||||
className: "m02-pick__item",
|
||||
attrs: { type: "button", "data-material": m.코드 },
|
||||
children: [
|
||||
el("span", { className: "m02-pick__key", text: m.코드 }),
|
||||
`${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
done(m);
|
||||
close();
|
||||
});
|
||||
return row;
|
||||
})
|
||||
: [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq) showToast(TEXT.searchFailed, "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
search.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void paint(), 250);
|
||||
});
|
||||
body.append(search.root, list);
|
||||
void paint();
|
||||
search.input.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */
|
||||
export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
|
||||
let key = opts.state.key;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
StaleError,
|
||||
type MaterialCombo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { pickMaterial } from "./M02_MasterTemplete_Materials";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { tm } from "./M02_MasterTemplete_Matset_Text";
|
||||
|
||||
export interface MatsetSectionOptions {
|
||||
|
||||
@@ -247,3 +247,8 @@
|
||||
.m02-struct__sheet-grid .ss-links__list {
|
||||
max-height: 8rem;
|
||||
}
|
||||
|
||||
/* 면적산출 카드 [알약] 끔(기본) — 아래 알약 줄만 숨김 · 연결 · 칸 세모는 그대로 */
|
||||
.m02-struct__sheet.is-pills-off .ss-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -81,18 +81,12 @@ export interface TreePanelOptions {
|
||||
dest: string[],
|
||||
) => Promise<TreeView>;
|
||||
};
|
||||
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
|
||||
clipKind?: string;
|
||||
/** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */
|
||||
pickCombo?: () => Promise<{ key: string; label: string } | null>;
|
||||
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
|
||||
copyTo?: { layer: Layer; owner: string | null; label: string }[];
|
||||
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
||||
canEdit: boolean;
|
||||
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
|
||||
maxDepth?: number;
|
||||
/** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */
|
||||
item?: { add: string; name: string; onAdd?: (path: string[]) => void };
|
||||
/** 항목을 누름 — 표에서 그 열을 고름 */
|
||||
onPick: (item: TreeItem) => void;
|
||||
/** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */
|
||||
@@ -123,7 +117,7 @@ const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint
|
||||
export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const owner = opt.owner ?? null;
|
||||
const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree };
|
||||
const clipKind = opt.clipKind ?? "구조물";
|
||||
const clipKind = "구조물"; // 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임
|
||||
let view: TreeView | null = null;
|
||||
let text = "";
|
||||
let activeCol: string | null = null;
|
||||
@@ -213,13 +207,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
return [
|
||||
...(target.kind === "folder"
|
||||
? [
|
||||
...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth
|
||||
? []
|
||||
: [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]),
|
||||
{
|
||||
label: opt.item?.add ?? L("M02_TreeAddItem"),
|
||||
run: () => void addItem(target.path),
|
||||
},
|
||||
{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) },
|
||||
{ label: L("M02_TreeAddItem"), run: () => void addItem(target.path) },
|
||||
]
|
||||
: []),
|
||||
...(isTop
|
||||
@@ -466,7 +455,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
|
||||
async function addItem(parent: string[]): Promise<void> {
|
||||
if (!(await ready())) return;
|
||||
if (opt.item?.onAdd) return opt.item.onAdd(parent);
|
||||
// 종류(맨 위 폴더 · 고른 폴더면 그 안) + 이름 + 일위대가 · 단위 안 받음(PLAN 31-2)
|
||||
const kinds = view ? buildFolders(view).folders.map((f) => f.path[0]) : [];
|
||||
const got = await askNewItem({
|
||||
@@ -490,10 +478,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const got = await askText(L("M02_TreeRename"), [
|
||||
{
|
||||
key: "name",
|
||||
label:
|
||||
target.kind === "item"
|
||||
? (opt.item?.name ?? L("M02_TreeItemName"))
|
||||
: L("M02_TreeFolderName"),
|
||||
label: target.kind === "item" ? L("M02_TreeItemName") : L("M02_TreeFolderName"),
|
||||
value: nameOf(target),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
type OpenTarget,
|
||||
type Root,
|
||||
} from "./M02_MasterTemplete_Layer";
|
||||
import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor";
|
||||
import {
|
||||
headRenameEdit,
|
||||
pickCombo,
|
||||
@@ -86,14 +85,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
settle: () => main.settle(),
|
||||
onChanged: (colId) => main.openTable(layer, colId, true),
|
||||
});
|
||||
// 일위대가 화면이 떠 있나 — 층을 바꿔도 일위대가로 다시 엶 ·
|
||||
// 해시 `open` 도 맞춤(새로 고침 · 카드 진입과 같게) · 옵션 · 도면은 옛 규칙(구조물 조합으로)
|
||||
let view: OpenTarget = entry.open;
|
||||
const mark = (open: OpenTarget): void => {
|
||||
view = open;
|
||||
const query = new URLSearchParams(entryQuery({ ...entry, open }));
|
||||
history.replaceState(history.state, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
|
||||
};
|
||||
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
|
||||
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
|
||||
if (!(await main.confirmLeave())) return;
|
||||
@@ -102,9 +93,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
|
||||
);
|
||||
};
|
||||
// 층 단추 셋(PLAN 29-2) — 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 한 벌씩
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌(하위 도면 · 일위대가 · 면적산출도 이 층)
|
||||
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 구조물 = 집계표 · 일위대가 = 조합 목록)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
@@ -127,11 +118,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
const main = createMain(
|
||||
isAdmin,
|
||||
() => void Promise.all([side.refresh(), tree.refresh()]),
|
||||
() => {
|
||||
side.paint();
|
||||
// 일위대가 화면에서 양식으로 옮김(일위대가 · 옵션 화면은 current 없음)
|
||||
if (main.current() && view === "일위대가") mark("구조물조합");
|
||||
},
|
||||
() => side.paint(),
|
||||
{
|
||||
top: readOnlyBar?.root,
|
||||
search: tree.search,
|
||||
@@ -168,19 +155,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
labor: ({ onOpen }) =>
|
||||
buildLaborSection({
|
||||
show: main.showView,
|
||||
confirmLeave: main.confirmLeave,
|
||||
onOpen: () => {
|
||||
onOpen();
|
||||
mark("일위대가");
|
||||
},
|
||||
bar: (add, back) => layerBar("일위대가", add, back),
|
||||
wrap: (panel) => peopleTree("일위대가", panel),
|
||||
make: (reveal) =>
|
||||
buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal, copyTo }),
|
||||
}),
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
@@ -200,8 +174,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
root.innerHTML = "";
|
||||
root.append(layout);
|
||||
void main.open(home);
|
||||
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 세 층 ·
|
||||
// 옵션 · 일위대가 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
|
||||
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 구조물 조합 하위(세 층) ·
|
||||
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
|
||||
if (entry.open !== "구조물조합") side.reveal(entry.open);
|
||||
void tree.refresh();
|
||||
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import type { LaborSection } from "./M02_MasterTemplete_Labor";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
@@ -74,8 +73,6 @@ export interface SideOptions {
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */
|
||||
labor?: (hooks: { onOpen: () => void }) => LaborSection;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
}
|
||||
@@ -85,7 +82,7 @@ export interface SideHandle {
|
||||
refresh: () => Promise<void>;
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
|
||||
reveal: (target: "옵션" | "도면" | "일위대가") => void;
|
||||
}
|
||||
|
||||
@@ -136,7 +133,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
close: () => opt.onOpen(null),
|
||||
onOpen: () => (options.clear(), labor?.clear(), paint()),
|
||||
onOpen: () => (options.clear(), paint()),
|
||||
});
|
||||
const matsetSection = section(matset.title, matset.body, ...matset.actions);
|
||||
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
|
||||
@@ -148,9 +145,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
isAdmin: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (matset.clear(), labor?.clear(), paint()),
|
||||
onOpen: () => (matset.clear(), paint()),
|
||||
});
|
||||
const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) });
|
||||
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
|
||||
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
|
||||
sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
|
||||
@@ -181,15 +177,9 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
: null;
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())] : []),
|
||||
summary,
|
||||
...(extras ? [extras.옵션, extras.도면] : []),
|
||||
],
|
||||
children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])],
|
||||
});
|
||||
attachCollapsible(listHost);
|
||||
// 들어오면 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채(PLAN 31-5 · 구조물 조합은 위에서 펼침)
|
||||
labor?.body.closest("section")?.classList.remove("is-collapsed");
|
||||
|
||||
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
|
||||
|
||||
@@ -263,7 +253,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
paintButtons();
|
||||
if (opt.getOpen()) matset.clear();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) labor?.clear();
|
||||
}
|
||||
|
||||
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
|
||||
@@ -388,10 +377,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가"): void => {
|
||||
if (target === "일위대가") {
|
||||
labor?.body.closest("section")?.classList.remove("is-collapsed");
|
||||
return void labor?.open();
|
||||
}
|
||||
if (target === "일위대가") return; // 구조물 조합은 처음부터 펼침
|
||||
extras?.[target].classList.remove("is-collapsed");
|
||||
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
|
||||
};
|
||||
|
||||
@@ -222,14 +222,6 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 나무는 좌측 패널(PLAN 29-2) — 일위대가 화면 = 찾기 칸 위 · 조합 목록 · 상세 아래 */
|
||||
.m02-labor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-main__head + .m02-tree__search {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* =============================================================================
|
||||
* M02_Material_Pick.ts
|
||||
* M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀).
|
||||
* 모양(`m02-pick*`)은 `M02_MasterTemplete_Structure.css`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
|
||||
/** 고른 자재 한 줄 — M01 자재(키 `MT`) */
|
||||
export interface MaterialItem {
|
||||
코드: string;
|
||||
이름: string | null;
|
||||
규격: string | null;
|
||||
단위: string | null;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
pickTitle: "자재 추가",
|
||||
pickClose: "닫기",
|
||||
search: "자재 이름 · 규격 · 코드",
|
||||
searching: "찾는 중…",
|
||||
noMatch: "맞는 자재 없음",
|
||||
searchFailed: "자재 목록을 못 받음",
|
||||
};
|
||||
|
||||
/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 */
|
||||
export function pickMaterial(done: (item: MaterialItem) => void): void {
|
||||
openModal({
|
||||
title: TEXT.pickTitle,
|
||||
closeLabel: TEXT.pickClose,
|
||||
dialogClass: "m02-modal m02-pick",
|
||||
mount: (body, close) => {
|
||||
const search = createInputField({ placeholder: TEXT.search, type: "search" });
|
||||
const list = el("div", { className: "m02-pick__list" });
|
||||
let seq = 0;
|
||||
const paint = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching }));
|
||||
try {
|
||||
const hits = await searchMaterials(search.input.value.trim());
|
||||
if (mine !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(hits.length
|
||||
? hits.map((m) => {
|
||||
const row = el("button", {
|
||||
className: "m02-pick__item",
|
||||
attrs: { type: "button", "data-material": m.코드 },
|
||||
children: [
|
||||
el("span", { className: "m02-pick__key", text: m.코드 }),
|
||||
`${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
done(m);
|
||||
close();
|
||||
});
|
||||
return row;
|
||||
})
|
||||
: [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq) showToast(TEXT.searchFailed, "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
search.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void paint(), 250);
|
||||
});
|
||||
body.append(search.root, list);
|
||||
void paint();
|
||||
search.input.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림.
|
||||
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */
|
||||
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands";
|
||||
import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
let pass = 0;
|
||||
const failures: string[] = [];
|
||||
const eq = (name: string, got: unknown, want: unknown): void => {
|
||||
if (JSON.stringify(got) === JSON.stringify(want)) pass += 1;
|
||||
else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
|
||||
};
|
||||
|
||||
const book = (): Workbook =>
|
||||
({
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "t",
|
||||
서식: [{}],
|
||||
시트: [
|
||||
{
|
||||
id: "s1",
|
||||
이름: "면적산출",
|
||||
칸: { B1: { 값: 12.5 } },
|
||||
연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }],
|
||||
},
|
||||
{ id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] },
|
||||
],
|
||||
활성: "s1",
|
||||
}) as unknown as Workbook;
|
||||
const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null);
|
||||
const run = (b: Workbook, c: Command | null | false) => {
|
||||
if (!c) return null;
|
||||
return applyCommand(b, c).undo;
|
||||
};
|
||||
|
||||
// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음
|
||||
let b = book();
|
||||
const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" };
|
||||
let undo = run(
|
||||
b,
|
||||
linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }),
|
||||
);
|
||||
eq("새 연결", b.시트[0].연결?.[1], {
|
||||
키: "자재|MT000008",
|
||||
칸: "D2",
|
||||
이름: "치즐",
|
||||
단위: "EA",
|
||||
자재: mt,
|
||||
});
|
||||
// 되돌리기 = 처음 그대로
|
||||
run(b, undo);
|
||||
eq("되돌림", links(b), links(book()));
|
||||
|
||||
// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "깔기|㎡", "B1"));
|
||||
eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]);
|
||||
|
||||
// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나)
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }));
|
||||
eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
|
||||
|
||||
// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다
|
||||
b = book();
|
||||
undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1"));
|
||||
eq("시트 넘어 옮김", links(b), [
|
||||
[
|
||||
{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" },
|
||||
{ 키: "치기|㎥", 칸: "A1", 이름: "치기" },
|
||||
],
|
||||
null,
|
||||
]);
|
||||
run(b, undo);
|
||||
eq("시트 넘어 되돌림", links(b), links(book()));
|
||||
|
||||
// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "깔기|㎡", null));
|
||||
eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]);
|
||||
|
||||
// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false
|
||||
b = book();
|
||||
eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null);
|
||||
eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null);
|
||||
eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false);
|
||||
eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false);
|
||||
eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false);
|
||||
eq("문서 그대로", links(b), links(book()));
|
||||
|
||||
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
|
||||
@@ -75,3 +75,17 @@ def test_저장_안_한_카드_고침_물음():
|
||||
assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle)
|
||||
assert main.index("await panel.saveCards();") < main.index("await panel.reload();")
|
||||
assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main
|
||||
|
||||
|
||||
def test_면적산출_카드_알약_줄_켬끔_기본_숨김():
|
||||
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
||||
assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in cards # 없으면 숨김
|
||||
assert 'writeStateRaw("m02-card-pills", String(shown));' in cards
|
||||
assert 'sheets[0].host.classList.toggle("is-pills-off", !shown);' in cards
|
||||
assert "pills.hidden = !sheetEdit;" in cards # 고칠 수 있는 카드만(보기만은 알약 줄 없음)
|
||||
css = _text(M02 / "M02_MasterTemplete_Structure.css")
|
||||
assert re.search(r"\.m02-struct__sheet\.is-pills-off \.ss-links \{\s*display: none;", css)
|
||||
state = _text(ROOT / "A00_Common" / "b_page_state.ts")
|
||||
assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state
|
||||
# 상세(전체 편집기)는 켬끔 없음 — 늘 보임
|
||||
assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
|
||||
@@ -1,124 +1,35 @@
|
||||
"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정.
|
||||
"""좌측 패널 일위대가(PLAN 39-2) — 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 ·
|
||||
일위대가는 구조물 조합 하위(구조물 도면 → 일위대가 → 면적산출). 글 검사(화면은 ORCA).
|
||||
|
||||
못박는 것
|
||||
- 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키.
|
||||
- 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 ·
|
||||
지우기는 고름 없음.
|
||||
옛 일위대가 라이브러리 나무 시험(PLAN 27-2b · 30-3 · 30-5)은 나무를 지우며 이 검사로 갈음.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = r"""
|
||||
const fs = require("fs");
|
||||
const ts = require(process.argv[1] + "/config/node_modules/typescript");
|
||||
const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts";
|
||||
const src = fs.readFileSync(process.argv[1] + file, "utf8");
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" });
|
||||
const list = {
|
||||
판: "v1",
|
||||
폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]],
|
||||
줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null),
|
||||
row("UP1-000003", "다", null, "무시")],
|
||||
};
|
||||
const view = m.toView(list);
|
||||
const notes = [
|
||||
{ op: "add-folder", 경로: ["석축"], 이름: "메쌓기" },
|
||||
{ op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" },
|
||||
{ op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] },
|
||||
{ op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] },
|
||||
{ op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" },
|
||||
{ op: "delete", 경로: ["석축"], 키: "UP1-000002" },
|
||||
{ op: "delete", 경로: ["빈"] },
|
||||
].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : []));
|
||||
console.log(JSON.stringify({ view, notes }));
|
||||
"""
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got():
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
proc = subprocess.run(
|
||||
["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
def _text(name: str) -> str:
|
||||
return (M02 / name).read_text("utf-8")
|
||||
|
||||
|
||||
def test_조합이_나무_항목으로(got):
|
||||
items = got["view"]["items"]
|
||||
assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]]
|
||||
assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"]
|
||||
assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]]
|
||||
assert "알림" not in got["view"]
|
||||
def test_일위대가_나무_파일과_이음이_없음():
|
||||
assert not (M02 / "M02_MasterTemplete_Labor.ts").exists()
|
||||
assert not (M02 / "M02_MasterTemplete_Labor_Tree.ts").exists()
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert "buildLaborSection" not in page and 'layerBar("일위대가"' not in page
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert "LaborSection" not in side and "labor?." not in side
|
||||
|
||||
|
||||
def test_나무_고침_알림(got):
|
||||
add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"]
|
||||
assert add["경로"] == ["석축", "메쌓기"] and "열" not in add
|
||||
assert rename["경로"] == ["석축", "찰"]
|
||||
assert move["경로"] == ["찰쌓기"]
|
||||
assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"]
|
||||
assert rename_item["경로"] == ["석축", "나나"]
|
||||
assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"]
|
||||
assert "경로" not in delete_folder
|
||||
def test_나무_부품에_일위대가_전용_칸_없음():
|
||||
panel = _text("M02_MasterTemplete_TreePanel.ts")
|
||||
for gone in ("maxDepth", "opt.item", "opt.clipKind", "clipKind?:"):
|
||||
assert gone not in panel, gone
|
||||
|
||||
|
||||
def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤():
|
||||
"""브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) ·
|
||||
일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시
|
||||
`open=일위대가`(다시 그리지 않고 주소만)."""
|
||||
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
|
||||
assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길
|
||||
assert (
|
||||
'bar: (add, back) => layerBar("일위대가", add, back),' in page
|
||||
and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
)
|
||||
labor = page.split("buildLaborSection({")[1].split("make:")[0]
|
||||
assert 'mark("일위대가")' in labor # 여는 순간 해시 open
|
||||
assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림)
|
||||
assert 'if (main.current() && view === "일위대가") mark("구조물조합")' in page
|
||||
|
||||
|
||||
def test_일위대가_나무에_여러_개_복사_붙여넣기_연결():
|
||||
"""PLAN 30-3 — 공용 나무 부품(30-2) 콜백을 일위대가 서버 길에 이음 ·
|
||||
붙임 종류는 따로(구조물과 안 섞임)."""
|
||||
labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8")
|
||||
assert 'clipKind: "일위대가"' in labor and "copyTo: opt.copyTo" in labor
|
||||
assert "editComboTreeMany(ops, 판, scope)" in labor # 여러 개 옮기기 · 지우기
|
||||
assert "pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)" in labor # 대상 층
|
||||
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
|
||||
assert "canEdit: isAdmin, reveal, copyTo })" in page # 구조물 나무와 같은 「〇〇로 복사」 목록
|
||||
|
||||
|
||||
def test_새_조합_단추는_제목_줄_오른쪽_목록은_공용_표():
|
||||
"""PLAN 30-5 — 「새 조합」 = 제목 줄 오른쪽 끝(읽기 층 없음) · 목록 = 공용 표(줄 선).
|
||||
머리 줄도 공용 표 모양."""
|
||||
edit = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts").read_text("utf-8")
|
||||
assert (
|
||||
"newButton: edit" in edit and 'className: "ui-general-block__table m02-labor__list"' in edit
|
||||
)
|
||||
assert (
|
||||
'tc("New"), variant: "filled", onClick: () => startNew([]) })]' not in edit
|
||||
) # 목록 안 옛 자리
|
||||
labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8")
|
||||
assert 'library.bind(opt.show(tc("Library"), library.root, library.tools))' in labor
|
||||
main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
|
||||
assert (
|
||||
'slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] }))'
|
||||
in main
|
||||
)
|
||||
css = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Style.css").read_text("utf-8")
|
||||
assert ".m02-main__tools {\n display: inline-flex;\n margin-left: auto;" in css
|
||||
def test_층_단추는_구조물_조합_한_벌():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
@@ -87,6 +87,55 @@ def test_주소_인자를_라우트에서_뗌():
|
||||
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_가져올_곳_탭():
|
||||
"""39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 ·
|
||||
일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터)."""
|
||||
text = _text(LAYER)
|
||||
parts = [
|
||||
re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M),
|
||||
re.search(r"^export function manages[\s\S]*?^}", text, re.M),
|
||||
re.search(r"^export interface Source[\s\S]*?^}", text, re.M),
|
||||
re.search(r"^export function readableSources[\s\S]*?^}", text, re.M),
|
||||
]
|
||||
assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음"
|
||||
script = (
|
||||
'type ManageLayer = "system" | "company" | "personal";\n'
|
||||
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
|
||||
" is_master?: boolean }\n"
|
||||
"const L = (key: string): string => key;\n"
|
||||
+ "\n".join(p.group(0).replace("export ", "") for p in parts if p)
|
||||
+ "\nconst cases = JSON.parse(process.argv[1]);"
|
||||
+ "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {"
|
||||
+ "\n const got = readableSources(u, c, { practice });"
|
||||
+ "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];"
|
||||
+ "\n})));"
|
||||
)
|
||||
sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1}
|
||||
admin = {"id": "5", "role": "ADMIN", "company_id": 3}
|
||||
master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True}
|
||||
user = {"id": "7", "role": "USER", "company_id": 3}
|
||||
orphan = {"id": "8", "role": "USER", "company_id": None}
|
||||
cases = [
|
||||
[sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False],
|
||||
[user, "3", True], [orphan, None, False],
|
||||
] # fmt: skip
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script, json.dumps(cases)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄
|
||||
["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님
|
||||
["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음
|
||||
["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자
|
||||
["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음
|
||||
["master", ["master"]], # 회사 없음
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_볼_회사는_시스템_관리자만_고름():
|
||||
"""29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 ·
|
||||
@@ -136,7 +185,7 @@ def test_층_단추_셋_남의_회사_사람_나무():
|
||||
assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page
|
||||
assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page
|
||||
assert 'peopleTree("구조물조합", tree.root)' in page
|
||||
assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page
|
||||
assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert (
|
||||
"api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel
|
||||
@@ -219,17 +268,13 @@ def test_읽기만_표는_단추_고치기_모두_막음():
|
||||
|
||||
|
||||
def test_켜진_층_단추는_목록으로_복사_끌기():
|
||||
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 ·
|
||||
저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) ·
|
||||
끌어다 옮기기는 쓸 수 있는 층만."""
|
||||
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) ·
|
||||
30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만."""
|
||||
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
||||
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
|
||||
assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page
|
||||
assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page
|
||||
labor = _text(M02 / "M02_MasterTemplete_Labor.ts")
|
||||
assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
@@ -259,10 +304,9 @@ def test_구조물_조합_제목_줄_새_항목():
|
||||
|
||||
|
||||
def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움():
|
||||
"""31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸
|
||||
"""31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸
|
||||
자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로)."""
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side
|
||||
assert 'summary.classList.remove("is-collapsed");' in side
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
mark = drag.split("const dropMark = ")[1].split("};")[0]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4).
|
||||
|
||||
순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 —
|
||||
새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소.
|
||||
손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts"
|
||||
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_setLink_순수_길():
|
||||
with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir:
|
||||
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
|
||||
build = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(VITE),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
entry_rel,
|
||||
"--outDir",
|
||||
outdir,
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
)
|
||||
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
|
||||
run = subprocess.run(
|
||||
["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=60,
|
||||
)
|
||||
assert run.stdout.strip(), run.stderr
|
||||
result = json.loads(run.stdout)
|
||||
assert result["fail"] == 0, result["failures"]
|
||||
assert result["pass"] == 13
|
||||
|
||||
|
||||
def test_손잡이_setLink_몸():
|
||||
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
|
||||
assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src
|
||||
body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0]
|
||||
assert "if (readOnly) return false;" in body
|
||||
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body
|
||||
assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님)
|
||||
assert "loadLinks();" in body
|
||||
@@ -103,6 +103,8 @@ export const ui_locales_m2 = {
|
||||
M02_RootShared: ["회사 공용", "Company shared"],
|
||||
M02_RootReadOnly: ["읽기", "read-only"],
|
||||
M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"],
|
||||
M02_SourcePeople: ["다른 사람", "Others"],
|
||||
M02_SourcePractice: ["실무문서", "Practice documents"],
|
||||
M02_TreeSearch: [
|
||||
"찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)",
|
||||
"Find — name, key, folder, unit (all words)",
|
||||
|
||||
Reference in New Issue
Block a user