Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
/* =============================================================================
|
||||
* design_structure_form.ts
|
||||
* 구조물 명세(`design_structure_spec`) 그리기 — B05 · B06 「구조물 배치」 칸이 같은 노드를 씀.
|
||||
*
|
||||
* · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기]
|
||||
* · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 자동(읽기만)
|
||||
* · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) —
|
||||
* 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김)
|
||||
* · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀
|
||||
* · 칸 표식 = `data-spec-item="{컨테이너}.{항목}"`(정의 규칙 · 그룹 상자가 안 건드리게 옵션 키 표식 안 씀)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
branchOf,
|
||||
choiceLabel,
|
||||
choicesOf,
|
||||
containerTitle,
|
||||
itemLabel,
|
||||
linkOf,
|
||||
readState,
|
||||
solveSplit,
|
||||
writeOptions,
|
||||
type SpecContainer,
|
||||
type SpecItem,
|
||||
type SpecNames,
|
||||
type SpecState,
|
||||
type SplitRole,
|
||||
type StructureSpec,
|
||||
} from "./design_structure_spec";
|
||||
import {
|
||||
group,
|
||||
grid,
|
||||
labeled,
|
||||
numberInput,
|
||||
stepper,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row";
|
||||
import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item";
|
||||
|
||||
/** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */
|
||||
export interface SpecLinks {
|
||||
get: () => GroupItems;
|
||||
pick: (key: string) => void;
|
||||
/** [해제] — 패널이 풀고 반영. */
|
||||
clear: (key: string) => void;
|
||||
/** 값만 옮김(빼기 뒤 당김) — 반영은 뒤이은 폼 변경 알림이 함. */
|
||||
set: (key: string, item: SummaryItem | null) => void;
|
||||
}
|
||||
|
||||
export interface SpecFormHooks {
|
||||
/** 자동 항목 값 글(빈 글 = 「—」). */
|
||||
auto?: (c: SpecContainer, item: SpecItem, state: SpecState) => string;
|
||||
/** 숫자 칸 빈 칸 안내. */
|
||||
hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string;
|
||||
onChange?: (c: SpecContainer, item: SpecItem) => void;
|
||||
}
|
||||
|
||||
export interface SpecForm {
|
||||
root: HTMLElement;
|
||||
/** 명세 · 저장 값으로 다시 그림. `names` = 정의 이름 바꿈 · `groupNames` = 정의 그룹 이름. */
|
||||
write: (
|
||||
spec: StructureSpec,
|
||||
options: Record<string, string | number>,
|
||||
names?: SpecNames,
|
||||
groupNames?: Record<string, string>,
|
||||
) => void;
|
||||
read: () => Record<string, string | number>;
|
||||
state: () => SpecState;
|
||||
/** 한 항목 값을 바꿔 그림(B06 조정창 맞춤 등) — change 알림은 안 함. */
|
||||
setValue: (containerId: string, itemId: string, value: string) => void;
|
||||
/** 필수 컨테이너 끝에 붙일 자리(B06 조정 행). */
|
||||
slot: (containerId: string) => HTMLElement;
|
||||
/** 잇기 값이 바뀌었을 때 줄을 다시 그림. */
|
||||
paintLinks: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {}): SpecForm {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-structure__spec";
|
||||
let spec: StructureSpec | null = null;
|
||||
let state: SpecState = {};
|
||||
let names: SpecNames = {};
|
||||
let groupNames: Record<string, string> = {};
|
||||
const recent = new Map<Instance, SplitRole[]>();
|
||||
const slots = new Map<string, HTMLElement>();
|
||||
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
|
||||
type Instance = Record<string, string>;
|
||||
|
||||
const slotOf = (id: string): HTMLElement => {
|
||||
let slot = slots.get(id);
|
||||
if (!slot) {
|
||||
slot = document.createElement("div");
|
||||
slot.className = "b05-structure__adjust-slot";
|
||||
slots.set(id, slot);
|
||||
}
|
||||
return slot;
|
||||
};
|
||||
|
||||
const control = (c: SpecContainer, item: SpecItem, values: Instance): HTMLElement => {
|
||||
if (item.kind === "select") {
|
||||
const select = document.createElement("select");
|
||||
const choices = choicesOf(item, values);
|
||||
select.replaceChildren(
|
||||
new Option("— 안 정함 —", ""),
|
||||
...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)),
|
||||
);
|
||||
const now = values[item.id] ?? "";
|
||||
// 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음)
|
||||
if (now && !choices.some((ch) => ch.value === now)) select.add(new Option(now, now));
|
||||
select.value = now;
|
||||
return select;
|
||||
}
|
||||
const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? "");
|
||||
if (item.kind === "auto") {
|
||||
input.type = "text";
|
||||
input.readOnly = true;
|
||||
input.value = hooks.auto?.(c, item, state) || "—";
|
||||
input.title = "자동 계산 — 고치지 않음";
|
||||
return input;
|
||||
}
|
||||
input.value = values[item.id] ?? "";
|
||||
return stepper(input, item.step ?? 0.1);
|
||||
};
|
||||
|
||||
const onEdit = (c: SpecContainer, item: SpecItem, values: Instance, raw: string): void => {
|
||||
values[item.id] = raw;
|
||||
if (item.split) {
|
||||
const order = [item.split, ...(recent.get(values) ?? []).filter((r) => r !== item.split)];
|
||||
recent.set(values, order);
|
||||
const role = (r: SplitRole) => c.items.find((i) => i.split === r)!;
|
||||
const solved = solveSplit(
|
||||
{
|
||||
length: values[role("length").id],
|
||||
before: values[role("before").id],
|
||||
after: values[role("after").id],
|
||||
},
|
||||
order,
|
||||
);
|
||||
for (const r of ["length", "before", "after"] as SplitRole[])
|
||||
values[role(r).id] = solved[r] ?? "";
|
||||
}
|
||||
// 위 항목이 바뀌면 갈래(선택지 · 저장 키)가 달라짐 — 선택지 밖 값은 비움
|
||||
for (const child of c.items.filter((i) => i.by?.item === item.id)) {
|
||||
const allowed = choicesOf(child, values);
|
||||
if (child.kind === "select" && !allowed.some((ch) => ch.value === values[child.id]))
|
||||
values[child.id] = "";
|
||||
}
|
||||
paint();
|
||||
hooks.onChange?.(c, item);
|
||||
};
|
||||
|
||||
const instanceBox = (c: SpecContainer, values: Instance, index: number): HTMLElement => {
|
||||
const title = containerTitle(c, names, groupNames[c.link]);
|
||||
const box = group(c.required ? title : `${title} ${index + 1}`);
|
||||
box.root.dataset.specContainer = c.id;
|
||||
const fields = c.items
|
||||
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, values))
|
||||
.map((item) => {
|
||||
const input = control(c, item, values);
|
||||
const wrap = labeled(itemLabel(c, item, names), input);
|
||||
wrap.dataset.specItem = `${c.id}.${item.id}`;
|
||||
const target = input.querySelector?.("input, select") ?? input;
|
||||
target.addEventListener("change", () =>
|
||||
onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value),
|
||||
);
|
||||
return wrap;
|
||||
});
|
||||
box.body.append(grid(...fields));
|
||||
if (c.required) box.body.append(slotOf(c.id));
|
||||
if (links) {
|
||||
const key = linkOf(c, index + 1);
|
||||
const row = buildSummaryRow();
|
||||
row.root.querySelector(".b05-structure__summary-label")!.textContent = "구조물 조합";
|
||||
row.root.dataset.specLink = key;
|
||||
row.set(links.get()[key] ?? null);
|
||||
row.bind({ pick: () => links.pick(key), clear: () => links.clear(key) });
|
||||
rows.push({ key, set: row.set });
|
||||
box.body.append(row.root);
|
||||
}
|
||||
if (!c.required) {
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "b05-route__irregular-btn";
|
||||
remove.textContent = "빼기";
|
||||
remove.dataset.specRemove = c.id;
|
||||
remove.addEventListener("click", () => {
|
||||
const list = state[c.id];
|
||||
// 잇기는 인스턴스 차례를 따름 — 뒤 것을 한 칸씩 당기고 마지막 키는 풂
|
||||
if (links) {
|
||||
const now = links.get();
|
||||
for (let n = index + 1; n < list.length; n++)
|
||||
links.set(linkOf(c, n), now[linkOf(c, n + 1)] ?? null);
|
||||
links.set(linkOf(c, list.length), null);
|
||||
}
|
||||
list.splice(index, 1);
|
||||
paint();
|
||||
hooks.onChange?.(c, c.items[0]);
|
||||
});
|
||||
box.root.querySelector("legend")!.append(" ", remove);
|
||||
}
|
||||
return box.root;
|
||||
};
|
||||
|
||||
function paint(): void {
|
||||
rows.length = 0;
|
||||
const boxes: HTMLElement[] = [];
|
||||
for (const c of spec?.containers ?? []) {
|
||||
const list = (state[c.id] ??= c.required ? [{}] : []);
|
||||
list.forEach((values, index) => boxes.push(instanceBox(c, values, index)));
|
||||
if (c.required) continue;
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
add.className = "b05-route__irregular-btn b05-structure__spec-add";
|
||||
add.textContent = `+ ${containerTitle(c, names, groupNames[c.link])}`;
|
||||
add.title = "추가";
|
||||
add.dataset.specAdd = c.id;
|
||||
add.addEventListener("click", () => {
|
||||
list.push({});
|
||||
paint();
|
||||
hooks.onChange?.(c, c.items[0]);
|
||||
});
|
||||
boxes.push(add);
|
||||
}
|
||||
root.replaceChildren(...boxes);
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
write(next, options, nextNames = {}, nextGroups = {}) {
|
||||
spec = next;
|
||||
names = nextNames;
|
||||
groupNames = nextGroups;
|
||||
state = readState(next, options);
|
||||
recent.clear();
|
||||
paint();
|
||||
},
|
||||
read: () => (spec ? writeOptions(spec, state) : {}),
|
||||
state: () => state,
|
||||
setValue(containerId, itemId, value) {
|
||||
const values = state[containerId]?.[0];
|
||||
if (!values) return;
|
||||
values[itemId] = value;
|
||||
paint();
|
||||
},
|
||||
slot: slotOf,
|
||||
paintLinks() {
|
||||
const now = links?.get() ?? {};
|
||||
rows.forEach((row) => row.set(now[row.key] ?? null));
|
||||
},
|
||||
refresh: paint,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* =============================================================================
|
||||
* design_structure_pipe.ts
|
||||
* 횡단배수 › 배관 명세 — 사용자 구조물 표 01(`docs/raw/구조물정의_표/01_횡단배수_배관.md`) 그대로.
|
||||
*
|
||||
* 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이 추가(선택 · 여러 개) ·
|
||||
* 도수로/돌붙임(선택 · 여러 개). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
|
||||
* 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리) — 흙막이 값은 옛 「기슭막이」
|
||||
* (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`).
|
||||
* 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림.
|
||||
* 계산(관 길이 · 높이 안내)은 `design_structure_pipe_calc`.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec";
|
||||
|
||||
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
|
||||
const BASIN = "집수정";
|
||||
const SOIL_FORMS: SpecChoice[] = [
|
||||
{ value: "돌쌓기(찰)", label: "돌 쌓기(찰)" },
|
||||
{ value: "돌쌓기(메)", label: "돌 쌓기(메)" },
|
||||
{ value: "콘크리트", label: "콘크리트" },
|
||||
{ value: "돌망태", label: "돌망태" },
|
||||
{ value: "통나무·목재틀", label: "통나무/목재틀" },
|
||||
{ value: "바자", label: "바자" },
|
||||
];
|
||||
const BASIN_FORMS: SpecChoice[] = [
|
||||
{ value: "I", label: "I형" },
|
||||
{ value: "L", label: "L형" },
|
||||
{ value: "U", label: "ㄷ형" },
|
||||
];
|
||||
|
||||
/** 형태 · 길이 · 높이 · 전 · 후 — 필수 보호공은 구조 갈래마다 옛 키 · 선택 흙막이는 인스턴스 키. */
|
||||
function guardItems(keys?: Record<string, Record<string, string>>): SpecItem[] {
|
||||
const by = (field: string, choices?: Record<string, SpecChoice[]>) =>
|
||||
keys
|
||||
? {
|
||||
item: "structure",
|
||||
map: Object.fromEntries(
|
||||
Object.entries(keys).map(([structure, k]) => [
|
||||
structure,
|
||||
{ key: k[field], choices: choices?.[structure] },
|
||||
]),
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
id: "form",
|
||||
label: "형태",
|
||||
kind: "select",
|
||||
choices: SOIL_FORMS,
|
||||
by: by("form", { [BASIN]: BASIN_FORMS, [SOIL]: SOIL_FORMS }),
|
||||
},
|
||||
{ id: "length", label: "길이 (m)", kind: "number", split: "length", step: 1, by: by("length") },
|
||||
{ id: "height", label: "높이 (m)", kind: "number", step: 0.1, by: by("height") },
|
||||
{
|
||||
id: "before",
|
||||
label: "기준측점 전 (m)",
|
||||
kind: "number",
|
||||
split: "before",
|
||||
step: 0.5,
|
||||
by: by("before"),
|
||||
},
|
||||
{
|
||||
id: "after",
|
||||
label: "기준측점 후 (m)",
|
||||
kind: "number",
|
||||
split: "after",
|
||||
step: 0.5,
|
||||
by: by("after"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => ({
|
||||
form: `${side}_revet_form`,
|
||||
length: `${side}_revet_length_m`,
|
||||
height: `${side}_revet_height_m`,
|
||||
before: `${side}_revet_before_m`,
|
||||
after: `${side}_revet_after_m`,
|
||||
});
|
||||
|
||||
export const PIPE_SPEC: StructureSpec = {
|
||||
typeId: "pipe",
|
||||
containers: [
|
||||
{
|
||||
id: "pipe",
|
||||
title: "관",
|
||||
required: true,
|
||||
link: "grv6lny", // 정의 그룹 「관」(46장) — 집계표 잇기 자리 그대로
|
||||
items: [
|
||||
{
|
||||
id: "diameter",
|
||||
label: "관경",
|
||||
kind: "select",
|
||||
key: "pipe_diameter_mm",
|
||||
asNumber: true,
|
||||
choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })),
|
||||
},
|
||||
{ id: "length", label: "관 길이 (m)", kind: "auto" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "inlet",
|
||||
title: "관 보호공 (유입)",
|
||||
required: true,
|
||||
link: "gnbf9m9",
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
key: "inlet_type",
|
||||
choices: [
|
||||
{ value: BASIN, label: "집수정" },
|
||||
{ value: SOIL, label: "흙막이", also: { inlet_structure: "revet" } },
|
||||
],
|
||||
},
|
||||
...guardItems({
|
||||
[BASIN]: {
|
||||
form: "inlet_structure",
|
||||
length: "inlet_basin_length_m",
|
||||
height: "inlet_basin_height_m",
|
||||
before: "inlet_basin_before_m",
|
||||
after: "inlet_basin_after_m",
|
||||
},
|
||||
[SOIL]: revetKeys("inlet"),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "outlet",
|
||||
title: "관 보호공 (유출)",
|
||||
required: true,
|
||||
link: "g9lq9uk",
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
key: "outlet_type",
|
||||
choices: [{ value: SOIL, label: "흙막이" }],
|
||||
},
|
||||
...guardItems({ [SOIL]: revetKeys("outlet") }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "soil_add",
|
||||
title: "흙막이 추가",
|
||||
required: false,
|
||||
prefix: "soil_add",
|
||||
link: "gsoiladd",
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
choices: [{ value: SOIL, label: "흙막이" }],
|
||||
},
|
||||
...guardItems(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chute_add",
|
||||
title: "도수로/돌붙임",
|
||||
required: false,
|
||||
prefix: "chute_add",
|
||||
link: "gchuteadd",
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
choices: [
|
||||
{ value: "도수로", label: "도수로" },
|
||||
{ value: "돌붙임", label: "돌붙임" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// 표 01 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||
retired: ["pipe_kind", "wing_wall_type", "inlet_basin_form", "inlet_basin_material"],
|
||||
};
|
||||
|
||||
/** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */
|
||||
export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = { pipe: PIPE_SPEC };
|
||||
@@ -0,0 +1,42 @@
|
||||
/* =============================================================================
|
||||
* design_structure_pipe_calc.ts
|
||||
* 배관 명세 계산 — 관 길이(자동) · 보호공 높이 안내. 계산 자리 = ② TS 를 서버 Node 로:
|
||||
* 관 길이는 횡단 기하 `computeStoredLayouts`(B06 · 서버 Node 진입점이 같은 함수로 `pipe_length_m` 를
|
||||
* 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함).
|
||||
* 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 ·
|
||||
* 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets";
|
||||
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { PIPE_MATCH_M } from "./design_selection";
|
||||
|
||||
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
|
||||
let sectionsSource: (() => readonly CrossSection[] | null) | null = null;
|
||||
export function setPipeSectionsSource(source: () => readonly CrossSection[] | null): void {
|
||||
sectionsSource = source;
|
||||
}
|
||||
|
||||
/** 관 길이(m) — 그 관의 주인 측점(관 자리에서 가장 가까운 측점) 횡단 기하 값 · 못 세우면 저장된
|
||||
* `pipe_length_m` · 그것도 없으면 null. */
|
||||
export function pipeLengthM(chainageM: number | null): number | null {
|
||||
const sections = sectionsSource?.() ?? null;
|
||||
if (chainageM === null || !sections?.length) return null;
|
||||
const holders = sections.filter(
|
||||
(s) => Math.abs((s.culvert?.chainage_m ?? Infinity) - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (!holders.length) return null;
|
||||
const owner = holders.reduce((a, b) =>
|
||||
Math.abs(b.chainage_m - chainageM) < Math.abs(a.chainage_m - chainageM) ? b : a,
|
||||
);
|
||||
const drawn = computeStoredLayouts(owner, sections)?.culvert?.pipe?.lengthM;
|
||||
const length = typeof drawn === "number" && drawn > 0 ? drawn : owner.design?.pipe_length_m;
|
||||
return typeof length === "number" && length > 0 ? length : null;
|
||||
}
|
||||
|
||||
/** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */
|
||||
export function guardHeightHint(diameterMm: string): string {
|
||||
const d = Number(diameterMm || "1000") / 1000;
|
||||
return `비우면 관경 기준 ${pipeWallMinHeightM(d)}`;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/* =============================================================================
|
||||
* design_structure_spec.ts
|
||||
* 구조물 명세 공용 틀(PLAN 52) — 화면 요소 없는 순수 규칙. 사용자 구조물 표 한 장 = 명세 한 벌.
|
||||
*
|
||||
* · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[]
|
||||
* · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개)
|
||||
* · 항목 꼴 = 고르기 · 숫자 · 자동(계산이 채움 · 저장 안 함) · 맞물림(길이 = 전 + 후)
|
||||
* · 상위 값에 따른 선택지 · 저장 키 = `by`(같은 컨테이너 안 상위 항목 값 → 갈래)
|
||||
* · 저장 = 정본 자리 옵션 그대로(관이면 pipe_points.json `options`) — 필수 컨테이너는 항목 `key` ·
|
||||
* 선택 컨테이너는 인스턴스마다 `{prefix}_{n}_{항목}`(n 은 1부터)
|
||||
* · 구조물 조합 잇기(`group_items`) 키 = 필수는 `link`(정의 그룹 id) · 선택은 `{link}{n}`
|
||||
* · 이름 = 정의 `종류.{종류}.이름` 이 있으면 그것(M02 바꿈) · 그룹 이름 · 없으면 명세 글
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface SpecChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
/** 이 값을 고르면 함께 적는 옵션(하류가 읽는 옛 칸 · 예: 흙막이 = inlet_structure revet). */
|
||||
also?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 상위 값 하나에 걸린 갈래 — 저장 키 · 선택지. */
|
||||
export interface SpecBranch {
|
||||
key: string;
|
||||
choices?: SpecChoice[];
|
||||
}
|
||||
|
||||
export type SplitRole = "length" | "before" | "after";
|
||||
|
||||
export interface SpecItem {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "select" | "number" | "auto";
|
||||
/** 저장 키(필수 컨테이너 · `by` 없을 때). */
|
||||
key?: string;
|
||||
choices?: SpecChoice[];
|
||||
/** 같은 컨테이너 상위 항목 값 → 갈래. 갈래가 없으면(상위 비었음) 칸을 가림. */
|
||||
by?: { item: string; map: Record<string, SpecBranch> };
|
||||
/** 길이 = 전 + 후 맞물림 몫. */
|
||||
split?: SplitRole;
|
||||
/** 고르기 값을 수로 저장(관경 등 옛 저장이 수). */
|
||||
asNumber?: boolean;
|
||||
step?: number;
|
||||
/** 빈 칸 안내 — 함수면 지금 값으로 셈. */
|
||||
hint?: string | ((values: Record<string, string>) => string);
|
||||
}
|
||||
|
||||
export interface SpecContainer {
|
||||
id: string;
|
||||
title: string;
|
||||
required: boolean;
|
||||
/** 선택 컨테이너 저장 키 머리 — `{prefix}_{n}_{항목}`. */
|
||||
prefix?: string;
|
||||
/** 구조물 조합 잇기 키 — 필수는 정의 그룹 id 그대로 · 선택은 뒤에 n. */
|
||||
link: string;
|
||||
items: SpecItem[];
|
||||
}
|
||||
|
||||
export interface StructureSpec {
|
||||
typeId: string;
|
||||
containers: SpecContainer[];
|
||||
/** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */
|
||||
retired: string[];
|
||||
}
|
||||
|
||||
/** 컨테이너 인스턴스 한 벌 값 `{항목 id: 글}`. */
|
||||
export type Instance = Record<string, string>;
|
||||
export type SpecState = Record<string, Instance[]>;
|
||||
|
||||
/** 항목의 지금 갈래 — `by` 없으면 항목 자체. 상위가 비었으면 null(가림 · 저장 안 함). */
|
||||
export function branchOf(item: SpecItem, values: Instance): SpecBranch | null {
|
||||
if (!item.by)
|
||||
return item.key || item.choices ? { key: item.key ?? "", choices: item.choices } : null;
|
||||
return item.by.map[values[item.by.item] ?? ""] ?? null;
|
||||
}
|
||||
|
||||
const instanceKey = (c: SpecContainer, n: number, item: SpecItem): string =>
|
||||
`${c.prefix}_${n}_${item.id}`;
|
||||
|
||||
/** 항목 저장 키 — 자동 항목 · 갈래 없음 = null. */
|
||||
export function keyOf(
|
||||
c: SpecContainer,
|
||||
item: SpecItem,
|
||||
n: number,
|
||||
values: Instance,
|
||||
): string | null {
|
||||
if (item.kind === "auto") return null;
|
||||
if (!c.required) return instanceKey(c, n, item);
|
||||
return branchOf(item, values)?.key || null;
|
||||
}
|
||||
|
||||
/** 지금 고를 수 있는 값 — 갈래 선택지가 앞섬. */
|
||||
export function choicesOf(item: SpecItem, values: Instance): SpecChoice[] {
|
||||
return branchOf(item, values)?.choices ?? item.choices ?? [];
|
||||
}
|
||||
|
||||
/** 인스턴스 잇기 키 — 필수 = 그룹 id · 선택 = `{link}{n}`. */
|
||||
export const linkOf = (c: SpecContainer, n: number): string =>
|
||||
c.required ? c.link : `${c.link}${n}`;
|
||||
|
||||
const text = (value: unknown): string =>
|
||||
value === undefined || value === null ? "" : String(value);
|
||||
|
||||
/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */
|
||||
export function readState(
|
||||
spec: StructureSpec,
|
||||
options: Record<string, string | number>,
|
||||
): SpecState {
|
||||
const state: SpecState = {};
|
||||
for (const c of spec.containers) {
|
||||
if (c.required) {
|
||||
const values: Instance = {};
|
||||
const plain = c.items.filter((item) => !item.by);
|
||||
for (const item of [...plain, ...c.items.filter((item) => item.by)]) {
|
||||
const key = keyOf(c, item, 1, values);
|
||||
if (key) values[item.id] = text(options[key]);
|
||||
}
|
||||
state[c.id] = [values];
|
||||
continue;
|
||||
}
|
||||
const head = new RegExp(`^${c.prefix}_(\\d+)_`);
|
||||
const ns = [
|
||||
...new Set(
|
||||
Object.keys(options)
|
||||
.map((k) => head.exec(k)?.[1])
|
||||
.filter((n): n is string => !!n)
|
||||
.map(Number),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
state[c.id] = ns.map((n) =>
|
||||
Object.fromEntries(c.items.map((item) => [item.id, text(options[instanceKey(c, n, item)])])),
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김). */
|
||||
export function writeOptions(
|
||||
spec: StructureSpec,
|
||||
state: SpecState,
|
||||
): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const c of spec.containers) {
|
||||
(state[c.id] ?? []).forEach((values, index) => {
|
||||
for (const item of c.items) {
|
||||
const key = keyOf(c, item, index + 1, values);
|
||||
const raw = (values[item.id] ?? "").trim();
|
||||
if (!key || raw === "") continue;
|
||||
if (item.kind === "number" || item.asNumber) {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) out[key] = Math.round(n * 100) / 100;
|
||||
continue;
|
||||
}
|
||||
out[key] = raw;
|
||||
if (c.required)
|
||||
Object.assign(out, choicesOf(item, values).find((ch) => ch.value === raw)?.also);
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 명세가 다루는 키 전부(필수 갈래 · also 포함) + 선택 컨테이너 머리 — 지난 정의 값 판정 · 저장 병합. */
|
||||
export function specKeys(spec: StructureSpec): { keys: string[]; prefixes: string[] } {
|
||||
const keys = new Set<string>();
|
||||
const prefixes: string[] = [];
|
||||
for (const c of spec.containers) {
|
||||
if (!c.required) {
|
||||
prefixes.push(`${c.prefix}_`);
|
||||
continue;
|
||||
}
|
||||
for (const item of c.items) {
|
||||
const branches = item.by ? Object.values(item.by.map) : [branchOf(item, {})];
|
||||
for (const branch of branches) {
|
||||
if (branch?.key) keys.add(branch.key);
|
||||
for (const ch of branch?.choices ?? item.choices ?? [])
|
||||
Object.keys(ch.also ?? {}).forEach((k) => keys.add(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { keys: [...keys], prefixes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 길이 = 전 + 후 맞물림 — 마지막에 고친 둘을 지키고 나머지 하나를 셈(정함 52).
|
||||
* `recent` = 고친 차례(최근이 앞) · 둘이 안 차면 길이 → 전 → 후 차례로 채움. 지킬 둘 중 빈 칸이 있으면 안 셈.
|
||||
*/
|
||||
export function solveSplit(
|
||||
values: Partial<Record<SplitRole, string>>,
|
||||
recent: SplitRole[],
|
||||
): Partial<Record<SplitRole, string>> {
|
||||
const keep = [...new Set([...recent, "length", "before", "after"] as SplitRole[])].slice(0, 2);
|
||||
const num = (role: SplitRole): number | null => {
|
||||
const raw = (values[role] ?? "").trim();
|
||||
const n = Number(raw);
|
||||
return raw !== "" && Number.isFinite(n) ? n : null;
|
||||
};
|
||||
const [a, b] = keep.map(num);
|
||||
if (a === null || b === null) return values;
|
||||
const free = (["length", "before", "after"] as SplitRole[]).find((r) => !keep.includes(r))!;
|
||||
const got = { length: num("length"), before: num("before"), after: num("after") };
|
||||
const result =
|
||||
free === "length"
|
||||
? (got.before ?? 0) + (got.after ?? 0)
|
||||
: Math.max((got.length ?? 0) - (free === "before" ? (got.after ?? 0) : (got.before ?? 0)), 0);
|
||||
return { ...values, [free]: String(Math.round(result * 100) / 100) };
|
||||
}
|
||||
|
||||
/** 이름 바꿈 표(정의 `종류.{종류}.이름`) — 키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`. */
|
||||
export type SpecNames = Record<string, string>;
|
||||
|
||||
export const containerTitle = (c: SpecContainer, names: SpecNames, groupName?: string): string =>
|
||||
names[c.id] ?? groupName ?? c.title;
|
||||
export const itemLabel = (c: SpecContainer, item: SpecItem, names: SpecNames): string =>
|
||||
names[`${c.id}.${item.id}`] ?? item.label;
|
||||
export const choiceLabel = (
|
||||
c: SpecContainer,
|
||||
item: SpecItem,
|
||||
ch: SpecChoice,
|
||||
names: SpecNames,
|
||||
): string => names[`${c.id}.${item.id}=${ch.value}`] ?? ch.label;
|
||||
|
||||
/** 명세의 이름 키 전부(정의 파일에 올릴 표) — `{키: 명세 글}`. */
|
||||
export function nameTable(spec: StructureSpec): SpecNames {
|
||||
const out: SpecNames = {};
|
||||
for (const c of spec.containers) {
|
||||
if (!c.required) out[c.id] = c.title; // 필수 컨테이너 이름 = 정의 그룹 이름(M02 그룹 표에서 바꿈)
|
||||
for (const item of c.items) {
|
||||
out[`${c.id}.${item.id}`] = item.label;
|
||||
const all = [
|
||||
...(item.choices ?? []),
|
||||
...Object.values(item.by?.map ?? {}).flatMap((b) => b.choices ?? []),
|
||||
];
|
||||
for (const ch of all) out[`${c.id}.${item.id}=${ch.value}`] = ch.label;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
createStandardSlot,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Panel";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
|
||||
import {
|
||||
bindDesignView,
|
||||
bindPipeOptions,
|
||||
@@ -94,6 +95,8 @@ export async function mountCrossView(
|
||||
const projectId = shared.projectId || null;
|
||||
let currentRouteId: number | null = null;
|
||||
let sectionDetail: SectionDetailResponse | null = null;
|
||||
// 배관 명세 관 길이(자동) = 이 횡단 자료의 기하(PLAN 52-1)
|
||||
setPipeSectionsSource(() => sectionDetail?.cross_sections ?? null);
|
||||
let stationInterval: number | undefined;
|
||||
|
||||
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
|
||||
|
||||
@@ -195,3 +195,72 @@
|
||||
transform: translate(-100%, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 칸을 골랐을 때 그 칸의 알약 — 굵은 테두리 + 바깥 고리(칸이 바뀐 첫 그림만 한 번 반짝) */
|
||||
.ss-link.is-here {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-link.is-pop {
|
||||
animation: ss-link-pop 1.2s ease-out 1 both;
|
||||
}
|
||||
|
||||
@keyframes ss-link-pop {
|
||||
0%,
|
||||
40%,
|
||||
80% {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 55%, transparent);
|
||||
}
|
||||
20%,
|
||||
60%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 일위대가에 없는 알약 — ⚠ + 점선 붉은 테두리 + [×] */
|
||||
.ss-link.is-orphan {
|
||||
border: 1px dashed var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ss-link__x {
|
||||
margin-left: 2px;
|
||||
padding: 0 4px;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-link__x:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||
}
|
||||
|
||||
.ss-link-cell.is-orphan {
|
||||
outline: 1px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */
|
||||
.ss-link-range {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-link-range__tag {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
bottom: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-canvas);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import "./spreadsheet_links.css";
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { applyCommand } from "./spreadsheet_commands";
|
||||
import { askShare } from "./spreadsheet_link_ask";
|
||||
import { linkHues } from "./spreadsheet_link_color";
|
||||
@@ -53,6 +53,11 @@ export interface LinkOptions {
|
||||
onItemsChange?: (항목: LinkItem[]) => void;
|
||||
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
|
||||
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
|
||||
/** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만
|
||||
* 목록에 없는 연결 알약에 ⚠ + [×] */
|
||||
확정?: boolean;
|
||||
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */
|
||||
표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[];
|
||||
}
|
||||
|
||||
export interface Links extends PartHandle {
|
||||
@@ -74,6 +79,8 @@ const TEXT = {
|
||||
fail: "자재 목록을 못 받음",
|
||||
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
|
||||
dup: "이미 목록에 있는 자재",
|
||||
orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음",
|
||||
orphanX: "연결 풀기",
|
||||
};
|
||||
|
||||
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
|
||||
@@ -146,6 +153,8 @@ export function mountLinks(
|
||||
let dragging: LinkItem | null = null;
|
||||
const dropMark = el("div", { className: "ss-link-drop" });
|
||||
let pick: HTMLElement | null = null;
|
||||
/** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */
|
||||
let lastAt = "";
|
||||
|
||||
/** 키 → 연결(시트 · 줄) */
|
||||
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
|
||||
@@ -204,6 +213,8 @@ export function mountLinks(
|
||||
item: LinkItem,
|
||||
hit: { sheet: Sheet; link: CellLink } | undefined,
|
||||
hue: number | undefined,
|
||||
orphan: boolean,
|
||||
here: string,
|
||||
) {
|
||||
const where = hit
|
||||
? hit.sheet.id === ctx.selection.시트
|
||||
@@ -211,15 +222,30 @@ export function mountLinks(
|
||||
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
|
||||
: "";
|
||||
const b = el("button", {
|
||||
className: hit ? "ss-link is-linked" : "ss-link",
|
||||
attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 },
|
||||
className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${
|
||||
here ? ` is-here${here === "new" ? " is-pop" : ""}` : ""
|
||||
}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free,
|
||||
"data-key": item.키,
|
||||
},
|
||||
children: [
|
||||
el("span", { text: item.이름 }),
|
||||
el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }),
|
||||
...(item.단위 ? [el("small", { text: item.단위 })] : []),
|
||||
...(where ? [el("code", { text: where })] : []),
|
||||
],
|
||||
});
|
||||
if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue));
|
||||
if (orphan && !ctx.readOnly) {
|
||||
const x = el("span", {
|
||||
className: "ss-link__x",
|
||||
text: "×",
|
||||
attrs: { role: "button", title: TEXT.orphanX },
|
||||
});
|
||||
x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back()));
|
||||
b.append(x);
|
||||
}
|
||||
b.draggable = !ctx.readOnly;
|
||||
b.addEventListener("dragstart", (e) => {
|
||||
dragging = item;
|
||||
@@ -233,13 +259,13 @@ export function mountLinks(
|
||||
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
|
||||
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
|
||||
const unlink = (): void => {
|
||||
function unlink(): void {
|
||||
if (hit)
|
||||
put(
|
||||
hit.sheet,
|
||||
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
|
||||
);
|
||||
};
|
||||
}
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
|
||||
@@ -307,7 +333,17 @@ export function mountLinks(
|
||||
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
|
||||
);
|
||||
const hues = linkHues(map.keys());
|
||||
const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키)));
|
||||
const listed = new Set(items.map((i) => i.키));
|
||||
const sure = opts.모드 === "입력" && opts.확정 === true;
|
||||
// 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new")
|
||||
const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`;
|
||||
const fresh = at !== lastAt;
|
||||
lastAt = at;
|
||||
const pills = shown.map((item) => {
|
||||
const hit = map.get(item.키);
|
||||
const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : "";
|
||||
return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here);
|
||||
});
|
||||
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
|
||||
const more = el("button", {
|
||||
className: "ss-link ss-link--add",
|
||||
@@ -318,6 +354,8 @@ export function mountLinks(
|
||||
pills.push(more);
|
||||
}
|
||||
bar.replaceChildren(...pills);
|
||||
if (fresh)
|
||||
bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
}
|
||||
|
||||
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
@@ -325,6 +363,8 @@ export function mountLinks(
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
const hues = linkHues(found().keys());
|
||||
const listed = new Set(items.map((i) => i.키));
|
||||
const sure = opts?.모드 === "입력" && opts.확정 === true;
|
||||
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
|
||||
const byCell = new Map<string, CellLink[]>();
|
||||
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
|
||||
@@ -340,6 +380,7 @@ export function mountLinks(
|
||||
dot.style.left = `${box.x}px`;
|
||||
dot.style.top = `${box.y + box.h - 7}px`;
|
||||
const marks = [tint, dot];
|
||||
if (sure && !listed.has(first.키)) tint.classList.add("is-orphan");
|
||||
if (list.length > 1) {
|
||||
const count = el("div", { className: "ss-link-count", text: String(list.length) });
|
||||
count.style.left = `${box.x + box.w}px`;
|
||||
@@ -350,6 +391,22 @@ export function mountLinks(
|
||||
layer.append(...marks);
|
||||
dots.push(...marks);
|
||||
}
|
||||
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
|
||||
for (const mark of opts?.표시범위 ?? []) {
|
||||
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
|
||||
if (!g) continue;
|
||||
const { layer, box } = slot(ctx, g.r0, g.c0);
|
||||
const end = slot(ctx, g.r1, g.c1).box;
|
||||
if (!box.w || !box.h) continue;
|
||||
const frame = el("div", {
|
||||
className: "ss-link-range",
|
||||
attrs: { "data-range": mark.범위 },
|
||||
children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })],
|
||||
});
|
||||
frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`;
|
||||
layer.append(frame);
|
||||
dots.push(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
|
||||
@@ -360,6 +417,11 @@ export function mountLinks(
|
||||
const names = a1
|
||||
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
|
||||
: [];
|
||||
if (hit && hit.r >= 0 && hit.c >= 0)
|
||||
for (const m of opts?.표시범위 ?? []) {
|
||||
const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null;
|
||||
if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름);
|
||||
}
|
||||
if (names.length) ctx.grid.root.title = names.join(" · ");
|
||||
else if (tipped) ctx.grid.root.removeAttribute("title");
|
||||
tipped = names.length > 0;
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import {
|
||||
type DefinedRule,
|
||||
type DefinedShape,
|
||||
type DefinedSpot,
|
||||
type StructureDefinition,
|
||||
withDefinition,
|
||||
} from "./B05_Profile_UI_Structures_Define";
|
||||
|
||||
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
|
||||
export type StructurePlacement = "point" | "interval" | "site";
|
||||
@@ -27,7 +34,7 @@ export interface StructureOptionField {
|
||||
default: string | number | null;
|
||||
/** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */
|
||||
required?: boolean;
|
||||
/** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */
|
||||
/** 거짓 = 「안 씀」(48 정의 틀 3장) — 폼에 안 그림 · 옛 값은 그대로 남음. */
|
||||
enabled?: boolean;
|
||||
/** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다
|
||||
* (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */
|
||||
@@ -79,6 +86,18 @@ export interface StructureType {
|
||||
enabled: boolean;
|
||||
/** 옵션 그룹(M02 PLAN 46) — 폼의 보조 컨테이너(제목 = name) · 안의 옵션 키 · 빈 그룹도 됨. */
|
||||
groups?: StructureOptionGroup[];
|
||||
/** 구조물군 이름(48 정의 `구조물군`) — 없으면 화면 기본 이름. */
|
||||
group_name?: string;
|
||||
/** 기준 측점 칸 보임(48 정의 `종류.기준측점`) — false 일 때만 가림. */
|
||||
show_anchor?: boolean;
|
||||
/** 손코딩 특수 동작 이름(48 정의 `종류.동작`) — 그 동작이 붙은 칸 · 폼을 그대로 살림. */
|
||||
behaviors?: string[];
|
||||
/** 옵션 키 → 배치 칸(48 정의 `배치`). */
|
||||
spots?: Record<string, DefinedSpot> | null;
|
||||
/** 상위 → 하위 규칙(48 정의 `규칙` 중 이 종류 것). */
|
||||
rules?: DefinedRule[] | null;
|
||||
/** 구조물 명세 이름 바꿈(52 정의 `종류.이름` · `{컨테이너}[.{항목}[={값}]]: 이름`). */
|
||||
spec_names?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */
|
||||
@@ -86,6 +105,8 @@ export interface StructureOptionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
options: string[];
|
||||
/** 그룹이 받을 형상 칸(48 정의 그룹 `형상`) — 길이 · 높이 · 전 · 후 받기 켬끔 + 뜻. */
|
||||
shape?: DefinedShape;
|
||||
}
|
||||
|
||||
export interface StructureInstance {
|
||||
@@ -181,10 +202,14 @@ let typesCache: Promise<StructureType[]> | null = null;
|
||||
*/
|
||||
export function fetchStructureTypes(includeDisabled = false): Promise<StructureType[]> {
|
||||
if (!typesCache) {
|
||||
// 구조물 정의(48) 덧칸 — 못 받으면 옛 화면 그대로(정의 없음 = 지금 차례 · 2열)
|
||||
const definition = requestJson<StructureDefinition>("/m02/structure-definition", {
|
||||
method: "GET",
|
||||
}).catch(() => null);
|
||||
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
|
||||
method: "GET",
|
||||
})
|
||||
.then((payload) => payload.types)
|
||||
.then(async (payload) => withDefinition(payload.types, await definition))
|
||||
.catch((error) => {
|
||||
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
|
||||
throw error;
|
||||
|
||||
@@ -12,7 +12,11 @@ import os
|
||||
import uuid
|
||||
from typing import Any, Iterable
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureInstance,
|
||||
definition_version,
|
||||
structure_type_map,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys
|
||||
|
||||
@@ -125,6 +129,8 @@ def save_structures(
|
||||
"revision": revision,
|
||||
"structures": [item.model_dump(mode="json") for item in items],
|
||||
"migrated_legacy": sorted(history),
|
||||
# 저장 때 구조물 정의 판(PLAN 48-1) — 뒤에 정의가 바뀌어도 이 값이 어느 정의로 적혔는지 앎
|
||||
"definition_version": definition_version(),
|
||||
}
|
||||
atomic_write_json(structures_file_path(project_root), payload)
|
||||
return revision
|
||||
@@ -192,12 +198,15 @@ def _validate_options(item: StructureInstance, definition, hidden: set[str] = fr
|
||||
for key, value in item.options.items():
|
||||
option = allowed.get(key)
|
||||
if option is None:
|
||||
raise ValueError(f"{definition.name}에 정의되지 않은 옵션입니다: {key}")
|
||||
# 지금 정의에 없는 키 = 지난 정의 값 — 버리지 않고 그대로 남김(PLAN 48-0 ② · 오류 없음)
|
||||
continue
|
||||
if option.input == "number":
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{definition.name}의 {option.label}은(는) 숫자여야 합니다.")
|
||||
if not (value == value and abs(value) != float("inf")) or value < 0:
|
||||
raise ValueError(f"{definition.name}의 {option.label}은(는) 0 이상이어야 합니다.")
|
||||
elif option.input == "bool" and not isinstance(value, bool):
|
||||
raise ValueError(f"{definition.name}의 {option.label}은(는) 예 · 아니오여야 합니다.")
|
||||
elif option.input == "select" and option.choices and str(value) not in option.choices:
|
||||
raise ValueError(f"{definition.name}의 {option.label} 값이 선택지에 없습니다: {value}")
|
||||
for option in definition.options:
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""B05 구조물 타입 레지스트리·인스턴스 검증 모델.
|
||||
|
||||
구조물 타입 정의는 코드가 아니라 데이터(`B05_Profile_Structure_Types.json`)다. 리스트가
|
||||
바뀌어도 코드를 고치지 않도록 분리했고, 프론트는 `GET /structure-types`로 같은 정본을 받는다.
|
||||
구조물 타입 정의는 코드가 아니라 데이터(M02 마스터 `resources/master_template/구조물정의.json`
|
||||
`types` · PLAN 48-1)다. 리스트가 바뀌어도 코드를 고치지 않도록 분리했고, 프론트는
|
||||
`GET /structure-types`로 같은 정본을 받는다. 정의 틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`.
|
||||
|
||||
인스턴스 정본은 `B05_Profile/route/structures.json` **하나뿐**이다(2026-08-16 사용자 확정).
|
||||
DB에는 참조 메타만 남긴다 — 두 곳에 같은 값을 두면 어긋난 쪽이 화면에 뜬다(관 매설 지점의
|
||||
"복원 유령" 전례).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
@@ -21,8 +23,12 @@ from common_util.common_util_summary_item import parse_group_items, parse_summar
|
||||
STRUCTURE_PLACEMENTS = ("point", "interval", "site")
|
||||
STRUCTURE_SIDES = ("left", "right", "center", "cross")
|
||||
|
||||
_REGISTRY_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "B05_Profile_Structure_Types.json"
|
||||
# 시험은 사본으로 바꿈 — 읽기 캐시는 (경로 · 고친 시각 · 크기)로 갈려 저장 즉시 새로 읽음
|
||||
DEFINITION_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"resources",
|
||||
"master_template",
|
||||
"구조물정의.json",
|
||||
)
|
||||
|
||||
Placement = Literal["point", "interval", "site"]
|
||||
@@ -35,7 +41,7 @@ class StructureOptionField(BaseModel):
|
||||
|
||||
key: str
|
||||
label: str
|
||||
input: Literal["select", "number", "text"]
|
||||
input: Literal["select", "number", "text", "bool"]
|
||||
choices: list[str] = Field(default_factory=list)
|
||||
unit: str | None = None
|
||||
default: Any = None
|
||||
@@ -99,14 +105,37 @@ class StructureType(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_structure_types() -> tuple[StructureType, ...]:
|
||||
"""레지스트리 정본을 읽어 타입 목록을 돌려준다(프로세스 수명 동안 1회 로드)."""
|
||||
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
def _stamp() -> tuple[str, int, int]:
|
||||
stat = os.stat(DEFINITION_PATH)
|
||||
return DEFINITION_PATH, stat.st_mtime_ns, stat.st_size
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]:
|
||||
payload = _read_definition(path, mtime, size)
|
||||
return tuple(StructureType.model_validate(item) for item in payload["types"])
|
||||
|
||||
|
||||
def load_definition() -> dict[str, Any]:
|
||||
"""구조물 정의 한 벌(사본) — `{판, types, 표시, 그룹, 종류, 배치, 규칙, 동작, …}`."""
|
||||
return copy.deepcopy(_read_definition(*_stamp()))
|
||||
|
||||
|
||||
def definition_version() -> int:
|
||||
return int(_read_definition(*_stamp()).get("판", 1))
|
||||
|
||||
|
||||
def load_structure_types() -> tuple[StructureType, ...]:
|
||||
"""정의 정본을 읽어 타입 목록을 돌려준다(파일이 바뀔 때만 다시 읽음)."""
|
||||
return _read_types(*_stamp())
|
||||
|
||||
|
||||
def structure_type_map() -> dict[str, StructureType]:
|
||||
return {item.type_id: item for item in load_structure_types()}
|
||||
|
||||
@@ -132,8 +161,7 @@ def station_planting_labels() -> set[str]:
|
||||
|
||||
|
||||
def registry_schema_version() -> int:
|
||||
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
|
||||
return int(json.load(handle).get("schema_version", 1))
|
||||
return int(_read_definition(*_stamp()).get("schema_version", 1))
|
||||
|
||||
|
||||
class StructureInstance(BaseModel):
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
* 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치
|
||||
* 칸이 담당하므로 여기에는 부속 옵션 필드만 있다.
|
||||
*
|
||||
* 배관 부속(2026-08-17 UI 개편): 관종·관경(기본 1000) 한 행 + 유입구·유출구
|
||||
* 소그룹. 유입구는 집수정/기슭막이 택일이고, 유출구는 기슭막이뿐이다(현재 다른
|
||||
* 선택지 없음). 기슭막이 조각은 형태·길이·높이·바닥 보호공을 한 벌로 묶어
|
||||
* 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다.
|
||||
* 배관 부속(PLAN 52-1): 사용자 구조물 표 01 명세 폼(`A00_Common/design/design_structure_pipe`)
|
||||
* — 관 · 관 보호공(유입 · 유출) · 흙막이 추가 · 도수로/돌붙임 컨테이너. 저장 키는 옛 키 그대로 ·
|
||||
* 옛 관종 · 날개벽 · 집수정 형식 칸은 화면에서 빠지고 값은 남음(지난 정의 값).
|
||||
*
|
||||
* 보호공(2026-08-17 사용자 지시): 구 "돌붙임 있음/없음 + 표면처리(찰/메)" 두 축을
|
||||
* `돌붙임(찰)·돌붙임(메)·도수로` 한 축으로 합쳤다. 도수로·산비탈수로(B4)는 유출부에서
|
||||
@@ -38,26 +37,23 @@ import type {
|
||||
import {
|
||||
BOX_SIZE_CUSTOM,
|
||||
BOX_SIZE_PRESETS,
|
||||
createBasinSplitFields,
|
||||
createRevetmentFields,
|
||||
createRevetSideGroup,
|
||||
createWingFields,
|
||||
grid,
|
||||
group,
|
||||
INLET_KINDS,
|
||||
INLET_REVET_KEYS,
|
||||
inletKindOf,
|
||||
type InletStructureKind,
|
||||
labeled,
|
||||
numberInput,
|
||||
optionalSelect,
|
||||
OUTLET_REVET_KEYS,
|
||||
putNumber,
|
||||
stepper,
|
||||
WING_IN_KEYS,
|
||||
WING_OUT_KEYS,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
|
||||
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
|
||||
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
|
||||
import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc";
|
||||
import {
|
||||
hasLinks,
|
||||
linkFields,
|
||||
@@ -206,7 +202,17 @@ export interface FacilityOptionsForm {
|
||||
options?: Record<string, string | number>,
|
||||
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. 없으면 안내만. */
|
||||
designFlow?: number | null,
|
||||
/** 종류 정의 — 배관 명세 이름 바꿈(`spec_names`) · 그룹 이름(PLAN 52). */
|
||||
definition?: {
|
||||
spec_names?: Record<string, string>;
|
||||
groups?: Array<{ id: string; name: string }>;
|
||||
} | null,
|
||||
) => void;
|
||||
/** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */
|
||||
bindLinks: (links: SpecLinks) => void;
|
||||
paintLinks: () => void;
|
||||
/** 고른 관 측점 — 관 길이(자동) 계산에 씀. */
|
||||
setChainage: (chainageM: number | null) => void;
|
||||
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */
|
||||
readOptions: () => Record<string, string | number>;
|
||||
/** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존).
|
||||
@@ -244,87 +250,56 @@ export function createFacilityOptionsForm(
|
||||
root.className = "b05-drainage__facility";
|
||||
root.hidden = true;
|
||||
|
||||
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
|
||||
// 기본값(관종 파형강관 · 관경 1000 · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴(PLAN 46-7).
|
||||
// ── 관종 · 관경 한 행 — 세월교 몫(배관은 아래 명세 폼 · PLAN 52-1) ─────────
|
||||
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
|
||||
const pipeDiameter = document.createElement("select");
|
||||
pipeDiameter.replaceChildren(
|
||||
new Option("— 안 정함 —", ""),
|
||||
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
|
||||
);
|
||||
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
|
||||
// (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다.
|
||||
const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]);
|
||||
const pipeRow = grid(
|
||||
labeled("관종", pipeMaterial, "pipe_kind"),
|
||||
labeled("관경", pipeDiameter, "pipe_diameter_mm"),
|
||||
);
|
||||
const wingRow = grid(labeled("관보호공 날개벽", wingWall, "wing_wall_type"));
|
||||
|
||||
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
|
||||
const inletGroup = group("유입구");
|
||||
// 「안 정함」이면 기슭막이 칸을 보이되 값은 안 실음.
|
||||
const inletType = document.createElement("select");
|
||||
inletType.replaceChildren(
|
||||
new Option("— 안 정함 —", ""),
|
||||
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
|
||||
);
|
||||
const basinForm = optionalSelect("선택", [
|
||||
"동물이동형",
|
||||
"□형(기본형)",
|
||||
"돌집수정 ㄴ형",
|
||||
"돌집수정 ㄷ형",
|
||||
]);
|
||||
// 집수정도 재질·표면처리를 형태 한 축으로 합친다(사용자 지시 1).
|
||||
const basinMaterial = optionalSelect("선택", [
|
||||
"콘크리트",
|
||||
"조립식 주철맨홀",
|
||||
"돌쌓기(찰)",
|
||||
"돌쌓기(메)",
|
||||
]);
|
||||
const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material");
|
||||
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
|
||||
const basinLength = numberInput("0.1", "0");
|
||||
// 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a).
|
||||
const basinSplit = createBasinSplitFields(basinLength);
|
||||
basinSplit.onChange(() => emit());
|
||||
const basinRows = [
|
||||
grid(
|
||||
labeled("형식", basinForm, "inlet_basin_form"),
|
||||
labeled("길이 (m)", stepper(basinLength, 1), "inlet_basin_length_m"),
|
||||
),
|
||||
basinSplit.row,
|
||||
];
|
||||
const height = (): string =>
|
||||
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
|
||||
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { height });
|
||||
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
|
||||
// (2026-08-17 사용자 지시 3).
|
||||
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4).
|
||||
// 비어 있으면 아무 자리도 차지하지 않는다.
|
||||
const inletSlot = document.createElement("div");
|
||||
inletSlot.className = "b05-structure__adjust-slot";
|
||||
const outletSlot = inletSlot.cloneNode() as HTMLDivElement;
|
||||
inletGroup.body.append(
|
||||
grid(
|
||||
labeled("구조", inletType, ["inlet_type", "inlet_structure"]),
|
||||
basinMaterialField,
|
||||
inletRevet.formField,
|
||||
),
|
||||
...basinRows,
|
||||
...inletRevet.rows,
|
||||
inletSlot,
|
||||
);
|
||||
|
||||
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
|
||||
const outletGroup = group("유출구");
|
||||
const outletType = optionalSelect("— 안 정함 —", ["기슭막이"]);
|
||||
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { height });
|
||||
outletGroup.body.append(
|
||||
grid(labeled("구조", outletType, "outlet_type"), outletRevet.formField),
|
||||
...outletRevet.rows,
|
||||
outletSlot,
|
||||
// ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ──
|
||||
let linkSource: SpecLinks | null = null;
|
||||
let chainage: number | null = null;
|
||||
const pipeForm = createSpecForm(
|
||||
{
|
||||
get: () => linkSource?.get() ?? {},
|
||||
pick: (key) => linkSource?.pick(key),
|
||||
clear: (key) => linkSource?.clear(key),
|
||||
set: (key, item) => linkSource?.set(key, item),
|
||||
},
|
||||
{
|
||||
auto: (_c, item) => {
|
||||
if (item.id !== "length") return "";
|
||||
const length = pipeLengthM(chainage);
|
||||
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
|
||||
},
|
||||
hint: (c, item, state) => {
|
||||
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
|
||||
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
|
||||
},
|
||||
onChange: (c, item) => {
|
||||
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
|
||||
inletStructureHandler?.(inletStructureOf());
|
||||
emit();
|
||||
},
|
||||
},
|
||||
);
|
||||
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
|
||||
const inletSlot = pipeForm.slot("inlet");
|
||||
const outletSlot = pipeForm.slot("outlet");
|
||||
/** 유입 보호공 구조 → B06 유입측 형식 값(조정창과 같은 값). */
|
||||
const inletStructureOf = (): InletStructureKind => {
|
||||
const inlet = pipeForm.state().inlet?.[0] ?? {};
|
||||
if (inlet.structure === "기슭막이") return "revet";
|
||||
return inlet.structure === "집수정" && ["I", "L", "U"].includes(inlet.form ?? "")
|
||||
? (inlet.form as InletStructureKind)
|
||||
: "auto";
|
||||
};
|
||||
|
||||
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
|
||||
// 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
|
||||
@@ -425,13 +400,11 @@ export function createFacilityOptionsForm(
|
||||
root.append(
|
||||
saveNote,
|
||||
pipeRow,
|
||||
wingRow,
|
||||
pipeForm.root,
|
||||
ford.widthRow,
|
||||
ford.slopeRow,
|
||||
ford.countRow,
|
||||
ford.summary,
|
||||
inletGroup.root,
|
||||
outletGroup.root,
|
||||
extraGroup.root,
|
||||
revetTopRow,
|
||||
revetInlet.root,
|
||||
@@ -448,12 +421,8 @@ export function createFacilityOptionsForm(
|
||||
if (current === null) return;
|
||||
const isPipe = current === "pipe";
|
||||
const isBox = current === "box_culvert";
|
||||
// 관종·관경은 배수관과 세월교가 공유한다.
|
||||
pipeRow.hidden = !isPipe && current !== "ford_bridge";
|
||||
// 관보호공 날개벽 원단위는 **배수관 Ø800·Ø1000** 것뿐이다 — 다른 시설에서는 숨긴다.
|
||||
wingRow.hidden = !isPipe;
|
||||
inletGroup.root.hidden = !isPipe;
|
||||
outletGroup.root.hidden = !isPipe;
|
||||
pipeRow.hidden = current !== "ford_bridge";
|
||||
pipeForm.root.hidden = !isPipe;
|
||||
boxWrap.hidden = !isBox;
|
||||
// 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정).
|
||||
const hasWing = isBox || current === "ford_bridge";
|
||||
@@ -482,17 +451,6 @@ export function createFacilityOptionsForm(
|
||||
wingInFields.syncVisibility();
|
||||
wingOutFields.syncVisibility();
|
||||
}
|
||||
if (isPipe) {
|
||||
// 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8).
|
||||
// 병합 목록이라 판정은 라벨이 아니라 매핑된 정본 종류로 한다(2026-08-29).
|
||||
const kind = inletKindOf(inletType.value);
|
||||
const basin = kind.type === "집수정";
|
||||
const revet = kind.type === "기슭막이";
|
||||
basinRows.forEach((row) => (row.hidden = !basin));
|
||||
basinMaterialField.hidden = !basin;
|
||||
inletRevet.formField.hidden = !revet;
|
||||
inletRevet.rows.forEach((row) => (row.hidden = !revet));
|
||||
}
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
@@ -500,11 +458,6 @@ export function createFacilityOptionsForm(
|
||||
callbacks.onChange?.();
|
||||
}
|
||||
|
||||
[inletType, outletType, wingWall, basinForm, basinMaterial, basinLength].forEach((input) =>
|
||||
input.addEventListener("change", emit),
|
||||
);
|
||||
inletRevet.onChange(emit);
|
||||
outletRevet.onChange(emit);
|
||||
revetInlet.fields.onChange(emit);
|
||||
revetOutlet.fields.onChange(emit);
|
||||
revetSide.addEventListener("change", emit);
|
||||
@@ -521,9 +474,6 @@ export function createFacilityOptionsForm(
|
||||
|
||||
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
|
||||
let inletStructureHandler: ((value: InletStructureKind) => void) | null = null;
|
||||
inletType.addEventListener("change", () =>
|
||||
inletStructureHandler?.(inletKindOf(inletType.value).structure),
|
||||
);
|
||||
|
||||
return {
|
||||
root,
|
||||
@@ -538,23 +488,31 @@ export function createFacilityOptionsForm(
|
||||
revetSideLabels = labels;
|
||||
syncVisibility();
|
||||
},
|
||||
inletStructure: () => inletKindOf(inletType.value).structure,
|
||||
inletStructure: inletStructureOf,
|
||||
setInletStructure(value) {
|
||||
// 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다.
|
||||
const type = inletKindOf(inletType.value).type;
|
||||
const hit =
|
||||
INLET_KINDS.find((kind) => kind.type === type && kind.structure === value) ??
|
||||
INLET_KINDS.find((kind) => kind.structure === value);
|
||||
if (hit) inletType.value = hit.label;
|
||||
// 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움)
|
||||
if (value === "revet") pipeForm.setValue("inlet", "structure", "기슭막이");
|
||||
else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정");
|
||||
if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정")
|
||||
pipeForm.setValue("inlet", "form", value === "auto" ? "" : value);
|
||||
syncVisibility();
|
||||
},
|
||||
bindLinks(links) {
|
||||
linkSource = links;
|
||||
pipeForm.paintLinks();
|
||||
},
|
||||
paintLinks: () => pipeForm.paintLinks(),
|
||||
setChainage(chainageM) {
|
||||
chainage = chainageM;
|
||||
if (current === "pipe") pipeForm.refresh();
|
||||
},
|
||||
onInletStructureChange(handler) {
|
||||
inletStructureHandler = handler;
|
||||
},
|
||||
setHiddenOptions(facility, registryKeys) {
|
||||
hiddenOptions.set(facility, registryKeys);
|
||||
},
|
||||
setFacility(facility, options = {}, designFlow = null) {
|
||||
setFacility(facility, options = {}, designFlow = null, definition = null) {
|
||||
current = facility;
|
||||
hiddenOptions.remember(options);
|
||||
ford.setDesignFlow(designFlow ?? null);
|
||||
@@ -567,25 +525,10 @@ export function createFacilityOptionsForm(
|
||||
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
|
||||
pipeMaterial.value = text("pipe_kind");
|
||||
pipeDiameter.value = text("pipe_diameter_mm");
|
||||
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
|
||||
const savedType = text("inlet_type");
|
||||
const savedStructure = text("inlet_structure");
|
||||
inletType.value = savedType
|
||||
? (
|
||||
INLET_KINDS.find(
|
||||
(kind) =>
|
||||
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
|
||||
) ?? inletKindOf(savedType)
|
||||
).label
|
||||
: "";
|
||||
outletType.value = text("outlet_type");
|
||||
wingWall.value = text("wing_wall_type");
|
||||
basinForm.value = text("inlet_basin_form");
|
||||
basinMaterial.value = text("inlet_basin_material");
|
||||
basinLength.value = text("inlet_basin_length_m");
|
||||
basinSplit.write(options);
|
||||
inletRevet.write(options);
|
||||
outletRevet.write(options);
|
||||
if (facility === "pipe") {
|
||||
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
|
||||
pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups);
|
||||
}
|
||||
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
|
||||
// 값이 없으면 「안 정함」.
|
||||
const savedWidth = Number.parseFloat(text("body_width_m"));
|
||||
@@ -625,24 +568,7 @@ export function createFacilityOptionsForm(
|
||||
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
|
||||
};
|
||||
if (current === "pipe") {
|
||||
putPipe();
|
||||
const inletKind = inletKindOf(inletType.value);
|
||||
if (wingWall.value) options.wing_wall_type = wingWall.value;
|
||||
// B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29).
|
||||
if (inletType.value) {
|
||||
options.inlet_type = inletKind.type;
|
||||
options.inlet_structure = inletKind.structure;
|
||||
}
|
||||
if (inletKind.type === "집수정") {
|
||||
if (basinForm.value) options.inlet_basin_form = basinForm.value;
|
||||
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
|
||||
putNumber(options, "inlet_basin_length_m", basinLength.value);
|
||||
basinSplit.read(options);
|
||||
} else {
|
||||
inletRevet.read(options);
|
||||
}
|
||||
if (outletType.value) options.outlet_type = outletType.value;
|
||||
outletRevet.read(options);
|
||||
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
|
||||
} else if (current === "box_culvert") {
|
||||
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
|
||||
putNumber(options, "body_width_m", boxWidth.value);
|
||||
|
||||
@@ -26,15 +26,14 @@ const wingKeys = (side: "in" | "out"): string[] => [
|
||||
|
||||
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
|
||||
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
// 배관 = 표 01 명세 키(PLAN 52-1 · `design_structure_pipe`) — 옛 관종 · 날개벽 · 집수정 형식은 폼에서
|
||||
// 빠져 이어 붙임(지난 정의 값) · 선택 컨테이너 키는 `FORM_OPTION_PREFIXES`
|
||||
pipe: [
|
||||
"pipe_diameter_mm",
|
||||
"pipe_kind",
|
||||
"wing_wall_type",
|
||||
"inlet_type",
|
||||
"inlet_structure",
|
||||
"inlet_basin_form",
|
||||
"inlet_basin_material",
|
||||
"inlet_basin_length_m",
|
||||
"inlet_basin_height_m",
|
||||
"inlet_basin_before_m",
|
||||
"inlet_basin_after_m",
|
||||
...revetKeys("inlet"),
|
||||
@@ -55,6 +54,11 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
|
||||
};
|
||||
|
||||
/** 폼이 인스턴스 번호를 붙여 적는 키 머리(명세 선택 컨테이너 · `{머리}{n}_{항목}`) — 빼면 지워짐. */
|
||||
export const FORM_OPTION_PREFIXES: Readonly<Record<string, readonly string[]>> = {
|
||||
pipe: ["soil_add_", "chute_add_"],
|
||||
};
|
||||
|
||||
/** 독립 기슭막이 레지스트리 키 → 폼은 유입·유출 두 채널 키로 적는다. */
|
||||
const REVETMENT_FIELDS = ["form", "length_m", "height_m", "before_m", "after_m"];
|
||||
|
||||
@@ -95,7 +99,10 @@ export function mergeFacilityOptions<T extends MergeableAttributes>(
|
||||
): T {
|
||||
if (!previous || previous.facility !== next.facility) return next;
|
||||
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
|
||||
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
|
||||
const heads = FORM_OPTION_PREFIXES[next.facility] ?? [];
|
||||
const kept = Object.entries(previous.options ?? {}).filter(
|
||||
([key]) => !managed.has(key) && !heads.some((head) => key.startsWith(head)),
|
||||
);
|
||||
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
|
||||
const summary =
|
||||
next.summary_item === undefined && previous.summary_item !== undefined
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Define.ts
|
||||
* 구조물 정의 → 「구조물 배치」 칸 모양 (PLAN 48-3 · 48-4) — 화면 요소 없는 순수 규칙.
|
||||
*
|
||||
* 정의(`resources/master_template/구조물정의.json` · GET /structure-types 가 실어 줌)를 읽어
|
||||
* 칸 차례 · 격자 · 안 씀 · 규칙(상위 값 → 하위 선택지 · 보이기 · 기본값)을 낸다. 틀 =
|
||||
* `docs/raw/M02_구조물옵션정의/정의_틀.md`. 선택지를 줄이면(숨김) 옛 값은 그 값만 남겨 보임.
|
||||
* 그리기는 `_Structures_Panel_Options`(레지스트리 칸) · `_Structures_Define_Form`(시설 폼 칸).
|
||||
* 값에 따른 계산 · 연동 제어는 여기 없음(48-5).
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 정의가 옵션 하나에 실어 주는 몫 — 칸 그리기가 쓰는 것만. */
|
||||
export interface DefinedOption {
|
||||
key: string;
|
||||
label: string;
|
||||
input: string;
|
||||
choices: string[];
|
||||
default: string | number | null;
|
||||
visible?: boolean;
|
||||
/** 거짓 = 「안 씀」(정의 틀 3장) — 칸을 안 그림 · 옛 값은 그대로 남음. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** 상위 → 하위 규칙 한 줄(정의 `규칙`) — 상위 칸 값이 `만약.값들` 중 하나면 하위 칸에 `그러면` 을 씀. */
|
||||
export interface DefinedRule {
|
||||
종류?: string;
|
||||
만약: { 옵션: string; 값들: Array<string | number> };
|
||||
그러면: {
|
||||
옵션: string;
|
||||
/** 하위 칸 선택지를 이 목록으로 좁힘. */
|
||||
선택지?: string[];
|
||||
/** 하위 칸을 보이거나 가림. */
|
||||
보이기?: boolean;
|
||||
/** 상위 값이 바뀌어 이 줄이 걸리면 하위 칸 값을 이 값으로. */
|
||||
기본값?: string | number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 배치 격자 한 칸 — 정의 `배치` 의 `{줄, 칸, 너비}`(1부터). */
|
||||
export interface DefinedSpot {
|
||||
줄?: number;
|
||||
칸?: number;
|
||||
너비?: number;
|
||||
}
|
||||
|
||||
export interface DefinedType {
|
||||
options: DefinedOption[];
|
||||
/** 종류 덧칸 — 기준 측점 칸 보임(기본 보임). */
|
||||
show_anchor?: boolean;
|
||||
/** 옵션 키 → 배치 칸. 비면 정의 옵션 차례대로 2열. */
|
||||
spots?: Record<string, DefinedSpot> | null;
|
||||
rules?: DefinedRule[] | null;
|
||||
}
|
||||
|
||||
/** 칸 하나의 지금 모양 — 규칙을 건 뒤. */
|
||||
export interface OptionState {
|
||||
visible: boolean;
|
||||
choices: string[];
|
||||
}
|
||||
|
||||
const whole = (n: unknown): number | null =>
|
||||
typeof n === "number" && Number.isInteger(n) && n >= 1 ? n : null;
|
||||
|
||||
/** 그릴 옵션 차례 — 배치가 있으면 (줄, 칸) 차례 · 없는 칸은 뒤에 정의 차례로. 안 씀 · 숨김은 뺌. */
|
||||
export function orderedOptions<T extends DefinedOption>(type: {
|
||||
options: T[];
|
||||
spots?: Record<string, DefinedSpot> | null;
|
||||
}): T[] {
|
||||
const spots = type.spots ?? {};
|
||||
const rank = (key: string): [number, number] => {
|
||||
const spot = spots[key];
|
||||
return [whole(spot?.줄) ?? Infinity, whole(spot?.칸) ?? Infinity];
|
||||
};
|
||||
return type.options
|
||||
.map((option, index) => ({ option, index, at: rank(option.key) }))
|
||||
.filter(({ option }) => option.enabled !== false && option.visible !== false)
|
||||
.sort((a, b) => a.at[0] - b.at[0] || a.at[1] - b.at[1] || a.index - b.index)
|
||||
.map(({ option }) => option);
|
||||
}
|
||||
|
||||
/** 한 줄 칸 수 — 배치가 쓰는 가장 오른쪽 칸(1 ~ 6) · 배치 없으면 2. */
|
||||
export function columnsOf(type: { spots?: Record<string, DefinedSpot> | null }): number {
|
||||
let widest = 0;
|
||||
for (const spot of Object.values(type.spots ?? {})) {
|
||||
const col = whole(spot.칸) ?? 1;
|
||||
widest = Math.max(widest, col + (whole(spot.너비) ?? 1) - 1);
|
||||
}
|
||||
return widest ? Math.min(Math.max(widest, 2), 6) : 2;
|
||||
}
|
||||
|
||||
/** 칸 하나의 CSS 격자 자리 — 배치 없으면 null(흐름대로). */
|
||||
export function gridPlace(
|
||||
spot: DefinedSpot | undefined,
|
||||
columns: number,
|
||||
): { row: string; column: string } | null {
|
||||
if (!spot) return null;
|
||||
const row = whole(spot.줄);
|
||||
const col = whole(spot.칸);
|
||||
const span = Math.min(whole(spot.너비) ?? 1, columns);
|
||||
return {
|
||||
row: row ? String(row) : "",
|
||||
column: col ? `${Math.min(col, columns)} / span ${span}` : `span ${span}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 기준 측점 칸을 보일지 — 정의가 끈 때만 가림. */
|
||||
export function showsAnchor(type: { show_anchor?: boolean }): boolean {
|
||||
return type.show_anchor !== false;
|
||||
}
|
||||
|
||||
const same = (a: unknown, b: unknown): boolean => String(a ?? "") === String(b ?? "");
|
||||
|
||||
/** 규칙을 지금 값에 건 칸 모양 — 걸린 줄의 선택지 · 보이기가 정의 기본을 덮음(뒤 줄이 이김). */
|
||||
export function ruleStates(
|
||||
type: DefinedType,
|
||||
values: Record<string, string | number | undefined>,
|
||||
): Map<string, OptionState> {
|
||||
const states = new Map<string, OptionState>();
|
||||
for (const option of type.options)
|
||||
states.set(option.key, { visible: true, choices: [...option.choices] });
|
||||
for (const rule of type.rules ?? []) {
|
||||
const state = states.get(rule.그러면.옵션);
|
||||
// 가려진 상위 칸에 매인 줄은 안 걸림 — 가림이 사슬로 이어짐(정의 차례대로 적용)
|
||||
if (!state || states.get(rule.만약.옵션)?.visible === false) continue;
|
||||
if (!hits(rule, values)) continue;
|
||||
const pick = rule.그러면.선택지;
|
||||
if (pick) state.choices = pick.filter((c) => state.choices.includes(c));
|
||||
if (typeof rule.그러면.보이기 === "boolean") state.visible = rule.그러면.보이기;
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
const hits = (rule: DefinedRule, values: Record<string, unknown>): boolean =>
|
||||
rule.만약.값들.some((value) => same(value, values[rule.만약.옵션]));
|
||||
|
||||
/** 상위 값이 바뀌었을 때 하위 칸에 넣을 기본값 `{하위 키: 값}` — 걸린 줄 중 기본값이 있는 것만. */
|
||||
export function ruleDefaults(
|
||||
type: DefinedType,
|
||||
parent: string,
|
||||
values: Record<string, string | number | undefined>,
|
||||
): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const rule of type.rules ?? []) {
|
||||
const value = rule.그러면.기본값;
|
||||
if (rule.만약.옵션 !== parent || value === undefined || value === null) continue;
|
||||
if (hits(rule, values)) out[rule.그러면.옵션] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 지금 정의에 없는 키의 값 — 「지난 정의 값」 읽기만 칸으로 보임. `known` = 폼이 아는 키 전부. */
|
||||
export function staleValues(
|
||||
values: Record<string, string | number> | undefined,
|
||||
known: Iterable<string>,
|
||||
): Array<[string, string | number]> {
|
||||
const keys = new Set(known);
|
||||
return Object.entries(values ?? {}).filter(([key]) => !keys.has(key));
|
||||
}
|
||||
|
||||
/** 그룹 형상 칸(정의 틀 4장) — 길이 · 높이 · 기준 측점 전 · 후 마다 받기 켬끔 + 뜻. */
|
||||
export type ShapeName = "길이" | "높이" | "전" | "후";
|
||||
export const SHAPE_FIELDS: ReadonlyArray<{ name: ShapeName; suffix: string; label: string }> = [
|
||||
{ name: "길이", suffix: "length_m", label: "길이 (m)" },
|
||||
{ name: "높이", suffix: "height_m", label: "높이 (m)" },
|
||||
{ name: "전", suffix: "before_m", label: "기준측점 전 (m)" },
|
||||
{ name: "후", suffix: "after_m", label: "기준측점 후 (m)" },
|
||||
];
|
||||
export type DefinedShape = Partial<Record<ShapeName, { 받기?: boolean; 뜻?: string }>>;
|
||||
|
||||
/** 그룹 형상 값 저장 키 — 구조물 옵션에 `{그룹 id}__{칸}` 으로 둠. */
|
||||
export const shapeKey = (groupId: string, suffix: string): string => `${groupId}__${suffix}`;
|
||||
|
||||
/** 그룹이 받는 형상 칸 — 받기 켠 것만 정해진 차례로. */
|
||||
export function shapeFields(
|
||||
shape: DefinedShape | undefined,
|
||||
): Array<{ suffix: string; label: string; meaning: string }> {
|
||||
return SHAPE_FIELDS.filter((f) => shape?.[f.name]?.받기 === true).map((f) => ({
|
||||
suffix: f.suffix,
|
||||
label: f.label,
|
||||
meaning: shape?.[f.name]?.뜻 ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/** 정의 한 벌(GET /m02/structure-definition) 중 화면이 쓰는 칸. */
|
||||
export interface StructureDefinition {
|
||||
판?: number;
|
||||
구조물군?: Record<string, string>;
|
||||
종류?: Record<string, { 기준측점?: boolean; 동작?: string[]; 이름?: Record<string, string> }>;
|
||||
그룹?: Record<string, Array<{ id: string; 이름?: string; 형상?: DefinedShape }>>;
|
||||
배치?: Record<string, Record<string, DefinedSpot>>;
|
||||
규칙?: DefinedRule[];
|
||||
}
|
||||
|
||||
/** 종류 목록에 정의 덧칸을 얹음 — 구조물군 이름 · 기준 측점 · 동작 · 배치 · 규칙 · 그룹 형상.
|
||||
* 정의를 못 받았으면(null) 그대로 — 지금 화면과 같음. */
|
||||
export function withDefinition<
|
||||
T extends { type_id: string; group: string; groups?: Array<{ id: string }> },
|
||||
>(types: T[], definition: StructureDefinition | null): T[] {
|
||||
if (!definition) return types;
|
||||
return types.map((type) => {
|
||||
const kind = definition.종류?.[type.type_id] ?? {};
|
||||
const shapes = new Map((definition.그룹?.[type.type_id] ?? []).map((g) => [g.id, g.형상]));
|
||||
return {
|
||||
...type,
|
||||
group_name: definition.구조물군?.[type.group],
|
||||
show_anchor: kind.기준측점,
|
||||
behaviors: kind.동작 ?? [],
|
||||
spec_names: kind.이름 ?? {},
|
||||
spots: definition.배치?.[type.type_id] ?? null,
|
||||
rules: (definition.규칙 ?? []).filter((rule) => rule.종류 === type.type_id),
|
||||
...(type.groups
|
||||
? { groups: type.groups.map((g) => ({ ...g, shape: shapes.get(g.id) })) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Define_Form.ts
|
||||
* 손코딩 시설 폼(동작 「계곡통과시설」)에 정의를 붙임(PLAN 48-3 · 48-4) — 숨긴 선택지 · 규칙.
|
||||
*
|
||||
* · 칸은 `labeled(..., 옵션키)` 가 붙인 `data-option-keys` 표식으로 찾음(키 하나인 칸만) · 찾는 자리 =
|
||||
* 폼 본문 전체(그룹 상자로 옮겨 간 칸까지).
|
||||
* · 칸 배치 · 이름은 손코딩 그대로(동작이 그림) — 안 씀은 끈 옵션과 같은 길(`_Drainage_Facility_Hidden`).
|
||||
* · 선택지 = 정의 · 규칙 선택지 — 새 선택지는 보기를 더하고 밖 보기는 `hidden` 으로만 가림(옛 값은 그대로).
|
||||
* 여러 키를 함께 적는 칸(유입구 구조 · 본체 규격)은 키 하나 표식이 아니라 안 건드림.
|
||||
* · 규칙이 가린 칸 = `is-rule-hidden`(끈 옵션 표식 `is-option-hidden` 과 따로).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureType } from "./B05_Profile_Api_Structures";
|
||||
import { ruleDefaults, ruleStates } from "./B05_Profile_UI_Structures_Define";
|
||||
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
|
||||
/** 시설 폼이 가릴 옵션 키 — 「구조물 옵션 보이기」에서 끈 것 + 정의 「안 씀」. */
|
||||
export function hiddenOptionKeys(type: StructureType | null): string[] {
|
||||
return (type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key);
|
||||
}
|
||||
|
||||
export interface FacilityDefinition {
|
||||
/** 지금 종류 정의로 칸 모양을 맞춤 — 시설 폼을 채운 뒤마다 부름. */
|
||||
apply: (type: StructureType | null) => void;
|
||||
}
|
||||
|
||||
export function createFacilityDefinition(root: HTMLElement): FacilityDefinition {
|
||||
let type: StructureType | null = null;
|
||||
const wrapOf = (key: string): HTMLElement | null =>
|
||||
[...root.querySelectorAll<HTMLElement>("[data-option-keys]")].find(
|
||||
(node) => node.dataset.optionKeys === key,
|
||||
) ?? null;
|
||||
const controlOf = (key: string): HTMLInputElement | HTMLSelectElement | null =>
|
||||
wrapOf(key)?.querySelector<HTMLInputElement | HTMLSelectElement>("select, input") ?? null;
|
||||
const values = (): Record<string, string> =>
|
||||
Object.fromEntries((type?.options ?? []).map((o) => [o.key, controlOf(o.key)?.value ?? ""]));
|
||||
|
||||
const paint = (): void => {
|
||||
if (!type) return;
|
||||
const states = ruleStates(type, values());
|
||||
for (const option of type.options) {
|
||||
const wrap = wrapOf(option.key);
|
||||
const state = states.get(option.key);
|
||||
if (!wrap || !state) continue;
|
||||
wrap.classList.toggle("is-rule-hidden", !state.visible);
|
||||
const control = controlOf(option.key);
|
||||
if (!(control instanceof HTMLSelectElement)) continue;
|
||||
// 정의에 새로 든 선택지는 보기를 더하고, 정의 · 규칙 밖 보기는 가림(빈 보기 · 지금 값은 그대로)
|
||||
const have = new Set([...control.options].map((item) => item.value));
|
||||
for (const choice of state.choices)
|
||||
if (!have.has(choice)) control.add(new Option(choice, choice));
|
||||
for (const item of control.options) {
|
||||
item.hidden =
|
||||
!!item.value && !state.choices.includes(item.value) && item.value !== control.value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 상위 칸이 바뀌면 걸린 규칙의 기본값을 하위 칸에 넣고, 그 칸의 change 로 폼 반영(시설 폼 emit)을 잇는다.
|
||||
root.addEventListener("change", (event) => {
|
||||
if (!type) return;
|
||||
const key = (event.target as HTMLElement).closest<HTMLElement>("[data-option-keys]")?.dataset
|
||||
.optionKeys;
|
||||
if (key) {
|
||||
for (const [child, value] of Object.entries(ruleDefaults(type, key, values()))) {
|
||||
const control = controlOf(child);
|
||||
if (!control || control.value === String(value)) continue;
|
||||
control.value = String(value);
|
||||
control.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
paint();
|
||||
});
|
||||
|
||||
return {
|
||||
apply(next) {
|
||||
type = next;
|
||||
root
|
||||
.querySelectorAll(".is-rule-hidden")
|
||||
.forEach((node) => node.classList.remove("is-rule-hidden"));
|
||||
root.querySelectorAll<HTMLOptionElement>("option[hidden]").forEach((o) => (o.hidden = false));
|
||||
paint();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -74,7 +74,12 @@ export function optionControl(
|
||||
const tip = option.not_in_table ?? option.empty_means ?? "";
|
||||
if (option.input === "select") {
|
||||
const blank = option.required ? "— 선택 —" : "— 안 정함 —";
|
||||
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
|
||||
// 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음)
|
||||
const kept = value && !option.choices.includes(value) ? [value] : [];
|
||||
const element = select([
|
||||
["", blank],
|
||||
...[...option.choices, ...kept].map((c) => [c, c] as [string, string]),
|
||||
]);
|
||||
element.value = value;
|
||||
if (tip) element.title = tip;
|
||||
return element;
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
|
||||
import { createGroupBoxes, type GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
|
||||
import {
|
||||
createFacilityDefinition,
|
||||
type FacilityDefinition,
|
||||
} from "./B05_Profile_UI_Structures_Define_Form";
|
||||
|
||||
export interface StructuresFormElements {
|
||||
root: HTMLElement;
|
||||
@@ -46,6 +50,10 @@ export interface StructuresFormElements {
|
||||
/** 버튼 묶음 — 패널이 그 앞에 메모 칸을 끼워 넣는다. */
|
||||
actions: HTMLElement;
|
||||
facilityOptions: ReturnType<typeof createFacilityOptionsForm>;
|
||||
/** 「지난 정의 값」 읽기만 상자(48-3) — 지금 정의에 없는 키의 값. */
|
||||
staleBox: HTMLElement;
|
||||
/** 시설 폼(손코딩)에 정의 붙이기(48-3 · 48-4) — 숨긴 선택지 · 규칙. */
|
||||
facilityDefinition: FacilityDefinition;
|
||||
primary: HTMLButtonElement;
|
||||
removeButton: HTMLButtonElement;
|
||||
resetButton: HTMLButtonElement;
|
||||
@@ -148,15 +156,27 @@ export function buildStructuresForm(options: {
|
||||
const summary = buildSummaryRow();
|
||||
summary.root.hidden = true;
|
||||
const groupBoxes = createGroupBoxes();
|
||||
// 배관 명세 컨테이너 「구조물 조합」 잇기 = 그룹 값(`group_items`)과 같은 자리 · 같은 [고르기] 길(PLAN 52-1)
|
||||
facilityOptions.bindLinks({
|
||||
get: groupBoxes.get,
|
||||
pick: groupBoxes.pick,
|
||||
clear: groupBoxes.clear,
|
||||
set: groupBoxes.setOne,
|
||||
});
|
||||
groupBoxes.watch(facilityOptions.paintLinks);
|
||||
const staleBox = document.createElement("fieldset");
|
||||
staleBox.className = "b05-structure__group b05-structure__stale";
|
||||
staleBox.hidden = true;
|
||||
|
||||
body.append(
|
||||
typeRow,
|
||||
ownerNote,
|
||||
positionRow,
|
||||
summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류)
|
||||
positionDivider,
|
||||
optionRow,
|
||||
facilityOptions.root,
|
||||
summary.root,
|
||||
staleBox,
|
||||
groupBoxes.root,
|
||||
actions,
|
||||
);
|
||||
@@ -178,6 +198,8 @@ export function buildStructuresForm(options: {
|
||||
optionRow,
|
||||
actions,
|
||||
facilityOptions,
|
||||
staleBox,
|
||||
facilityDefinition: createFacilityDefinition(body),
|
||||
primary,
|
||||
removeButton,
|
||||
resetButton,
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘).
|
||||
* · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄).
|
||||
* · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠.
|
||||
* · 그룹 형상 칸(48 정의 그룹 `형상` · 길이 · 높이 · 전 · 후 받기 켬끔) — 값은 구조물 옵션
|
||||
* `{그룹 id}__{칸}` 으로 저장(패널이 옵션에 얹음) · 계산은 안 함(48-5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { openItemPicker } from "@ui/ui_template_item_picker";
|
||||
@@ -17,6 +19,10 @@ import { pipeStructureId } from "@ui/ui_template_item_picker_project";
|
||||
import type { StructureType } from "./B05_Profile_Api_Structures";
|
||||
import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
|
||||
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define";
|
||||
import { field, numberInput } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import {
|
||||
GROUP_JOIN,
|
||||
type GroupItems,
|
||||
@@ -33,8 +39,25 @@ export interface GroupBoxes {
|
||||
set: (items: GroupItems) => void;
|
||||
/** 한 그룹 값 — null = 해제. */
|
||||
setOne: (groupId: string, item: SummaryItem | null) => void;
|
||||
/** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */
|
||||
bind: (handlers: { pick: (groupId: string) => void; clear: (groupId: string) => void }) => void;
|
||||
/** [고르기] · [해제] · 형상 칸 고침 배선 — 패널이 한 번 부른다. */
|
||||
bind: (handlers: GroupHandlers) => void;
|
||||
/** 형상 칸 값 `{그룹 id}__{칸}: 수` — 빈 칸은 뺌. 옵션에 얹어 저장. */
|
||||
shapes: () => Record<string, number>;
|
||||
/** 지금 종류 그룹의 형상 키 전부(받기 끈 칸 포함) — 저장 병합 · 지난 정의 값 판정. */
|
||||
shapeKeys: (type?: StructureType | null) => string[];
|
||||
/** 구조물 옵션에서 형상 값을 실음(폼 불러오기). */
|
||||
setShapes: (options: Record<string, string | number> | undefined) => void;
|
||||
/** 명세 컨테이너 잇기 줄(PLAN 52-1)이 쓰는 [고르기] · [해제] — 그룹 상자와 같은 길. */
|
||||
pick: (groupId: string) => void;
|
||||
clear: (groupId: string) => void;
|
||||
/** 값이 바뀔 때마다 부름(명세 컨테이너 잇기 줄 다시 그림). */
|
||||
watch: (listener: () => void) => void;
|
||||
}
|
||||
|
||||
interface GroupHandlers {
|
||||
pick: (groupId: string) => void;
|
||||
clear: (groupId: string) => void;
|
||||
change?: () => void;
|
||||
}
|
||||
|
||||
export function createGroupBoxes(): GroupBoxes {
|
||||
@@ -43,7 +66,14 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
root.dataset.b05Groups = "";
|
||||
root.hidden = true;
|
||||
let items: GroupItems = {};
|
||||
let handlers: { pick: (id: string) => void; clear: (id: string) => void } | null = null;
|
||||
let handlers: GroupHandlers | null = null;
|
||||
let shapes: Record<string, string> = {};
|
||||
let current: StructureType | null = null;
|
||||
const listeners: Array<() => void> = [];
|
||||
const changed = (): void => listeners.forEach((listener) => listener());
|
||||
const keysOf = (type: StructureType | null | undefined): string[] =>
|
||||
(type?.groups ?? []).flatMap((g) => SHAPE_FIELDS.map((f) => shapeKey(g.id, f.suffix)));
|
||||
const shapeInputs = new Map<string, HTMLInputElement>();
|
||||
const rows = new Map<string, SummaryRow>();
|
||||
/** 옮긴 칸 · 제자리 표지 */
|
||||
const moved: Array<{ node: HTMLElement; mark: Comment }> = [];
|
||||
@@ -83,13 +113,16 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
render(type, scopes, facility) {
|
||||
restore();
|
||||
rows.clear();
|
||||
const groups = type?.groups ?? [];
|
||||
shapeInputs.clear();
|
||||
current = type;
|
||||
// 명세 종류(PLAN 52 · 배관)는 컨테이너가 잇기 줄을 가짐 — 그룹 상자를 안 세움
|
||||
const groups = type && STRUCTURE_SPECS[type.type_id] ? [] : (type?.groups ?? []);
|
||||
const boxes: HTMLElement[] = [];
|
||||
bodies = [];
|
||||
// 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식)
|
||||
const off = hiddenFormKeys(
|
||||
facility,
|
||||
(type?.options ?? []).filter((o) => o.visible === false).map((o) => o.key),
|
||||
(type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key),
|
||||
);
|
||||
for (const group of groups) {
|
||||
const keys = hiddenFormKeys(facility, group.options);
|
||||
@@ -133,6 +166,19 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const shape of shapeFields(group.shape)) {
|
||||
const key = shapeKey(group.id, shape.suffix);
|
||||
const input = numberInput("0.1", "0");
|
||||
input.value = shapes[key] ?? "";
|
||||
input.dataset.shapeKey = key;
|
||||
input.title = shape.meaning;
|
||||
input.addEventListener("change", () => {
|
||||
shapes = { ...shapes, [key]: input.value.trim() };
|
||||
handlers?.change?.();
|
||||
});
|
||||
shapeInputs.set(key, input);
|
||||
body.append(field(shape.label, input));
|
||||
}
|
||||
if (!body.childElementCount) {
|
||||
const none = document.createElement("p");
|
||||
none.className = "b05-structure__owner-note";
|
||||
@@ -150,7 +196,10 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
boxes.push(box);
|
||||
bodies.push(body);
|
||||
}
|
||||
const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null;
|
||||
const stale =
|
||||
type && !STRUCTURE_SPECS[type.type_id]
|
||||
? paintStale(new Set(groups.map((g) => g.id)))
|
||||
: null;
|
||||
root.replaceChildren(...boxes, ...(stale ? [stale] : []));
|
||||
root.hidden = !type || (!boxes.length && !stale);
|
||||
// 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로
|
||||
@@ -167,6 +216,7 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
set(next) {
|
||||
items = { ...next };
|
||||
for (const [id, row] of rows) row.set(items[id] ?? null);
|
||||
changed();
|
||||
},
|
||||
setOne(groupId, item) {
|
||||
if (item) items = { ...items, [groupId]: item };
|
||||
@@ -175,10 +225,31 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
items = rest;
|
||||
}
|
||||
rows.get(groupId)?.set(item);
|
||||
changed();
|
||||
},
|
||||
pick: (groupId) => handlers?.pick(groupId),
|
||||
clear: (groupId) => handlers?.clear(groupId),
|
||||
watch: (listener) => listeners.push(listener),
|
||||
bind(next) {
|
||||
handlers = next;
|
||||
},
|
||||
shapes() {
|
||||
const out: Record<string, number> = {};
|
||||
for (const key of shapeInputs.keys()) {
|
||||
const text = (shapes[key] ?? "").trim();
|
||||
if (text !== "" && Number.isFinite(Number(text))) out[key] = Number(text);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
shapeKeys: (type) => keysOf(type === undefined ? current : type),
|
||||
setShapes(options) {
|
||||
shapes = Object.fromEntries(
|
||||
Object.entries(options ?? {})
|
||||
.filter(([key]) => key.includes(GROUP_JOIN))
|
||||
.map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
for (const [key, input] of shapeInputs) input.value = shapes[key] ?? "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,6 +264,8 @@ export function bindSummaryPickers(opts: {
|
||||
editingId: () => string | null;
|
||||
selectedPipeChainage: () => number | null;
|
||||
commitSummary: () => void;
|
||||
/** 형상 칸 고침 반영 — 고치는 중이면 곧바로(새 항목은 [추가]가 확정). */
|
||||
liveCommit: () => void;
|
||||
}): void {
|
||||
const base = (): string | null => {
|
||||
const pipe = opts.selectedPipeChainage();
|
||||
@@ -214,6 +287,7 @@ export function bindSummaryPickers(opts: {
|
||||
},
|
||||
});
|
||||
opts.groups.bind({
|
||||
change: opts.liveCommit,
|
||||
pick: (groupId) => {
|
||||
const id = base();
|
||||
void pick(
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options";
|
||||
import {
|
||||
hasSpecContainers,
|
||||
paintStale,
|
||||
renderOptionFields as renderOptionFieldsInto,
|
||||
} from "./B05_Profile_UI_Structures_Panel_Options";
|
||||
import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups";
|
||||
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
|
||||
import {
|
||||
@@ -38,7 +42,8 @@ import {
|
||||
loadPipeForm as loadPipeFormInto,
|
||||
} from "./B05_Profile_UI_Structures_Panel_Commit";
|
||||
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
|
||||
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
import { showsAnchor } from "./B05_Profile_UI_Structures_Define";
|
||||
import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
|
||||
|
||||
@@ -52,11 +57,9 @@ import {
|
||||
hasComputedRange,
|
||||
readBeforeOption,
|
||||
readLengthOption,
|
||||
} from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
import type {
|
||||
PipeFacilityItem,
|
||||
StructuresCallbacks,
|
||||
StructuresSection,
|
||||
type PipeFacilityItem,
|
||||
type StructuresCallbacks,
|
||||
type StructuresSection,
|
||||
} from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
|
||||
export function createStructuresSection(
|
||||
@@ -86,6 +89,8 @@ export function createStructuresSection(
|
||||
optionRow,
|
||||
actions,
|
||||
facilityOptions,
|
||||
staleBox,
|
||||
facilityDefinition,
|
||||
primary,
|
||||
removeButton,
|
||||
resetButton,
|
||||
@@ -222,7 +227,7 @@ export function createStructuresSection(
|
||||
!!type && !managed && type.placement === "interval" && !hasComputedRange(type);
|
||||
positionRow.hidden = !type;
|
||||
positionDivider.hidden = !type;
|
||||
summary.root.hidden = !type;
|
||||
summary.root.hidden = !type || hasSpecContainers(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1)
|
||||
startFields.wrap.hidden = !isInterval;
|
||||
endFields.wrap.hidden = !isInterval;
|
||||
primary.disabled = !type;
|
||||
@@ -232,6 +237,8 @@ export function createStructuresSection(
|
||||
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
|
||||
: "";
|
||||
ownerNote.hidden = !type?.design_owner;
|
||||
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
|
||||
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
|
||||
anchorFields.wrap.querySelector("span")!.textContent = isInterval
|
||||
? "기준 측점 (비우면 시작)"
|
||||
: "기준 측점";
|
||||
@@ -244,13 +251,14 @@ export function createStructuresSection(
|
||||
designFlow: number | null = null,
|
||||
): void {
|
||||
const kind = facilityFormKind(currentType());
|
||||
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow);
|
||||
facilityOptions.setChainage(selectedPipeChainage ?? tempPipeChainage); // 관 길이(자동 · 52-1)
|
||||
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow, currentType());
|
||||
if (kind)
|
||||
paintStale(staleBox, currentType(), options, kind, groupBoxes.shapeKeys(currentType()));
|
||||
// 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장)
|
||||
facilityOptions.setHiddenOptions(
|
||||
kind,
|
||||
(currentType()?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key),
|
||||
);
|
||||
facilityOptions.setHiddenOptions(kind, hiddenOptionKeys(currentType()));
|
||||
regroup();
|
||||
facilityDefinition.apply(kind ? currentType() : null); // 숨긴 선택지 · 규칙(48-3 · 48-4)
|
||||
syncOptionLock();
|
||||
}
|
||||
|
||||
@@ -321,6 +329,8 @@ export function createStructuresSection(
|
||||
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */
|
||||
function renderOptionFields(values?: Record<string, string | number>): void {
|
||||
renderOptionFieldsInto(optionContext, values ?? newDefaults());
|
||||
if (!facilityFormKind(currentType()))
|
||||
paintStale(staleBox, currentType(), values, null, groupBoxes.shapeKeys(currentType()));
|
||||
regroup();
|
||||
}
|
||||
|
||||
@@ -506,8 +516,7 @@ export function createStructuresSection(
|
||||
},
|
||||
summary: () => summary.get(),
|
||||
setSummary: (value) => summary.set(value),
|
||||
groupItems: () => groupBoxes.get(),
|
||||
setGroupItems: (value) => groupBoxes.set(value),
|
||||
groupBoxes,
|
||||
};
|
||||
bindStructuresEvents(panelContext);
|
||||
// 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups`
|
||||
@@ -518,6 +527,7 @@ export function createStructuresSection(
|
||||
editingId: () => editingId,
|
||||
selectedPipeChainage: () => selectedPipeChainage,
|
||||
commitSummary,
|
||||
liveCommit,
|
||||
});
|
||||
body.insertBefore(field("메모", memoField), actions);
|
||||
syncButtons();
|
||||
@@ -530,11 +540,11 @@ export function createStructuresSection(
|
||||
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
|
||||
setTypes(next) {
|
||||
types = next;
|
||||
const groups = [...new Set(next.map((t) => t.group))];
|
||||
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다.
|
||||
const groups = new Map(next.map((t) => [t.group, t.group_name] as const));
|
||||
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저.
|
||||
groupSelect.replaceChildren(
|
||||
new Option("— 선택 —", ""),
|
||||
...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)),
|
||||
...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)),
|
||||
);
|
||||
groupSelect.value = "";
|
||||
syncTypeOptions();
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D
|
||||
import type { StationFields } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement";
|
||||
import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary";
|
||||
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
|
||||
import {
|
||||
readGroupItems,
|
||||
readSummaryItem,
|
||||
@@ -75,13 +76,13 @@ export interface StructuresCommitContext {
|
||||
summary: () => SummaryItem | null;
|
||||
setSummary: (value: SummaryItem | null) => void;
|
||||
/** 옵션 그룹 보조 컨테이너마다 고른 항목(PLAN 46-3) — 저장은 `group_items` 칸. */
|
||||
groupItems: () => GroupItems;
|
||||
setGroupItems: (value: GroupItems) => void;
|
||||
/** 옵션 그룹 상자 — 집계 항목(46-3) · 형상 칸 값(48-3). */
|
||||
groupBoxes: GroupBoxes;
|
||||
}
|
||||
|
||||
/** 저장할 그룹 연결 — 고른 것이 있을 때만 칸을 실음(빈 묶음 = 칸 없음). */
|
||||
function groupField(ctx: StructuresCommitContext): { group_items?: GroupItems } {
|
||||
const items = ctx.groupItems();
|
||||
const items = ctx.groupBoxes.get();
|
||||
return Object.keys(items).length ? { group_items: items } : {};
|
||||
}
|
||||
|
||||
@@ -105,7 +106,8 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
ctx.memoField.value = target.memo ?? "";
|
||||
ctx.renderOptionFields(target.options);
|
||||
ctx.setSummary(readSummaryItem(target.summary_item));
|
||||
ctx.setGroupItems(readGroupItems(target.group_items));
|
||||
ctx.groupBoxes.set(readGroupItems(target.group_items));
|
||||
ctx.groupBoxes.setShapes(target.options);
|
||||
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된
|
||||
// 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
|
||||
if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) {
|
||||
@@ -134,7 +136,8 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
ctx.memoField.value = "";
|
||||
ctx.renderOptionFields();
|
||||
ctx.setSummary(null);
|
||||
ctx.setGroupItems({});
|
||||
ctx.groupBoxes.set({});
|
||||
ctx.groupBoxes.setShapes({});
|
||||
ctx.syncFacilityForm();
|
||||
}
|
||||
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
|
||||
@@ -182,7 +185,8 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte
|
||||
ctx.memoField.value = "";
|
||||
ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null);
|
||||
ctx.setSummary(readSummaryItem(pipe.summary_item));
|
||||
ctx.setGroupItems(readGroupItems(pipe.group_items));
|
||||
ctx.groupBoxes.set(readGroupItems(pipe.group_items));
|
||||
ctx.groupBoxes.setShapes(pipe.options);
|
||||
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
|
||||
ctx.syncPlacementFields();
|
||||
ctx.syncButtons();
|
||||
@@ -191,9 +195,11 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte
|
||||
}
|
||||
|
||||
export function readOptions(ctx: StructuresCommitContext): Record<string, string | number> {
|
||||
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다.
|
||||
if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions();
|
||||
const values: Record<string, string | number> = {};
|
||||
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. 그룹 형상 칸(48-3)은 둘 다 얹음.
|
||||
const shapes = ctx.groupBoxes.shapes();
|
||||
if (ctx.facilityFormKind(ctx.currentType()))
|
||||
return { ...ctx.facilityOptions.readOptions(), ...shapes };
|
||||
const values: Record<string, string | number> = { ...shapes };
|
||||
ctx.optionInputs.forEach((input) => {
|
||||
// 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다.
|
||||
if (input.isEmpty()) return;
|
||||
@@ -241,7 +247,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
}
|
||||
// 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2).
|
||||
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
|
||||
const options = ctx.facilityOptions.readOptions();
|
||||
const options = readOptions(ctx);
|
||||
if (Object.keys(options).length) attributes.options = options;
|
||||
// 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다.
|
||||
attributes.summary_item = ctx.summary();
|
||||
@@ -363,7 +369,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
options: mergeStructureOptions(
|
||||
previous,
|
||||
type.type_id,
|
||||
ctx.optionInputs.map((entry) => entry.key),
|
||||
[...ctx.optionInputs.map((entry) => entry.key), ...ctx.groupBoxes.shapeKeys()],
|
||||
records[0].options,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -11,7 +11,21 @@
|
||||
import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { field, optionControl } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
import {
|
||||
columnsOf,
|
||||
gridPlace,
|
||||
orderedOptions,
|
||||
ruleDefaults,
|
||||
ruleStates,
|
||||
staleValues,
|
||||
} from "./B05_Profile_UI_Structures_Define";
|
||||
import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specKeys } from "../A00_Common/design/design_structure_spec";
|
||||
|
||||
/** 구조물 명세 컨테이너로 그리는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1). */
|
||||
export const hasSpecContainers = (type: StructureType): boolean =>
|
||||
!!STRUCTURE_SPECS[type.type_id]?.containers.length;
|
||||
|
||||
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
|
||||
export interface OptionRenderContext {
|
||||
@@ -46,39 +60,48 @@ export function renderOptionFields(
|
||||
optionInputs.length = 0;
|
||||
optionRow.replaceChildren();
|
||||
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
|
||||
// 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`).
|
||||
const shown =
|
||||
type && !ctx.facilityFormKind(type)
|
||||
? type.options.filter((o) => (ctx.includeDetail || isB05Option(o)) && isOptionVisible(o))
|
||||
? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o))
|
||||
: [];
|
||||
if (!shown.length) {
|
||||
optionRow.style.gridTemplateColumns = "";
|
||||
if (!type || !shown.length) {
|
||||
optionRow.hidden = true;
|
||||
ctx.syncOptionLock();
|
||||
return;
|
||||
}
|
||||
optionRow.hidden = false;
|
||||
const columns = columnsOf(type);
|
||||
if (columns !== 2) optionRow.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`;
|
||||
// 규칙(48-4)이 가린 칸은 `optionInputs` 에서 뺌 — 필수 검사 · 저장이 안 봄(옛 값은 병합이 지킴)
|
||||
const all: Array<{ entry: (typeof optionInputs)[number]; wrap: HTMLElement }> = [];
|
||||
shown.forEach((option) => {
|
||||
// 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7).
|
||||
// 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘.
|
||||
const input = optionControl(option, values[option.key]);
|
||||
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
|
||||
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
|
||||
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
|
||||
const locked = option.enabled === false;
|
||||
if (locked && !input.value) input.value = String(option.default ?? "");
|
||||
if (locked) {
|
||||
input.disabled = true;
|
||||
input.classList.add("is-locked");
|
||||
input.title = "지금은 고를 수 없는 항목입니다.";
|
||||
}
|
||||
const wrap = field(label, input, option.default_basis);
|
||||
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
|
||||
// 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로
|
||||
const place = gridPlace(type.spots?.[option.key], columns);
|
||||
wrap.style.gridRow = place?.row ?? "";
|
||||
wrap.style.gridColumn = place?.column ?? "";
|
||||
optionRow.append(wrap);
|
||||
input.addEventListener("change", () => ctx.liveCommit());
|
||||
optionInputs.push({
|
||||
key: option.key,
|
||||
required: !locked && !!option.required,
|
||||
input,
|
||||
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
|
||||
isEmpty: () => input.value.trim() === "",
|
||||
// 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4)
|
||||
input.addEventListener("change", () => {
|
||||
applyRules(option.key);
|
||||
ctx.liveCommit();
|
||||
});
|
||||
all.push({
|
||||
wrap,
|
||||
entry: {
|
||||
key: option.key,
|
||||
required: !!option.required,
|
||||
input,
|
||||
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
|
||||
isEmpty: () => input.value.trim() === "",
|
||||
},
|
||||
});
|
||||
// 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을
|
||||
// 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다.
|
||||
@@ -110,6 +133,80 @@ export function renderOptionFields(
|
||||
input.addEventListener("change", () => refresh(true));
|
||||
}
|
||||
});
|
||||
/** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */
|
||||
function applyRules(parent: string | null): void {
|
||||
const now = (): Record<string, string> =>
|
||||
Object.fromEntries(all.map(({ entry }) => [entry.key, entry.input.value]));
|
||||
if (parent) {
|
||||
for (const [key, value] of Object.entries(ruleDefaults(type!, parent, now()))) {
|
||||
const hit = all.find(({ entry }) => entry.key === key);
|
||||
if (hit) hit.entry.input.value = String(value);
|
||||
}
|
||||
}
|
||||
const next = ruleStates(type!, now());
|
||||
optionInputs.length = 0;
|
||||
for (const { entry, wrap } of all) {
|
||||
const state = next.get(entry.key);
|
||||
const off = state?.visible === false;
|
||||
wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식
|
||||
if (!off) optionInputs.push(entry);
|
||||
if (state && entry.input.tagName.toLowerCase() === "select")
|
||||
syncChoices(entry.input as HTMLSelectElement, state.choices);
|
||||
}
|
||||
}
|
||||
applyRules(null);
|
||||
ctx.syncOptionLock();
|
||||
ctx.syncRangeDisplay();
|
||||
}
|
||||
|
||||
/** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */
|
||||
function syncChoices(element: HTMLSelectElement, choices: string[]): void {
|
||||
const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value);
|
||||
const keep = element.value && !choices.includes(element.value) ? [element.value] : [];
|
||||
const want = [...choices, ...keep];
|
||||
if (now.length === want.length && now.every((v, i) => v === want[i])) return;
|
||||
const value = element.value;
|
||||
const blank = element.children[0];
|
||||
element.replaceChildren(blank, ...want.map((c) => new Option(c, c)));
|
||||
element.value = value;
|
||||
}
|
||||
|
||||
/** 「지난 정의 값」 상자(48-3) — 지금 정의에 없는 키의 값을 읽기만 칸으로 보임(오류 없음 · 저장은
|
||||
* 병합이 옛 값 그대로 지킴). 아는 키 = 정의 옵션 전부(안 씀 포함) + 시설 폼이 적는 칸. */
|
||||
export function paintStale(
|
||||
box: HTMLElement,
|
||||
type: StructureType | null,
|
||||
values: Record<string, string | number> | undefined,
|
||||
facility: PipeFacility | null,
|
||||
/** 그룹 형상 키(`{그룹 id}__{칸}`) — 정의된 그룹 몫이라 지난 값이 아님. */
|
||||
shapeKeys: string[] = [],
|
||||
): void {
|
||||
// 명세 종류(52) — 명세가 뺀 옛 키(retired)는 지난 정의 값 · 선택 컨테이너 키(머리)는 앎
|
||||
const spec = type ? STRUCTURE_SPECS[type.type_id] : undefined;
|
||||
const own = spec ? specKeys(spec) : { keys: [], prefixes: [] };
|
||||
const known = [
|
||||
...shapeKeys,
|
||||
...(type?.options ?? []).map((o) => o.key),
|
||||
...(facility ? (FORM_OPTION_KEYS[facility] ?? []) : []),
|
||||
...own.keys,
|
||||
].filter((key) => !spec?.retired.includes(key));
|
||||
const stale = type
|
||||
? staleValues(values, known).filter(([key]) => !own.prefixes.some((p) => key.startsWith(p)))
|
||||
: [];
|
||||
box.replaceChildren();
|
||||
box.hidden = !stale.length;
|
||||
if (!stale.length) return;
|
||||
const legend = document.createElement("legend");
|
||||
legend.textContent = "지난 정의 값";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-structure__grid";
|
||||
for (const [key, value] of stale) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.readOnly = true;
|
||||
input.value = String(value);
|
||||
input.title = "지금 정의에 없는 칸 — 읽기만 · 계산에 안 쓰임";
|
||||
body.append(field(key, input));
|
||||
}
|
||||
box.append(legend, body);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 일 때만 숨김. 없으면 보임. */
|
||||
export function isOptionVisible(option: { visible?: boolean }): boolean {
|
||||
return option.visible !== false;
|
||||
/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 이거나 정의가 「안 씀」(`enabled:false` · 48)일 때 숨김. */
|
||||
export function isOptionVisible(option: { visible?: boolean; enabled?: boolean }): boolean {
|
||||
return option.visible !== false && option.enabled !== false;
|
||||
}
|
||||
|
||||
/** 저장할 옵션 — 같은 종류를 고치는 중이면 폼이 안 그린 옛 칸을 이어 붙임.
|
||||
|
||||
@@ -515,3 +515,32 @@
|
||||
.b05-structure__optgroup > .b05-structure__grid.is-locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 구조물 정의 규칙(PLAN 48-4)이 가린 칸 — 그룹 상자로 옮겨 가도 같은 표식 */
|
||||
.b05-structure-section .is-rule-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 「지난 정의 값」 읽기만 상자(PLAN 48-3) — 지금 정의에 없는 키의 값 */
|
||||
.b05-structure__stale input[readonly] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 구조물 명세 컨테이너(PLAN 52-1) — 상자 차례 · 긴 잇기 이름이 상자를 밀어 넓히지 않게(fieldset 최소 폭) */
|
||||
.b05-structure__spec {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.b05-structure__spec[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-structure__spec .b05-structure__group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b05-structure__spec-add {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@@ -327,7 +327,8 @@ def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> d
|
||||
height = _number(
|
||||
values.get(f"{prefix}_height_m"), _number(defaults.get(f"{prefix}_height_m"), 0.0)
|
||||
)
|
||||
installed = str(install or "").strip() != "없음"
|
||||
# BOX암거 유입·유출 구조(표 02) — 없음 · 날개벽 · 흙막이 · 옛 값 있음 = 날개벽. 날개벽만 세움.
|
||||
installed = str(install or "").strip() not in ("없음", "흙막이")
|
||||
extend = (length or 0.0) * math.cos(math.radians(angle or 0.0)) if installed else 0.0
|
||||
return {
|
||||
"installed": installed,
|
||||
|
||||
@@ -221,19 +221,21 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l
|
||||
|
||||
|
||||
def _labor_files() -> list[tuple[str, Path]]:
|
||||
"""모든 층(마스터 · 회사 · 개인)의 일위대가 문서 `labor/<열>.json` — (어디, 파일)."""
|
||||
"""모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 단가승격 `promote/<키>.json`
|
||||
· 사전조합 `precombo/<키>.json`(51-11) — (어디, 파일)."""
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게
|
||||
|
||||
root = layers.storage_root()
|
||||
found = [("마스터", p) for p in sorted((layers.system_dir() / "labor").glob("*.json"))]
|
||||
found += [(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob("*/templates/labor/*.json"))]
|
||||
personal = sorted(root.glob("*/*/templates/labor/*.json"))
|
||||
return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal]
|
||||
return [
|
||||
(f"{what} {where}", path)
|
||||
for kind, what in (("labor", "일위대가"), ("promote", "단가승격"), ("precombo", "사전조합"))
|
||||
for where, path in layers.layer_files(kind)
|
||||
]
|
||||
|
||||
|
||||
def labor_errors(whole) -> list[str]:
|
||||
"""일위대가 문서가 담은 로직이 마스터에 없음(39-1d) — 로직 지우기를 막음 · 어디서 쓰는지 알림.
|
||||
전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌."""
|
||||
"""일위대가 · 단가승격 · 사전조합 문서가 담은 로직이 마스터에 없음(39-1d · 50-3) —
|
||||
로직 지우기를 막음 · 어디서 쓰는지 알림 · 참조 줄(로직 없음)은 건너뜀. 전부터 깨진 것은
|
||||
`save` 가 고치기 전 검사와 견주어 뺌."""
|
||||
out = []
|
||||
for where, path in _labor_files():
|
||||
try:
|
||||
@@ -243,10 +245,12 @@ def labor_errors(whole) -> list[str]:
|
||||
name = (doc.get("일위대가번") if isinstance(doc, dict) else None) or path.stem
|
||||
for one in (doc.get("담은로직") if isinstance(doc, dict) else None) or []:
|
||||
key = str(one.get("로직") or "") if isinstance(one, dict) else ""
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
whole.logic(key)
|
||||
except cm.mf.FormulaError:
|
||||
out.append(f"일위대가 {where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌")
|
||||
out.append(f"{where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -104,30 +104,88 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi
|
||||
|
||||
|
||||
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
|
||||
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
|
||||
_NUMBER = re.compile(r"(?<![\d-])(\d+(?:-\d+)+)(?![\d-])(?:\s+(주[①-⑳]?|\d{1,2})(?![\d-]))?")
|
||||
_ESCAPE = re.compile(r"\\([.\-*_()#\[\]>~`])") # 마크다운 이스케이프 `\.` `\-` …
|
||||
_COMMENT = re.compile(r"<!--.*?-->") # 글 안 주석(글자층 표시) — 글자만 남김
|
||||
_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
|
||||
|
||||
|
||||
def _plain(text: str) -> str:
|
||||
"""원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `."""
|
||||
text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip()
|
||||
return text[1:].strip() if text.startswith(">") else text
|
||||
|
||||
|
||||
def _blocks(lines: list[str]) -> list[dict]:
|
||||
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌."""
|
||||
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌 ·
|
||||
빈 줄 · 주석이 표를 끊음(붙은 표 둘을 한 표로 안 합침) · `<br>` = 줄바꿈."""
|
||||
out: list[dict] = []
|
||||
joinable = False
|
||||
for line in lines:
|
||||
text = line.strip()
|
||||
text = re.sub(r"<br\s*/?>", "\n", line.strip())
|
||||
if not text or text.startswith("<!--"):
|
||||
joinable = False
|
||||
continue
|
||||
if not text.startswith(("#", "|")):
|
||||
text = _plain(text)
|
||||
if not text:
|
||||
joinable = False
|
||||
continue
|
||||
if text.startswith("#"):
|
||||
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
|
||||
elif text.startswith("|"):
|
||||
cells = [c.strip() for c in text.strip("|").split("|")]
|
||||
cells = [_plain(c) for c in text.strip("|").split("|")]
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
|
||||
continue # 머리 · 몸 가름 줄
|
||||
if not out or "table" not in out[-1]:
|
||||
if not joinable:
|
||||
out.append({"table": []})
|
||||
out[-1]["table"].append(cells)
|
||||
joinable = True
|
||||
continue
|
||||
else:
|
||||
out.append({"p": text})
|
||||
joinable = False
|
||||
return out
|
||||
|
||||
|
||||
def _mark(blocks: list[dict], item: str) -> bool:
|
||||
"""출처 항 · 주 번호(「1」 · 「주③」 · 「주」)에 드는 조각에 hit 표시 — 못 찾으면 False(절 통째만)."""
|
||||
texts = [b.get("p") or b.get("text") or "" for b in blocks]
|
||||
if item.startswith("주"):
|
||||
note = next((i for i, t in enumerate(texts) if t.startswith("[주]")), None)
|
||||
if note is None:
|
||||
return False
|
||||
start, stop = note, len(blocks)
|
||||
if len(item) > 1: # 「주③」 — [주] 아래 ③ 줄 하나(다음 ① ~ ⑳ 줄 앞까지)
|
||||
start = next(
|
||||
(i for i in range(note + 1, len(texts)) if texts[i].startswith(item[1])), None
|
||||
)
|
||||
if start is None:
|
||||
return False
|
||||
stop = next(
|
||||
(i for i in range(start + 1, len(texts)) if texts[i][:1] in _CIRCLED), len(blocks)
|
||||
)
|
||||
else: # 「1」 — 「1. …」 제목 · 줄 ~ 다음 제목 · 「2. …」 앞(줄이면 [주] 앞도)
|
||||
head = re.compile(rf"{item}[.)]\s")
|
||||
start = next((i for i, t in enumerate(texts) if head.match(t)), None)
|
||||
if start is None:
|
||||
return False
|
||||
under = "h" in blocks[start]
|
||||
stop = next(
|
||||
(
|
||||
i
|
||||
for i in range(start + 1, len(blocks))
|
||||
if "h" in blocks[i]
|
||||
or (not under and re.match(r"\d+[.)]\s|\[주\]", texts[i]))
|
||||
or (under and re.match(r"\d+[.)]\s", texts[i]) and "p" in blocks[i])
|
||||
),
|
||||
len(blocks),
|
||||
)
|
||||
for b in blocks[start:stop]:
|
||||
b["hit"] = True
|
||||
return True
|
||||
|
||||
|
||||
def _section(book: str, division: str, number: str) -> dict | None:
|
||||
"""번호 절을 품셈 본문에서 찾음 — 없으면 끝 마디를 하나씩 떼며 위 절로(두 마디까지)."""
|
||||
parts = number.split("-")
|
||||
@@ -172,8 +230,14 @@ def source(src: str) -> dict:
|
||||
if book == "건설품셈"
|
||||
else ""
|
||||
)
|
||||
found = [_section(book, division, n) for n in dict.fromkeys(_NUMBER.findall(src))]
|
||||
return {"source": src, "found": [x for x in found if x]}
|
||||
found = []
|
||||
for number, item in dict.fromkeys(_NUMBER.findall(src)):
|
||||
one = _section(book, division, number)
|
||||
if one:
|
||||
if item and _mark(one["blocks"], item):
|
||||
one["item"] = item
|
||||
found.append(one)
|
||||
return {"source": src, "found": found}
|
||||
|
||||
|
||||
# ── 표 조건 값 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* 원문 꼴 한 벌(M01 · M02 같음) — 둘레 글자 크기(M01 14)를 안 따르고 본문 크기 */
|
||||
.m01lab-src {
|
||||
font-size: var(--text-body);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
@@ -26,8 +28,6 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01lab-src__head {
|
||||
@@ -51,6 +51,13 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 칸 안 줄바꿈(원문 <br>)만 살리고 저절로 접지 않음 */
|
||||
.m01lab-src__table td {
|
||||
white-space: nowrap;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* 출처가 가리킨 항 · 주 — 옅은 바탕 + 왼쪽 막대 */
|
||||
.m01lab-src__hit {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
box-shadow: -3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -26,15 +26,24 @@ interface Block {
|
||||
text?: string;
|
||||
p?: string;
|
||||
table?: string[][];
|
||||
/** 출처가 가리킨 항 · 주 — 강조 */
|
||||
hit?: boolean;
|
||||
}
|
||||
interface Found {
|
||||
number: string;
|
||||
matched: string;
|
||||
file: string;
|
||||
blocks: Block[];
|
||||
item?: string;
|
||||
}
|
||||
|
||||
function blockNode(b: Block): HTMLElement {
|
||||
const node = rawNode(b);
|
||||
if (b.hit) node.classList.add("m01lab-src__hit");
|
||||
return node;
|
||||
}
|
||||
|
||||
function rawNode(b: Block): HTMLElement {
|
||||
if (b.table)
|
||||
return el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
@@ -89,11 +98,20 @@ export function createSourceView(): SourceView {
|
||||
...found.flatMap((f) => [
|
||||
el("p", {
|
||||
className: "m01-logic__muted m01lab-src__file",
|
||||
text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`,
|
||||
text: [
|
||||
f.file,
|
||||
f.matched === f.number ? "" : ts("Near", f.number),
|
||||
f.item ? (/^\d+$/.test(f.item) ? `${f.item}항` : f.item) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
...f.blocks.map(blockNode),
|
||||
]),
|
||||
);
|
||||
requestAnimationFrame(() =>
|
||||
body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
}
|
||||
|
||||
/** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */
|
||||
function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] {
|
||||
export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] {
|
||||
const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : [];
|
||||
if (!answer.ok) {
|
||||
return [
|
||||
|
||||
@@ -106,7 +106,6 @@ export function createUnitPick(opts: UnitPickOptions): HTMLElement {
|
||||
const open = createButton({
|
||||
label: opts.value || NO_UNIT,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: opts.readOnly,
|
||||
onClick: () => toggle(),
|
||||
});
|
||||
|
||||
@@ -244,10 +244,15 @@ def load_prices(prices: dict[str, Any]) -> Any:
|
||||
# ── 계산(25-2e) ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _logic_keys(labor: dict[str, Any]) -> list[str]:
|
||||
"""담은 로직 키 — 참조 줄(로직 없음 · 50-3)은 뺌."""
|
||||
return [key for key in mcb.logic_keys(labor.get("줄") or {}) if key]
|
||||
|
||||
|
||||
def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) -> list[dict]:
|
||||
"""담은 로직마다 값이 없는(null · 안 적음) 입력 — `{로직, 입력}`."""
|
||||
out = []
|
||||
for key in mcb.logic_keys(labor.get("줄") or {}):
|
||||
for key in _logic_keys(labor):
|
||||
try:
|
||||
row = snapshot.logic(key)
|
||||
except mf.FormulaError:
|
||||
@@ -277,7 +282,7 @@ def calc(
|
||||
blocked = {gap["로직"] for gap in gaps}
|
||||
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
|
||||
lines, stuck = [], []
|
||||
for at, key in enumerate(mcb.logic_keys(labor.get("줄") or {}), start=1):
|
||||
for at, key in enumerate(_logic_keys(labor), start=1):
|
||||
head: dict[str, Any] = {"차례": at, "로직": key}
|
||||
if key in blocked:
|
||||
names = [gap["입력"] for gap in gaps if gap["로직"] == key]
|
||||
@@ -306,10 +311,37 @@ def calc(
|
||||
"계": got["계"],
|
||||
}
|
||||
)
|
||||
for line in _ref_lines(snapshot, labor, len(lines)): # 참조 줄(50-3) — 굳힌 사본으로
|
||||
if line["ok"]:
|
||||
for name in mf.COST_ITEMS:
|
||||
sums[name] += line[name]
|
||||
else:
|
||||
stuck.append(line["참조"]["키"])
|
||||
lines.append(line)
|
||||
out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}
|
||||
return to_json({**out, **_reflected(snapshot, labor, lines, area)})
|
||||
|
||||
|
||||
def _ref_lines(snapshot: Any, labor: dict[str, Any], start: int) -> list[dict]:
|
||||
"""참조 줄 — 기준 = 굳힌 사본(`참조사본`)을 복사본 단가로 펼친 1 단위 값
|
||||
(입력값은 승격 때 고정)."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
copies = labor.get("참조사본") or {}
|
||||
expand = refs.Expand(snapshot, lambda ref: copies.get(refs.ref_id(ref)))
|
||||
out = []
|
||||
for at, one in enumerate((labor.get("줄") or {}).get("담은로직") or [], start=start + 1):
|
||||
if not (isinstance(one, dict) and isinstance(one.get("참조"), dict)):
|
||||
continue
|
||||
got = expand(one["참조"])
|
||||
head = {"차례": at, "참조": one["참조"], "이름": got.get("이름"), "분모": got.get("단위")}
|
||||
if got.get("기준") is None:
|
||||
out.append({**head, "ok": False, "까닭": got.get("까닭")})
|
||||
continue
|
||||
out.append({**head, "ok": True, "갈래": "돈", **got["기준"]})
|
||||
return out
|
||||
|
||||
|
||||
def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict:
|
||||
"""줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
@@ -318,8 +350,12 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An
|
||||
pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []}
|
||||
base = []
|
||||
for line in lines:
|
||||
pill = pill_of.get(line["로직"]) or {}
|
||||
money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None
|
||||
if "참조" in line: # 알약 키 = ref_id · 배 1(50-3)
|
||||
head = {"참조": line["참조"], "알약": labor_doc.line_id(line), "배": 1}
|
||||
base.append({**head, "분모": line.get("분모"), "기준": money})
|
||||
continue
|
||||
pill = pill_of.get(line["로직"]) or {}
|
||||
head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1}
|
||||
base.append({**head, "분모": pill.get("단위"), "기준": money})
|
||||
mats = []
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
모으는 것 = `{파일 이름: 내용}`:
|
||||
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
|
||||
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) ·
|
||||
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
|
||||
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
|
||||
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
|
||||
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
|
||||
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
|
||||
@@ -89,10 +90,13 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None:
|
||||
|
||||
def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 —
|
||||
복사본 계산이 마스터를 안 읽게)."""
|
||||
복사본 계산이 마스터를 안 읽게) + 참조사본 `{ref_id: 문서}`(50-3 · 원본이 바뀌어도 그대로)."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
return {**logic_rows(doc), "알약": labor_doc.pills(doc)}
|
||||
rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)}
|
||||
copies = refs.frozen(doc)
|
||||
return {**rows, "참조사본": copies} if copies else rows
|
||||
|
||||
|
||||
def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -142,7 +146,10 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
files[LABOR_FILE] = labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
|
||||
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
|
||||
wanted = logic_keys + [code for code in materials if code]
|
||||
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||||
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}],
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}
|
||||
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
|
||||
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
|
||||
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
|
||||
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
|
||||
참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) ·
|
||||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
"""
|
||||
@@ -19,6 +22,7 @@ from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
mf = m01.cm.mf
|
||||
@@ -67,11 +71,27 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
if not isinstance(doc, dict):
|
||||
raise _bad("일위대가 문서가 묶음 아님")
|
||||
whole = m01.loaded()[1]
|
||||
lines, seen = [], set()
|
||||
lines, seen, here = [], set(), None
|
||||
for at, one in enumerate(doc.get("담은로직") or [], start=1):
|
||||
where = f"단가산출 로직 {at}째"
|
||||
if not isinstance(one, dict):
|
||||
raise _bad(f"{where} · 묶음 아님")
|
||||
if one.get("참조") is not None and not one.get("로직"):
|
||||
here = here or refs.here()
|
||||
ref = refs.check_ref(one["참조"], where, here)
|
||||
if refs.ref_id(ref) in seen:
|
||||
raise _bad(f"{where} · 같은 참조를 두 번 담음 「{ref['키']}」")
|
||||
seen.add(refs.ref_id(ref))
|
||||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||||
lines.append(
|
||||
{
|
||||
"참조": ref,
|
||||
"별칭": alias,
|
||||
"메모": str(memo) if memo else None,
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
}
|
||||
)
|
||||
continue
|
||||
code = str(one.get("로직") or "")
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
@@ -84,16 +104,11 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
given = one.get("입력값") or {}
|
||||
if not isinstance(given, dict):
|
||||
raise _bad(f"{where} · 입력값이 묶음 아님")
|
||||
memo, alias = one.get("메모"), one.get("별칭")
|
||||
if alias is not None and not isinstance(alias, str):
|
||||
raise _bad(f"{where} · 별칭은 글")
|
||||
alias = (alias or "").strip()
|
||||
if len(alias) > ALIAS_MAX:
|
||||
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
|
||||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||||
lines.append(
|
||||
{
|
||||
"로직": code,
|
||||
"별칭": alias or None,
|
||||
"별칭": alias,
|
||||
"메모": str(memo) if memo else None,
|
||||
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
@@ -133,6 +148,15 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _alias(alias: Any, where: str) -> str | None:
|
||||
if alias is not None and not isinstance(alias, str):
|
||||
raise _bad(f"{where} · 별칭은 글")
|
||||
alias = (alias or "").strip()
|
||||
if len(alias) > ALIAS_MAX:
|
||||
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
|
||||
return alias or None
|
||||
|
||||
|
||||
def from_combo(key: str) -> dict[str, Any]:
|
||||
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
|
||||
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
|
||||
@@ -161,8 +185,10 @@ def material_key(code: str) -> str:
|
||||
|
||||
|
||||
def pills(doc: dict) -> list[dict[str, Any]]:
|
||||
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」."""
|
||||
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 참조 알약(`refs.pill`) +
|
||||
자재 알약 「자재|MT키」."""
|
||||
logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||||
logic += [refs.pill(one["참조"]) for one in doc.get("담은로직") or [] if _is_ref(one)]
|
||||
mats = [
|
||||
{
|
||||
"키": material_key(m["코드"]),
|
||||
@@ -230,16 +256,25 @@ def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | Non
|
||||
return None, "없음"
|
||||
|
||||
|
||||
def _is_ref(one: Any) -> bool:
|
||||
return isinstance(one, dict) and isinstance(one.get("참조"), dict)
|
||||
|
||||
|
||||
def line_id(one: dict) -> Any:
|
||||
"""줄 가름 — 로직 키 · 참조 줄은 `ref_id`(짝 `Labor_Calc.ts` `lineId`)."""
|
||||
return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직")
|
||||
|
||||
|
||||
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
|
||||
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
|
||||
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
|
||||
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
|
||||
hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []}
|
||||
hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []}
|
||||
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
|
||||
zero = dict.fromkeys((*COSTS, "계"), Decimal(0))
|
||||
lines, mats, bases, reflects = [], [], [], []
|
||||
for line in result.get("줄") or []:
|
||||
qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have)
|
||||
qty, source = _pick(hands.get(line_id(line)), line.get("알약"), have)
|
||||
base = _money(line.get("기준"))
|
||||
done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None
|
||||
bases.append(base)
|
||||
@@ -277,6 +312,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
|
||||
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
|
||||
whole = m01.loaded()[1]
|
||||
expand = refs.Expand(whole)
|
||||
by_logic = {
|
||||
code: pill
|
||||
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||||
@@ -284,6 +320,9 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
}
|
||||
lines = []
|
||||
for one in doc.get("담은로직") or []:
|
||||
if _is_ref(one):
|
||||
lines.append(ref_line(one["참조"], expand(one["참조"])))
|
||||
continue
|
||||
code = one["로직"]
|
||||
pill = by_logic.get(code) or {}
|
||||
line = {
|
||||
@@ -292,17 +331,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"분모": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
|
||||
got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})})
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__})
|
||||
continue
|
||||
if "결과" in got: # 돈 아닌 로직 — 반영 없음
|
||||
lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None})
|
||||
continue
|
||||
lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None})
|
||||
lines.append({**line, **run_line(whole, code, one.get("입력값"))})
|
||||
mats = [
|
||||
{
|
||||
"코드": one["코드"],
|
||||
@@ -315,6 +344,30 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||||
|
||||
|
||||
def run_line(whole: Any, code: str, given: Any) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` ·
|
||||
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
|
||||
got = mf.run(whole, code, {**auto, **m01._dec(given or {})})
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"기준": None, "까닭": str(e) or type(e).__name__}
|
||||
if "결과" in got:
|
||||
return {"결과": got["결과"], "기준": None, "까닭": None}
|
||||
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
|
||||
return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
|
||||
|
||||
|
||||
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
|
||||
"""참조 줄 계산 답 — 분모 = 가리킨 문서 단위 · 배 1 · `안` = 사전조합 안 줄(한 층)."""
|
||||
line = {"참조": ref, "이름": got.get("이름"), "알약": refs.ref_id(ref), "분모": got.get("단위")}
|
||||
line.update({"배": 1, "기준": got.get("기준"), "까닭": got.get("까닭")})
|
||||
if "줄" in got: # 단가승격 = 원 로직 키 · 표(줄 누름 상세 · 50-3 ④)
|
||||
line.update({"원로직": got.get("로직"), "줄": got["줄"]})
|
||||
return {**line, "안": got["안"]} if "안" in got else line
|
||||
|
||||
|
||||
def _json(value: Any) -> Any:
|
||||
"""Decimal → JSON 수(정수면 int)."""
|
||||
if isinstance(value, Decimal):
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""M02 일위대가 조합 서고 — 단가승격 · 사전조합 · 참조(PLAN 50-3 · 조사 갈래 A).
|
||||
|
||||
자리 = 층 양식 길 새 종류 둘(`M02_Template_Layers.LIBRARY_KINDS`) — 파일 이름 = 키:
|
||||
promote `{층}/promote/SP-######.json` 단가승격
|
||||
`{종류:"단가승격", 키, 이름, 단위, 배, 본뜬, 담은로직:[{로직, 입력값}]}` —
|
||||
줄 하나 · 입력값은 승격 때 고정 · 값 = 로직 기준 ÷ 배(1 단위 값 · `배` 는 저장 때)
|
||||
precombo `{층}/precombo/SC-######.json` 사전조합
|
||||
`{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모}
|
||||
| {로직, 입력값, 메모, 수량:{손}}]}` — 승격 · 사전조합 · 단가산출 로직(51-11) ·
|
||||
수량은 손(기본 1) · 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배)
|
||||
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) ·
|
||||
위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) ·
|
||||
같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭.
|
||||
지우기 = 쓰는 곳(모든 층 일위대가 · 사전조합)이 있으면 409 · 층 사이 복사 = 대상 층이 못 읽는
|
||||
참조는 그 문서도 같이 복사(새 키 · `본뜬`) · 프로젝트 = 참조사본으로 굳힘(`frozen`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
PREFIX = {"promote": "SP", "precombo": "SC"}
|
||||
TITLE = {"promote": "단가승격", "precombo": "사전조합"}
|
||||
DEPTH = 8 # 참조 깊이 상한(저장 · 계산 둘 다)
|
||||
REF_HEAD = "참조|"
|
||||
_KEY = re.compile(r"^(SP|SC)-(\d{6})$")
|
||||
_OWNER = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")}
|
||||
|
||||
Ref = dict[str, Any]
|
||||
Here = tuple[str, str | None]
|
||||
|
||||
|
||||
def _bad(text: str, status: int = 400) -> store.StoreError:
|
||||
return store.StoreError(status, text)
|
||||
|
||||
|
||||
def kind_of(key: str) -> str:
|
||||
return "promote" if str(key).startswith("SP") else "precombo"
|
||||
|
||||
|
||||
def ref_id(ref: Ref) -> str:
|
||||
"""참조 한 줄의 이름표 — 알약 키 · 줄 가름(짝 `Labor_Calc.ts` `lineId`)."""
|
||||
return f"{REF_HEAD}{ref.get('층')}|{ref.get('주인') or ''}|{ref.get('키')}"
|
||||
|
||||
|
||||
# ── 자리 ──────────────────────────────────────────────────────────────
|
||||
def where(folder: str | Path) -> Here:
|
||||
"""층 폴더 → (층, 주인) — 저장소 밖 · 프로젝트는 ("project", None)."""
|
||||
path = Path(folder).resolve()
|
||||
if path in (Path(store.FOLDER).resolve(), layers.system_dir().resolve()):
|
||||
return "system", None
|
||||
try:
|
||||
parts = path.relative_to(layers.storage_root()).parts
|
||||
except ValueError:
|
||||
return "project", None
|
||||
if len(parts) == 2 and parts[1] == layers.TEMPLATES_DIRNAME:
|
||||
return "company", parts[0]
|
||||
if len(parts) == 3 and parts[2] == layers.TEMPLATES_DIRNAME:
|
||||
return "personal", f"{parts[0]}/{parts[1]}"
|
||||
return "project", None
|
||||
|
||||
|
||||
def here() -> Here:
|
||||
return where(store.root())
|
||||
|
||||
|
||||
def folder_of(layer: str, owner: str | None) -> Path:
|
||||
if layer == "system":
|
||||
return Path(store.FOLDER)
|
||||
if layer == "company":
|
||||
return layers.company_dir(owner or "")
|
||||
company, user = str(owner).split("/", 1)
|
||||
return layers.personal_dir(company, user)
|
||||
|
||||
|
||||
def ref_path(ref: Ref) -> Path:
|
||||
return folder_of(ref["층"], ref.get("주인")) / kind_of(ref["키"]) / f"{ref['키']}.json"
|
||||
|
||||
|
||||
def read_ref(ref: Ref) -> dict[str, Any] | None:
|
||||
try:
|
||||
got = json.loads(ref_path(ref).read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return got if isinstance(got, dict) else None
|
||||
|
||||
|
||||
def readable(at: Here, ref: Ref) -> bool:
|
||||
"""`at` 층이 `ref` 를 가리켜도 되나 — 위 층만(프로젝트는 아무것도 · 굳힌 사본만 씀)."""
|
||||
layer, owner = at
|
||||
if layer == "project":
|
||||
return False
|
||||
if ref["층"] == "system":
|
||||
return True
|
||||
if ref["층"] == "company":
|
||||
return layer in ("company", "personal") and str(owner).split("/")[0] == ref["주인"]
|
||||
return layer == "personal" and owner == ref["주인"]
|
||||
|
||||
|
||||
def check_ref(raw: Any, at: str, here_: Here) -> Ref:
|
||||
"""참조 꼴 · 위 층만 · 있는 문서 — 아니면 400."""
|
||||
if not isinstance(raw, dict):
|
||||
raise _bad(f"{at} · 참조는 {{층, 주인, 키}}")
|
||||
layer, owner, key = raw.get("층"), raw.get("주인"), str(raw.get("키") or "")
|
||||
if layer == "system":
|
||||
owner = None
|
||||
elif layer not in _OWNER or not _OWNER[layer].match(str(owner or "")):
|
||||
raise _bad(f"{at} · 참조 층 · 주인 꼴이 틀림")
|
||||
if not _KEY.match(key):
|
||||
raise _bad(f"{at} · 참조 키는 SP-###### · SC-######")
|
||||
ref = {"층": layer, "주인": None if owner is None else str(owner), "키": key}
|
||||
if not readable(here_, ref):
|
||||
raise _bad(f"{at} · 위 층만 가리킴 — 「{key}」({layer} {owner or ''})")
|
||||
if read_ref(ref) is None:
|
||||
raise _bad(f"{at} · 없는 참조 「{key}」")
|
||||
return ref
|
||||
|
||||
|
||||
# ── 서고 문서 정리 ─────────────────────────────────────────────────────
|
||||
def check_key(kind: str, name: str) -> None:
|
||||
found = _KEY.match(name)
|
||||
if not found or found.group(1) != PREFIX[kind]:
|
||||
raise _bad(f"{TITLE[kind]} 키는 {PREFIX[kind]}-######")
|
||||
|
||||
|
||||
def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
|
||||
"""서고 문서를 서버가 받는 꼴로 — `doc["키"]` = 파일 이름(부르는 쪽이 넣음)."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
raise _bad("서고 문서가 묶음 아님")
|
||||
key = str(doc.get("키") or "")
|
||||
check_key(kind, key)
|
||||
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
|
||||
out: dict[str, Any] = {"종류": TITLE[kind], "키": key}
|
||||
if kind == "promote":
|
||||
lines = doc.get("담은로직") or []
|
||||
if len(lines) != 1 or not isinstance(lines[0], dict) or not lines[0].get("로직"):
|
||||
raise _bad("단가승격은 단가산출 로직 한 줄")
|
||||
line = labor.clean({"담은로직": lines})["담은로직"][0]
|
||||
pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0]
|
||||
out.update(
|
||||
이름=name or pill.get("이름") or line["로직"],
|
||||
단위=unit or pill.get("단위") or None,
|
||||
배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장
|
||||
담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}],
|
||||
**origin(line["로직"]),
|
||||
)
|
||||
else:
|
||||
if not name:
|
||||
raise _bad("사전조합 이름이 빔")
|
||||
out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key))
|
||||
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
|
||||
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
|
||||
return out
|
||||
|
||||
|
||||
def origin(code: str) -> dict[str, Any]:
|
||||
"""단가승격 칸 — 원 로직의 구분 · 상세구분 + 연결 {로직 키, 출처 글}(51-12 · 없으면 빈 글)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
try:
|
||||
got = m01.loaded()[1].logic(code)
|
||||
except m01.cm.mf.FormulaError:
|
||||
got = {}
|
||||
return {
|
||||
"구분": got.get("구분") or "",
|
||||
"상세구분": got.get("상세구분") or "",
|
||||
"연결": {"로직": code, "출처": got.get("출처") or ""},
|
||||
}
|
||||
|
||||
|
||||
def filled(kind: str, doc: Any) -> Any:
|
||||
"""옛 서고 문서(51-12 앞) 읽을 때 칸 채움 — 승격 = 원 로직 것 · 사전조합 = 빈 글(화면 몫)."""
|
||||
if not isinstance(doc, dict) or kind not in PREFIX or "구분" in doc:
|
||||
return doc
|
||||
if kind == "precombo":
|
||||
return {**doc, "구분": "", "상세구분": ""}
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
code = str(line.get("로직") or "") if isinstance(line, dict) else ""
|
||||
return {**doc, **origin(code)}
|
||||
|
||||
|
||||
def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
|
||||
"""참조 줄 `{참조, 수량, 메모}` · 로직 줄 `{로직, 입력값, 메모, 수량}`(51-11) — 검사는
|
||||
일위대가와 같은 길(`labor.clean` · 같은 로직 · 같은 참조 두 번 · 입력값) · 수량 기본 {손: 1}."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
lines = []
|
||||
for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]:
|
||||
head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]}
|
||||
if "로직" in one:
|
||||
head["입력값"] = one["입력값"]
|
||||
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
|
||||
at_ = here()
|
||||
me = {"층": at_[0], "주인": at_[1], "키": key}
|
||||
for one in lines:
|
||||
if "참조" not in one:
|
||||
continue
|
||||
if ref_id(one["참조"]) == ref_id(me):
|
||||
raise _bad(f"순환 — 자기 자신을 담음 「{key}」", 422)
|
||||
_no_loop(one["참조"], [me, one["참조"]])
|
||||
return lines
|
||||
|
||||
|
||||
def _no_loop(ref: Ref, trail: list[Ref]) -> None:
|
||||
"""저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」."""
|
||||
if len(trail) > DEPTH:
|
||||
raise _bad(f"참조가 {DEPTH}단을 넘음 — " + " → ".join(r["키"] for r in trail), 422)
|
||||
for one in (read_ref(ref) or {}).get("담은로직") or []:
|
||||
child = one.get("참조") if isinstance(one, dict) else None
|
||||
if not isinstance(child, dict) or not child.get("키"):
|
||||
continue
|
||||
if ref_id(child) == ref_id(trail[0]):
|
||||
raise _bad("순환 — " + " → ".join(r["키"] for r in [*trail, child]), 422)
|
||||
_no_loop(child, [*trail, child])
|
||||
|
||||
|
||||
def next_key(kind: str) -> str:
|
||||
"""지금 층 그 종류의 다음 키(가장 큰 번호 + 1)."""
|
||||
folder = store.root() / kind
|
||||
have = [int(m.group(2)) for p in folder.glob("*.json") if (m := _KEY.match(p.stem))]
|
||||
return f"{PREFIX[kind]}-{max(have, default=0) + 1:06d}"
|
||||
|
||||
|
||||
def create(kind: str, doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""지금 층에 새 키로 씀 → `store.write` 정보(+ 키) — 그새 남이 같은 키를 잡으면 다음 키."""
|
||||
for _ in range(5):
|
||||
key = next_key(kind)
|
||||
try:
|
||||
return store.write(kind, key, "", {**doc, "키": key})
|
||||
except store.StoreError as e:
|
||||
if e.status != 409 or not isinstance(e.detail, dict):
|
||||
raise
|
||||
raise _bad("새 키를 못 잡음 — 다시 누름", 409)
|
||||
|
||||
|
||||
def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> dict[str, Any]:
|
||||
"""층 목록 한 줄에 붙일 것 — `{제목, 단위, 구분, 상세구분, 연결}`(51-12) · `expand` 면
|
||||
1 단위 기준 계(`기준금액` · 51-4)."""
|
||||
try:
|
||||
doc = filled(kind, json.loads((Path(folder) / kind / f"{key}.json").read_bytes()))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
keep = ("구분", "상세구분", "연결")
|
||||
out = {"제목": doc.get("이름"), "단위": doc.get("단위"), **{k: doc.get(k) for k in keep}}
|
||||
if expand is not None:
|
||||
layer, owner = where(folder)
|
||||
base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {}
|
||||
out["기준금액"] = float(base["계"]) if "계" in base else None
|
||||
return out
|
||||
|
||||
|
||||
def summaries(folder: Path, rows: list[dict[str, Any]]) -> None:
|
||||
"""층 목록의 서고 줄마다 `summary`(값 포함) — 한 번 셈 캐시를 함께 씀."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
expand = Expand(m01.loaded()[1])
|
||||
for row in rows:
|
||||
if row["종류"] in layers.LIBRARY_KINDS:
|
||||
row.update(summary(folder, row["종류"], row["이름"], expand))
|
||||
|
||||
|
||||
# ── 쓰는 곳 · 지우기 ──────────────────────────────────────────────────
|
||||
def users(ref: Ref) -> list[str]:
|
||||
"""`ref` 를 담은 일위대가 · 사전조합 — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌)."""
|
||||
rid, out = ref_id(ref), []
|
||||
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합")):
|
||||
for at, path in layers.layer_files(kind):
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
lines = doc.get("담은로직") if isinstance(doc, dict) else None
|
||||
if any(
|
||||
isinstance(one, dict)
|
||||
and isinstance(one.get("참조"), dict)
|
||||
and ref_id(one["참조"]) == rid
|
||||
for one in lines or []
|
||||
):
|
||||
name = doc.get("일위대가번") or doc.get("이름") or path.stem
|
||||
out.append(f"{what} {at} 「{name}」")
|
||||
return out
|
||||
|
||||
|
||||
def refuse_used(folder: Path, kind: str, key: str) -> None:
|
||||
"""쓰는 곳이 있으면 409 + 이름(서고 종류만)."""
|
||||
if kind not in PREFIX:
|
||||
return
|
||||
layer, owner = where(folder)
|
||||
found = users({"층": layer, "주인": owner, "키": key})
|
||||
if found:
|
||||
raise _bad(f"쓰는 곳이 있어 못 지움 「{key}」 — " + " · ".join(found), 409)
|
||||
|
||||
|
||||
# ── 층 사이 복사 ──────────────────────────────────────────────────────
|
||||
def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
|
||||
"""복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로
|
||||
같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈. 원본 층 읽기 권한은 부르는 쪽 몫."""
|
||||
target, done = here(), {}
|
||||
|
||||
def move(ref: Ref, depth: int) -> Ref:
|
||||
if readable(target, ref):
|
||||
return ref
|
||||
if ref_id(ref) in done:
|
||||
return done[ref_id(ref)]
|
||||
if depth > DEPTH or not readable(source, ref):
|
||||
raise _bad(f"복사할 수 없는 참조 「{ref.get('키')}」")
|
||||
got = read_ref(ref)
|
||||
if got is None:
|
||||
raise _bad(f"없는 참조 「{ref['키']}」")
|
||||
body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1), "본뜬": ref}
|
||||
made = create(kind_of(ref["키"]), body)
|
||||
done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]}
|
||||
return done[ref_id(ref)]
|
||||
|
||||
return {**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)}
|
||||
|
||||
|
||||
def _moved(lines: Any, move: Callable[[Ref, int], Ref], depth: int) -> list[Any]:
|
||||
return [
|
||||
{**one, "참조": move(one["참조"], depth)}
|
||||
if isinstance(one, dict) and isinstance(one.get("참조"), dict)
|
||||
else one
|
||||
for one in lines or []
|
||||
]
|
||||
|
||||
|
||||
# ── 펼침(계산) ────────────────────────────────────────────────────────
|
||||
class Expand:
|
||||
"""참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 ·
|
||||
깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본)."""
|
||||
|
||||
def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = read_ref) -> None:
|
||||
self.whole, self.read, self.memo = whole, read, {}
|
||||
|
||||
def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]:
|
||||
rid = ref_id(ref)
|
||||
if rid in trail:
|
||||
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
|
||||
return self._stop(ref, "순환 — " + " → ".join(keys))
|
||||
if len(trail) >= DEPTH:
|
||||
return self._stop(ref, f"참조가 {DEPTH}단을 넘음")
|
||||
if rid not in self.memo:
|
||||
doc = self.read(ref)
|
||||
if not isinstance(doc, dict):
|
||||
return self._stop(ref, f"없는 참조 「{ref['키']}」")
|
||||
if kind_of(ref["키"]) == "promote":
|
||||
self.memo[rid] = self._promote(doc)
|
||||
else:
|
||||
self.memo[rid] = self._combo(doc, (*trail, rid))
|
||||
return self.memo[rid]
|
||||
|
||||
@staticmethod
|
||||
def _stop(ref: Ref, why: str) -> dict[str, Any]:
|
||||
return {"이름": ref["키"], "단위": None, "기준": None, "까닭": why}
|
||||
|
||||
def _promote(self, doc: dict) -> dict[str, Any]:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값"))
|
||||
times = labor._number(doc.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
why = got.get("까닭") or (None if base else "돈 아닌 로직")
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"로직": line.get("로직"),
|
||||
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
|
||||
"까닭": why,
|
||||
# 원 로직 줄(줄 누름 하위 행 · 51-2) — 1 단위로 나눔(하위 합 = 기준)
|
||||
"줄": [
|
||||
{
|
||||
**x,
|
||||
"수량": Decimal(str(x["수량"])) / times,
|
||||
"금액": Decimal(str(x["금액"])) / times,
|
||||
"비목": {k: Decimal(str(v)) / times for k, v in (x.get("비목") or {}).items()},
|
||||
}
|
||||
for x in got.get("줄") or []
|
||||
],
|
||||
}
|
||||
|
||||
def _logic(self, one: dict, pill: dict | None) -> dict[str, Any]:
|
||||
"""사전조합 안 로직 줄 — 일위대가 로직 줄과 같은 `run_line` · 배는 물량 알약 것."""
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(self.whole, one["로직"], one.get("입력값"))
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
"이름": pill.get("이름") or one["로직"],
|
||||
"단위": pill.get("단위"),
|
||||
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
|
||||
"까닭": got.get("까닭") or (None if base else "돈 아닌 로직"),
|
||||
}
|
||||
|
||||
def _combo(self, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]:
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
|
||||
by_logic = {
|
||||
code: pill
|
||||
for pill in combo_store.combo_pills({"담은로직": lines})["물량"]
|
||||
for code in pill["로직"]
|
||||
}
|
||||
inner, rows, why = [], [], None
|
||||
for one in lines:
|
||||
ref = one.get("참조")
|
||||
if isinstance(ref, dict):
|
||||
head, sub = {"참조": ref}, self(ref, trail)
|
||||
elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음)
|
||||
head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"]))
|
||||
else:
|
||||
continue
|
||||
qty = labor._number((one.get("수량") or {}).get("손"))
|
||||
qty = Decimal(1) if qty is None else qty
|
||||
keep = ("이름", "단위", "기준", "까닭")
|
||||
inner.append({**head, "수량": qty, **{k: sub.get(k) for k in keep}})
|
||||
if sub.get("기준") is None:
|
||||
why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}"
|
||||
continue
|
||||
rows.append({k: v * qty for k, v in sub["기준"].items()})
|
||||
return {
|
||||
"이름": doc.get("이름"),
|
||||
"단위": doc.get("단위"),
|
||||
"기준": None if why else labor._sum(rows),
|
||||
"까닭": why,
|
||||
"안": inner,
|
||||
}
|
||||
|
||||
|
||||
def pill(ref: Ref, read: Callable[[Ref], dict | None] = read_ref) -> dict[str, Any]:
|
||||
"""참조 줄 면적산출 알약 — 키 = `ref_id` · 이름 · 단위 = 가리킨 문서 것."""
|
||||
doc = read(ref) or {}
|
||||
return {
|
||||
"키": ref_id(ref),
|
||||
"이름": doc.get("이름") or ref["키"],
|
||||
"단위": doc.get("단위") or "",
|
||||
"로직": [],
|
||||
"참조": ref,
|
||||
}
|
||||
|
||||
|
||||
# ── 프로젝트 굳힘 ─────────────────────────────────────────────────────
|
||||
def frozen(doc: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""일위대가 문서가 닿는 참조 문서 전부 `{ref_id: 문서}` — 가져올 때 값으로 굳힘
|
||||
(원본이 바뀌어도 프로젝트는 그대로)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def walk(lines: Any, depth: int) -> None:
|
||||
for one in lines or []:
|
||||
ref = one.get("참조") if isinstance(one, dict) else None
|
||||
if not isinstance(ref, dict) or ref_id(ref) in out or depth > DEPTH:
|
||||
continue
|
||||
got = read_ref(ref)
|
||||
if got is not None:
|
||||
out[ref_id(ref)] = got
|
||||
walk(got.get("담은로직"), depth + 1)
|
||||
|
||||
walk(doc.get("담은로직"), 1)
|
||||
return out
|
||||
|
||||
|
||||
def frozen_logics(copies: dict[str, Any]) -> list[str]:
|
||||
"""굳힌 단가승격 · 사전조합이 담은 로직 키 — 프로젝트 단가 묶음에 넣음."""
|
||||
return [
|
||||
str(line["로직"])
|
||||
for doc in copies.values()
|
||||
for line in doc.get("담은로직") or []
|
||||
if isinstance(line, dict) and line.get("로직")
|
||||
]
|
||||
@@ -1,19 +1,22 @@
|
||||
"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`.
|
||||
|
||||
문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 ·
|
||||
여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌).
|
||||
여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 ·
|
||||
지우기는 양식 길 그대로). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
@@ -62,3 +65,39 @@ def labor_pills(
|
||||
) -> dict:
|
||||
"""면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본)."""
|
||||
return _call(_root(session, layer, owner), _pills, column)
|
||||
|
||||
|
||||
class LibraryBody(BaseModel):
|
||||
"""새로 = `문서` · 복사 = `본뜬` {층, 주인, 키}(원본 읽기 권한 · 못 읽는 참조는 같이 복사)."""
|
||||
|
||||
문서: dict[str, Any] | None = None
|
||||
본뜬: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[str, Any]:
|
||||
return refs.create(kind, refs.carry({**doc, "본뜬": ref}, source))
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/library/{kind}")
|
||||
def new_library(
|
||||
layer: Layer,
|
||||
kind: Literal["promote", "precombo"],
|
||||
body: LibraryBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""서고 새로 · 복사 — 키는 서버가 그 층 다음 번호(SP · SC) · 답 `이름` = 새 키."""
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
if body.본뜬 is None:
|
||||
return _call(folder, refs.create, kind, body.문서 or {})
|
||||
src = body.본뜬
|
||||
if src.get("층") not in ("system", "company", "personal"):
|
||||
raise HTTPException(status_code=400, detail="본뜬 층이 틀림")
|
||||
got = access.grant(session, src["층"], src.get("주인")) # 원본 읽기 권한
|
||||
ref = {"층": got.layer, "주인": got.owner, "키": str(src.get("키") or "")}
|
||||
if refs.kind_of(ref["키"]) != kind:
|
||||
raise HTTPException(status_code=400, detail="본뜬 종류가 틀림")
|
||||
doc = refs.read_ref(ref) if refs._KEY.match(ref["키"]) else None
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail=f"없는 {refs.TITLE[kind]} 「{ref['키']}」")
|
||||
return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref)
|
||||
|
||||
@@ -9,8 +9,9 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「면적산출」 = area(구조물집계표 열마다 한 통합문서) ·
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor";
|
||||
* 「일위대가」 = labor(열마다 문서 · 39장) ·
|
||||
* 「단가승격」 = promote · 「사전조합」 = precombo(일위대가 조합 서고 · 50-3 · 키가 이름) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "promote" | "precombo";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -125,6 +126,19 @@ export const deleteLayerTemplate = (
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
/** 서고 새로 · 복사(50-3) — 새로 = `문서` · 복사 = `본뜬` {층, 주인, 키} · 키는 서버가 줌(답 `이름`) */
|
||||
export type LibraryBody =
|
||||
{ 문서: Record<string, unknown> } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } };
|
||||
export const newLibrary = (
|
||||
layer: Layer,
|
||||
kind: "promote" | "precombo",
|
||||
body: LibraryBody,
|
||||
): Promise<TemplateInfo> =>
|
||||
call(owned(layer, `/layers/${layer}/library/${kind}`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
|
||||
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
getActiveOwner,
|
||||
readTemplate,
|
||||
type Layer,
|
||||
type StructureDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */
|
||||
@@ -71,6 +77,8 @@ const TEXT = {
|
||||
noPlace: "이 도면 화면은 표 놓기를 아직 못 함",
|
||||
stale: "면적산출 값이 바뀐 표 {n}개",
|
||||
update: "업데이트",
|
||||
tag: "{id} 도면",
|
||||
tip: "도면 표 {id} 로 보낸 범위 {range} · 글자 {size}",
|
||||
updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음",
|
||||
} as const;
|
||||
|
||||
@@ -112,6 +120,22 @@ const sameValues = (a: Record<string, string> = {}, b: Record<string, string> =
|
||||
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
|
||||
};
|
||||
|
||||
/** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) */
|
||||
export async function sentRanges(at: Selection): Promise<NonNullable<LinkOptions["표시범위"]>> {
|
||||
try {
|
||||
const got = await readTemplate(at.layer, "structure", at.name, at.projectId);
|
||||
const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? [];
|
||||
return rows.map((row) => ({
|
||||
시트: row.시트,
|
||||
범위: row.범위,
|
||||
이름: fill(TEXT.tag, "id", row.연결),
|
||||
설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface AreaToDrawingOptions {
|
||||
/** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */
|
||||
open: (sel: Selection) => Promise<void>;
|
||||
|
||||
@@ -95,3 +95,24 @@ export const CARD_HELP: Record<"structure" | "labor" | "area", CardHelp> = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** 상세에만 있는 단추 줄(49-7) — 카드 도움말 글 한 벌 뒤에 더함 */
|
||||
const DETAIL_ONLY: Record<keyof typeof CARD_HELP, [string, string][]> = {
|
||||
structure: [["파일 불러오기", "DXF · DWG 파일을 도면으로 불러옴(상세 머리 📂)"]],
|
||||
labor: [],
|
||||
area: [["도면으로 보내기", "고른 범위를 도면 표로 보냄(상세 머리)"]],
|
||||
};
|
||||
const BACK: [string, string] = ["돌아가기", "구조물집계표로 · 그 열을 고른 채"];
|
||||
|
||||
/** 상세 머리 [?] 글 — 카드 글 그대로 + 상세에만 있는 줄 */
|
||||
export const detailHelp = (kind: keyof typeof CARD_HELP): CardHelp => ({
|
||||
...CARD_HELP[kind],
|
||||
features: [...CARD_HELP[kind].features, ...DETAIL_ONLY[kind], BACK],
|
||||
});
|
||||
|
||||
/** 도움말 상자 제목 — 카드 제목과 같은 글 */
|
||||
export const HELP_TITLE: Record<keyof typeof CARD_HELP, string> = {
|
||||
structure: "도면 정보",
|
||||
labor: "일위대가",
|
||||
area: "면적산출",
|
||||
};
|
||||
|
||||
@@ -159,8 +159,11 @@ const pairs = (rows: [string, string][]): HTMLElement =>
|
||||
children: rows.flatMap(([key, what]) => [el("dt", { text: key }), el("dd", { text: what })]),
|
||||
});
|
||||
|
||||
/** ? — 카드 제목 바로 옆 · 누르면 도움말 상자(카드 오른쪽 위 · 보이고 숨김) · 상자는 본문 앞에 끼움 */
|
||||
export function helpButton(shell: CardShell, title: string, help: CardHelp): HTMLElement {
|
||||
/** ? 단추 + 도움말 상자 한 쌍(카드 · 상세 머리 공용 · 49-7) — 놓는 자리는 부른 쪽이 정함 */
|
||||
export function helpParts(
|
||||
title: string,
|
||||
help: CardHelp,
|
||||
): { button: HTMLElement; box: HTMLElement } {
|
||||
const button = createButton({ label: CARD_TEXT.help, variant: "ghost", size: "sm" });
|
||||
button.title = button.ariaLabel = CARD_TEXT.helpTip.replace("{value}", title);
|
||||
button.classList.add("m02-struct__help");
|
||||
@@ -188,6 +191,12 @@ export function helpButton(shell: CardShell, title: string, help: CardHelp): HTM
|
||||
event.stopPropagation(); // 누름 ≠ 접기
|
||||
box.hidden = !box.hidden;
|
||||
});
|
||||
return { button, box };
|
||||
}
|
||||
|
||||
/** ? — 카드 제목 바로 옆 · 누르면 도움말 상자(카드 오른쪽 위 · 보이고 숨김) · 상자는 본문 앞에 끼움 */
|
||||
export function helpButton(shell: CardShell, title: string, help: CardHelp): HTMLElement {
|
||||
const { button, box } = helpParts(title, help);
|
||||
shell.title.after(button);
|
||||
shell.root.insertBefore(box, shell.body);
|
||||
return button;
|
||||
|
||||
@@ -176,7 +176,7 @@ def copy_item(
|
||||
have = store.read(kind, new_id)["문서"]
|
||||
body = {k: v for k, v in got.items() if k != "계산값"}
|
||||
body = {**body, "열": new_id, number: have.get(number)}
|
||||
body = store._server(kind, body)
|
||||
body = store._server(kind, body, source)
|
||||
with store._LOCK:
|
||||
atomic_write_json(store._path(kind, new_id), body)
|
||||
store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
|
||||
|
||||
@@ -90,6 +90,14 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 순서 칸 2행(51-14) — 위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
|
||||
.m02-labrow__tools {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--btn-group-gap);
|
||||
}
|
||||
|
||||
/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */
|
||||
.m02-labrow .m02-labrow__optrow td {
|
||||
padding-top: 0;
|
||||
@@ -130,6 +138,26 @@
|
||||
box-shadow: inset 3px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 구분 칸(50-4) — 넉 자 · 흐린 글 */
|
||||
.m02-labrow__kind {
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 줄 접고 폄 표(51-2) · 고른 줄 하위 행 — 이름 들여쓰기 · 흐린 바탕 · 위 줄보다 촘촘(51-7) */
|
||||
.m02-labrow .m02-labrow__sub td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
background: var(--color-bg);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__sub .m02-labrow__subname {
|
||||
padding-left: var(--spacing-24);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */
|
||||
.m02-labrow__alias {
|
||||
min-width: 7rem;
|
||||
@@ -159,6 +187,11 @@
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 참조 줄 모달(51-14) — 일위조합 [수정] = 사전조합 상세 */
|
||||
.m02-labref {
|
||||
width: min(72rem, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
/* 입력 모달 */
|
||||
.m02-labin {
|
||||
width: min(40rem, calc(100vw - 32px));
|
||||
@@ -259,6 +292,12 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__kind {
|
||||
grid-column: 1 / -1;
|
||||
order: 1;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__no,
|
||||
.m02-labrow .m02-labrow__unit:not([data-label]),
|
||||
.m02-labrow .m02-labrow__code,
|
||||
@@ -322,6 +361,7 @@
|
||||
|
||||
/* 옵션 값 줄 — 한 줄 통째 */
|
||||
.m02-labrow__optrow .m02-labrow__no,
|
||||
.m02-labrow__sum .m02-labrow__kind,
|
||||
.m02-labrow__sum .m02-labrow__alias {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
type LogicSummary,
|
||||
type SubBrief,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
call,
|
||||
getActiveLayer,
|
||||
getActiveOwner,
|
||||
type ItemLogic,
|
||||
@@ -118,9 +119,11 @@ export const fetchLibLogicSubs = (): Promise<SubBrief[]> =>
|
||||
|
||||
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
|
||||
export type LaborQty = { 손: number } | null;
|
||||
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */
|
||||
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) ·
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3) = `참조` 있고 `로직` 빈 글 · 줄 가름은 `lineId` */
|
||||
export interface LaborRow {
|
||||
로직: string;
|
||||
참조?: LaborRef;
|
||||
/** 사용자가 지은 이름(44-6 · 빈 것 = null) */
|
||||
별칭?: string | null;
|
||||
메모: string | null;
|
||||
@@ -154,11 +157,13 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
열: d.열 ?? column,
|
||||
단위: d.단위 ?? null,
|
||||
담은로직: (d.담은로직 ?? []).map((r) => ({
|
||||
로직: r.로직,
|
||||
로직: r.로직 ?? "",
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
별칭: r.별칭 ?? null,
|
||||
메모: r.메모 ?? null,
|
||||
입력값: r.입력값 ?? {},
|
||||
수량: r.수량 ?? null,
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
@@ -223,6 +228,27 @@ export const calcLabor = (
|
||||
...(수량 ? { 수량 } : {}),
|
||||
});
|
||||
|
||||
/** 서고 한 줄(50-3) — 키 = SP-· SC-###### · 제목 · 단위 = 문서 것 */
|
||||
export interface LibraryRow {
|
||||
이름: string;
|
||||
제목?: string | null;
|
||||
단위?: string | null;
|
||||
/** 단가승격 = 원 로직 것 · 사전조합 = 사용자가 고름(51-12) */
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
}
|
||||
/** 서고 목록 — 그 층(주인) 단가승격 `promote` · 사전조합 `precombo` 문서 */
|
||||
export const fetchLibrary = async (
|
||||
ref: Omit<LaborRef, "키">,
|
||||
kind: "promote" | "precombo",
|
||||
): Promise<LibraryRow[]> => {
|
||||
const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : "";
|
||||
const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>(
|
||||
`/layers/${ref.층}/templates${q}`,
|
||||
);
|
||||
return got.양식.filter((r) => r.종류 === kind);
|
||||
};
|
||||
|
||||
/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */
|
||||
export const fetchLogicInputs = (
|
||||
keys: string[],
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
* 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 —
|
||||
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
|
||||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */
|
||||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
|
||||
|
||||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
|
||||
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
|
||||
@@ -11,8 +14,17 @@ export type QtySource = "손" | "칸" | "없음";
|
||||
/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */
|
||||
export type Qty = { 손: number } | null;
|
||||
|
||||
/** 참조 — 단가승격(SP) · 사전조합(SC) 문서(50-3) */
|
||||
export interface LaborRef {
|
||||
층: string;
|
||||
주인: string | null;
|
||||
키: string;
|
||||
}
|
||||
|
||||
export interface CalcLine {
|
||||
/** 참조 줄은 서버 답에 없음(빈 글로 다룸) */
|
||||
로직: string;
|
||||
참조?: LaborRef;
|
||||
알약?: string | null;
|
||||
분모?: string | null;
|
||||
배: number;
|
||||
@@ -22,6 +34,21 @@ export interface CalcLine {
|
||||
반영?: Money | null;
|
||||
까닭?: string | null;
|
||||
결과?: unknown;
|
||||
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
|
||||
줄?: LogicLine[];
|
||||
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
|
||||
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */
|
||||
이름?: string | null;
|
||||
원로직?: string | null;
|
||||
안?: {
|
||||
참조?: LaborRef;
|
||||
로직?: string;
|
||||
수량: number;
|
||||
이름?: string | null;
|
||||
단위?: string | null;
|
||||
기준: Money | null;
|
||||
까닭?: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CalcMaterial {
|
||||
@@ -47,7 +74,7 @@ export interface LaborCalc {
|
||||
|
||||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
|
||||
export interface LaborQtyDoc {
|
||||
담은로직?: { 로직: string; 수량?: Qty }[];
|
||||
담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[];
|
||||
자재?: { 코드: string; 수량?: Qty }[];
|
||||
}
|
||||
|
||||
@@ -57,6 +84,12 @@ function num(raw: unknown): number | null {
|
||||
return String(raw).trim() !== "" && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 줄 가름 — 로직 키 · 참조 줄은 「참조|층|주인|키」(짝 `M02_Labor_Doc.line_id`) */
|
||||
export function lineId(one: { 로직?: string; 참조?: LaborRef }): string | undefined {
|
||||
if (one.참조) return `참조|${one.참조.층}|${one.참조.주인 ?? ""}|${one.참조.키}`;
|
||||
return one.로직;
|
||||
}
|
||||
|
||||
function zero(): Money {
|
||||
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
|
||||
}
|
||||
@@ -91,7 +124,7 @@ export function settle(
|
||||
const v = num(raw);
|
||||
if (v !== null) have[k] = v;
|
||||
}
|
||||
const hands = new Map((doc.담은로직 ?? []).map((one) => [one.로직, one.수량 ?? null]));
|
||||
const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null]));
|
||||
const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null]));
|
||||
const base = zero();
|
||||
const done = zero();
|
||||
@@ -99,7 +132,7 @@ export function settle(
|
||||
if (m) for (const k of MONEY_KEYS) sum[k] += m[k];
|
||||
};
|
||||
const 줄 = result.줄.map((line) => {
|
||||
const [qty, source] = pick(hands.get(line.로직), line.알약, have);
|
||||
const [qty, source] = pick(hands.get(lineId(line)), line.알약, have);
|
||||
const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null;
|
||||
add(base, line.기준);
|
||||
add(done, own);
|
||||
|
||||
@@ -2,32 +2,46 @@
|
||||
* M02_MasterTemplete_Labor_Detail.ts
|
||||
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
|
||||
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
|
||||
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) · [옵션] = 입력 모달(`Labor_Input`) ·
|
||||
* · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 그 줄 바로 아래 하위 행(51-2 · `Labor_Rows`) ·
|
||||
* 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · 참조 줄 [옵션] · [수정] = 서고 원본 모달(51-14 · `Labor_RefEdit`) ·
|
||||
* [확인] = 화면 캐시만 · 저장은 [저장]
|
||||
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
||||
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
||||
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
|
||||
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
|
||||
* 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) ·
|
||||
* 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import type { SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import {
|
||||
getActiveLayer,
|
||||
getActiveOwner,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type ItemLogic,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
areaLinks,
|
||||
calcLabor,
|
||||
fetchLibLogicSubs,
|
||||
fetchLogicInputs,
|
||||
normalizeLabor,
|
||||
type LaborCalc,
|
||||
@@ -42,6 +56,7 @@ import {
|
||||
} from "./M02_MasterTemplete_Labor_Diagram";
|
||||
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
|
||||
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
@@ -62,8 +77,10 @@ export interface LaborDetailOptions {
|
||||
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */
|
||||
goArea: GoArea | null;
|
||||
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 ·
|
||||
* 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 자재 추가 · 줄 · 수량 모달은 상세와 같음 */
|
||||
* 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */
|
||||
card?: boolean;
|
||||
/** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */
|
||||
mode?: "promote" | "precombo";
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
@@ -82,6 +99,8 @@ export function mountLaborDetail(
|
||||
const cur = normalizeLabor(doc, opt.column);
|
||||
const saved = JSON.stringify(cur);
|
||||
const edit = !opt.readOnly;
|
||||
const lib = opt.mode ?? null;
|
||||
const promote = lib === "promote";
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let calc: LaborCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
@@ -131,13 +150,13 @@ export function mountLaborDetail(
|
||||
if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root);
|
||||
}
|
||||
|
||||
const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key);
|
||||
const lineOf = (key: string) => calc?.줄.find((l) => lineId(l) === key);
|
||||
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
|
||||
|
||||
const drawDg = (): void => {
|
||||
if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음
|
||||
if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음
|
||||
const data = buildLaborDiagram({
|
||||
rows: cur.담은로직,
|
||||
rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함
|
||||
logics: [...logics.values()],
|
||||
calc,
|
||||
mats: cur.자재.length,
|
||||
@@ -156,23 +175,26 @@ export function mountLaborDetail(
|
||||
};
|
||||
dgBox.addEventListener("toggle", drawDg);
|
||||
|
||||
const draw = (): void => {
|
||||
const drawLogic = (): void =>
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
...(opt.card ? {} : { move }),
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
: { open: pick, option: openRow },
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
),
|
||||
);
|
||||
const draw = (): void => {
|
||||
drawLogic();
|
||||
matHost.replaceChildren(
|
||||
matTable(
|
||||
{ mats: cur.자재, calc, fail, cells: cells() },
|
||||
@@ -261,6 +283,7 @@ export function mountLaborDetail(
|
||||
readOnly: !edit,
|
||||
sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill },
|
||||
goArea: opt.goArea,
|
||||
...(lib ? { handOnly: true } : {}),
|
||||
done: (r) => {
|
||||
if (r.kind === "keep") return;
|
||||
if (r.kind === "hand") target.수량 = { 손: r.손 };
|
||||
@@ -277,7 +300,7 @@ export function mountLaborDetail(
|
||||
|
||||
/** 아직 안 받은 로직 입력 목록만 받음 */
|
||||
const loadLogics = async (): Promise<void> => {
|
||||
const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
|
||||
const want = cur.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k)); // 참조 줄 = 로직 없음
|
||||
if (!want.length) return;
|
||||
try {
|
||||
const got = await fetchLogicInputs(want);
|
||||
@@ -293,15 +316,16 @@ export function mountLaborDetail(
|
||||
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
|
||||
changed();
|
||||
}
|
||||
/** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 원문 패널(47-4) · 화면 캐시 안 바뀜 */
|
||||
/** 줄 고름(47-5) — 하이라이트 + 하위 행(51-2 · 표만 다시 그림 · 다이어그램 안 다시 그림) + 원문 패널(47-4) ·
|
||||
* 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */
|
||||
function pick(i: number): void {
|
||||
picked = cur.담은로직[i].로직;
|
||||
for (const tr of logicHost.querySelectorAll<HTMLElement>("[data-labor-logic],[data-labor-opt]"))
|
||||
tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked);
|
||||
const key = lineId(cur.담은로직[i]) ?? "";
|
||||
picked = picked === key ? null : key;
|
||||
drawLogic();
|
||||
showSource();
|
||||
}
|
||||
function removeRow(i: number): void {
|
||||
if (cur.담은로직[i].로직 === picked) picked = null;
|
||||
if (lineId(cur.담은로직[i]) === picked) picked = null;
|
||||
cur.담은로직.splice(i, 1);
|
||||
changed();
|
||||
}
|
||||
@@ -334,12 +358,25 @@ export function mountLaborDetail(
|
||||
});
|
||||
}
|
||||
|
||||
/** 참조 줄 [옵션] · [수정](51-14) — 서고 원본 모달 · 자기 층 서고만 고침(위 층 = 읽기만 · 서버도 막음) ·
|
||||
* 닫으면 다시 셈(원본이 바뀌었을 수 있음) */
|
||||
function openRef(i: number): void {
|
||||
const ref = cur.담은로직[i].참조;
|
||||
if (!ref) return;
|
||||
void openRefEdit({
|
||||
ref,
|
||||
title: `${ref.키} ${lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? ""}`.trim(),
|
||||
readOnly: !edit || ref.층 !== opt.layer,
|
||||
closed: () => alive && recalc(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 수량산출 칸 누름 — 로직 줄 */
|
||||
function openQty(i: number): void {
|
||||
const row = cur.담은로직[i];
|
||||
const line = lineOf(row.로직);
|
||||
const line = lineOf(lineId(row) ?? "");
|
||||
openQtyFor(
|
||||
`${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(),
|
||||
`${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
|
||||
row,
|
||||
line,
|
||||
line?.분모 ?? "",
|
||||
@@ -352,16 +389,35 @@ export function mountLaborDetail(
|
||||
openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? "");
|
||||
}
|
||||
|
||||
/** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */
|
||||
const library = {
|
||||
layer: opt.layer,
|
||||
owner: opt.layer === getActiveLayer() ? getActiveOwner() : null,
|
||||
pick: (ref: LaborRef, name: string) => {
|
||||
const id = lineId({ 참조: ref });
|
||||
if (cur.담은로직.some((r) => lineId(r) === id))
|
||||
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
|
||||
const 수량 = lib === "precombo" ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
|
||||
cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 });
|
||||
changed();
|
||||
},
|
||||
};
|
||||
const addLogic = createButton({
|
||||
label: tc("AddLogic"),
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) => {
|
||||
if (cur.담은로직.some((r) => r.로직 === x.키))
|
||||
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
||||
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
|
||||
void loadLogics().then(() => changed());
|
||||
}),
|
||||
openLogicPicker(
|
||||
(x) => {
|
||||
if (cur.담은로직.some((r) => r.로직 === x.키))
|
||||
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
||||
// 사전조합 = 면적산출 없음 → 손 1(51-11)
|
||||
const 수량 = lib === "precombo" ? { 손: 1 } : null;
|
||||
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량 });
|
||||
void loadLogics().then(() => changed());
|
||||
},
|
||||
// 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422)
|
||||
library,
|
||||
),
|
||||
});
|
||||
const addMat = createButton({
|
||||
label: tc("Labor_AddMat"),
|
||||
@@ -415,6 +471,77 @@ export function mountLaborDetail(
|
||||
],
|
||||
});
|
||||
|
||||
// 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름)
|
||||
const nameField = createInputField({ type: "text", label: tc("Lib_Name") });
|
||||
nameField.root.dataset.laborField = "이름";
|
||||
nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? "");
|
||||
nameField.input.readOnly = !edit;
|
||||
nameField.input.addEventListener("input", () => {
|
||||
(cur as { 이름?: string }).이름 = nameField.input.value.trim();
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
|
||||
/** 서고 구분 · 상세구분(51-12) — 승격 = 원 로직 것 읽기만 + 연결(원 로직 키 · 출처) ·
|
||||
* 사전조합 = 로직 구분 목록(logic-lib/subs)에서 고름(구분 바꾸면 상세구분 = 그 첫 줄) */
|
||||
const meta = cur as { 구분?: string | null; 상세구분?: string | null; 연결?: unknown };
|
||||
const readField = (label: string, field: string, value: string): HTMLElement => {
|
||||
const f = createInputField({ type: "text", label });
|
||||
f.root.dataset.laborField = field;
|
||||
f.input.value = value;
|
||||
f.input.readOnly = true;
|
||||
return f.root;
|
||||
};
|
||||
const libMeta = (): HTMLElement[] => {
|
||||
if (promote) {
|
||||
const link = (meta.연결 ?? {}) as { 로직?: string; 출처?: string };
|
||||
return [
|
||||
readField(tc("Sub"), "구분", meta.구분 ?? ""),
|
||||
readField(tc("Detail"), "상세구분", meta.상세구분 ?? ""),
|
||||
readField(tc("Lib_Link"), "연결", [link.로직, link.출처].filter(Boolean).join(" · ")),
|
||||
];
|
||||
}
|
||||
const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit });
|
||||
const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit });
|
||||
sub.root.dataset.laborField = "구분";
|
||||
detail.root.dataset.laborField = "상세구분";
|
||||
let subs: SubBrief[] = [];
|
||||
// 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠
|
||||
const opts = (names: string[], now: string | null | undefined) =>
|
||||
[...new Set([now ?? "", ...names])]
|
||||
.filter((v, i) => v || i === 0)
|
||||
.map((v) => ({ value: v, text: v || "—" }));
|
||||
const fill = (): void => {
|
||||
sub.setOptions(
|
||||
opts(
|
||||
subs.map((s) => s.name),
|
||||
meta.구분,
|
||||
),
|
||||
meta.구분 ?? "",
|
||||
);
|
||||
const details = subs.find((s) => s.name === meta.구분)?.details ?? [];
|
||||
detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? "");
|
||||
};
|
||||
sub.select.addEventListener("change", () => {
|
||||
meta.구분 = sub.select.value;
|
||||
meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null;
|
||||
fill();
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
detail.select.addEventListener("change", () => {
|
||||
meta.상세구분 = detail.select.value;
|
||||
opt.onChanged(dirty());
|
||||
});
|
||||
fill();
|
||||
void fetchLibLogicSubs().then(
|
||||
(got) => {
|
||||
subs = got;
|
||||
fill();
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return [sub.root, detail.root];
|
||||
};
|
||||
|
||||
const editor = (body: HTMLElement): HTMLElement =>
|
||||
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
|
||||
host.replaceChildren(
|
||||
@@ -429,13 +556,21 @@ export function mountLaborDetail(
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", { className: "m01lab__info", children: [unit.root, origin.root] }),
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
...(lib ? [nameField.root, ...libMeta()] : []),
|
||||
unit.root,
|
||||
origin.root,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
|
||||
section(tc("Labor_Mats"), addMat, matHost),
|
||||
...(opt.card ? [] : [dgBox]),
|
||||
// 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = 같은 [로직 추가](51-11)
|
||||
section(tc("Logics_Title"), opt.card || promote ? null : addLogic, logicHost),
|
||||
...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]),
|
||||
...(opt.card || lib ? [] : [dgBox]),
|
||||
],
|
||||
}),
|
||||
),
|
||||
@@ -444,19 +579,28 @@ export function mountLaborDetail(
|
||||
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
|
||||
void Promise.all([
|
||||
loadLogics(),
|
||||
readTemplate(opt.layer, "area", opt.column, null).then(
|
||||
(got) => {
|
||||
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
|
||||
links = areaLinks(got.문서);
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
lib // 서고 모드엔 면적산출 없음
|
||||
? undefined
|
||||
: readTemplate(opt.layer, "area", opt.column, null).then(
|
||||
(got) => {
|
||||
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
|
||||
links = areaLinks(got.문서);
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
]).then(() => {
|
||||
if (alive) recalc();
|
||||
});
|
||||
|
||||
return {
|
||||
getDoc: () => cur,
|
||||
// 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음
|
||||
getDoc: () => {
|
||||
if (!lib) return cur;
|
||||
const out: Record<string, unknown> = { ...cur, 종류: promote ? "단가승격" : "사전조합" };
|
||||
delete out.열;
|
||||
delete out.자재;
|
||||
return out as unknown as LaborDoc;
|
||||
},
|
||||
saveArea: async () => {
|
||||
if (!area || !areaDirty) return false;
|
||||
const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc);
|
||||
|
||||
@@ -19,14 +19,19 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
|
||||
/** 면적산출 상세로 감 — 옮겨 갔으면 참(물음에서 취소 = 거짓) */
|
||||
export type GoArea = () => Promise<boolean>;
|
||||
|
||||
function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement {
|
||||
function footer(
|
||||
readOnly: boolean,
|
||||
ok: () => void,
|
||||
close: () => void,
|
||||
okLabel = tc("In_Ok"),
|
||||
): HTMLElement {
|
||||
return el("div", {
|
||||
className: "m02-labin__foot",
|
||||
children: readOnly
|
||||
? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })]
|
||||
: [
|
||||
createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }),
|
||||
createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }),
|
||||
createButton({ label: okLabel, variant: "filled", onClick: ok }),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -45,6 +50,10 @@ export interface LaborInputOptions {
|
||||
memo: string | null;
|
||||
readOnly: boolean;
|
||||
done: (next: LaborInputResult) => void;
|
||||
/** 머리 한 줄 알림(51-14 · 서고 원본 고침) */
|
||||
notice?: string;
|
||||
/** 확인 단추 글(기본 「확인」) */
|
||||
okLabel?: string;
|
||||
}
|
||||
|
||||
/** 담은 로직 줄 입력 모달 */
|
||||
@@ -100,6 +109,15 @@ export function openLaborInput(opt: LaborInputOptions): void {
|
||||
el("div", {
|
||||
className: "m02-inputval",
|
||||
children: [
|
||||
...(opt.notice
|
||||
? [
|
||||
el("p", {
|
||||
className: "m02-labin__muted",
|
||||
text: opt.notice,
|
||||
attrs: { "data-labor-notice": "" },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(lost.length
|
||||
? [
|
||||
el("p", {
|
||||
@@ -120,7 +138,7 @@ export function openLaborInput(opt: LaborInputOptions): void {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
footer(opt.readOnly, ok, close),
|
||||
footer(opt.readOnly, ok, close, opt.okLabel),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Pick.ts
|
||||
* [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음)
|
||||
* [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) ·
|
||||
* 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} ·
|
||||
* 프로젝트 · 카드엔 서고 줄 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
fetchLibLogics as fetchLogics,
|
||||
fetchLibLogicSubs as fetchLogicSubs,
|
||||
fetchLibrary,
|
||||
type LibraryRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { ownOwner } from "./M02_MasterTemplete_Layer";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -23,58 +26,181 @@ import "./M02_MasterTemplete_Labor.css";
|
||||
const opts = (values: string[]): { value: string; text: string }[] =>
|
||||
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v }));
|
||||
|
||||
export function openLogicPicker(done: (logic: LogicSummary) => void): void {
|
||||
type Scope = Omit<LaborRef, "키">;
|
||||
/** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */
|
||||
export interface LibraryPick {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
pick: (ref: LaborRef, name: string) => void;
|
||||
}
|
||||
|
||||
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
|
||||
async function upper(layer: Layer, owner: string | null): Promise<Scope[]> {
|
||||
if (layer === "project") return [];
|
||||
const own =
|
||||
layer === "system"
|
||||
? null
|
||||
: (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null)));
|
||||
const out: Scope[] = [{ 층: "system", 주인: null }];
|
||||
if (own) out.push({ 층: "company", 주인: own.split("/")[0] });
|
||||
if (own && layer === "personal") out.push({ 층: "personal", 주인: own });
|
||||
return out;
|
||||
}
|
||||
const LAYER_TEXT = {
|
||||
system: "Lib_System",
|
||||
company: "Lib_Company",
|
||||
personal: "Lib_Personal",
|
||||
} as const;
|
||||
|
||||
export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void {
|
||||
const subs: SubBrief[] = [];
|
||||
let all: LogicSummary[] = [];
|
||||
const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") });
|
||||
const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") });
|
||||
detail.select.disabled = true;
|
||||
const searchField = createInputField({ type: "search", placeholder: tc("Pick_Search") });
|
||||
const list = el("div", { className: "m01-logic__pick-list" });
|
||||
const hint = el("p", { className: "m01-logic__muted", text: tc("Pick_Hint") });
|
||||
const close = (): void => backdrop.remove();
|
||||
|
||||
/* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */
|
||||
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
|
||||
let scopes: Scope[] = [];
|
||||
let scope: Scope | null = null;
|
||||
let libRows: { kind: "Kind_Promote" | "Kind_Combo"; row: LibraryRow }[] = [];
|
||||
let loading = false;
|
||||
let seq = 0;
|
||||
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
|
||||
const finder = createFindList({
|
||||
placeholder: tc("Pick_Search"),
|
||||
filters: [sub.root, detail.root],
|
||||
extra: [layerBar],
|
||||
});
|
||||
|
||||
const find = (): void => {
|
||||
const q = searchField.input.value.trim().toLowerCase();
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
const found = all.filter(
|
||||
(x) =>
|
||||
(!sub.select.value || x.구분 === sub.select.value) &&
|
||||
(!detail.select.value || x.상세구분 === detail.select.value) &&
|
||||
(!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)),
|
||||
);
|
||||
hint.textContent = found.length ? `${found.length} / ${all.length}` : tc("Pick_None");
|
||||
list.replaceChildren(
|
||||
...found.slice(0, 100).map((x) => {
|
||||
const row = el("button", {
|
||||
className: "m01-logic__pick-row m01-logic__pick-row--button",
|
||||
attrs: { type: "button", "data-pick": x.키 },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__stack",
|
||||
children: [
|
||||
el("strong", { text: x.이름 }),
|
||||
el("span", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${x.키} · ${x.구분} ${x.상세구분}`.trim(),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("span", { text: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}` }),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
const libFound = libRows.filter(
|
||||
(x) =>
|
||||
(!sub.select.value || x.row.구분 === sub.select.value) &&
|
||||
(!detail.select.value || x.row.상세구분 === detail.select.value) &&
|
||||
(!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)),
|
||||
);
|
||||
const total = all.length + libRows.length;
|
||||
const shown = found.length + libFound.length;
|
||||
finder.hint.textContent = loading
|
||||
? tc("Loading")
|
||||
: shown
|
||||
? `${shown} / ${total}`
|
||||
: tc("Pick_None");
|
||||
finder.setRows([
|
||||
...found.slice(0, 100).map((x) => ({
|
||||
key: x.키,
|
||||
tag: tc("Kind_Logic"),
|
||||
name: x.이름,
|
||||
sub: `${x.키} · ${x.구분} ${x.상세구분}`.trim(),
|
||||
unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`,
|
||||
onPick: () => {
|
||||
close();
|
||||
done(x);
|
||||
});
|
||||
return row;
|
||||
},
|
||||
})),
|
||||
...libFound.map((x) => {
|
||||
const at = scope as Scope;
|
||||
return {
|
||||
key: x.row.이름,
|
||||
tag: tc(x.kind),
|
||||
name: x.row.제목 || x.row.이름,
|
||||
sub: [x.row.이름, `${x.row.구분 ?? ""} ${x.row.상세구분 ?? ""}`.trim()]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
unit: x.row.단위 ?? "",
|
||||
onPick: () => {
|
||||
close();
|
||||
lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름);
|
||||
},
|
||||
};
|
||||
}),
|
||||
]);
|
||||
};
|
||||
const loadLib = async (): Promise<void> => {
|
||||
if (!lib || !scope) return;
|
||||
const mine = ++seq;
|
||||
const at = scope;
|
||||
loading = true;
|
||||
find();
|
||||
try {
|
||||
const [promote, combo] = await Promise.all([
|
||||
fetchLibrary(at, "promote"),
|
||||
fetchLibrary(at, "precombo"),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
libRows = [
|
||||
...promote.map((row) => ({ kind: "Kind_Promote" as const, row })),
|
||||
...combo.map((row) => ({ kind: "Kind_Combo" as const, row })),
|
||||
];
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
libRows = [];
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
|
||||
}
|
||||
loading = false;
|
||||
drawSubs();
|
||||
find();
|
||||
};
|
||||
const drawLayers = (): void =>
|
||||
layerBar.replaceChildren(
|
||||
...scopes.map((x) =>
|
||||
createButton({
|
||||
label: tc(LAYER_TEXT[x.층 as keyof typeof LAYER_TEXT]),
|
||||
variant: x === scope ? "filled" : "ghost",
|
||||
onClick: () => {
|
||||
scope = x;
|
||||
drawLayers();
|
||||
void loadLib();
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
/** 구분 → 상세구분 — 로직 구분에 서고 줄(승격 · 조합) 것을 더함 · 같은 값은 한 칸 */
|
||||
const subMap = (): Map<string, Set<string>> => {
|
||||
const m = new Map(subs.map((s) => [s.name, new Set(s.details)]));
|
||||
for (const { row } of libRows) {
|
||||
if (!row.구분) continue;
|
||||
if (!m.has(row.구분)) m.set(row.구분, new Set());
|
||||
if (row.상세구분) m.get(row.구분)!.add(row.상세구분);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const drawDetails = (keep: string): void => {
|
||||
const d = [...(subMap().get(sub.select.value) ?? [])];
|
||||
detail.setOptions(opts(d), d.includes(keep) ? keep : "");
|
||||
detail.select.disabled = d.length === 0;
|
||||
};
|
||||
const drawSubs = (): void => {
|
||||
const keep = detail.select.value;
|
||||
sub.setOptions(opts([...subMap().keys()]), sub.select.value);
|
||||
drawDetails(keep);
|
||||
};
|
||||
sub.select.addEventListener("change", () => {
|
||||
const d = subs.find((s) => s.name === sub.select.value)?.details ?? [];
|
||||
detail.setOptions(opts(d), "");
|
||||
detail.select.disabled = d.length === 0;
|
||||
drawDetails("");
|
||||
find();
|
||||
});
|
||||
detail.select.addEventListener("change", find);
|
||||
finder.search.addEventListener("input", find);
|
||||
|
||||
layerBar.style.display = "none";
|
||||
if (lib)
|
||||
void upper(lib.layer, lib.owner).then((got) => {
|
||||
scopes = got;
|
||||
scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층
|
||||
if (!got.length) return;
|
||||
layerBar.style.display = "";
|
||||
drawLayers();
|
||||
void loadLib();
|
||||
});
|
||||
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick m01-combo",
|
||||
@@ -87,31 +213,21 @@ export function openLogicPicker(done: (logic: LogicSummary) => void): void {
|
||||
createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-logic__filters",
|
||||
children: [
|
||||
sub.root,
|
||||
detail.root,
|
||||
searchField.root,
|
||||
createButton({ label: tc("Pick_Find"), variant: "filled", onClick: find }),
|
||||
],
|
||||
}),
|
||||
hint,
|
||||
list,
|
||||
finder.root,
|
||||
],
|
||||
});
|
||||
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
|
||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
document.body.append(backdrop);
|
||||
searchField.input.addEventListener("keydown", (ev) => ev.key === "Enter" && find());
|
||||
searchField.input.focus();
|
||||
finder.search.focus();
|
||||
|
||||
void Promise.all([fetchLogicSubs(), fetchLogics()])
|
||||
.then(([s, l]) => {
|
||||
subs.push(...s);
|
||||
all = l;
|
||||
sub.setOptions(opts(s.map((x) => x.name)), "");
|
||||
drawSubs();
|
||||
find();
|
||||
})
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface QtyModalOptions {
|
||||
sheet: { doc: Workbook | null; layer: Layer; column: string; pill: string | null };
|
||||
/** [면적산출 상세로] — 없으면 단추 없음 */
|
||||
goArea: GoArea | null;
|
||||
/** 손 입력만(서고 사전조합 줄 · 51-6) — 면적산출 시트 없음 */
|
||||
handOnly?: boolean;
|
||||
done: (result: QtyResult) => void;
|
||||
}
|
||||
|
||||
@@ -104,7 +106,9 @@ export function openQtyModal(opt: QtyModalOptions): void {
|
||||
className: "m02-cellpick__sheet",
|
||||
attrs: { "data-labor-qty-sheet": "" },
|
||||
});
|
||||
if (opt.sheet.pill) {
|
||||
if (opt.handOnly) {
|
||||
// 시트 없음
|
||||
} else if (opt.sheet.pill) {
|
||||
sheet = mountAreaSheet(host, {
|
||||
doc: opt.sheet.doc,
|
||||
layer: opt.sheet.layer,
|
||||
@@ -153,8 +157,9 @@ export function openQtyModal(opt: QtyModalOptions): void {
|
||||
],
|
||||
}),
|
||||
state,
|
||||
el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }),
|
||||
host,
|
||||
...(opt.handOnly
|
||||
? []
|
||||
: [el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }), host]),
|
||||
el("div", {
|
||||
className: "m02-labin__foot",
|
||||
children: opt.readOnly
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_RefEdit.ts
|
||||
* 일위대가 참조 줄 모달(51-14) — 서고 원본(50-3)을 그 자리에서 고침 · [저장] = 서고 저장 길(`saveTemplate`) ·
|
||||
* 그 문서를 쓰는 모든 구조물에 따라감(머리 한 줄 알림) · 그 층을 못 고치면 읽기만.
|
||||
* · 단가승격 [옵션] = 승격 문서 로직 한 줄의 넣을 값 모달(`Labor_Input`)
|
||||
* · 일위조합 [수정] = 사전조합 상세(서고 모드 표 · 안 줄 [옵션] · 수량 · 위로 · 아래로 · 삭제 · 추가)
|
||||
* 닫으면(저장 · 취소 모두) `closed` — 부르는 쪽이 구조물 일위대가 표를 다시 셈.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { readTemplate, saveTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchLogicInputs, type LaborRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { keepKnown } from "./M02_Labor_Values_Model";
|
||||
|
||||
export interface RefEditOptions {
|
||||
ref: LaborRef;
|
||||
/** 「SP-000001 메쌓기」 */
|
||||
title: string;
|
||||
readOnly: boolean;
|
||||
closed: () => void;
|
||||
}
|
||||
|
||||
const fail = (error: unknown, key: "Load_Failed" | "Lib_SaveFailed"): void =>
|
||||
showToast(error instanceof Error ? error.message : tc(key), "error");
|
||||
|
||||
export async function openRefEdit(opt: RefEditOptions): Promise<void> {
|
||||
const kind = opt.ref.키.startsWith("SP-") ? "promote" : "precombo";
|
||||
const layer = opt.ref.층 as Layer;
|
||||
let doc: { 담은로직?: LaborRow[] };
|
||||
let 판: string;
|
||||
try {
|
||||
const got = await readTemplate(layer, kind, opt.ref.키, null);
|
||||
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(() =>
|
||||
showToast(tc("Saved"), "success"),
|
||||
);
|
||||
const notice = opt.readOnly ? `${tc("Ref_Shared")} · ${tc("Ref_ReadOnly")}` : tc("Ref_Shared");
|
||||
|
||||
if (kind === "promote") {
|
||||
const row = doc.담은로직?.[0];
|
||||
if (!row) return fail(null, "Load_Failed");
|
||||
const logic = row.로직
|
||||
? ((await fetchLogicInputs([row.로직]).catch(() => null))?.로직[0] ?? null)
|
||||
: null;
|
||||
return openLaborInput({
|
||||
title: opt.title,
|
||||
logic,
|
||||
values: row.입력값,
|
||||
memo: row.메모,
|
||||
readOnly: opt.readOnly,
|
||||
notice,
|
||||
okLabel: tc("Save"),
|
||||
done: (next) => {
|
||||
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
||||
row.메모 = next.메모;
|
||||
void save(doc)
|
||||
.catch((error) => fail(error, "Lib_SaveFailed"))
|
||||
.finally(opt.closed);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 사전조합 — 상세와 같은 부품(서로 부르므로 늦게 받음)
|
||||
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
|
||||
let dirty = false;
|
||||
let handle: ReturnType<typeof mountLaborDetail> | null = null;
|
||||
const finish = (): void => {
|
||||
handle?.destroy();
|
||||
opt.closed();
|
||||
};
|
||||
openModal({
|
||||
title: opt.title,
|
||||
closeLabel: tc("In_Close"),
|
||||
dialogClass: "m02-modal m02-labref",
|
||||
beforeClose: async () => {
|
||||
if (dirty && !(await showConfirmDialog(tc("Ref_Discard")))) return false;
|
||||
finish();
|
||||
return true;
|
||||
},
|
||||
mount: (body, close) => {
|
||||
const host = el("div", { attrs: { "data-labor-ref": opt.ref.키 } });
|
||||
handle = mountLaborDetail(host, doc, {
|
||||
layer,
|
||||
column: opt.ref.키,
|
||||
readOnly: opt.readOnly,
|
||||
onChanged: (d) => (dirty = d && !opt.readOnly),
|
||||
goArea: null,
|
||||
mode: "precombo",
|
||||
});
|
||||
const cancel = createButton({
|
||||
label: tc(opt.readOnly ? "In_Close" : "In_Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: async () => {
|
||||
if (dirty && !(await showConfirmDialog(tc("Ref_Discard")))) return;
|
||||
close();
|
||||
finish();
|
||||
},
|
||||
});
|
||||
const ok = createButton({
|
||||
label: tc("Save"),
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
void save(handle?.getDoc()).then(
|
||||
() => {
|
||||
close();
|
||||
finish();
|
||||
},
|
||||
(error) => fail(error, "Lib_SaveFailed"),
|
||||
),
|
||||
});
|
||||
body.append(
|
||||
el("p", {
|
||||
className: "m02-labin__muted",
|
||||
text: notice,
|
||||
attrs: { "data-labor-notice": "" },
|
||||
}),
|
||||
host,
|
||||
el("div", {
|
||||
className: "m02-labin__foot",
|
||||
children: opt.readOnly ? [cancel] : [cancel, ok],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Rows.ts
|
||||
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
|
||||
* 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
|
||||
* 담은 로직 = 차례 · 구분(키 머리에서 읽음 · 50-4) · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
|
||||
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
|
||||
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조).
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
|
||||
* 금액은 calc 답(없으면 「…」).
|
||||
* 줄 누름 · 줄 단추(위로 · 아래로 · 옵션 · 삭제 · 묶음 `ui-btn-group`)는 `on` 이 있을 때만(보기 갈래 = 없음) ·
|
||||
* 상세는 줄 누름 = 고름(`selected` 줄 하이라이트) · 넣을 값 모달은 [옵션](47-5).
|
||||
* 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] ·
|
||||
* 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 단가승격 줄 = [옵션] · 일위조합 줄 = [수정](`ref` · 서고 원본) ·
|
||||
* 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
LaborMat,
|
||||
LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -34,8 +36,16 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
|
||||
계: "Prev_Sum",
|
||||
};
|
||||
|
||||
/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
|
||||
const SPAN = 4 + COSTS.length;
|
||||
/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
|
||||
const SPAN = 5 + COSTS.length;
|
||||
|
||||
/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */
|
||||
export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
|
||||
const key = row.참조?.키 ?? row.로직;
|
||||
return tc(
|
||||
key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic",
|
||||
);
|
||||
};
|
||||
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
@@ -165,6 +175,8 @@ export interface RowHandlers {
|
||||
open: (i: number) => void;
|
||||
/** [옵션] 단추(47-5 · 넣을 값 모달) — 없으면 단추 없음 */
|
||||
option?: (i: number) => void;
|
||||
/** 참조 줄 단추(51-14) — 단가승격 [옵션] · 일위조합 [수정] = 서고 원본 모달 · 없으면 단추 없음 */
|
||||
ref?: (i: number) => void;
|
||||
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
|
||||
qty?: (i: number) => void;
|
||||
move?: (i: number, d: number) => void;
|
||||
@@ -219,18 +231,81 @@ export interface LogicTableData {
|
||||
selected?: string | null;
|
||||
}
|
||||
|
||||
/** 고른 줄 하위 행(51-2 · 같은 표 · 같은 칸) — 단가산출 · 단가승격 = 그 로직 재료 · 노무 · 경비 줄 ·
|
||||
* 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) ·
|
||||
* 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) ·
|
||||
* 하위 합 = 위 줄 금액 · 줄이 없으면 빈 한 줄(「…」 · 까닭 · 「—」) */
|
||||
function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean): HTMLElement[] {
|
||||
const times = line?.수량 === null || line?.수량 === undefined ? null : line.수량 / (line.배 || 1);
|
||||
const items = line?.안
|
||||
? line.안.map((x) => {
|
||||
const key = x.참조?.키 ?? x.로직 ?? "";
|
||||
const label = x.이름 && x.이름 !== key ? `${key} ${x.이름}` : key; // 로직 줄 이름 없으면 키만
|
||||
return {
|
||||
name: `${label}${x.까닭 ? ` · ${x.까닭}` : ""}`,
|
||||
qty: x.수량,
|
||||
unit: x.단위 ?? "",
|
||||
cost: x.기준 ? Object.fromEntries(COSTS.map((c) => [c, x.기준![c] * x.수량])) : null,
|
||||
};
|
||||
})
|
||||
: (line?.줄 ?? []).map((x) => ({
|
||||
name: x.출처 ? `${x.이름} (${x.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : x.이름,
|
||||
qty: x.수량,
|
||||
unit: x.단위,
|
||||
cost: { ...x.비목, 계: x.금액 } as Record<string, number>,
|
||||
}));
|
||||
const blank = (className: string): HTMLElement => el("td", { className, text: "" });
|
||||
const none = wait ? "…" : (line?.까닭 ?? "—");
|
||||
return (items.length ? items : [null]).map((x) =>
|
||||
el("tr", {
|
||||
className: "m02-labrow__sub",
|
||||
attrs: { "data-labor-sub": "" },
|
||||
children: [
|
||||
blank("m02-labrow__no"),
|
||||
blank("m02-labrow__kind"),
|
||||
el("td", {
|
||||
className: "m02-labrow__name m02-labrow__subname",
|
||||
text: x?.name ?? none,
|
||||
attrs: { title: x?.name ?? none },
|
||||
}),
|
||||
blank("m02-labrow__alias"),
|
||||
el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
text: x ? num(x.qty) : "",
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__unit", text: x?.unit ?? "" }),
|
||||
...COSTS.map((c) => {
|
||||
const base = x?.cost ? Number(x.cost[c] ?? 0) : null;
|
||||
return moneyCell(
|
||||
base,
|
||||
base === null || times === null ? null : base * times,
|
||||
wait,
|
||||
tc(HEAD[c]),
|
||||
);
|
||||
}),
|
||||
...(tools ? [blank("")] : []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 담은 로직 표 — 보기 갈래는 `on` 없이 */
|
||||
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const { move, remove, option } = on ?? {};
|
||||
const { move, remove, option, ref } = on ?? {};
|
||||
// 옵션 · 삭제만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만
|
||||
const tools = !!(move || remove || option);
|
||||
const tools = !!(move || remove || option || ref);
|
||||
const group = (children: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "ui-btn-group", children });
|
||||
const lineOf = (key: string): LaborCalcLine | undefined =>
|
||||
data.calc?.줄.find((l) => l.로직 === key);
|
||||
data.calc?.줄.find((l) => lineId(l) === key);
|
||||
const trs = data.rows.flatMap((row, i) => {
|
||||
const id = lineId(row) ?? "";
|
||||
const logic = data.logics.get(row.로직);
|
||||
const line = lineOf(row.로직);
|
||||
const unit = logic?.결과단위 ?? "";
|
||||
const line = lineOf(id);
|
||||
// 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정)
|
||||
const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? "");
|
||||
const values = valueLine(logic?.입력 ?? [], row.입력값);
|
||||
const note = values.text;
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
@@ -247,18 +322,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
run();
|
||||
},
|
||||
});
|
||||
const sel = data.selected === row.로직 ? " is-selected" : "";
|
||||
const sel = data.selected === id ? " is-selected" : "";
|
||||
const tr = el("tr", {
|
||||
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
|
||||
attrs: { "data-labor-logic": row.로직 },
|
||||
attrs: { "data-labor-logic": id },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
|
||||
el("td", { className: "m02-labrow__kind", text: kindOf(row) }),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("span", { className: "m02-labrow__key", text: row.로직 }),
|
||||
el("strong", { text: logic?.이름 ?? row.로직 }),
|
||||
el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }),
|
||||
el("strong", {
|
||||
text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직,
|
||||
}),
|
||||
...(values.empty
|
||||
? [
|
||||
el("small", {
|
||||
@@ -289,13 +367,31 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-btn-group",
|
||||
className: "m02-labrow__tools",
|
||||
children: [
|
||||
...(move
|
||||
? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))]
|
||||
? [
|
||||
group([
|
||||
small(tc("Up"), () => move(i, -1)),
|
||||
small(tc("Down"), () => move(i, 1)),
|
||||
]),
|
||||
]
|
||||
: []),
|
||||
...(option ? [small(tc("Labor_Option"), () => option(i))] : []),
|
||||
...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []),
|
||||
group([
|
||||
...(row.참조
|
||||
? ref
|
||||
? [
|
||||
small(
|
||||
tc(row.참조.키.startsWith("SP-") ? "Labor_Option" : "Labor_Edit"),
|
||||
() => ref(i),
|
||||
),
|
||||
]
|
||||
: []
|
||||
: option
|
||||
? [small(tc("Labor_Option"), () => option(i))]
|
||||
: []),
|
||||
...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -305,11 +401,12 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
if (!note) return [tr];
|
||||
const subs = sel ? subRows(line, wait, tools) : [];
|
||||
if (!note) return [tr, ...subs];
|
||||
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
|
||||
const opt = el("tr", {
|
||||
className: (on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow") + sel,
|
||||
attrs: { "data-labor-opt": row.로직 },
|
||||
attrs: { "data-labor-opt": id },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: "" }),
|
||||
el("td", {
|
||||
@@ -321,7 +418,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
],
|
||||
});
|
||||
if (on) opt.addEventListener("click", () => on.open(i));
|
||||
return [tr, opt];
|
||||
return [tr, opt, ...subs];
|
||||
});
|
||||
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
|
||||
part?.[c] ?? null;
|
||||
@@ -331,6 +428,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
className: "m02-labrow__sum",
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: "" }),
|
||||
el("td", { className: "m02-labrow__kind", text: "" }),
|
||||
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
|
||||
el("td", { className: "m02-labrow__alias", text: "" }),
|
||||
el("td", { className: "m02-labrow__qty", text: "" }),
|
||||
@@ -352,6 +450,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
: [];
|
||||
const heads = [
|
||||
tc("Col_Order"),
|
||||
tc("Sub"),
|
||||
tc("Col_Name"),
|
||||
tc("Col_Alias"),
|
||||
tc("Labor_QtyCol"),
|
||||
|
||||
@@ -35,6 +35,14 @@ const TEXT = {
|
||||
Down: ["아래로", "Down"],
|
||||
Remove: ["삭제", "Delete"],
|
||||
Labor_Option: ["옵션", "Options"],
|
||||
Labor_Edit: ["수정", "Edit"],
|
||||
Ref_Shared: [
|
||||
"서고 원본을 고침 — 이 문서를 쓰는 모든 구조물에 따라감",
|
||||
"Edits the library original — every structure using it follows",
|
||||
],
|
||||
Ref_ReadOnly: ["이 층은 고칠 수 없음 — 읽기만", "Read-only layer"],
|
||||
Lib_SaveFailed: ["저장 못 함", "Save failed"],
|
||||
Ref_Discard: ["저장 안 한 고침을 버리고 닫음?", "Discard unsaved edits?"],
|
||||
Labor_Source: ["원문", "Source"],
|
||||
Labor_SourceHint: [
|
||||
"단가산출 로직 줄을 고르면 그 원문이 보임",
|
||||
@@ -45,11 +53,6 @@ const TEXT = {
|
||||
Open: ["열기", "Open"],
|
||||
Pick_Title: ["로직 고르기", "Pick a logic"],
|
||||
Pick_Search: ["이름·키로 찾기", "Search by name or key"],
|
||||
Pick_Find: ["찾기", "Find"],
|
||||
Pick_Hint: [
|
||||
"구분 → 상세구분을 고르고 [찾기]를 누름 — 하나씩 직접 고름",
|
||||
"Choose category, detail, then Find — pick one by one",
|
||||
],
|
||||
Pick_None: ["찾은 로직 없음", "No logics found"],
|
||||
Pick_Close: ["닫기", "Close"],
|
||||
Pick_Blocked: ["막힘", "Blocked"],
|
||||
@@ -84,6 +87,19 @@ const TEXT = {
|
||||
Labor_BasePrice: ["기준 단가", "Base price"],
|
||||
Labor_AppliedSum: ["반영 금액", "Applied amount"],
|
||||
Labor_Calc_Fail: ["계산 못 함", "Calculation failed"],
|
||||
// 구분(50-4)
|
||||
Kind_Logic: ["단가산출", "Logic"],
|
||||
Kind_Promote: ["단가승격", "Promoted"],
|
||||
Kind_Combo: ["일위조합", "Combination"],
|
||||
Labor_LineDetail: ["상세 — {value}", "Detail — {value}"],
|
||||
Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"],
|
||||
Labor_LineBase: ["기준", "Base"],
|
||||
Labor_FromLogic: ["원 로직", "Source logic"],
|
||||
Lib_Precombo: ["사전조합", "Pre-combination"],
|
||||
Lib_System: ["마스터", "Master"],
|
||||
Lib_Company: ["회사", "Company"],
|
||||
Lib_Personal: ["개인", "Personal"],
|
||||
Labor_AlreadyRef: ["이미 담은 일위대가 조합 — {value}", "Already added — {value}"],
|
||||
Labor_Empty: ["빈 값 {n}", "{n} empty"],
|
||||
Labor_Lost: [
|
||||
"로직 입력이 바뀌어 저장 때 빠지는 값 — {value}",
|
||||
@@ -105,6 +121,19 @@ const TEXT = {
|
||||
In_Ok: ["확인", "OK"],
|
||||
In_Cancel: ["취소", "Cancel"],
|
||||
In_Close: ["닫기", "Close"],
|
||||
Lib_Name: ["이름", "Name"],
|
||||
// 서고 목록표(51-4)
|
||||
Lib_Promote: ["단가산출 승격", "Promoted logic"],
|
||||
Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"],
|
||||
Lib_Create: ["생성", "Create"],
|
||||
Lib_Copy: ["복사", "Copy"],
|
||||
Lib_Delete: ["삭제", "Delete"],
|
||||
Lib_Key: ["키", "Key"],
|
||||
Lib_Unit: ["단위", "Unit"],
|
||||
Lib_Base: ["기준 금액", "Base amount"],
|
||||
Lib_Date: ["고친 날", "Modified"],
|
||||
// 서고 구분 · 연결(51-12)
|
||||
Lib_Link: ["연결", "Linked logic"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type LaborCalc,
|
||||
type LaborDoc,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -64,12 +64,13 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
|
||||
onRow ? () => onRow(row) : undefined;
|
||||
const logicOpen = (i: number): void => {
|
||||
const row = d.담은로직[i];
|
||||
const line = calc?.줄.find((l) => l.로직 === row.로직);
|
||||
const id = lineId(row) ?? "";
|
||||
const line = calc?.줄.find((l) => lineId(l) === id);
|
||||
pick({
|
||||
kind: "logic",
|
||||
key: row.로직,
|
||||
key: id,
|
||||
pill: line?.알약 ?? null,
|
||||
name: logics.get(row.로직)?.이름 ?? row.로직,
|
||||
name: (row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? row.참조?.키 ?? row.로직,
|
||||
unit: line?.분모 ?? "",
|
||||
})?.();
|
||||
};
|
||||
@@ -111,7 +112,7 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
|
||||
const got = await readTemplate(layer, "labor", column, null);
|
||||
if (mine !== seq) return;
|
||||
doc = normalizeLabor(got.문서, column);
|
||||
const want = doc.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
|
||||
const want = doc.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k));
|
||||
const [inputs, area] = await Promise.all([
|
||||
fetchLogicInputs(want),
|
||||
readTemplate(layer, "area", column, null).then(
|
||||
|
||||
@@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A
|
||||
export type ManageLayer = "system" | "company" | "personal";
|
||||
/** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */
|
||||
export type Root = "master" | "company" | "personal";
|
||||
export type OpenTarget = "구조물조합" | "옵션" | "도면";
|
||||
export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합";
|
||||
|
||||
export interface Entry {
|
||||
root: Root;
|
||||
@@ -27,7 +27,7 @@ export interface Entry {
|
||||
company: string | null;
|
||||
}
|
||||
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면"];
|
||||
const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"];
|
||||
|
||||
export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root);
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Library_List.ts
|
||||
* 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸.
|
||||
* 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세.
|
||||
* 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로).
|
||||
* 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
||||
import {
|
||||
deleteLayerTemplate,
|
||||
deleteTemplate,
|
||||
getActiveOwner,
|
||||
listTemplates,
|
||||
newLibrary,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type Layer,
|
||||
type LibraryBody,
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchLibLogicSubs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
|
||||
/** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */
|
||||
type Row = TemplateInfo & {
|
||||
제목?: string | null;
|
||||
단위?: string | null;
|
||||
기준금액?: number | null;
|
||||
구분?: string | null;
|
||||
상세구분?: string | null;
|
||||
};
|
||||
|
||||
export interface LibraryListOptions {
|
||||
layer: Layer;
|
||||
kind: PrecomboKind;
|
||||
canEdit: boolean;
|
||||
/** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */
|
||||
open: (key: string, label: string) => void;
|
||||
}
|
||||
|
||||
const SIZE = 30;
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const money = (v: number | null | undefined): string =>
|
||||
v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름);
|
||||
|
||||
/** `tools` = 상세 머리 단추 자리(페이지 slot) · 돌려준 `destroy` 는 안 씀(칸째 비움) */
|
||||
export function mountLibraryList(
|
||||
host: HTMLElement,
|
||||
tools: HTMLElement,
|
||||
opt: LibraryListOptions,
|
||||
): { getDoc: () => null; destroy: () => void } {
|
||||
let rows: Row[] = [];
|
||||
let picked: string | null = null;
|
||||
let page = 1;
|
||||
const searchField = createInputField({ type: "search", placeholder: tc("Search") });
|
||||
const body = el("tbody");
|
||||
let pager = buildPager(0, SIZE, 1, () => {});
|
||||
const btnNew = createButton({ label: tc("Lib_Create"), variant: "filled" });
|
||||
const btnCopy = createButton({ label: tc("Lib_Copy"), variant: "ghost" });
|
||||
const btnDel = createButton({ label: tc("Lib_Delete"), variant: "danger" });
|
||||
const paintButtons = (): void => {
|
||||
btnCopy.disabled = btnDel.disabled = !rows.some((r) => r.이름 === picked);
|
||||
};
|
||||
|
||||
const draw = (): void => {
|
||||
const q = searchField.input.value.trim().toLowerCase();
|
||||
const shown = rows.filter((r) => !q || libraryLabel(r).toLowerCase().includes(q));
|
||||
page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE)));
|
||||
const next = buildPager(shown.length, SIZE, page, (p) => {
|
||||
page = p;
|
||||
draw();
|
||||
});
|
||||
pager.replaceWith(next);
|
||||
pager = next;
|
||||
body.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.slice((page - 1) * SIZE, page * SIZE).map((row) => {
|
||||
const radio = el("input", {
|
||||
attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 },
|
||||
}) as HTMLInputElement;
|
||||
radio.checked = row.이름 === picked;
|
||||
const pick = el("td", { className: "m02-lib__pick", children: [radio] });
|
||||
pick.addEventListener("click", (event) => {
|
||||
event.stopPropagation(); // 고름 칸은 열지 않음
|
||||
picked = row.이름;
|
||||
radio.checked = true;
|
||||
paintButtons();
|
||||
});
|
||||
const tr = el("tr", {
|
||||
className: "m01-master__table-row",
|
||||
attrs: { "data-lib-key": row.이름 },
|
||||
children: [
|
||||
pick,
|
||||
...[
|
||||
row.이름,
|
||||
row.구분 ?? "",
|
||||
row.상세구분 ?? "",
|
||||
row.제목 ?? "",
|
||||
row.단위 ?? "",
|
||||
money(row.기준금액),
|
||||
row.수정일,
|
||||
].map((v) => el("td", { text: v, attrs: { title: v } })),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row)));
|
||||
return tr;
|
||||
})
|
||||
: [
|
||||
el("tr", {
|
||||
children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "8" } })],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
paintButtons();
|
||||
};
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
rows = (await listTemplates(opt.layer, null)).filter((r) => r.종류 === opt.kind) as Row[];
|
||||
} catch (error) {
|
||||
rows = [];
|
||||
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
draw();
|
||||
};
|
||||
|
||||
const nameDialog = (title: string, start: string, onOk: (name: string) => Promise<void>) => {
|
||||
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
|
||||
name.input.value = start;
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: L("M02_Close"),
|
||||
dialogClass: "m02-modal",
|
||||
mount: (box, close) => {
|
||||
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
|
||||
ok.addEventListener("click", async () => {
|
||||
const text = name.input.value.trim();
|
||||
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
} finally {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
name.input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.isComposing) ok.click();
|
||||
});
|
||||
box.append(name.root, ok);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */
|
||||
const made = (key: string, label: string): void => {
|
||||
showToast(L("M02_Created"), "success");
|
||||
opt.open(key, label);
|
||||
};
|
||||
const create = async (body: LibraryBody, label: string): Promise<void> =>
|
||||
made((await newLibrary(opt.layer, opt.kind, body)).이름, label);
|
||||
|
||||
/** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위 · 구분 · 연결은 로직 것(서버가 채움) ·
|
||||
* 사전조합 = 이름 + 구분 · 상세구분 처음 값 = 로직 구분 목록 첫 줄(51-12 · 상세에서 바꿈) · 빈 묶음 */
|
||||
btnNew.addEventListener("click", () => {
|
||||
if (opt.kind === "promote")
|
||||
return openLogicPicker((x) => {
|
||||
const doc = {
|
||||
이름: x.이름,
|
||||
단위: x.결과단위 || null,
|
||||
담은로직: [{ 로직: x.키, 입력값: {} }],
|
||||
};
|
||||
void create({ 문서: doc }, x.이름).catch((error) =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"),
|
||||
);
|
||||
});
|
||||
nameDialog(L("M02_NewTitle"), "", async (name) => {
|
||||
const first = (await fetchLibLogicSubs())[0];
|
||||
const 문서 = {
|
||||
이름: name,
|
||||
구분: first?.name ?? null,
|
||||
상세구분: first?.details[0] ?? null,
|
||||
단위: null,
|
||||
담은로직: [],
|
||||
};
|
||||
await create({ 문서 }, name);
|
||||
});
|
||||
});
|
||||
|
||||
/** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */
|
||||
btnCopy.addEventListener("click", () => {
|
||||
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,
|
||||
});
|
||||
made(got.이름, name);
|
||||
});
|
||||
});
|
||||
|
||||
btnDel.addEventListener("click", async () => {
|
||||
await load(); // 판을 최신으로
|
||||
const info = rows.find((r) => r.이름 === picked);
|
||||
if (!info) return;
|
||||
const label = libraryLabel(info);
|
||||
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.판);
|
||||
showToast(L("M02_Deleted"), "success");
|
||||
picked = null;
|
||||
await load();
|
||||
} catch (error) {
|
||||
// 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로
|
||||
showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error");
|
||||
}
|
||||
});
|
||||
|
||||
searchField.input.addEventListener("input", () => {
|
||||
page = 1;
|
||||
draw();
|
||||
});
|
||||
const heads = [
|
||||
"",
|
||||
"Lib_Key",
|
||||
"Sub",
|
||||
"Detail",
|
||||
"Lib_Name",
|
||||
"Lib_Unit",
|
||||
"Lib_Base",
|
||||
"Lib_Date",
|
||||
] as const;
|
||||
host.replaceChildren(
|
||||
buildHeadFilters([searchField.root, pager]),
|
||||
el("div", {
|
||||
className: "m01-master__grid-wrap",
|
||||
attrs: { "data-lib-list": opt.kind },
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (opt.canEdit)
|
||||
tools.replaceChildren(
|
||||
el("span", { className: "m02-main__tools", children: [btnNew, btnCopy, btnDel] }),
|
||||
);
|
||||
void load();
|
||||
return { getDoc: () => null, destroy: () => undefined };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ from common_util import common_util_spreadsheet as spreadsheet
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
@@ -278,6 +279,8 @@ async def list_layer(
|
||||
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
|
||||
if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음
|
||||
row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])]
|
||||
# 서고(50-3) — 보이는 이름 · 단위 · 기준 금액(51-4)
|
||||
await asyncio.to_thread(refs.summaries, folder, rows)
|
||||
return {"층": layer, "양식": rows}
|
||||
|
||||
|
||||
@@ -306,6 +309,7 @@ async def read_layer_template(
|
||||
raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.")
|
||||
found["층"] = layer
|
||||
found["출처"] = layers.read_manifest(folder).get(f"{kind}/{name}")
|
||||
found["문서"] = refs.filled(kind, found["문서"]) # 옛 서고 문서 구분 · 상세구분(51-12)
|
||||
return found
|
||||
|
||||
|
||||
@@ -324,6 +328,8 @@ async def save_layer_template(
|
||||
session, layer, project_id=project_id, user_id=user_id, owner=owner, write=True
|
||||
)
|
||||
document = body.문서
|
||||
if layer == "project" and kind in layers.LIBRARY_KINDS:
|
||||
raise HTTPException(status_code=400, detail="프로젝트는 서고 문서를 안 둠 — 굳힌 사본만")
|
||||
try:
|
||||
layers.check_skeleton(kind, document) # 빈 `{}` · 뼈대 없는 문서는 파일 안 씀
|
||||
except ValueError as error:
|
||||
@@ -406,6 +412,10 @@ async def delete_layer_template(
|
||||
layers.check_name(name)
|
||||
except ValueError as error:
|
||||
raise _bad(error) from error
|
||||
try: # 서고 문서를 담은 일위대가 · 사전조합이 있으면 409(50-3)
|
||||
await asyncio.to_thread(refs.refuse_used, folder, kind, name)
|
||||
except store.StoreError as error:
|
||||
raise HTTPException(status_code=error.status, detail=error.detail) from error
|
||||
if not 판:
|
||||
raise HTTPException(status_code=400, detail="지울 양식의 판이 필요합니다.")
|
||||
try:
|
||||
|
||||
@@ -153,6 +153,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
|
||||
path = _path(kind, name)
|
||||
_check_skeleton(kind, doc)
|
||||
if kind in layers.LIBRARY_KINDS: # 서고 문서 키 = 파일 이름(50-3)
|
||||
doc = {**doc, "키": name}
|
||||
origins = _take_origins(doc) if kind == "table" else {}
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
|
||||
@@ -186,6 +188,10 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
|
||||
path = _path(kind, name)
|
||||
if (kind, name) in IN_USE:
|
||||
raise StoreError(409, f"쓰는 양식이라 못 지움 「{name}」 — {IN_USE[(kind, name)]}")
|
||||
if kind in layers.LIBRARY_KINDS:
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
refs.refuse_used(root(), kind, name)
|
||||
with _LOCK:
|
||||
if not path.is_file():
|
||||
raise StoreError(404, f"없는 양식 「{name}」")
|
||||
@@ -387,7 +393,7 @@ def copy_from(
|
||||
body = read(kind, source)["문서"]
|
||||
body = read(kind, source)["문서"] if body is None else body
|
||||
doc = {**body, **{k: target.get(k) for k in ("열", _KEY[kind])}}
|
||||
doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"})
|
||||
doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}, source_root)
|
||||
with _LOCK:
|
||||
atomic_write_json(_path(kind, column), doc)
|
||||
info = _info(kind, _path(kind, column))
|
||||
@@ -493,14 +499,21 @@ def renumber() -> dict[str, list[str]]:
|
||||
return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made}
|
||||
|
||||
|
||||
def _server(kind: str, doc: Any) -> Any:
|
||||
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`)."""
|
||||
def _server(kind: str, doc: Any, source: Path | None = None) -> Any:
|
||||
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`) ·
|
||||
서고 문서 정리(`M02_Labor_Ref` · 50-3) · `source` = 층 사이 복사의 원본 층 폴더(지금 층이
|
||||
못 읽는 참조는 같이 복사)."""
|
||||
if kind in SHEETS:
|
||||
return _server_values(doc)
|
||||
if kind == "labor":
|
||||
if kind in ("labor", *layers.LIBRARY_KINDS):
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
return labor.server(doc)
|
||||
if source is not None and isinstance(doc, dict) and kind != "promote":
|
||||
doc = refs.carry(doc, refs.where(source))
|
||||
if kind == "labor":
|
||||
return labor.server(doc)
|
||||
return refs.clean_doc(kind, doc)
|
||||
return doc
|
||||
|
||||
|
||||
|
||||
@@ -160,7 +160,9 @@ export async function pillsFor(
|
||||
...(p.자재 ? { 자재: p.자재 } : {}),
|
||||
고정: true,
|
||||
}));
|
||||
return 항목.length ? { links: { 항목, 모드: "입력" } } : none(TEXT.noPills);
|
||||
// 받기 성공 = 목록 확정 — 이 목록에 없는 연결 알약만 ⚠(받기 실패 때는 표시 안 함)
|
||||
const links: LinkOptions = { 항목, 모드: "입력", 확정: true };
|
||||
return 항목.length ? { links } : { ...none(TEXT.noPills), links };
|
||||
} catch (error) {
|
||||
return none(fill(TEXT.pillsFailed, why(error)));
|
||||
}
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 찾기 칸 — 상세 위쪽(PLAN 29-2) */
|
||||
.m02-tree__search {
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
/* 층 단추 셋 · 사람 고르기 — 좌측 패널 줄 아래 한 줄(PLAN 29-2) */
|
||||
.m02-layers {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_TreePanel.ts
|
||||
* 관리 화면(마스터 · 회사 · 개인 공용) 「구조물 라이브러리」 한 층의 나무 — 폴더 → … → 항목(깊이 자유) · 찾기.
|
||||
* 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 층 단추는 `_Roots` · 찾기 칸은 부르는 쪽이 상세 위쪽에 · 자료 길은 `api`.
|
||||
* 관리 화면(마스터 · 회사 · 개인 공용) 「구조물 라이브러리」 한 층의 나무 — 폴더 → … → 항목(깊이 자유).
|
||||
* 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 층 단추는 `_Roots` · 자료 길은 `api`.
|
||||
* 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목.
|
||||
* 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 —
|
||||
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 [복사]만. 나무 자료는 `_Model`.
|
||||
@@ -10,13 +10,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import "./M02_MasterTemplete_TreePanel.css";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
@@ -103,8 +97,6 @@ export interface TreePanelOptions {
|
||||
export interface TreePanelHandle {
|
||||
/** 나무(좌측 패널에 붙임) */
|
||||
root: HTMLElement;
|
||||
/** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */
|
||||
search: HTMLElement;
|
||||
/** 층 단추 줄 오른쪽 도구 — 「N개 고름」(30-2) · 「+」(맨 위 폴더 · 항목 더하기 · 읽기만 층 = 숨김 · 29-6) */
|
||||
add: HTMLElement;
|
||||
refresh: () => Promise<void>;
|
||||
@@ -132,7 +124,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree };
|
||||
const clipKind = "구조물"; // 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임
|
||||
let view: TreeView | null = null;
|
||||
let text = "";
|
||||
let activeCol: string | null = null;
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
@@ -142,10 +133,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
let picked = new Set<string>();
|
||||
let anchor: string | null = null;
|
||||
|
||||
const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true });
|
||||
const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } });
|
||||
const root = el("div", { className: "m02-tree", children: [body] });
|
||||
search.root.classList.add("m02-tree__search");
|
||||
// 층 단추 옆 「+」 — 맨 위에 폴더 · 항목 더하기(뿌리 줄 오른쪽 클릭을 갈음)
|
||||
const add = createButton({
|
||||
label: "+",
|
||||
@@ -241,10 +230,10 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
return { ...base, id, ...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {}) };
|
||||
}
|
||||
|
||||
function folderNode(folder: FolderNode, searching: boolean): TreeNode {
|
||||
function folderNode(folder: FolderNode): TreeNode {
|
||||
const id = folderIdOf(folder.path);
|
||||
const kids = [
|
||||
...folder.folders.map((one) => folderNode(one, searching)),
|
||||
...folder.folders.map((one) => folderNode(one)),
|
||||
...folder.items.map((item) =>
|
||||
node(
|
||||
{ kind: "item", item },
|
||||
@@ -265,7 +254,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
count: countItems(folder),
|
||||
folder: true,
|
||||
children: kids,
|
||||
open: searching || open.has(id),
|
||||
open: open.has(id),
|
||||
run: () => void (open.has(id) ? open.delete(id) : open.add(id)),
|
||||
},
|
||||
id,
|
||||
@@ -348,19 +337,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const focused = (document.activeElement as HTMLElement | null)?.dataset?.node;
|
||||
targets.clear();
|
||||
if (!view) return body.replaceChildren(hint(L("M02_PickerLoading")));
|
||||
const searching = text.trim() !== "";
|
||||
const tree = buildFolders(view, text);
|
||||
const none = countItems(tree) === 0 && (searching || !tree.folders.length);
|
||||
const tree = buildFolders(view);
|
||||
const none = countItems(tree) === 0 && !tree.folders.length;
|
||||
body.replaceChildren(
|
||||
// 맨 위 뿌리 줄 없음 — 폴더 · 항목부터(PLAN 29-6 · 뿌리 메뉴 = 빈 자리 오른쪽 클릭 · 「+」)
|
||||
...renderTree(folderNode(tree, searching).children ?? [], make),
|
||||
...(none
|
||||
? [
|
||||
hint(
|
||||
L(searching ? "M02_TreeNoHit" : opt.canEdit ? "M02_TreeEmptyEdit" : "M02_TreeEmpty"),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...renderTree(folderNode(tree).children ?? [], make),
|
||||
...(none ? [hint(L(opt.canEdit ? "M02_TreeEmptyEdit" : "M02_TreeEmpty"))] : []),
|
||||
);
|
||||
if (focused) rowOf(focused)?.focus({ preventScroll: true });
|
||||
setPicked(picked, anchor); // 없어진 줄은 고름에서 뺌
|
||||
@@ -599,11 +581,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
: from.folders.map((one) => findFolder(one, path)).find(Boolean);
|
||||
|
||||
/* --- 이음 --- */
|
||||
search.input.addEventListener("input", () => {
|
||||
text = search.input.value;
|
||||
paint();
|
||||
});
|
||||
|
||||
// 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기 · 붙여넣기)
|
||||
body.addEventListener("contextmenu", (event) => {
|
||||
if ((event.target as HTMLElement).closest("[data-node]")) return;
|
||||
@@ -676,7 +653,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
};
|
||||
return {
|
||||
root,
|
||||
search: search.root,
|
||||
add: tools,
|
||||
refresh,
|
||||
select,
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
|
||||
import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp";
|
||||
import { helpParts } from "./M02_MasterTemplete_CardShell";
|
||||
import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
export interface Selection {
|
||||
@@ -101,11 +104,9 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean {
|
||||
}
|
||||
|
||||
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 ·
|
||||
* `search` = 「구조물 조합」을 열 때만 상세 위쪽에 보이는 나무 찾기 칸(나무는 좌측 패널 · PLAN 29-2) ·
|
||||
* `newItem` = 「구조물 조합」 제목 줄 오른쪽 끝 「새 항목」(쓸 수 있는 층만 · PLAN 30-5 · 공용 `m02-main__new`) */
|
||||
export interface MainExtras {
|
||||
top?: HTMLElement;
|
||||
search?: HTMLElement;
|
||||
newItem?: HTMLElement;
|
||||
onColumn?: (column: string | null) => void;
|
||||
/** 구조물 조합 표 머리 이름 칸 고침 → 나무 이름 바꾸기(쓸 수 있는 층만 · PLAN 31-1) */
|
||||
@@ -132,6 +133,7 @@ export function createMain(
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
||||
// 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님)
|
||||
const back = createButton({ label: L("M02_Back") });
|
||||
const helpSlot = el("span", { className: "m02-main__help" }); // 상세 [?](49-7) — 제목 바로 옆
|
||||
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
|
||||
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
|
||||
const host = el("div", { className: "m02-main__host" });
|
||||
@@ -143,9 +145,16 @@ export function createMain(
|
||||
el("div", {
|
||||
className: "m02-main__head",
|
||||
// M01 머리 꼴(39-5) — ← 는 제목 앞 · 제목 18px 왼쪽 · 단추는 오른쪽(slot 이 늘어나 밂)
|
||||
children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])],
|
||||
children: [
|
||||
back,
|
||||
title,
|
||||
helpSlot,
|
||||
slot,
|
||||
reload,
|
||||
save,
|
||||
...(extras.newItem ? [extras.newItem] : []),
|
||||
],
|
||||
}),
|
||||
...(extras.search ? [extras.search] : []),
|
||||
notice,
|
||||
host,
|
||||
extra,
|
||||
@@ -154,10 +163,9 @@ export function createMain(
|
||||
const root = column;
|
||||
const isCombo = (at: Selection | null): boolean =>
|
||||
!!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project";
|
||||
// 찾기 칸 · 「새 항목」 · 표 접기(제목)는 「구조물 조합」일 때만
|
||||
// 「새 항목」 · 표 접기(제목)는 「구조물 조합」일 때만
|
||||
const showAside = (): void => {
|
||||
const combo = isCombo(sel);
|
||||
if (extras.search) extras.search.hidden = !combo;
|
||||
if (extras.newItem) extras.newItem.hidden = !combo;
|
||||
title.classList.toggle("m02-main__title--fold", combo);
|
||||
title.title = combo ? L("M02_TableFold") : "";
|
||||
@@ -166,7 +174,7 @@ export function createMain(
|
||||
title.classList.remove("is-folded");
|
||||
}
|
||||
};
|
||||
// 「구조물 조합」 표 접기(PLAN 31-2) — 제목을 누르면 표만 접힘 · 펴짐(카드 · 찾기는 그대로)
|
||||
// 「구조물 조합」 표 접기(PLAN 31-2) — 제목을 누르면 표만 접힘 · 펴짐(카드는 그대로)
|
||||
title.addEventListener("click", () => {
|
||||
if (!isCombo(sel)) return;
|
||||
host.hidden = !host.hidden;
|
||||
@@ -220,6 +228,9 @@ export function createMain(
|
||||
reload.hidden = !sel;
|
||||
save.hidden = !sel || !canEdit(sel.layer, isAdmin);
|
||||
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
|
||||
const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null;
|
||||
const help = kind && helpParts(HELP_TITLE[kind], detailHelp(kind)); // 열 때마다 새로 — 상자는 닫힘
|
||||
helpSlot.replaceChildren(...(help ? [help.button, help.box] : []));
|
||||
};
|
||||
|
||||
// 면적산출 범위 → 도면 표(41-3) — 몸은 `AreaToDrawing` · 여기는 갈고리만
|
||||
@@ -263,13 +274,14 @@ export function createMain(
|
||||
): Promise<void> {
|
||||
const mine = seq;
|
||||
try {
|
||||
const [{ createSpreadsheet }, pills] = await Promise.all([
|
||||
const [{ createSpreadsheet }, pills, sent] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
pillsFor(kind, at.name),
|
||||
sentRanges(at), // 도면으로 보낸 범위 — 시트 위 점선 테두리
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (pills.notice) showNotice([pills.notice]);
|
||||
const links: LinkOptions = pills.links;
|
||||
const links: LinkOptions = { ...pills.links, 표시범위: sent };
|
||||
const handle: SpreadsheetHandle = createSpreadsheet(
|
||||
host,
|
||||
doc as Parameters<typeof createSpreadsheet>[1],
|
||||
@@ -368,19 +380,24 @@ export function createMain(
|
||||
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
|
||||
return;
|
||||
}
|
||||
if (at.kind === "labor") {
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로
|
||||
if (at.kind === "labor" || isPrecombo(at.kind)) {
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 ·
|
||||
// 단가승격 · 사전조합(50-3) = 같은 상세 모드(도면 · 면적 · 자재 없음 · 이름 = 키)
|
||||
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
|
||||
const mode = isPrecombo(at.kind) ? at.kind : undefined;
|
||||
const labor = mountLaborDetail(host, doc, {
|
||||
layer: at.layer,
|
||||
column: at.name,
|
||||
readOnly,
|
||||
onChanged: (d) => (dirty = d && !readOnly),
|
||||
goArea: async () => {
|
||||
if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로
|
||||
void open({ ...at, kind: "area" });
|
||||
return true;
|
||||
},
|
||||
goArea: mode
|
||||
? null
|
||||
: async () => {
|
||||
if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로
|
||||
void open({ ...at, kind: "area" });
|
||||
return true;
|
||||
},
|
||||
...(mode ? { mode } : {}),
|
||||
});
|
||||
// [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값)
|
||||
editor = {
|
||||
@@ -437,6 +454,19 @@ export function createMain(
|
||||
return empty("");
|
||||
}
|
||||
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
|
||||
if (isPrecombo(next.kind) && !next.name) {
|
||||
// 서고 목록표(51-4) — 이름 "" · 저장할 것 없음 · 줄 누름 = 그 문서 상세
|
||||
save.hidden = true;
|
||||
const { mountLibraryList } = await import("./M02_MasterTemplete_Library_List");
|
||||
if (mine !== seq) return;
|
||||
editor = mountLibraryList(host, slot, {
|
||||
layer: next.layer,
|
||||
kind: next.kind,
|
||||
canEdit: canEdit(next.layer, isAdmin),
|
||||
open: (name, label) => void open({ ...next, name, label }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
|
||||
try {
|
||||
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Page.ts
|
||||
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록 · 층 단추 · 고른 층의 나무) /
|
||||
* 우측 편집 칸(표·도면 부품 · 「구조물 조합」이면 위쪽에 나무 찾기 칸 · PLAN 29-2)
|
||||
* 우측 편집 칸(표·도면 부품 · 「구조물 조합」이면 나무는 좌측 패널 · PLAN 29-2)
|
||||
*
|
||||
* 한 화면에 층 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 층 = 진입 해시 `?open=&root=&owner=&company=`
|
||||
* (`M02_MasterTemplete_Layer.ts`) · 층 단추 · 「사람 고르기」를 누르면 해시를 바꿔 이 화면을 다시 세움.
|
||||
@@ -105,13 +105,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌
|
||||
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement | null, reselect: () => void): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: entry.root, owner },
|
||||
company,
|
||||
go: (next, nextOwner) => void go(next, nextOwner, open),
|
||||
add,
|
||||
...(add ? { add } : {}),
|
||||
reselect,
|
||||
});
|
||||
// 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6)
|
||||
@@ -130,7 +130,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
() => side.paint(),
|
||||
{
|
||||
top: readOnlyBar?.root,
|
||||
search: tree.search,
|
||||
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
|
||||
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}),
|
||||
onColumn: (column) => {
|
||||
@@ -185,6 +184,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
// 일위대가 조합(50-3) — 같은 층 단추 · 누르면 그 층 화면에서 이 컨테이너를 펼침
|
||||
precomboBar: [layerBar("일위대가조합", null, () => undefined)],
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 조합」(층 단추 · 단가승격 · 사전조합 ·
|
||||
* `UI_Side_Precombo` · 50-3) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
|
||||
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
|
||||
@@ -31,6 +32,7 @@ import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildPrecomboSection, PRECOMBO_TITLE } from "./M02_MasterTemplete_UI_Side_Precombo";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
@@ -58,6 +60,8 @@ export interface SideOptions {
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 「일위대가 조합」 줄 아래 층 단추(50-3 · 페이지가 만듦) */
|
||||
precomboBar?: HTMLElement[];
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -66,7 +70,7 @@ export interface SideHandle {
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
|
||||
reveal: (target: "옵션" | "도면") => void;
|
||||
reveal: (target: "옵션" | "도면" | "일위대가조합") => void;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
@@ -130,11 +134,20 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 옵션 보이기(그룹)는 시스템 관리자만(PLAN 46)
|
||||
// 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인)
|
||||
const precombo = buildPrecomboSection({
|
||||
layer,
|
||||
bar: opt.precomboBar ?? [],
|
||||
confirmLeave: opt.confirmLeave,
|
||||
onOpen: opt.onOpen,
|
||||
openKind: () => (opt.getOpen()?.layer === layer ? (opt.getOpen()?.kind ?? null) : null),
|
||||
});
|
||||
const precomboSec = section(PRECOMBO_TITLE, precombo.body);
|
||||
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
|
||||
const extras =
|
||||
layer === "system"
|
||||
? {
|
||||
옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null,
|
||||
옵션: section(options.title, options.body),
|
||||
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
}
|
||||
: null;
|
||||
@@ -151,6 +164,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
precomboSec,
|
||||
...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []),
|
||||
...(practice ? [section(practice.title, practice.body)] : []),
|
||||
],
|
||||
@@ -205,6 +219,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
* (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */
|
||||
function paint(): void {
|
||||
paintList();
|
||||
precombo.paint();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
@@ -228,6 +243,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (mine !== seq) return;
|
||||
paintList();
|
||||
paintButtons();
|
||||
precombo.paint();
|
||||
}
|
||||
|
||||
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
|
||||
@@ -307,8 +323,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면"): void => {
|
||||
const sec = extras?.[target];
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => {
|
||||
const sec = target === "일위대가조합" ? precomboSec : extras?.[target];
|
||||
if (!sec) return; // 옵션 보이기는 시스템 관리자만
|
||||
sec.classList.remove("is-collapsed");
|
||||
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Precombo.ts
|
||||
* 좌측 「일위대가 조합」 컨테이너(50-3 ③ · 51-4) — 구조물 조합 아래 · 층 단추(페이지가 만듦) ·
|
||||
* 항목 둘 「단가산출 승격」(promote) · 「일위대가 사전조합」(precombo)만.
|
||||
* 항목 누름 = 상세 칸에 그 종류 목록표(`Library_List` · 생성 · 복사 · 삭제는 거기) ·
|
||||
* 표 줄 누름 = 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { Kind, Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
export type PrecomboKind = Extract<Kind, "promote" | "precombo">;
|
||||
|
||||
const PARTS = [
|
||||
{ kind: "promote", label: "Lib_Promote" },
|
||||
{ kind: "precombo", label: "Lib_PrecomboAll" },
|
||||
] as const;
|
||||
export const PRECOMBO_TITLE = "일위대가 조합";
|
||||
export const isPrecombo = (kind: Kind): kind is PrecomboKind =>
|
||||
kind === "promote" || kind === "precombo";
|
||||
|
||||
export interface PrecomboOptions {
|
||||
layer: Layer;
|
||||
/** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */
|
||||
bar: HTMLElement[];
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
/** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */
|
||||
openKind: () => Kind | null;
|
||||
}
|
||||
|
||||
export interface PrecomboHandle {
|
||||
body: HTMLElement;
|
||||
paint: () => void;
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
// 이름 "" = 목록표(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 body = el("div", {
|
||||
className: "m02-side__items ui-sidebar-list",
|
||||
children: [...opt.bar, ...items],
|
||||
});
|
||||
const paint = (): void => {
|
||||
const on = opt.openKind();
|
||||
items.forEach((b, i) => b.classList.toggle("is-active", PARTS[i].kind === on));
|
||||
};
|
||||
return { body, paint };
|
||||
}
|
||||
@@ -56,6 +56,12 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 일위대가 조합 서고 목록표(51-4) — 표 꼴은 M01 목록(`m01-master__grid`) · 첫 칸 = 고름 */
|
||||
.m01-master__grid .m02-lib__pick {
|
||||
width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m02-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -65,6 +71,7 @@
|
||||
}
|
||||
|
||||
.m02-main__head {
|
||||
position: relative; /* 상세 [?] 도움말 상자의 기준(49-7) */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -84,6 +91,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 상세 [?] — 단추는 제목 바로 옆 흐름 · 상자는 머리 아래 오른쪽에 뜸 */
|
||||
.m02-main__help {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.m02-main__head > .m02-main__help > .m02-struct__keys {
|
||||
top: calc(100% + var(--spacing-8));
|
||||
right: 0;
|
||||
width: max-content;
|
||||
max-width: min(36rem, 100%);
|
||||
max-height: 70vh;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.m02-main__slot {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -94,6 +115,7 @@
|
||||
/* 제목 줄 오른쪽 끝 도구(일위대가 「새 조합」 · PLAN 30-5) */
|
||||
.m02-main__tools {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-8); /* M01 머리 줄(`m01-head`)과 같은 틈 */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -165,10 +187,6 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m02-main__head + .m02-tree__search {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 상세 제목 줄 오른쪽 끝 「새 항목」 · 「새 조합」(PLAN 30-5 · 공용) — 늘어나는 칸이 없어도 끝으로 */
|
||||
.m02-main__new {
|
||||
margin-left: auto;
|
||||
|
||||
@@ -28,7 +28,9 @@ from config import config_system
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
# area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
|
||||
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area")
|
||||
# promote = 단가승격(SP-) · precombo = 사전조합(SC-) — 일위대가 조합 서고(`M02_Labor_Ref`)
|
||||
LIBRARY_KINDS = ("promote", "precombo")
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area", *LIBRARY_KINDS)
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
@@ -171,6 +173,8 @@ _SKELETON = {
|
||||
"structure": ("도면.entities",),
|
||||
"labor": ("담은로직",),
|
||||
"area": ("시트",),
|
||||
"promote": ("담은로직",),
|
||||
"precombo": ("담은로직",),
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +234,17 @@ def delete_template(
|
||||
return True
|
||||
|
||||
|
||||
def layer_files(kind: str) -> list[tuple[str, Path]]:
|
||||
"""모든 층(마스터 · 회사 · 개인 · 프로젝트 빼고)의 `kind/*.json` — (어디, 파일)."""
|
||||
root = storage_root()
|
||||
found = [("마스터", p) for p in sorted((system_dir() / kind).glob("[!.]*.json"))]
|
||||
found += [
|
||||
(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob(f"*/templates/{kind}/[!.]*.json"))
|
||||
]
|
||||
personal = sorted(root.glob(f"*/*/templates/{kind}/[!.]*.json"))
|
||||
return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal]
|
||||
|
||||
|
||||
# ── manifest ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -283,7 +298,8 @@ def copy_templates(
|
||||
) -> list[str]:
|
||||
"""`source_dir` 층의 양식을 `target_dir` 로 복사 → 복사한 `종류/이름` 목록.
|
||||
|
||||
`kind`·`name` 으로 좁힘 · `only_missing` 이면 이미 있는 파일은 건너뜀(더하기만).
|
||||
`kind`·`name` 으로 좁힘(안 좁히면 서고 종류 `LIBRARY_KINDS` 는 뺌 — 프로젝트는 굳힌 사본) ·
|
||||
`only_missing` 이면 이미 있는 파일은 건너뜀(더하기만).
|
||||
manifest 에 출처(층 · 이름 · 판 · `source_ref`)를 적음. 출처 manifest 의 원래 출처는
|
||||
적지 않음 — 「어디서 가져왔나」 한 단계만.
|
||||
"""
|
||||
@@ -292,6 +308,8 @@ def copy_templates(
|
||||
rows = list_templates(source_dir)
|
||||
if kind is not None:
|
||||
rows = [row for row in rows if row["종류"] == check_kind(kind)]
|
||||
else:
|
||||
rows = [row for row in rows if row["종류"] not in LIBRARY_KINDS]
|
||||
if name is not None:
|
||||
rows = [row for row in rows if row["이름"] == check_name(name)]
|
||||
manifest = read_manifest(target_dir)
|
||||
|
||||
@@ -1,56 +1,29 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Api.ts
|
||||
* 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py`
|
||||
* (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`)
|
||||
* 구조물 정의 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물정의.json` 한 벌 · PLAN 48)
|
||||
* ========================================================================== */
|
||||
|
||||
import { call } from "../M02_MasterTemplete_Api_Fetch";
|
||||
import type { StructDef } from "./M02_SO_Def_Model";
|
||||
|
||||
export interface OptionKind {
|
||||
type_id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
/** 거짓이면 B05 선택지에 안 나오는 종류 */
|
||||
enabled: boolean;
|
||||
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
|
||||
/* --- 구조물 정의(PLAN 48 · 마스터 템플릿 `구조물정의.json` · 판 번호 · 이력) --- */
|
||||
|
||||
/** 지난 판 한 줄 */
|
||||
export interface DefVersion {
|
||||
판: number;
|
||||
저장일: string;
|
||||
}
|
||||
|
||||
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
|
||||
export interface OptionsDoc {
|
||||
판: string;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
/** 정의 한 벌 — 읽기 = 로그인 누구나 */
|
||||
export const fetchStructureDef = (): Promise<StructDef> => call("/structure-definition");
|
||||
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
|
||||
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자
|
||||
/** 통째로 고쳐 씀(시스템 관리자만) — 읽어 간 판이 지금 판과 다르면 StaleError · 돌려받는 것 = 판 +1 정의 */
|
||||
export const saveStructureDef = (판: number, 정의: StructDef): Promise<StructDef> =>
|
||||
call("/structure-definition", { method: "PUT", body: JSON.stringify({ 판, 정의 }) });
|
||||
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
|
||||
/** 지난 판 목록(`구조물정의_이력/판_N.json`) */
|
||||
export const fetchStructureDefHistory = (): Promise<DefVersion[]> =>
|
||||
call("/structure-definition/history");
|
||||
|
||||
/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */
|
||||
|
||||
/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */
|
||||
export interface OptionGroupDef {
|
||||
id: string;
|
||||
이름: string;
|
||||
옵션: string[];
|
||||
}
|
||||
|
||||
/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */
|
||||
export interface GroupsDoc {
|
||||
판: string;
|
||||
그룹: Record<string, OptionGroupDef[]>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptionGroups = (): Promise<GroupsDoc> =>
|
||||
call("/structure-option-groups");
|
||||
|
||||
/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptionGroups = (
|
||||
판: string,
|
||||
그룹: GroupsDoc["그룹"],
|
||||
): Promise<GroupsDoc> =>
|
||||
call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) });
|
||||
export const fetchStructureDefAt = (판: number): Promise<StructDef> =>
|
||||
call(`/structure-definition/history/${판}`);
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) ·
|
||||
* 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) ·
|
||||
* 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄).
|
||||
* 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
fetchStructureDef,
|
||||
fetchStructureDefAt,
|
||||
fetchStructureDefHistory,
|
||||
saveStructureDef,
|
||||
type DefVersion,
|
||||
} from "./M02_SO_Api";
|
||||
import { buildLayoutBlock } from "./M02_SO_Def_Layout";
|
||||
import {
|
||||
baseShown,
|
||||
clone,
|
||||
problems,
|
||||
stepFamily,
|
||||
stepType,
|
||||
type StructDef,
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { buildOptionsBlock } from "./M02_SO_Def_Options";
|
||||
import { buildRulesBlock } from "./M02_SO_Def_Rules";
|
||||
import { checkBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { buildGroupBlock } from "./M02_SO_Groups";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface DefScreenOptions {
|
||||
/** 시스템 관리자 — 아니면 읽기만 */
|
||||
isAdmin: boolean;
|
||||
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
|
||||
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */
|
||||
function tidy(def: StructDef): StructDef {
|
||||
const out = clone(def);
|
||||
for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record<string, unknown>[])
|
||||
for (const [k, v] of Object.entries(part))
|
||||
if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 작은 단추 */
|
||||
function smallButton(
|
||||
label: string,
|
||||
run: () => void,
|
||||
off = false,
|
||||
variant: "ghost" | "danger" = "ghost",
|
||||
): HTMLButtonElement {
|
||||
const b = createButton({ label, variant, size: "sm" });
|
||||
b.disabled = off;
|
||||
b.addEventListener("click", run);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 화면을 받아 띄움 — 못 받으면 알림만 · 띄웠으면 참 */
|
||||
export async function openDefScreen(opt: DefScreenOptions): Promise<boolean> {
|
||||
let def: StructDef;
|
||||
try {
|
||||
def = await fetchStructureDef();
|
||||
} catch (error) {
|
||||
showToast(to("Load_Failed", why(error)), "error");
|
||||
return false;
|
||||
}
|
||||
drawDef(def, opt);
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
/** 지금 판(서버) · 고칠 사본 · 보는 지난 판(있으면 읽기만) */
|
||||
let base = first;
|
||||
let work = clone(first);
|
||||
let past: StructDef | null = null;
|
||||
let dirty = false;
|
||||
let current: string | null = null;
|
||||
const open = new Set<string>(); // 선택지를 펼친 옵션
|
||||
let versions: DefVersion[] | null = null;
|
||||
|
||||
const shown = (): StructDef => past ?? work;
|
||||
const canEdit = (): boolean => opt.isAdmin && !past;
|
||||
const kindList = el("div", { className: "m02-opts__kinds", attrs: { "data-sod-kinds": "" } });
|
||||
const detail = el("div", { className: "m02-opts__list", attrs: { "data-sod-detail": "" } });
|
||||
const banner = el("div", { className: "m02-sod__banner", attrs: { "data-sod-banner": "" } });
|
||||
const issues = el("div", { className: "m02-sod__issues", attrs: { "data-sod-issues": "" } });
|
||||
const historyBox = el("div", {
|
||||
className: "m02-sod__history",
|
||||
attrs: { "data-sod-history": "" },
|
||||
});
|
||||
|
||||
const changed = (): void => {
|
||||
dirty = true;
|
||||
sync();
|
||||
paint();
|
||||
};
|
||||
|
||||
const paintKinds = (): void => {
|
||||
const d = shown();
|
||||
const edit = canEdit();
|
||||
const parts: HTMLElement[] = [];
|
||||
const families = Object.keys(d.구조물군);
|
||||
families.forEach((g, gi) => {
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m02-sod__family",
|
||||
attrs: { "data-sod-family": g },
|
||||
children: [
|
||||
textBox(d.구조물군[g], (v) => (d.구조물군[g] = v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-family-name",
|
||||
}),
|
||||
...(edit
|
||||
? [
|
||||
smallButton(to("GroupUp"), () => stepFamily(d, g, -1) && changed(), gi === 0),
|
||||
smallButton(
|
||||
to("GroupDown"),
|
||||
() => stepFamily(d, g, 1) && changed(),
|
||||
gi === families.length - 1,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const kinds = d.types.filter((t) => t.group === g);
|
||||
kinds.forEach((t, ti) => {
|
||||
const off = t.enabled === false;
|
||||
const groups = d.그룹[t.type_id]?.length ?? 0;
|
||||
const b = el("button", {
|
||||
className: `m02-opts__kind${t.type_id === current ? " is-active" : ""}${off ? " is-off" : ""}`,
|
||||
attrs: { type: "button", "data-sod-kind": t.type_id },
|
||||
children: [
|
||||
el("span", { text: t.name }),
|
||||
...(groups ? [createTag(to("GroupTag", String(groups)), "accent")] : []),
|
||||
...(off ? [createTag(to("Off"), "neutral")] : []),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
current = t.type_id;
|
||||
paint();
|
||||
});
|
||||
parts.push(
|
||||
el("div", {
|
||||
className: "m02-sod__kindrow",
|
||||
children: [
|
||||
b,
|
||||
...(edit
|
||||
? [
|
||||
smallButton(
|
||||
to("GroupUp"),
|
||||
() => stepType(d, t.type_id, -1) && changed(),
|
||||
ti === 0,
|
||||
),
|
||||
smallButton(
|
||||
to("GroupDown"),
|
||||
() => stepType(d, t.type_id, 1) && changed(),
|
||||
ti === kinds.length - 1,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
kindList.replaceChildren(...parts);
|
||||
};
|
||||
|
||||
const paintDetail = (): void => {
|
||||
const d = shown();
|
||||
const type = d.types.find((t) => t.type_id === current);
|
||||
if (!type) {
|
||||
detail.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
|
||||
return;
|
||||
}
|
||||
const edit = canEdit();
|
||||
const extra = (): NonNullable<StructDef["종류"][string]> => (d.종류[type.type_id] ??= {});
|
||||
detail.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-sod__kindhead",
|
||||
children: [
|
||||
textBox(type.name, (v) => (type.name = v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-kind-name",
|
||||
cls: "m02-sod__name",
|
||||
}),
|
||||
el("small", { className: "m02-opts__key", text: type.type_id }),
|
||||
checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-kind-off",
|
||||
}),
|
||||
checkBox(
|
||||
to("BaseStationShow"),
|
||||
baseShown(d, type.type_id),
|
||||
(v) => {
|
||||
if (v) delete extra().기준측점;
|
||||
else extra().기준측점 = false;
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-base" },
|
||||
),
|
||||
...(d.종류[type.type_id]?.동작 ?? []).map((a) => {
|
||||
const tag = createTag(a, "neutral");
|
||||
tag.title = d.동작[a] ?? "";
|
||||
return tag;
|
||||
}),
|
||||
],
|
||||
}),
|
||||
buildOptionsBlock({ def: d, type, edit, open, changed }),
|
||||
buildLayoutBlock({ def: d, type, edit, changed }),
|
||||
buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }),
|
||||
buildRulesBlock({ def: d, type, edit, changed }),
|
||||
);
|
||||
};
|
||||
|
||||
const paintFoot = (): void => {
|
||||
banner.replaceChildren(
|
||||
...(past
|
||||
? [
|
||||
el("span", { text: to("PastView", String(past.판)) }),
|
||||
smallButton(to("PastBack"), () => ((past = null), paint(), paintHistory())),
|
||||
]
|
||||
: [
|
||||
el("span", { text: to("Version", String(base.판)) }),
|
||||
...(opt.isAdmin ? [] : [createTag(to("ReadOnly"), "neutral")]),
|
||||
]),
|
||||
);
|
||||
banner.classList.toggle("is-past", !!past);
|
||||
const list = past ? [] : problems(work);
|
||||
issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t })));
|
||||
save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0;
|
||||
};
|
||||
|
||||
const paint = (): void => {
|
||||
paintKinds();
|
||||
paintDetail();
|
||||
paintFoot();
|
||||
};
|
||||
|
||||
const reload = async (): Promise<void> => {
|
||||
try {
|
||||
base = await fetchStructureDef();
|
||||
work = clone(base);
|
||||
dirty = false;
|
||||
sync();
|
||||
} catch (again) {
|
||||
showToast(to("Load_Failed", why(again)), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const save = createButton({ label: to("Save"), variant: "filled" });
|
||||
save.setAttribute("data-sod-save", "");
|
||||
save.addEventListener("click", async () => {
|
||||
if (problems(work).length) return;
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
base = await saveStructureDef(base.판, tidy(work));
|
||||
work = clone(base);
|
||||
dirty = false;
|
||||
versions = null;
|
||||
sync();
|
||||
showToast(to("SavedVersion", String(base.판)), "success");
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
showToast(to("Stale"), "error");
|
||||
await reload();
|
||||
} else showToast(to("Failed", why(error)), "error");
|
||||
} finally {
|
||||
paint();
|
||||
paintHistory();
|
||||
}
|
||||
});
|
||||
|
||||
/** 지난 판 목록 — 펼칠 때 받음 · 누르면 그 판을 읽기만으로 */
|
||||
const paintHistory = (): void => {
|
||||
if (!versions) return historyBox.replaceChildren();
|
||||
historyBox.replaceChildren(
|
||||
...(versions.length
|
||||
? versions.map((v) => {
|
||||
const b = createButton({
|
||||
label: to("PastItem", `${v.판} · ${v.저장일}`),
|
||||
variant: past?.판 === v.판 ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
});
|
||||
b.setAttribute("data-sod-past", String(v.판));
|
||||
b.addEventListener("click", async () => {
|
||||
try {
|
||||
past = await fetchStructureDefAt(v.판);
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
paint();
|
||||
paintHistory();
|
||||
});
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: to("PastNone") })]),
|
||||
);
|
||||
};
|
||||
const history = createButton({ label: to("PastList"), variant: "ghost" });
|
||||
history.setAttribute("data-sod-history-toggle", "");
|
||||
history.addEventListener("click", async () => {
|
||||
if (versions) {
|
||||
versions = null;
|
||||
return paintHistory();
|
||||
}
|
||||
try {
|
||||
versions = (await fetchStructureDefHistory()).sort((a, b) => b.판 - a.판);
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
paintHistory();
|
||||
});
|
||||
|
||||
const node = el("div", {
|
||||
className: "m02-opts m02-sod",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("DefNote") }),
|
||||
banner,
|
||||
el("div", { className: "m02-opts__panes", children: [kindList, detail] }),
|
||||
issues,
|
||||
el("div", {
|
||||
className: "m02-side__row",
|
||||
children: [...(opt.isAdmin ? [save] : []), history],
|
||||
}),
|
||||
historyBox,
|
||||
],
|
||||
});
|
||||
const setDirty = opt.show(to("Screen"), node);
|
||||
const sync = (): void => setDirty(dirty);
|
||||
paint();
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Layout.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **배치 격자**(B05 · B06 좌측 구조물 칸 = 두 칸)
|
||||
* 옵션마다 줄 · 칸 · 너비 + 미리보기(기준 측점은 맨 위 한 줄 · 겹치면 빨강).
|
||||
* 자리를 안 정한 옵션은 차례대로 빈 칸을 채움 · 숫자를 고치면 그 옵션 자리만 정의 `배치` 에 남음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
baseShown,
|
||||
GRID_COLS,
|
||||
live,
|
||||
overlaps,
|
||||
placeAll,
|
||||
type Spot,
|
||||
type StructDef,
|
||||
type TypeDef,
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { head, numberBox } from "./M02_SO_Def_Ui";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface LayoutBlockOptions {
|
||||
def: StructDef;
|
||||
type: TypeDef;
|
||||
edit: boolean;
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
const ROW_MAX = 60;
|
||||
|
||||
/** 미리보기 — 두 칸 격자 · 옵션마다 상자 */
|
||||
function preview(def: StructDef, type: TypeDef, cells: Map<string, Spot>): HTMLElement {
|
||||
const bad = new Set(overlaps(cells));
|
||||
const base = baseShown(def, type.type_id);
|
||||
const offset = base ? 1 : 0;
|
||||
const boxes: HTMLElement[] = [];
|
||||
if (base)
|
||||
boxes.push(
|
||||
el("div", {
|
||||
className: "m02-sod__cell is-base",
|
||||
text: to("BaseStation"),
|
||||
attrs: { style: `grid-row: 1; grid-column: 1 / span ${GRID_COLS}` },
|
||||
}),
|
||||
);
|
||||
for (const o of live(type.options)) {
|
||||
const c = cells.get(o.key)!;
|
||||
boxes.push(
|
||||
el("div", {
|
||||
className: `m02-sod__cell${bad.has(o.key) ? " is-bad" : ""}`,
|
||||
text: o.label,
|
||||
attrs: {
|
||||
"data-sod-cell": o.key,
|
||||
title: o.label,
|
||||
style: `grid-row: ${c.줄 + offset}; grid-column: ${c.칸} / span ${c.너비}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("div", {
|
||||
className: "m02-sod__grid",
|
||||
attrs: {
|
||||
"data-sod-preview": "",
|
||||
style: `grid-template-columns: repeat(${GRID_COLS}, minmax(0, 1fr))`,
|
||||
},
|
||||
children: boxes,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLayoutBlock(opt: LayoutBlockOptions): HTMLElement {
|
||||
const { def, type, edit, changed } = opt;
|
||||
const cells = placeAll(type.options, def.배치[type.type_id]);
|
||||
const rows = live(type.options).map((o) => {
|
||||
const c = cells.get(o.key)!;
|
||||
const fix = (patch: Partial<Spot>): void => {
|
||||
(def.배치[type.type_id] ??= {})[o.key] = { ...c, ...patch };
|
||||
changed();
|
||||
};
|
||||
return el("div", {
|
||||
className: "m02-sod__place",
|
||||
attrs: { "data-sod-place-row": o.key },
|
||||
children: [
|
||||
el("span", { className: "m02-sod__placename", text: o.label }),
|
||||
el("small", { text: to("GridRow") }),
|
||||
numberBox(c.줄, (n) => fix({ 줄: n }), {
|
||||
edit,
|
||||
min: 1,
|
||||
max: ROW_MAX,
|
||||
attr: "data-sod-row",
|
||||
}),
|
||||
el("small", { text: to("GridCol") }),
|
||||
numberBox(c.칸, (n) => fix({ 칸: n }), {
|
||||
edit,
|
||||
min: 1,
|
||||
max: GRID_COLS,
|
||||
attr: "data-sod-col",
|
||||
}),
|
||||
el("small", { text: to("GridWidth") }),
|
||||
numberBox(c.너비, (n) => fix({ 너비: n }), {
|
||||
edit,
|
||||
min: 1,
|
||||
max: GRID_COLS,
|
||||
attr: "data-sod-width",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__block",
|
||||
children: [
|
||||
...head(to("LayoutHead"), to("LayoutNote")),
|
||||
el("div", {
|
||||
className: "m02-sod__layout",
|
||||
children: [
|
||||
el("div", { className: "m02-sod__places", children: rows }),
|
||||
preview(def, type, cells),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Model.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2)의 자료 꼴 · 순수 손질 함수 — 화면 그리기 없음.
|
||||
* 정본 = 마스터 템플릿 `구조물정의.json`(48-1 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`).
|
||||
* 키는 만들 때 자동 · 안 바뀜 · 이름 자유 · 지움 = 「안 씀」(`enabled: false`).
|
||||
* 선택지는 글 그대로가 값 — 이름을 바꾸면 이 정의 안의 기본값 · 규칙도 같이 바꿈.
|
||||
* ========================================================================== */
|
||||
|
||||
export type OptionForm = "select" | "number" | "text" | "bool";
|
||||
|
||||
export interface Spot {
|
||||
줄: number;
|
||||
칸: number;
|
||||
너비: number;
|
||||
}
|
||||
|
||||
export interface OptionDef {
|
||||
key: string;
|
||||
label: string;
|
||||
input: OptionForm;
|
||||
choices?: string[];
|
||||
unit?: string | null;
|
||||
default?: unknown;
|
||||
enabled?: boolean;
|
||||
phase?: "b05" | "detail";
|
||||
[more: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TypeDef {
|
||||
type_id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
options: OptionDef[];
|
||||
enabled?: boolean;
|
||||
[more: string]: unknown;
|
||||
}
|
||||
|
||||
export type ShapeKey = "길이" | "높이" | "전" | "후";
|
||||
export const SHAPE_KEYS: readonly ShapeKey[] = ["길이", "높이", "전", "후"];
|
||||
|
||||
export interface GroupDef {
|
||||
id: string;
|
||||
이름: string;
|
||||
옵션: string[];
|
||||
형상?: Partial<Record<ShapeKey, { 받기: boolean; 뜻: string }>>;
|
||||
}
|
||||
|
||||
/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */
|
||||
export interface RuleDef {
|
||||
종류: string;
|
||||
만약: { 옵션: string; 값들: string[] };
|
||||
그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown };
|
||||
}
|
||||
|
||||
export interface StructDef {
|
||||
판: number;
|
||||
구조물군: Record<string, string>;
|
||||
동작: Record<string, string>;
|
||||
types: TypeDef[];
|
||||
종류: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
그룹: Record<string, GroupDef[]>;
|
||||
배치: Record<string, Record<string, Spot>>;
|
||||
규칙: RuleDef[];
|
||||
[more: string]: unknown;
|
||||
}
|
||||
|
||||
export const NAME_MAX = 40;
|
||||
/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */
|
||||
export const GRID_COLS = 2;
|
||||
|
||||
/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */
|
||||
export const clone = <T>(value: T): T => structuredClone(value);
|
||||
|
||||
/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */
|
||||
export function newKey(prefix: string, taken: Iterable<string>): string {
|
||||
const used = new Set(taken);
|
||||
for (;;) {
|
||||
const key = `${prefix}${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (key.length > prefix.length + 3 && !used.has(key)) return key;
|
||||
}
|
||||
}
|
||||
|
||||
/** 목록 안에서 한 칸 옮김 — 끝이면 그대로 · 옮겼으면 참 */
|
||||
export function step<T>(list: T[], index: number, delta: number): boolean {
|
||||
const at = index + delta;
|
||||
if (index < 0 || at < 0 || at >= list.length) return false;
|
||||
[list[index], list[at]] = [list[at], list[index]];
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 쓰는 것만 — 「안 씀」(enabled 거짓) 뺌 */
|
||||
export const live = <T extends { enabled?: boolean }>(list: readonly T[]): T[] =>
|
||||
list.filter((x) => x.enabled !== false);
|
||||
|
||||
/** 기준 측점 칸 보이기 — 없으면 보임 */
|
||||
export const baseShown = (def: StructDef, typeId: string): boolean =>
|
||||
def.종류[typeId]?.기준측점 !== false;
|
||||
|
||||
/** 이름 검사 — 1~40자 · 같은 목록 안에서 안 겹침 · 틀린 이름들 */
|
||||
export function badNames(names: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const bad: string[] = [];
|
||||
for (const raw of names) {
|
||||
const name = raw.trim();
|
||||
if (!name || name.length > NAME_MAX || seen.has(name)) bad.push(name || "(빈 이름)");
|
||||
seen.add(name);
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
/** 구조물군 순서 바꿈 — `{키: 이름}` 묶음의 키 차례 */
|
||||
export function stepFamily(def: StructDef, key: string, delta: number): boolean {
|
||||
const entries = Object.entries(def.구조물군);
|
||||
if (
|
||||
!step(
|
||||
entries,
|
||||
entries.findIndex(([k]) => k === key),
|
||||
delta,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
def.구조물군 = Object.fromEntries(entries);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 종류 순서 바꿈 — 같은 군 안 앞뒤 종류와 `types` 자리를 바꿈 */
|
||||
export function stepType(def: StructDef, typeId: string, delta: number): boolean {
|
||||
const t = def.types.find((x) => x.type_id === typeId);
|
||||
if (!t) return false;
|
||||
const same = def.types.filter((x) => x.group === t.group);
|
||||
const other = same[same.indexOf(t) + delta];
|
||||
if (!other) return false;
|
||||
const i = def.types.indexOf(t);
|
||||
const j = def.types.indexOf(other);
|
||||
[def.types[i], def.types[j]] = [def.types[j], def.types[i]];
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 선택지 이름 바꿈 — 그 옵션 기본값 · 이 종류 규칙의 값들 · 선택지 · 기본값도 같이 */
|
||||
export function renameChoice(
|
||||
def: StructDef,
|
||||
typeId: string,
|
||||
o: OptionDef,
|
||||
from: string,
|
||||
to: string,
|
||||
): void {
|
||||
if (from === to || !o.choices) return;
|
||||
o.choices = o.choices.map((c) => (c === from ? to : c));
|
||||
if (o.default === from) o.default = to;
|
||||
for (const r of def.규칙) {
|
||||
if (r.종류 !== typeId) continue;
|
||||
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.map((v) => (v === from ? to : v));
|
||||
if (r.그러면.옵션 === o.key) {
|
||||
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.map((v) => (v === from ? to : v));
|
||||
if (r.그러면.기본값 === from) r.그러면.기본값 = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 선택지 뺌 — 기본값 · 규칙에서도 뺌(규칙 값들이 비면 그 규칙은 검사에서 걸림) */
|
||||
export function dropChoice(def: StructDef, typeId: string, o: OptionDef, value: string): void {
|
||||
o.choices = (o.choices ?? []).filter((c) => c !== value);
|
||||
if (o.default === value) o.default = null;
|
||||
for (const r of def.규칙) {
|
||||
if (r.종류 !== typeId) continue;
|
||||
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.filter((v) => v !== value);
|
||||
if (r.그러면.옵션 === o.key) {
|
||||
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.filter((v) => v !== value);
|
||||
if (r.그러면.기본값 === value) delete r.그러면.기본값;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 배치 격자 — 정한 자리 먼저 · 안 정한 옵션은 차례대로 빈 칸 채움 · 안 씀은 빠짐 */
|
||||
export function placeAll(
|
||||
options: readonly OptionDef[],
|
||||
spots: Record<string, Spot> = {},
|
||||
cols = GRID_COLS,
|
||||
): Map<string, Spot> {
|
||||
const out = new Map<string, Spot>();
|
||||
const taken = new Set<string>();
|
||||
const mark = (c: Spot): void => {
|
||||
for (let i = 0; i < c.너비; i += 1) taken.add(`${c.줄}:${c.칸 + i}`);
|
||||
};
|
||||
const shown = live(options);
|
||||
for (const o of shown) {
|
||||
const s = spots[o.key];
|
||||
if (!s) continue;
|
||||
const width = Math.max(1, Math.min(cols, s.너비 || 1));
|
||||
const cell = { 줄: s.줄, 칸: Math.min(s.칸, cols - width + 1), 너비: width };
|
||||
out.set(o.key, cell);
|
||||
mark(cell);
|
||||
}
|
||||
let row = 1;
|
||||
let col = 1;
|
||||
for (const o of shown) {
|
||||
if (out.has(o.key)) continue;
|
||||
while (taken.has(`${row}:${col}`)) {
|
||||
col += 1;
|
||||
if (col > cols) ((row += 1), (col = 1));
|
||||
}
|
||||
const cell = { 줄: row, 칸: col, 너비: 1 };
|
||||
out.set(o.key, cell);
|
||||
mark(cell);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 겹친 칸 — 두 옵션이 같은 줄 · 칸을 쓰면 그 키들 */
|
||||
export function overlaps(cells: Map<string, Spot>): string[] {
|
||||
const owner = new Map<string, string>();
|
||||
const bad = new Set<string>();
|
||||
for (const [key, c] of cells)
|
||||
for (let i = 0; i < c.너비; i += 1) {
|
||||
const at = `${c.줄}:${c.칸 + i}`;
|
||||
const other = owner.get(at);
|
||||
if (other) bad.add(other).add(key);
|
||||
else owner.set(at, key);
|
||||
}
|
||||
return [...bad];
|
||||
}
|
||||
|
||||
/** 규칙 한 줄이 틀렸나 — 서버 검사(`M02_SO_Definition._rule_errors`)와 같은 뜻 */
|
||||
export function ruleBad(def: StructDef, r: RuleDef): boolean {
|
||||
const t = def.types.find((x) => x.type_id === r.종류);
|
||||
const src = t?.options.find((o) => o.key === r.만약.옵션);
|
||||
const dst = t?.options.find((o) => o.key === r.그러면.옵션);
|
||||
if (!src || !dst || src === dst || !r.만약.값들.length) return true;
|
||||
if (src.input === "select" && !r.만약.값들.every((v) => src.choices?.includes(v))) return true;
|
||||
const { 선택지, 보이기, 기본값 } = r.그러면;
|
||||
if (선택지 === undefined && 보이기 === undefined && 기본값 === undefined) return true;
|
||||
let allowed = dst.choices ?? [];
|
||||
if (선택지 !== undefined) {
|
||||
if (dst.input !== "select" || !선택지.every((v) => allowed.includes(v))) return true;
|
||||
allowed = 선택지;
|
||||
}
|
||||
return 기본값 !== undefined && dst.input === "select" && !allowed.includes(String(기본값));
|
||||
}
|
||||
|
||||
/** 저장 전 검사 — 화면에 띄울 까닭 글들(빈 목록 = 저장 됨) */
|
||||
export function problems(def: StructDef): string[] {
|
||||
const out: string[] = [];
|
||||
const fam = badNames(Object.values(def.구조물군));
|
||||
if (fam.length) out.push(`구조물군 이름: ${fam.join(", ")}`);
|
||||
for (const g of Object.keys(def.구조물군)) {
|
||||
const bad = badNames(def.types.filter((t) => t.group === g).map((t) => t.name));
|
||||
if (bad.length) out.push(`${def.구조물군[g]} 종류 이름: ${bad.join(", ")}`);
|
||||
}
|
||||
for (const t of def.types) {
|
||||
const opt = badNames(t.options.map((o) => o.label));
|
||||
if (opt.length) out.push(`${t.name} 옵션 이름: ${opt.join(", ")}`);
|
||||
for (const o of t.options) {
|
||||
if (o.input !== "select") continue;
|
||||
const ch = badNames(o.choices ?? []);
|
||||
if (ch.length) out.push(`${t.name}.${o.label} 선택지: ${ch.join(", ")}`);
|
||||
if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`);
|
||||
}
|
||||
const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름));
|
||||
if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`);
|
||||
const lap = overlaps(placeAll(t.options, def.배치[t.type_id]));
|
||||
if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`);
|
||||
}
|
||||
def.규칙.forEach((r, i) => {
|
||||
if (ruleBad(def, r)) out.push(`규칙 ${i + 1} 이 틀림`);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Options.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **옵션 목록**
|
||||
* (이름 · 꼴 · 단위 · 기본값 · 보이기 · 들어갈 자리 · 안 씀 · 순서 · [옵션 더함]) +
|
||||
* 고르기 꼴의 **선택지 목록**(이름 바꿈 · 빼기 · 순서 · [선택지 더함]).
|
||||
* 옵션 키는 더할 때 자동 · 안 바뀜 · 선택지는 글이 곧 값(이름 바꿈 = 기본값 · 규칙도 같이) ·
|
||||
* 저장은 화면 [저장](`M02_SO_Def.ts`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import {
|
||||
dropChoice,
|
||||
newKey,
|
||||
renameChoice,
|
||||
type OptionDef,
|
||||
type OptionForm,
|
||||
type StructDef,
|
||||
type TypeDef,
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { checkBox, head, moveButtons, pickBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { buildPlaceSelect } from "./M02_SO_Groups";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface OptionsBlockOptions {
|
||||
def: StructDef;
|
||||
type: TypeDef;
|
||||
edit: boolean;
|
||||
/** 선택지를 펼친 옵션 키 — 다시 그려도 남음 */
|
||||
open: Set<string>;
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
const FORMS: readonly OptionForm[] = ["select", "number", "text", "bool"];
|
||||
const FORM_TEXT = {
|
||||
select: "FormSelect",
|
||||
number: "FormNumber",
|
||||
text: "FormText",
|
||||
bool: "FormBool",
|
||||
} as const;
|
||||
|
||||
/** 기본값 칸 — 꼴에 맞춤(고르기 = 선택지 · 예/아니오 · 숫자 · 글) · 비우면 기본값 없음 */
|
||||
function defaultBox(o: OptionDef, edit: boolean, changed: () => void): HTMLElement {
|
||||
const attr = "data-sod-default";
|
||||
const now = o.default == null ? "" : String(o.default);
|
||||
if (o.input === "select" || o.input === "bool") {
|
||||
const pairs: [string, string][] =
|
||||
o.input === "select"
|
||||
? (o.choices ?? []).map((c) => [c, c])
|
||||
: [
|
||||
["true", to("Yes")],
|
||||
["false", to("No")],
|
||||
];
|
||||
return pickBox(
|
||||
[["", to("NoDefault")], ...pairs],
|
||||
now,
|
||||
(v) => {
|
||||
o.default = v === "" ? null : o.input === "bool" ? v === "true" : v;
|
||||
changed();
|
||||
},
|
||||
{ edit, attr, cls: "m02-sod__default" },
|
||||
);
|
||||
}
|
||||
return textBox(
|
||||
now,
|
||||
(v) => {
|
||||
const n = Number(v);
|
||||
o.default = v.trim() === "" ? null : o.input === "number" && Number.isFinite(n) ? n : v;
|
||||
},
|
||||
changed,
|
||||
{ edit, attr, cls: "m02-sod__default", place: to("NoDefault") },
|
||||
);
|
||||
}
|
||||
|
||||
/** 선택지 목록 — 이름(떠날 때 바꿈) · 빼기 · 순서 · [선택지 더함] */
|
||||
function choicesBlock(opt: OptionsBlockOptions, o: OptionDef): HTMLElement {
|
||||
const { def, type, edit, changed } = opt;
|
||||
const list = (o.choices ??= []);
|
||||
const rows = list.map((c) => {
|
||||
let next = c;
|
||||
const del = createButton({ label: to("ChoiceDrop"), variant: "danger", size: "sm" });
|
||||
del.addEventListener("click", async () => {
|
||||
if (!(await showConfirmDialog(to("ChoiceDropAsk", c), to("ChoiceDrop")))) return;
|
||||
dropChoice(def, type.type_id, o, c);
|
||||
changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__choice",
|
||||
attrs: { "data-sod-choice": c },
|
||||
children: [
|
||||
textBox(
|
||||
c,
|
||||
(v) => (next = v.trim()),
|
||||
() => {
|
||||
if (next && next !== c && !list.includes(next))
|
||||
renameChoice(def, type.type_id, o, c, next);
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-choice-name" },
|
||||
),
|
||||
...moveButtons(list, c, changed, edit),
|
||||
...(edit ? [del] : []),
|
||||
],
|
||||
});
|
||||
});
|
||||
const add = createButton({ label: to("ChoiceAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sod-choice-add", "");
|
||||
add.addEventListener("click", () => {
|
||||
let n = list.length + 1;
|
||||
while (list.includes(to("ChoiceNew", String(n)))) n += 1;
|
||||
list.push(to("ChoiceNew", String(n)));
|
||||
changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__choices",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("ChoiceNote") }),
|
||||
...(rows.length ? rows : [el("p", { className: "m02-sod__bad", text: to("ChoiceNone") })]),
|
||||
...(edit ? [add] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 옵션 한 줄(+ 펼친 선택지) */
|
||||
function optionRow(opt: OptionsBlockOptions, o: OptionDef): HTMLElement {
|
||||
const { def, type, edit, open, changed } = opt;
|
||||
const off = o.enabled === false;
|
||||
const hidden = def.표시[type.type_id]?.[o.key] === false;
|
||||
const form = pickBox(
|
||||
FORMS.map((f) => [f, to(FORM_TEXT[f])] as const),
|
||||
o.input,
|
||||
(v) => {
|
||||
o.input = v as OptionForm;
|
||||
o.default = null; // 꼴이 바뀌면 옛 기본값은 뜻이 없음
|
||||
if (o.input === "select") open.add(o.key);
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-form", cls: "m02-sod__form" },
|
||||
);
|
||||
const choices =
|
||||
o.input === "select"
|
||||
? createButton({
|
||||
label: to("Choices", String(o.choices?.length ?? 0)),
|
||||
variant: open.has(o.key) ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
})
|
||||
: null;
|
||||
choices?.setAttribute("data-sod-choices", o.key);
|
||||
choices?.addEventListener("click", () => {
|
||||
if (open.has(o.key)) open.delete(o.key);
|
||||
else open.add(o.key);
|
||||
changed();
|
||||
});
|
||||
const show = checkBox(
|
||||
to("Shown"),
|
||||
!hidden,
|
||||
(v) => {
|
||||
const row = (def.표시[type.type_id] ??= {});
|
||||
if (v) delete row[o.key];
|
||||
else row[o.key] = false;
|
||||
if (!Object.keys(row).length) delete def.표시[type.type_id];
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-shown" },
|
||||
);
|
||||
const row = el("div", {
|
||||
className: `m02-sod__option${off ? " is-off" : ""}`,
|
||||
attrs: { "data-sod-option": o.key },
|
||||
children: [
|
||||
textBox(o.label, (v) => (o.label = v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-name",
|
||||
cls: "m02-sod__name",
|
||||
}),
|
||||
el("small", { className: "m02-opts__key", text: o.key }),
|
||||
...(o.phase === "detail" ? [createTag(to("Detail"), "accent")] : []),
|
||||
form,
|
||||
textBox(o.unit ?? "", (v) => (o.unit = v.trim() || null), changed, {
|
||||
edit,
|
||||
attr: "data-sod-unit",
|
||||
cls: "m02-sod__unit",
|
||||
place: to("Unit"),
|
||||
max: 12,
|
||||
}),
|
||||
defaultBox(o, edit, changed),
|
||||
show,
|
||||
buildPlaceSelect((def.그룹[type.type_id] ??= []), o.key, edit, changed),
|
||||
checkBox(to("Unused"), off, (v) => ((o.enabled = !v), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-off",
|
||||
}),
|
||||
...moveButtons(type.options, o, changed, edit),
|
||||
...(choices ? [choices] : []),
|
||||
],
|
||||
});
|
||||
return o.input === "select" && open.has(o.key)
|
||||
? el("div", { className: "m02-sod__optwrap", children: [row, choicesBlock(opt, o)] })
|
||||
: row;
|
||||
}
|
||||
|
||||
/** 옵션 목록 칸 — 제목 · 안내 · 옵션 줄 · [옵션 더함] */
|
||||
export function buildOptionsBlock(opt: OptionsBlockOptions): HTMLElement {
|
||||
const { type, edit, changed } = opt;
|
||||
const add = createButton({ label: to("OptionAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sod-option-add", "");
|
||||
add.addEventListener("click", () => {
|
||||
const n = type.options.length + 1;
|
||||
const o: OptionDef = {
|
||||
key: newKey(
|
||||
"opt_",
|
||||
type.options.map((x) => x.key),
|
||||
),
|
||||
label: to("OptionNew", String(n)),
|
||||
input: "select",
|
||||
choices: [to("ChoiceNew", "1")],
|
||||
default: null,
|
||||
};
|
||||
type.options.push(o);
|
||||
opt.open.add(o.key);
|
||||
changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__block",
|
||||
children: [
|
||||
...head(to("OptionHead"), to("OptionNote")),
|
||||
...(type.options.length
|
||||
? type.options.map((o) => optionRow(opt, o))
|
||||
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Rules.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **규칙 표**(정의 `규칙` 중 그 종류 것만)
|
||||
* 「만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값」 한 줄씩 · [규칙 더함] · 지우기.
|
||||
* 「그대로」 칸은 정의에 안 남김 · 셋 다 그대로면 틀린 줄(빨강) · 적용은 B05 · B06(48-4).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
live,
|
||||
ruleBad,
|
||||
type OptionDef,
|
||||
type RuleDef,
|
||||
type StructDef,
|
||||
type TypeDef,
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { head, pickBox } from "./M02_SO_Def_Ui";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface RulesBlockOptions {
|
||||
def: StructDef;
|
||||
type: TypeDef;
|
||||
edit: boolean;
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
const bool = (): [string, string][] => [
|
||||
["true", to("Yes")],
|
||||
["false", to("No")],
|
||||
];
|
||||
|
||||
/** 여럿 고르기 — 체크한 것만 차례대로 */
|
||||
function multiPick(
|
||||
all: readonly string[],
|
||||
picked: readonly string[],
|
||||
set: (v: string[]) => void,
|
||||
edit: boolean,
|
||||
attr: string,
|
||||
): HTMLElement {
|
||||
const on = new Set(picked);
|
||||
return el("div", {
|
||||
className: "m02-sod__rulechoices",
|
||||
attrs: { [attr]: "" },
|
||||
children: all.map((c) => {
|
||||
const box = el("input", { attrs: { type: "checkbox", value: c } }) as HTMLInputElement;
|
||||
box.checked = on.has(c);
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => {
|
||||
if (box.checked) on.add(c);
|
||||
else on.delete(c);
|
||||
set(all.filter((x) => on.has(x)));
|
||||
});
|
||||
return el("label", { className: "m02-sod__check", children: [box, el("span", { text: c })] });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 글 칸 — 쉼표로 여럿 */
|
||||
function listText(
|
||||
value: string[],
|
||||
set: (v: string[]) => void,
|
||||
edit: boolean,
|
||||
attr: string,
|
||||
): HTMLElement {
|
||||
const box = el("input", {
|
||||
className: "ui-input ui-input--sm m02-sod__rulepick",
|
||||
attrs: { type: "text", [attr]: "", placeholder: to("RuleValues") },
|
||||
}) as HTMLInputElement;
|
||||
box.value = value.join(", ");
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () =>
|
||||
set(
|
||||
box.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 만약 값들 — 고르기 = 선택지 체크 · 예/아니오 = 하나 · 그 밖 = 쉼표 글 */
|
||||
function whenValues(
|
||||
src: OptionDef | undefined,
|
||||
rule: RuleDef,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
): HTMLElement {
|
||||
const set = (v: string[]): void => ((rule.만약.값들 = v), changed());
|
||||
if (src?.input === "select")
|
||||
return multiPick(src.choices ?? [], rule.만약.값들, set, edit, "data-sod-rule-value");
|
||||
if (src?.input === "bool")
|
||||
return pickBox(bool(), rule.만약.값들[0] ?? "true", (v) => set([v]), {
|
||||
edit,
|
||||
attr: "data-sod-rule-value",
|
||||
cls: "m02-sod__rulepick",
|
||||
});
|
||||
return listText(rule.만약.값들, set, edit, "data-sod-rule-value");
|
||||
}
|
||||
|
||||
/** 그러면 기본값 — 「그대로」 + (좁힌) 선택지 · 예/아니오 · 글 */
|
||||
function thenDefault(
|
||||
dst: OptionDef | undefined,
|
||||
rule: RuleDef,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
): HTMLElement {
|
||||
const then = rule.그러면;
|
||||
const now = then.기본값 === undefined ? "" : String(then.기본값);
|
||||
const set = (v: string): void => {
|
||||
if (v === "") delete then.기본값;
|
||||
else
|
||||
then.기본값 =
|
||||
dst?.input === "bool"
|
||||
? v === "true"
|
||||
: dst?.input === "number" && Number.isFinite(Number(v))
|
||||
? Number(v)
|
||||
: v;
|
||||
changed();
|
||||
};
|
||||
const pairs: [string, string][] =
|
||||
dst?.input === "select"
|
||||
? (then.선택지 ?? dst.choices ?? []).map((c) => [c, c])
|
||||
: dst?.input === "bool"
|
||||
? bool()
|
||||
: [];
|
||||
if (pairs.length || (dst?.input !== "number" && dst?.input !== "text"))
|
||||
return pickBox([["", to("KeepDefault")], ...pairs], now, set, {
|
||||
edit,
|
||||
attr: "data-sod-rule-default",
|
||||
cls: "m02-sod__rulepick",
|
||||
});
|
||||
const box = el("input", {
|
||||
className: "ui-input ui-input--sm m02-sod__rulepick",
|
||||
attrs: { type: "text", "data-sod-rule-default": "", placeholder: to("KeepDefault") },
|
||||
}) as HTMLInputElement;
|
||||
box.value = now;
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => set(box.value.trim()));
|
||||
return box;
|
||||
}
|
||||
|
||||
function ruleRow(opt: RulesBlockOptions, rule: RuleDef, index: number): HTMLElement {
|
||||
const { def, type, edit, changed } = opt;
|
||||
const byKey = new Map(type.options.map((o) => [o.key, o]));
|
||||
const names = live(type.options).map((o) => [o.key, o.label] as [string, string]);
|
||||
const src = byKey.get(rule.만약.옵션);
|
||||
const dst = byKey.get(rule.그러면.옵션);
|
||||
const then = rule.그러면;
|
||||
const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" });
|
||||
del.addEventListener("click", () => (def.규칙.splice(def.규칙.indexOf(rule), 1), changed()));
|
||||
const show = then.보이기 === undefined ? "" : then.보이기 ? "1" : "0";
|
||||
return el("div", {
|
||||
className: `m02-sod__rule${ruleBad(def, rule) ? " is-bad" : ""}`,
|
||||
attrs: { "data-sod-rule": String(index) },
|
||||
children: [
|
||||
el("small", { text: to("RuleIf") }),
|
||||
pickBox(names, rule.만약.옵션, (v) => ((rule.만약 = { 옵션: v, 값들: [] }), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-rule-top",
|
||||
cls: "m02-sod__rulepick",
|
||||
}),
|
||||
el("small", { text: "=" }),
|
||||
whenValues(src, rule, edit, changed),
|
||||
el("small", { text: to("RuleThen") }),
|
||||
pickBox(names, then.옵션, (v) => ((rule.그러면 = { 옵션: v }), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-rule-target",
|
||||
cls: "m02-sod__rulepick",
|
||||
}),
|
||||
...(dst?.input === "select"
|
||||
? [
|
||||
el("small", { text: to("RuleChoices") }),
|
||||
multiPick(
|
||||
dst.choices ?? [],
|
||||
then.선택지 ?? [],
|
||||
(v) => {
|
||||
if (v.length) then.선택지 = v;
|
||||
else delete then.선택지;
|
||||
changed();
|
||||
},
|
||||
edit,
|
||||
"data-sod-rule-choice",
|
||||
),
|
||||
]
|
||||
: []),
|
||||
pickBox(
|
||||
[
|
||||
["", to("Keep")],
|
||||
["1", to("Show")],
|
||||
["0", to("Hide")],
|
||||
],
|
||||
show,
|
||||
(v) => {
|
||||
if (v === "") delete then.보이기;
|
||||
else then.보이기 = v === "1";
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-rule-show", cls: "m02-sod__rulepick" },
|
||||
),
|
||||
thenDefault(dst, rule, edit, changed),
|
||||
...(edit ? [del] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRulesBlock(opt: RulesBlockOptions): HTMLElement {
|
||||
const { def, type, edit, changed } = opt;
|
||||
const mine = def.규칙.filter((r) => r.종류 === type.type_id);
|
||||
const opts = live(type.options);
|
||||
const add = createButton({ label: to("RuleAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sod-rule-add", "");
|
||||
add.disabled = opts.length < 2;
|
||||
add.addEventListener("click", () => {
|
||||
const top = opts.find((o) => o.input === "select") ?? opts[0];
|
||||
const target = opts.find((o) => o.key !== top.key)!;
|
||||
def.규칙.push({
|
||||
종류: type.type_id,
|
||||
만약: { 옵션: top.key, 값들: top.choices?.length ? [top.choices[0]] : [] },
|
||||
그러면: { 옵션: target.key, 보이기: false },
|
||||
});
|
||||
changed();
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__block",
|
||||
children: [
|
||||
...head(to("RuleHead"), to("RuleNote")),
|
||||
...(mine.length
|
||||
? mine.map((r) => ruleRow(opt, r, def.규칙.indexOf(r)))
|
||||
: [el("p", { className: "m02-side__empty", text: to("RuleNone") })]),
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Ui.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2)의 작은 입력 부품 — 글 · 숫자 · 체크 · 고르기 · 위아래 단추.
|
||||
* 값은 받은 손잡이로 사본만 고침 · 글은 칸을 떠날 때(`change`) 다시 그림(초점 안 끊김).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { step } from "./M02_SO_Def_Model";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
/** 글 칸 — 글자마다 `set` · 떠날 때 `done` */
|
||||
export function textBox(
|
||||
value: string,
|
||||
set: (v: string) => void,
|
||||
done: () => void,
|
||||
opt: { edit: boolean; attr?: string; place?: string; cls?: string; max?: number },
|
||||
): HTMLInputElement {
|
||||
const box = el("input", {
|
||||
className: `ui-input ui-input--sm ${opt.cls ?? ""}`,
|
||||
attrs: {
|
||||
type: "text",
|
||||
maxlength: String(opt.max ?? 40),
|
||||
...(opt.attr ? { [opt.attr]: "" } : {}),
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
box.value = value;
|
||||
box.placeholder = opt.place ?? "";
|
||||
box.disabled = !opt.edit;
|
||||
box.addEventListener("input", () => {
|
||||
set(box.value);
|
||||
box.classList.toggle("is-invalid", !box.value.trim() && !opt.place);
|
||||
});
|
||||
box.addEventListener("change", done);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 숫자 칸 — 범위 밖 · 빈 칸은 무름 */
|
||||
export function numberBox(
|
||||
value: number,
|
||||
set: (v: number) => void,
|
||||
opt: { edit: boolean; min: number; max: number; attr?: string },
|
||||
): HTMLInputElement {
|
||||
const box = el("input", {
|
||||
className: "ui-input ui-input--sm m02-sod__num",
|
||||
attrs: {
|
||||
type: "number",
|
||||
min: String(opt.min),
|
||||
max: String(opt.max),
|
||||
...(opt.attr ? { [opt.attr]: "" } : {}),
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
box.value = String(value);
|
||||
box.disabled = !opt.edit;
|
||||
box.addEventListener("change", () => {
|
||||
const n = Math.round(Number(box.value));
|
||||
if (box.value === "" || !Number.isFinite(n) || n < opt.min || n > opt.max) {
|
||||
box.value = String(value);
|
||||
return;
|
||||
}
|
||||
set(n);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 체크 + 글 */
|
||||
export function checkBox(
|
||||
label: string,
|
||||
on: boolean,
|
||||
set: (v: boolean) => void,
|
||||
opt: { edit: boolean; attr?: string },
|
||||
): HTMLLabelElement {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", ...(opt.attr ? { [opt.attr]: "" } : {}) },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = on;
|
||||
box.disabled = !opt.edit;
|
||||
box.addEventListener("change", () => set(box.checked));
|
||||
return el("label", { className: "m02-sod__check", children: [box, el("span", { text: label })] });
|
||||
}
|
||||
|
||||
/** 고르기 — [값, 글] 짝 */
|
||||
export function pickBox(
|
||||
pairs: readonly (readonly [string, string])[],
|
||||
value: string,
|
||||
set: (v: string) => void,
|
||||
opt: { edit: boolean; attr?: string; cls?: string },
|
||||
): HTMLSelectElement {
|
||||
const box = el("select", {
|
||||
className: `ui-input ui-input--sm ${opt.cls ?? ""}`,
|
||||
attrs: opt.attr ? { [opt.attr]: "" } : {},
|
||||
}) as HTMLSelectElement;
|
||||
box.append(...pairs.map(([v, t]) => new Option(t, v)));
|
||||
box.value = value;
|
||||
box.disabled = !opt.edit;
|
||||
box.addEventListener("change", () => set(box.value));
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 위로 · 아래로 단추 둘 — 고치는 사람만 · 옮겼으면 `changed` */
|
||||
export function moveButtons<T>(
|
||||
list: T[],
|
||||
item: T,
|
||||
changed: () => void,
|
||||
edit: boolean,
|
||||
): HTMLElement[] {
|
||||
if (!edit) return [];
|
||||
const index = list.indexOf(item);
|
||||
const make = (label: string, delta: number, off: boolean): HTMLButtonElement => {
|
||||
const b = createButton({ label, variant: "ghost", size: "sm" });
|
||||
b.disabled = off;
|
||||
b.addEventListener("click", () => step(list, list.indexOf(item), delta) && changed());
|
||||
return b;
|
||||
};
|
||||
return [make(to("GroupUp"), -1, index <= 0), make(to("GroupDown"), 1, index >= list.length - 1)];
|
||||
}
|
||||
|
||||
/** 절 제목 + 안내 한 줄 */
|
||||
export const head = (title: string, note?: string): HTMLElement[] => [
|
||||
el("h4", { className: "m02-opts__group", text: title }),
|
||||
...(note ? [el("p", { className: "m02-side__empty", text: note })] : []),
|
||||
];
|
||||
@@ -0,0 +1,226 @@
|
||||
"""M02 구조물 정의 한 벌(PLAN 48-1) — 정본 `resources/master_template/구조물정의.json`.
|
||||
|
||||
틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`. 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만.
|
||||
판 = 정수 · 저장마다 +1 · 앞 판은 `구조물정의_이력/판_N.json` 으로 남김(지난 정의 값 읽기용).
|
||||
검사 = 칸 꼴 · 키 겹침 · 표시 · 그룹 · 배치 · 규칙 대상 있음 — 틀리면 422 · 낡은 판 409.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FILE = "구조물정의.json"
|
||||
HISTORY = "구조물정의_이력"
|
||||
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types"}
|
||||
SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list}
|
||||
SPOT = ("줄", "칸", "너비")
|
||||
|
||||
|
||||
def read() -> dict[str, Any]:
|
||||
return schema.load_definition()
|
||||
|
||||
|
||||
def history_path(version: int) -> Path:
|
||||
return Path(schema.DEFINITION_PATH).parent / HISTORY / f"판_{version}.json"
|
||||
|
||||
|
||||
def history() -> list[dict[str, Any]]:
|
||||
"""지난 판 목록 `[{판, 저장일}]` — 이력 `판_N.json` · 저장일 = 파일 고친 때(분까지)."""
|
||||
folder = history_path(1).parent
|
||||
rows = []
|
||||
for path in folder.glob("판_*.json") if folder.is_dir() else []:
|
||||
number = path.stem.removeprefix("판_")
|
||||
if number.isdigit():
|
||||
when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
rows.append({"판": int(number), "저장일": when})
|
||||
return sorted(rows, key=lambda r: r["판"], reverse=True)
|
||||
|
||||
|
||||
def read_version(version: int) -> dict[str, Any]:
|
||||
"""지난 판 한 벌(읽기만) — 없으면 404."""
|
||||
path = history_path(version)
|
||||
if not path.is_file():
|
||||
raise store.StoreError(404, f"판 {version} 이력이 없음")
|
||||
return json.loads(path.read_text("utf-8"))
|
||||
|
||||
|
||||
def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
"""(틀린 것 · 종류 → {옵션 키: 옵션}) — 종류 꼴 · 키 겹침 · 구조물군 있음."""
|
||||
errors: list[str] = []
|
||||
keys: dict[str, dict[str, Any]] = {}
|
||||
groups = doc.get("구조물군")
|
||||
if not isinstance(groups, dict):
|
||||
errors.append("구조물군은 {키: 이름} 묶음이어야 함")
|
||||
groups = {}
|
||||
for row in doc.get("types") if isinstance(doc.get("types"), list) else []:
|
||||
try:
|
||||
t = schema.StructureType.model_validate(row)
|
||||
except ValidationError as e:
|
||||
errors.append(
|
||||
f"종류 「{row.get('type_id') if isinstance(row, dict) else row}」 꼴 — {e}"
|
||||
)
|
||||
continue
|
||||
if t.type_id in keys:
|
||||
errors.append(f"종류 키 「{t.type_id}」 겹침")
|
||||
if t.group not in groups:
|
||||
errors.append(f"「{t.type_id}」 구조물군 「{t.group}」 이 구조물군에 없음")
|
||||
opts: dict[str, Any] = {}
|
||||
for o in t.options:
|
||||
if o.key in opts:
|
||||
errors.append(f"「{t.type_id}」 옵션 키 「{o.key}」 겹침")
|
||||
if o.input == "select" and not o.choices:
|
||||
errors.append(f"「{t.type_id}.{o.key}」 고르기 꼴은 선택지가 있어야 함")
|
||||
opts[o.key] = o
|
||||
keys[t.type_id] = opts
|
||||
if not isinstance(doc.get("types"), list) or not keys:
|
||||
errors.append("types 는 종류 목록이어야 함")
|
||||
return errors, keys
|
||||
|
||||
|
||||
def _names_ok(value: Any) -> bool:
|
||||
"""구조물 명세 이름 바꿈 표 — `{컨테이너[.항목[=값]]: 빈 글 아닌 이름}`."""
|
||||
return isinstance(value, dict) and all(
|
||||
isinstance(k, str) and isinstance(v, str) and v.strip() for k, v in value.items()
|
||||
)
|
||||
|
||||
|
||||
def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""종류마다 덧칸 — `{기준측점: 참거짓, 동작: [동작 이름], 이름: {명세 이름 키: 이름}}`(이름 = PLAN 52)."""
|
||||
errors: list[str] = []
|
||||
acts = doc.get("동작") if isinstance(doc.get("동작"), dict) else {}
|
||||
for tid, row in doc["종류"].items():
|
||||
if tid not in keys or not isinstance(row, dict):
|
||||
errors.append(f"종류 덧칸 「{tid}」 — 없는 종류이거나 묶음 아님")
|
||||
continue
|
||||
for name, value in row.items():
|
||||
if name == "기준측점" and isinstance(value, bool):
|
||||
continue
|
||||
if name == "동작" and isinstance(value, list) and all(a in acts for a in value):
|
||||
continue
|
||||
if name == "이름" and _names_ok(value):
|
||||
continue
|
||||
errors.append(
|
||||
f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · 이름 = 글 묶음)"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _spot_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""배치 — `{종류: {옵션 키: {줄, 칸, 너비}}}` · 1 이상 정수."""
|
||||
errors: list[str] = []
|
||||
for tid, row in doc["배치"].items():
|
||||
if tid not in keys or not isinstance(row, dict):
|
||||
errors.append(f"배치 「{tid}」 — 없는 종류이거나 묶음 아님")
|
||||
continue
|
||||
for key, spot in row.items():
|
||||
ok = isinstance(spot, dict) and set(spot) <= set(SPOT)
|
||||
ok = ok and all(type(v) is int and v >= 1 for v in spot.values())
|
||||
if key not in keys[tid] or not ok:
|
||||
errors.append(
|
||||
f"배치 「{tid}.{key}」 — 없는 옵션이거나 줄 · 칸 · 너비가 1 이상 정수 아님"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""규칙 — `{종류, 만약: {옵션, 값들}, 그러면: {옵션, 선택지? · 보이기? · 기본값?}}`."""
|
||||
errors: list[str] = []
|
||||
for i, rule in enumerate(doc["규칙"], 1):
|
||||
where = f"규칙 {i}"
|
||||
if not isinstance(rule, dict) or rule.get("종류") not in keys:
|
||||
errors.append(f"{where} — 없는 종류")
|
||||
continue
|
||||
opts = keys[rule["종류"]]
|
||||
when, then = rule.get("만약"), rule.get("그러면")
|
||||
if not isinstance(when, dict) or not isinstance(then, dict):
|
||||
errors.append(f"{where} — 만약 · 그러면은 묶음이어야 함")
|
||||
continue
|
||||
src, dst = opts.get(when.get("옵션")), opts.get(then.get("옵션"))
|
||||
if src is None or dst is None or src is dst:
|
||||
errors.append(f"{where} — 만약 · 그러면 옵션이 없거나 같음")
|
||||
continue
|
||||
values = when.get("값들")
|
||||
if not isinstance(values, list) or not values:
|
||||
errors.append(f"{where} — 값들이 비었음")
|
||||
elif src.input == "select" and not set(map(str, values)) <= set(src.choices):
|
||||
errors.append(f"{where} — 값들이 「{src.key}」 선택지에 없음")
|
||||
effects = set(then) - {"옵션"}
|
||||
if not effects or not effects <= {"선택지", "보이기", "기본값"}:
|
||||
errors.append(f"{where} — 그러면은 선택지 · 보이기 · 기본값 중 하나 이상")
|
||||
allowed = dst.choices
|
||||
if "선택지" in then:
|
||||
pick = then["선택지"]
|
||||
if dst.input != "select" or not isinstance(pick, list) or not set(pick) <= set(allowed):
|
||||
errors.append(f"{where} — 선택지는 「{dst.key}」 고르기 선택지 안에서만")
|
||||
else:
|
||||
allowed = pick
|
||||
if "보이기" in then and not isinstance(then["보이기"], bool):
|
||||
errors.append(f"{where} — 보이기는 참거짓")
|
||||
if "기본값" in then and dst.input == "select" and str(then["기본값"]) not in allowed:
|
||||
errors.append(f"{where} — 기본값이 「{dst.key}」 선택지에 없음")
|
||||
return errors
|
||||
|
||||
|
||||
def check(doc: Any) -> list[str]:
|
||||
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과)."""
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
if not isinstance(doc, dict):
|
||||
return ["정의는 묶음이어야 함"]
|
||||
errors = [f"모르는 칸 「{k}」" for k in doc if k not in TOP and k not in SECTIONS]
|
||||
errors += [
|
||||
f"「{k}」 꼴이 틀림" for k, kind in SECTIONS.items() if not isinstance(doc.get(k), kind)
|
||||
]
|
||||
if not isinstance(doc.get("동작"), dict):
|
||||
errors.append("동작은 {이름: 뜻} 묶음이어야 함")
|
||||
if errors:
|
||||
return errors
|
||||
found, keys = _type_errors(doc)
|
||||
errors += found
|
||||
plain = {tid: set(opts) for tid, opts in keys.items()}
|
||||
for fn, section in ((options._clean, "표시"), (groups._clean, "그룹")):
|
||||
try:
|
||||
fn(doc[section], plain)
|
||||
except store.StoreError as e:
|
||||
errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)]
|
||||
return errors + _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys)
|
||||
|
||||
|
||||
def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]:
|
||||
"""판이 같을 때만 고쳐 씀 — 앞 판은 이력으로 · 새 판 = 앞 판 + 1."""
|
||||
path = Path(schema.DEFINITION_PATH)
|
||||
with store._LOCK:
|
||||
current = read()
|
||||
have = int(current.get("판", 1))
|
||||
if str(version) != str(have):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
doc = {**change(current), "판": have}
|
||||
errors = check(doc)
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
atomic_write_json(history_path(have), current)
|
||||
atomic_write_json(path, {**doc, "판": have + 1})
|
||||
return read()
|
||||
|
||||
|
||||
def save(doc: Any, version: Any) -> dict[str, Any]:
|
||||
"""정의 통째로 고쳐 씀(시스템 관리자) — 돌려주는 것 = 새 정의."""
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(400, "정의는 묶음이어야 함")
|
||||
return _commit(lambda _: dict(doc), version)
|
||||
|
||||
|
||||
def save_sections(changes: dict[str, Any], version: Any) -> dict[str, Any]:
|
||||
"""정의 몇 칸만 고쳐 씀(옵션 보이기 · 그룹 화면)."""
|
||||
return _commit(lambda current: {**current, **changes}, version)
|
||||
@@ -1,45 +1,34 @@
|
||||
"""M02 구조물 옵션 그룹 — 구조물 종류 안에 이름 붙은 묶음(PLAN 46-2).
|
||||
|
||||
정본 = `resources/master_template/구조물옵션그룹.json` =
|
||||
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["inlet_revet_form", …]}]}`
|
||||
— 없는 종류 = 그룹 없음(파일이 없어도 됨) · 빈 그룹(옵션 0)도 그룹.
|
||||
정본 = 구조물 정의 한 벌(`M02_SO_Definition` · PLAN 48-1)의 「그룹」 칸 =
|
||||
`{type_id: [{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": [...], "형상"?: {...}}]}`
|
||||
— 없는 종류 = 그룹 없음 · 빈 그룹(옵션 0)도 그룹 · 형상 = 길이 · 높이 · 전 · 후 받기 + 뜻.
|
||||
|
||||
그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 이름) — 그 안에서 구조물 조합
|
||||
항목 하나를 지정(구조물 기록 `group_items[그룹 id]`). 그룹에 안 넣은 옵션 = 본체 칸(`summary_item`).
|
||||
옵션 하나는 한 종류 안에서 그룹 하나에만. 시스템 공통 · 고쳐 씀은 시스템 관리자(길이 막음).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
판 = 정의 판(글) · 낡은 판으로 쓰면 409 · 저장마다 정의 판 +1 · 이력.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FILE = "구조물옵션그룹.json"
|
||||
GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
|
||||
NAME_MAX = 40
|
||||
SHAPE = ("길이", "높이", "전", "후") # 그룹이 받을 형상 칸 — `{칸: {받기: 참거짓, 뜻: 글}}`
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load() -> tuple[dict[str, Any], str]:
|
||||
"""(그룹 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
|
||||
path = store.FOLDER / FILE
|
||||
if not path.is_file():
|
||||
return {}, ""
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
|
||||
return doc, store.version_of(raw)
|
||||
"""(그룹 표 · 판) — 정의 한 벌(`M02_SO_Definition`)의 「그룹」 칸 · 판 = 정의 판 글."""
|
||||
doc = load_definition()
|
||||
return doc.get("그룹") or {}, str(doc.get("판", 1))
|
||||
|
||||
|
||||
def groups_of() -> dict[str, list[dict[str, Any]]]:
|
||||
@@ -83,11 +72,23 @@ def listing() -> dict[str, Any]:
|
||||
return {"판": version, "그룹": doc, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹은 422 · 빈 종류는 뺌."""
|
||||
def _shape_ok(shape: Any) -> bool:
|
||||
return isinstance(shape, dict) and all(
|
||||
k in SHAPE
|
||||
and isinstance(v, dict)
|
||||
and isinstance(v.get("받기"), bool)
|
||||
and isinstance(v.get("뜻", ""), str)
|
||||
and set(v) <= {"받기", "뜻"}
|
||||
for k, v in shape.items()
|
||||
)
|
||||
|
||||
|
||||
def _clean(groups: Any, keys: dict[str, set[str]] | None = None) -> dict[str, list[dict[str, Any]]]:
|
||||
"""받은 표 검사 — 없는 종류 · 키 · 겹친 id · 이름 · 옵션 두 그룹 · 틀린 형상 422."""
|
||||
if not isinstance(groups, dict):
|
||||
raise store.StoreError(400, "그룹은 종류별 묶음이어야 함")
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
if keys is None:
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
errors: list[str] = []
|
||||
out: dict[str, list[dict[str, Any]]] = {}
|
||||
for type_id, rows in groups.items():
|
||||
@@ -126,9 +127,14 @@ def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
)
|
||||
else:
|
||||
owner[key] = name
|
||||
if "형상" in row and not _shape_ok(row["형상"]):
|
||||
errors.append(
|
||||
f"「{type_id}.{name}」 형상은 {{길이 · 높이 · 전 · 후: {{받기, 뜻}}}}"
|
||||
)
|
||||
ids.add(gid)
|
||||
names.add(name)
|
||||
clean.append({"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]})
|
||||
kept = {"id": gid, "이름": name, "옵션": [k for k in opts if k in keys[type_id]]}
|
||||
clean.append({**kept, "형상": row["형상"]} if "형상" in row else kept)
|
||||
if clean:
|
||||
out[type_id] = clean
|
||||
if errors:
|
||||
@@ -138,11 +144,7 @@ def _clean(groups: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
|
||||
def save(groups: Any, version: str) -> dict[str, Any]:
|
||||
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
|
||||
fixed = _clean(groups)
|
||||
path = store.FOLDER / FILE
|
||||
with store._LOCK:
|
||||
have = store.version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
atomic_write_json(path, fixed)
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Definition import save_sections
|
||||
|
||||
save_sections({"그룹": _clean(groups)}, version)
|
||||
return listing()
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Groups.ts
|
||||
* 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록
|
||||
* (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
|
||||
* 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`).
|
||||
* 구조물 정의 화면의 **옵션 그룹** 부품(PLAN 46-2 · 48-2) — 고른 종류의 그룹 목록
|
||||
* (이름 · 위로 · 아래로 · 지우기 · 형상 칸 길이 · 높이 · 전 · 후 받기 켬끔과 뜻 · [그룹 추가]) ·
|
||||
* 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
|
||||
* 값은 화면이 쥔 고칠 사본에만 — 저장은 화면 [저장](`M02_SO_Def.ts`).
|
||||
* 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { OptionGroupDef } from "./M02_SO_Api";
|
||||
import { SHAPE_KEYS, type GroupDef } from "./M02_SO_Def_Model";
|
||||
import { checkBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
const NAME_MAX = 40;
|
||||
|
||||
/** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */
|
||||
export function newGroupId(list: readonly OptionGroupDef[]): string {
|
||||
export function newGroupId(list: readonly GroupDef[]): string {
|
||||
const taken = new Set(list.map((g) => g.id));
|
||||
for (;;) {
|
||||
const id = `g${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -22,35 +24,49 @@ export function newGroupId(list: readonly OptionGroupDef[]): string {
|
||||
}
|
||||
|
||||
/** 그 옵션이 든 그룹 id — 없으면 null(본체) */
|
||||
export function groupOf(list: readonly OptionGroupDef[], key: string): string | null {
|
||||
export function groupOf(list: readonly GroupDef[], key: string): string | null {
|
||||
return list.find((g) => g.옵션.includes(key))?.id ?? null;
|
||||
}
|
||||
|
||||
/** 옵션을 그룹(null = 본체)으로 옮김 — 한 옵션은 한 그룹에만 */
|
||||
export function moveOption(list: OptionGroupDef[], key: string, gid: string | null): void {
|
||||
export function moveOption(list: GroupDef[], key: string, gid: string | null): void {
|
||||
for (const g of list) g.옵션 = g.옵션.filter((k) => k !== key);
|
||||
const target = gid ? list.find((g) => g.id === gid) : null;
|
||||
if (target) target.옵션.push(key);
|
||||
}
|
||||
|
||||
/** 이름이 다 괜찮은가 — 1~40자 · 같은 종류 안에서 안 겹침(서버도 같은 검사) */
|
||||
export function namesOk(groups: Map<string, OptionGroupDef[]>): boolean {
|
||||
for (const list of groups.values()) {
|
||||
const names = list.map((g) => g.이름.trim());
|
||||
if (names.some((n) => !n || n.length > NAME_MAX)) return false;
|
||||
if (new Set(names).size !== names.length) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface GroupBlockOptions {
|
||||
/** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */
|
||||
list: OptionGroupDef[];
|
||||
list: GroupDef[];
|
||||
edit: boolean;
|
||||
/** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
/** 그룹 형상 줄 — 칸마다 받기 체크 + 뜻(무엇을 정하는지 한 줄) */
|
||||
function shapeLine(group: GroupDef, edit: boolean, changed: () => void): HTMLElement {
|
||||
const shape = (group.형상 ??= {});
|
||||
return el("div", {
|
||||
className: "m02-sog__shape",
|
||||
attrs: { "data-sog-shape": group.id },
|
||||
children: SHAPE_KEYS.map((k) => {
|
||||
const f = shape[k] ?? { 받기: false, 뜻: "" };
|
||||
const on = checkBox(
|
||||
to(`Shape_${k}` as const),
|
||||
f.받기,
|
||||
(v) => ((shape[k] = { ...f, 받기: v }), changed()),
|
||||
{ edit, attr: `data-sog-shape-${SHAPE_KEYS.indexOf(k)}` },
|
||||
);
|
||||
const mean = textBox(f.뜻, (v) => (shape[k] = { ...(shape[k] ?? f), 뜻: v }), changed, {
|
||||
edit: edit && f.받기,
|
||||
cls: "m02-sog__mean",
|
||||
place: to("ShapeMean"),
|
||||
});
|
||||
return el("div", { className: "m02-sog__shapecell", children: [on, mean] });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */
|
||||
export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
const { list } = opt;
|
||||
@@ -87,7 +103,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
list.splice(list.indexOf(group), 1);
|
||||
opt.changed();
|
||||
});
|
||||
return el("div", {
|
||||
const line = el("div", {
|
||||
className: "m02-sog__row",
|
||||
attrs: { "data-sog-group": group.id },
|
||||
children: [
|
||||
@@ -99,6 +115,10 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
...(opt.edit ? [up, down, del] : []),
|
||||
],
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sog__item",
|
||||
children: [line, shapeLine(group, opt.edit, opt.changed)],
|
||||
});
|
||||
});
|
||||
const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" });
|
||||
add.setAttribute("data-sog-add", "");
|
||||
@@ -121,7 +141,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
|
||||
|
||||
/** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */
|
||||
export function buildPlaceSelect(
|
||||
list: OptionGroupDef[],
|
||||
list: GroupDef[],
|
||||
key: string,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크.
|
||||
|
||||
정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}`
|
||||
— 없는 종류 · 키는 보임(파일이 없어도 모두 보임).
|
||||
정본 = 구조물 정의(`M02_SO_Definition` · PLAN 48-1) 「표시」 칸 = `{type_id: {option_key: false}}`
|
||||
— 없는 종류 · 키는 보임.
|
||||
저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`.
|
||||
옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
판 = 정의 판(글) · 낡은 판으로 쓰면 409 · 저장마다 정의 판 +1 · 이력.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -19,14 +18,13 @@ from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureOptionField,
|
||||
StructureType,
|
||||
StructureTypesResponse,
|
||||
load_definition,
|
||||
load_structure_types,
|
||||
registry_schema_version,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
|
||||
|
||||
FILE = "구조물옵션표시.json"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,18 +54,9 @@ class TypesWithVisible(StructureTypesResponse):
|
||||
|
||||
|
||||
def _load() -> tuple[dict[str, Any], str]:
|
||||
"""(숨김 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
|
||||
path = store.FOLDER / FILE
|
||||
if not path.is_file():
|
||||
return {}, ""
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
|
||||
return doc, store.version_of(raw)
|
||||
"""(표시 표 · 판) — 정의 한 벌(`M02_SO_Definition`)의 「표시」 칸 · 판 = 정의 판 글."""
|
||||
doc = load_definition()
|
||||
return doc.get("표시") or {}, str(doc.get("판", 1))
|
||||
|
||||
|
||||
def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool:
|
||||
@@ -138,11 +127,12 @@ def listing() -> dict[str, Any]:
|
||||
return {"판": version, "표시": hidden, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(shown: Any) -> dict[str, dict[str, bool]]:
|
||||
def _clean(shown: Any, keys: dict[str, set[str]] | None = None) -> dict[str, dict[str, bool]]:
|
||||
"""받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422."""
|
||||
if not isinstance(shown, dict):
|
||||
raise store.StoreError(400, "표시는 종류별 묶음이어야 함")
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
if keys is None:
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
errors: list[str] = []
|
||||
out: dict[str, dict[str, bool]] = {}
|
||||
for type_id, row in shown.items():
|
||||
@@ -165,11 +155,7 @@ def _clean(shown: Any) -> dict[str, dict[str, bool]]:
|
||||
|
||||
def save(shown: Any, version: str) -> dict[str, Any]:
|
||||
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
|
||||
fixed = _clean(shown)
|
||||
path = store.FOLDER / FILE
|
||||
with store._LOCK:
|
||||
have = store.version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
atomic_write_json(path, fixed)
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Definition import save_sections
|
||||
|
||||
save_sections({"표시": _clean(shown)}, version)
|
||||
return listing()
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Options.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에
|
||||
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
|
||||
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
|
||||
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
||||
* 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`).
|
||||
* 좌측 「구조물 옵션 정의」 줄(구조물 집계 양식 아래 별도 · 시스템 층) — 누르면 메인 칸에
|
||||
* 정의 편집 화면(PLAN 48-2 · `M02_SO_Def.ts`) · 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만.
|
||||
* 옛 「보이기 · 그룹」 화면(46장)은 정의 화면의 옵션 줄 「보이기」 · 그룹 칸으로 들어감.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
fetchStructureOptionGroups,
|
||||
fetchStructureOptions,
|
||||
saveStructureOptionGroups,
|
||||
saveStructureOptions,
|
||||
type GroupsDoc,
|
||||
type OptionGroupDef,
|
||||
type OptionKind,
|
||||
type OptionsDoc,
|
||||
} from "./M02_SO_Api";
|
||||
import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { openDefScreen } from "./M02_SO_Def";
|
||||
import "./M02_SO_Style.css";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
@@ -40,8 +27,6 @@ export interface OptionsSection {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection {
|
||||
const row = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
@@ -49,7 +34,6 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
attrs: { type: "button", "data-options-row": "" },
|
||||
});
|
||||
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
|
||||
row.addEventListener("click", () => void open());
|
||||
|
||||
/** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */
|
||||
let opened = false;
|
||||
@@ -57,241 +41,12 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
|
||||
opened = on;
|
||||
row.classList.toggle("is-active", on);
|
||||
};
|
||||
|
||||
async function open(): Promise<void> {
|
||||
row.addEventListener("click", async () => {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
let doc: OptionsDoc;
|
||||
let groups: GroupsDoc;
|
||||
try {
|
||||
[doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]);
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
draw(doc, groups);
|
||||
}
|
||||
|
||||
/** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
|
||||
function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void {
|
||||
let version = doc.판;
|
||||
let groupVersion = groupsDoc.판;
|
||||
const edit = opt.isAdmin;
|
||||
/** 숨긴 옵션 — 종류 → 키 집합 */
|
||||
const hidden = new Map<string, Set<string>>();
|
||||
const load = (src: OptionsDoc["표시"]): void => {
|
||||
hidden.clear();
|
||||
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
|
||||
};
|
||||
/** 그룹 — 종류 → 그룹[](고칠 사본) */
|
||||
const groups = new Map<string, OptionGroupDef[]>();
|
||||
const loadGroups = (src: GroupsDoc["그룹"]): void => {
|
||||
groups.clear();
|
||||
for (const [id, list] of Object.entries(src))
|
||||
groups.set(
|
||||
id,
|
||||
list.map((g) => ({ ...g, 옵션: [...g.옵션] })),
|
||||
);
|
||||
};
|
||||
load(doc.표시);
|
||||
loadGroups(groupsDoc.그룹);
|
||||
/** 종류 = 레지스트리 전부(옵션 없는 종류도 그룹을 둘 수 있음) */
|
||||
let kinds = groupsDoc.종류;
|
||||
let current: OptionKind | null = null;
|
||||
let shownDirty = false;
|
||||
let groupDirty = false;
|
||||
|
||||
const kindList = el("div", { className: "m02-opts__kinds" });
|
||||
const optionList = el("div", { className: "m02-opts__list" });
|
||||
const off = (id: string): number => hidden.get(id)?.size ?? 0;
|
||||
const groupList = (id: string): OptionGroupDef[] => {
|
||||
if (!groups.has(id)) groups.set(id, []);
|
||||
return groups.get(id)!;
|
||||
};
|
||||
|
||||
const paintKinds = (): void => {
|
||||
const parts: HTMLElement[] = [];
|
||||
let group = "";
|
||||
for (const k of kinds) {
|
||||
if (k.group !== group) {
|
||||
group = k.group;
|
||||
parts.push(
|
||||
el("h4", {
|
||||
className: "m02-opts__group",
|
||||
text: group.length === 1 ? to("Group", group) : group,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const n = off(k.type_id);
|
||||
const g = groups.get(k.type_id)?.length ?? 0;
|
||||
const b = el("button", {
|
||||
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
|
||||
attrs: { type: "button", "data-options-kind": k.type_id },
|
||||
children: [
|
||||
el("span", { text: k.name }),
|
||||
...(g ? [createTag(to("GroupTag", String(g)), "accent")] : []),
|
||||
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
|
||||
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
current = k;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
parts.push(b);
|
||||
}
|
||||
kindList.replaceChildren(...parts);
|
||||
};
|
||||
|
||||
/** 그룹을 고쳤음 — 고침 표시 · 종류 목록(그룹 수) · 오른쪽 다시 그림 */
|
||||
const groupsChanged = (): void => {
|
||||
groupDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
};
|
||||
|
||||
const paintOptions = (): void => {
|
||||
if (!current) {
|
||||
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
|
||||
return;
|
||||
}
|
||||
const kind = current;
|
||||
const list = groupList(kind.type_id);
|
||||
const rows = kind.options.map((o) => {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-options-key": o.key },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = !hidden.get(kind.type_id)?.has(o.key);
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => {
|
||||
const set = hidden.get(kind.type_id) ?? new Set<string>();
|
||||
if (box.checked) set.delete(o.key);
|
||||
else set.add(o.key);
|
||||
hidden.set(kind.type_id, set);
|
||||
shownDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
});
|
||||
const tags: HTMLElement[] = [];
|
||||
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
|
||||
if (o.required && o.phase === "b05") {
|
||||
const tag = createTag(to("Required"), "danger");
|
||||
tag.title = to("RequiredTip");
|
||||
tags.push(tag);
|
||||
}
|
||||
return el("div", {
|
||||
className: "m02-opts__option",
|
||||
children: [
|
||||
el("label", {
|
||||
className: "m02-opts__check",
|
||||
children: [
|
||||
box,
|
||||
el("span", { text: o.label }),
|
||||
el("small", { className: "m02-opts__key", text: o.key }),
|
||||
...tags,
|
||||
],
|
||||
}),
|
||||
buildPlaceSelect(list, o.key, edit, groupsChanged),
|
||||
],
|
||||
});
|
||||
});
|
||||
optionList.replaceChildren(
|
||||
el("h3", { className: "m02-options__head", text: kind.name }),
|
||||
buildGroupBlock({ list, edit, changed: groupsChanged }),
|
||||
el("h4", { className: "m02-opts__group", text: to("OptionHead") }),
|
||||
...(rows.length
|
||||
? rows
|
||||
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
|
||||
);
|
||||
};
|
||||
|
||||
const save = createButton({ label: to("Save"), variant: "filled" });
|
||||
const all = createButton({ label: to("ShowAll"), variant: "ghost" });
|
||||
all.addEventListener("click", () => {
|
||||
if (![...hidden.values()].some((s) => s.size)) return;
|
||||
hidden.clear();
|
||||
shownDirty = true;
|
||||
sync();
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
|
||||
/** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */
|
||||
const reload = async (): Promise<void> => {
|
||||
try {
|
||||
const [fresh, freshGroups] = await Promise.all([
|
||||
fetchStructureOptions(),
|
||||
fetchStructureOptionGroups(),
|
||||
]);
|
||||
version = fresh.판;
|
||||
groupVersion = freshGroups.판;
|
||||
kinds = freshGroups.종류;
|
||||
load(fresh.표시);
|
||||
loadGroups(freshGroups.그룹);
|
||||
shownDirty = groupDirty = false;
|
||||
sync();
|
||||
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (again) {
|
||||
showToast(to("Load_Failed", why(again)), "error");
|
||||
}
|
||||
};
|
||||
|
||||
save.addEventListener("click", async () => {
|
||||
if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error");
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
if (shownDirty) {
|
||||
const 표시: OptionsDoc["표시"] = {};
|
||||
for (const [id, keys] of hidden)
|
||||
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
|
||||
const done = await saveStructureOptions(version, 표시);
|
||||
version = done.판;
|
||||
load(done.표시);
|
||||
shownDirty = false;
|
||||
}
|
||||
if (groupDirty) {
|
||||
const 그룹: GroupsDoc["그룹"] = {};
|
||||
for (const [id, list] of groups)
|
||||
if (list.length) 그룹[id] = list.map((g) => ({ ...g, 이름: g.이름.trim() }));
|
||||
const done = await saveStructureOptionGroups(groupVersion, 그룹);
|
||||
groupVersion = done.판;
|
||||
loadGroups(done.그룹);
|
||||
groupDirty = false;
|
||||
}
|
||||
sync();
|
||||
showToast(to("Saved"), "success");
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
showToast(to("Stale"), "error");
|
||||
await reload();
|
||||
} else showToast(to("Failed", why(error)), "error");
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const node = el("div", {
|
||||
className: "m02-opts",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("Note") }),
|
||||
el("div", {
|
||||
className: "m02-opts__panes",
|
||||
children: [kindList, optionList],
|
||||
}),
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []),
|
||||
],
|
||||
});
|
||||
const setDirty = opt.show(to("Screen"), node);
|
||||
const sync = (): void => setDirty(shownDirty || groupDirty);
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
if (!(await openDefScreen({ isAdmin: opt.isAdmin, show: opt.show }))) return;
|
||||
opt.onOpen();
|
||||
mark(true);
|
||||
}
|
||||
});
|
||||
|
||||
return { title: to("Title"), body, clear: () => mark(false) };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""M02 구조물 옵션 보이기 · 그룹 길 — 정본 `M02_SO_Options.py` · `M02_SO_Groups.py`.
|
||||
"""M02 구조물 옵션 보이기 · 그룹 · 정의 길 — 정본 `M02_SO_Definition.py`(PLAN 48-1) 한 벌.
|
||||
|
||||
`router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) ·
|
||||
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임).
|
||||
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임) · 정의 쓰기는 여기서
|
||||
시스템 관리자만(`access.require(write=True)` · 아니면 403).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +15,7 @@ from pydantic import BaseModel
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
@@ -31,6 +33,11 @@ class GroupsBody(BaseModel):
|
||||
그룹: dict[str, Any] # {종류: [{id, 이름, 옵션[]}]}
|
||||
|
||||
|
||||
class DefinitionBody(BaseModel):
|
||||
판: int # 읽어 간 정의 판 — 어긋나면 409
|
||||
정의: dict[str, Any] # 정의 한 벌(틀 = docs/raw/M02_구조물옵션정의/정의_틀.md)
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
with store.at(store.FOLDER):
|
||||
try:
|
||||
@@ -68,3 +75,31 @@ def get_structure_options_read(session=Depends(verify_session)) -> dict:
|
||||
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(options.listing)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition")
|
||||
def get_structure_definition(session=Depends(verify_session)) -> dict:
|
||||
"""구조물 정의 한 벌(판 · 구조물군 · 동작 · types · 종류 · 표시 · 그룹 · 배치 · 규칙)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.read)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition/history")
|
||||
def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]:
|
||||
"""지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.history)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition/history/{version}")
|
||||
def get_structure_definition_at(version: int, session=Depends(verify_session)) -> dict:
|
||||
"""지난 판 한 벌(읽기만) — 없으면 404."""
|
||||
access.require(session, "system", None)
|
||||
return _call(definition.read_version, version)
|
||||
|
||||
|
||||
@read_router.put("/structure-definition")
|
||||
def put_structure_definition(body: DefinitionBody, session=Depends(verify_session)) -> dict:
|
||||
"""정의 고쳐 씀 — 시스템 관리자만(403) · 낡은 판 409 · 검사 422 · 판 +1 · 앞 판은 이력."""
|
||||
access.require(session, "system", None, write=True)
|
||||
return _call(definition.save, body.정의, body.판)
|
||||
|
||||
@@ -112,3 +112,223 @@
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 구조물 정의 편집 화면(PLAN 48-2) — 판 띠 · 종류 머리 · 옵션 · 선택지 · 배치 격자 · 규칙 · 지난 판 */
|
||||
.m02-sod {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.m02-sod__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-sod__banner.is-past {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.m02-sod__family,
|
||||
.m02-sod__kindrow,
|
||||
.m02-sod__kindhead,
|
||||
.m02-sod__option,
|
||||
.m02-sod__choice,
|
||||
.m02-sod__place,
|
||||
.m02-sod__rule {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-sod__family {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-sod__family > .ui-input {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-sod__kindrow {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.m02-sod__kindrow > .m02-opts__kind {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sod__kindrow > button:not(.m02-opts__kind),
|
||||
.m02-sod__family > button {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-sod__block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-sod__option {
|
||||
padding: var(--spacing-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.is-off > .ui-input,
|
||||
.m02-opts__kind.is-off > span:first-child {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.m02-sod__name {
|
||||
flex: 1 1 10rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sod__form,
|
||||
.m02-sod__default {
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.m02-sod__unit,
|
||||
.m02-sod__num {
|
||||
width: 4.5rem;
|
||||
}
|
||||
|
||||
.m02-sod__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-sod__choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: 0 0 var(--spacing-8) var(--spacing-24);
|
||||
padding-left: var(--spacing-8);
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 배치 격자 — 왼쪽 칸 숫자 · 오른쪽 미리보기 */
|
||||
.m02-sod__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-sod__place {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.m02-sod__placename {
|
||||
flex: 1 1 6rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m02-sod__place > .m02-sod__num {
|
||||
flex: none;
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.m02-sod__grid {
|
||||
display: grid;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-sod__cell {
|
||||
overflow: hidden;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m02-sod__cell.is-base {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-sod__cell.is-bad,
|
||||
.m02-sod__rule.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m02-sod__rule {
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-sod__rulepick {
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
.m02-sod__rulechoices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 + 뜻 */
|
||||
.m02-sog__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-sog__shape {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-sog__shapecell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-sog__mean {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-sod__issues:empty,
|
||||
.m02-sod__history:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-sod__history {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-sod__bad {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["구조물 옵션 보이기", "Structure options shown"],
|
||||
Row: ["종류마다 그룹 · 보일 옵션 정하기", "Groups and shown options per type"],
|
||||
Screen: ["구조물 옵션 보이기 · 그룹", "Structure options · groups"],
|
||||
Note: [
|
||||
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
|
||||
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
|
||||
Title: ["구조물 옵션 정의", "Structure option definition"],
|
||||
Row: [
|
||||
"구조물군 · 종류 · 옵션 · 배치 · 그룹 · 규칙",
|
||||
"Families · types · options · layout · groups · rules",
|
||||
],
|
||||
Screen: ["구조물 옵션 정의", "Structure option definition"],
|
||||
GroupHead: ["그룹", "Groups"],
|
||||
GroupNote: [
|
||||
"그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체",
|
||||
@@ -35,35 +34,92 @@ const TEXT = {
|
||||
],
|
||||
GroupTag: ["그룹 {value}", "{value} groups"],
|
||||
Main: ["본체", "Main body"],
|
||||
OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"],
|
||||
OptionHead: ["옵션", "Options"],
|
||||
OptionNote: [
|
||||
"이름 · 꼴 · 단위 · 기본값 · 들어갈 자리(본체 · 그룹) · 안 씀 · 순서 — 키(작은 글)는 안 바뀜 · 고르기 꼴은 [선택지]로 목록을 고침",
|
||||
"Name · form · unit · default · container · unused · order — keys never change",
|
||||
],
|
||||
OptionAdd: ["옵션 더함", "Add option"],
|
||||
OptionNew: ["새 옵션 {value}", "New option {value}"],
|
||||
FormSelect: ["고르기", "Select"],
|
||||
FormNumber: ["숫자", "Number"],
|
||||
FormText: ["글", "Text"],
|
||||
FormBool: ["예/아니오", "Yes/No"],
|
||||
Yes: ["예", "Yes"],
|
||||
No: ["아니오", "No"],
|
||||
Unit: ["단위", "Unit"],
|
||||
NoDefault: ["기본값 없음", "No default"],
|
||||
Unused: ["안 씀", "Unused"],
|
||||
Choices: ["선택지 {value}", "{value} choices"],
|
||||
ChoiceAdd: ["선택지 더함", "Add choice"],
|
||||
ChoiceNew: ["새 선택지 {value}", "New choice {value}"],
|
||||
ChoiceNone: ["선택지 없음 — 고르기 꼴은 하나 이상", "No choices — select needs at least one"],
|
||||
ChoiceNote: [
|
||||
"선택지 글이 곧 저장 값 — 이름을 바꾸면 기본값 · 규칙도 같이 바뀜 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음",
|
||||
"Choice text is the stored value — renaming updates defaults and rules here",
|
||||
],
|
||||
ChoiceDrop: ["빼기", "Remove"],
|
||||
ChoiceDropAsk: [
|
||||
"선택지 「{value}」 를 뺌 — 기본값 · 규칙에서도 빠짐 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음",
|
||||
"Remove choice “{value}” — also from defaults and rules",
|
||||
],
|
||||
Shown: ["보이기", "Shown"],
|
||||
RuleValues: ["값(쉼표로 여럿)", "Values (comma separated)"],
|
||||
RuleChoices: ["선택지", "Choices"],
|
||||
LayoutHead: ["배치 격자", "Layout grid"],
|
||||
LayoutNote: [
|
||||
"B05 · B06 좌측 구조물 칸(두 칸)의 배치 — 옵션마다 줄 · 칸 · 너비 · 기준 측점은 맨 위 한 줄 · 겹치면 빨강 · 안 정한 옵션은 차례대로",
|
||||
"Left panel layout (two columns) — row · column · width per option · overlaps in red",
|
||||
],
|
||||
GridRow: ["줄", "Row"],
|
||||
GridCol: ["칸", "Col"],
|
||||
GridWidth: ["너비", "Width"],
|
||||
BaseStation: ["기준 측점", "Base station"],
|
||||
BaseStationShow: ["기준 측점 표시", "Show base station"],
|
||||
Shape_길이: ["길이", "Length"],
|
||||
Shape_높이: ["높이", "Height"],
|
||||
Shape_전: ["기준 측점 전", "Before"],
|
||||
Shape_후: ["기준 측점 후", "After"],
|
||||
ShapeMean: ["무엇을 정하는지", "What it controls"],
|
||||
RuleHead: ["규칙", "Rules"],
|
||||
RuleNote: [
|
||||
"만약 상위 옵션 = 값 → 하위 옵션의 선택지 · 보이기 · 기본값 — 「그대로」 칸은 손대지 않음 · 틀린 줄은 빨강",
|
||||
"If parent = value → child choices · visibility · default",
|
||||
],
|
||||
RuleNone: ["규칙 없음", "No rules"],
|
||||
RuleAdd: ["규칙 더함", "Add rule"],
|
||||
RuleIf: ["만약", "If"],
|
||||
RuleThen: ["→", "→"],
|
||||
Keep: ["보이기 그대로", "Keep visibility"],
|
||||
Show: ["보임", "Show"],
|
||||
Hide: ["숨김", "Hide"],
|
||||
KeepDefault: ["기본값 그대로", "Keep default"],
|
||||
DefNote: [
|
||||
"구조물군 · 종류 · 옵션 · 선택지 · 배치 · 그룹 · 규칙 정의 — [저장]하면 판이 하나 오르고 B05 · B06 에 바로 적용 · 지난 판은 이력으로 남음",
|
||||
"Structure definition — Save bumps the version; past versions stay in history",
|
||||
],
|
||||
Version: ["판 {value}", "Version {value}"],
|
||||
ReadOnly: ["읽기만", "Read only"],
|
||||
SavedVersion: ["저장함 — 판 {value}", "Saved — version {value}"],
|
||||
PastList: ["지난 판", "Past versions"],
|
||||
PastItem: ["판 {value}", "v{value}"],
|
||||
PastNone: ["지난 판 없음", "No past versions"],
|
||||
PastView: ["판 {value} 보는 중 — 읽기만", "Viewing version {value} — read only"],
|
||||
PastBack: ["지금 판으로", "Back to current"],
|
||||
NoOptionKind: [
|
||||
"이 종류는 옵션이 없음 — 그룹만 만들 수 있음(그룹마다 조합 연결)",
|
||||
"No options — groups only (each links a combo)",
|
||||
],
|
||||
NameBad: [
|
||||
"그룹 이름은 1~40자 · 같은 종류 안에서 겹치면 안 됨",
|
||||
"Group names: 1–40 chars, unique",
|
||||
],
|
||||
Group: ["{value}군", "Group {value}"],
|
||||
Hidden: ["숨김 {value}", "{value} hidden"],
|
||||
Off: ["안 쓰는 종류", "Not offered"],
|
||||
NoOption: ["옵션 없음", "No options"],
|
||||
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
|
||||
Detail: ["B06 전용", "B06 only"],
|
||||
Required: ["필수", "Required"],
|
||||
RequiredTip: [
|
||||
"필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)",
|
||||
"Required — when hidden it is not shown and not enforced",
|
||||
],
|
||||
Save: ["저장", "Save"],
|
||||
ShowAll: ["모두 보임", "Show all"],
|
||||
Saved: ["저장함", "Saved"],
|
||||
Stale: [
|
||||
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
|
||||
"그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
|
||||
"Changed meanwhile — reloaded, please edit again",
|
||||
],
|
||||
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
|
||||
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
|
||||
Load_Failed: ["구조물 정의를 못 받음 — {value}", "Could not load the definition — {value}"],
|
||||
Failed: ["구조물 정의를 못 저장함 — {value}", "Could not save the definition — {value}"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type OptionsTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -241,7 +241,8 @@ function wingSpec(
|
||||
const length = num(values[`${prefix}_length_m`], num(defaults[`${prefix}_length_m`], 0.0));
|
||||
const angle = num(values[`${prefix}_angle_deg`], num(defaults[`${prefix}_angle_deg`], 45.0));
|
||||
const height = num(values[`${prefix}_height_m`], num(defaults[`${prefix}_height_m`], 0.0));
|
||||
const installed = String(install ?? "").trim() !== "없음";
|
||||
// BOX암거 유입·유출 구조(표 02) — 없음 · 날개벽 · 흙막이 · 옛 값 있음 = 날개벽. 날개벽만 세움.
|
||||
const installed = !["없음", "흙막이"].includes(String(install ?? "").trim());
|
||||
return {
|
||||
installed,
|
||||
height_m: height,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 구조물 정의 틀 (PLAN 48-1)
|
||||
|
||||
정본 = `resources/master_template/구조물정의.json` 한 벌. 서버 · 화면은 이 한 벌만 읽음.
|
||||
|
||||
## 1. 길
|
||||
|
||||
| 길 | 누가 | 하는 것 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/m02/structure-definition` | 로그인 누구나 | 정의 한 벌 그대로 |
|
||||
| `PUT /api/m02/structure-definition` `{판, 정의}` | 시스템 관리자만(아니면 403) | 통째로 고쳐 씀 · 낡은 판 409 · 검사 틀림 422 |
|
||||
| `GET /api/projects/structure-types` | 로그인 | 정의의 `types` + `표시` · `그룹` 을 옛 꼴 그대로(`visible` · `groups`) |
|
||||
| `PUT /api/m02/structure-options` · `structure-option-groups` | 시스템 관리자 | 정의의 `표시` · `그룹` 칸만 고쳐 씀(같은 판 · 같은 이력) |
|
||||
|
||||
- 서버 읽기 = `B05_Profile_Structures_Schema.load_definition()` · `load_structure_types()` · 쓰기 = `M02_SO_Definition.save()` · `save_sections()`.
|
||||
|
||||
## 2. 담는 것
|
||||
|
||||
| 칸 | 꼴 | 뜻 |
|
||||
| --- | --- | --- |
|
||||
| `판` | 정수 | 저장마다 +1 · 앞 판은 `구조물정의_이력/판_N.json` |
|
||||
| `schema_version` · `comment` | 수 · 글 | 옛 레지스트리 머리 그대로 |
|
||||
| `구조물군` | `{군 키: 이름}` | 구조물군 이름(A 횡단배수 …) |
|
||||
| `동작` | `{동작 이름: 뜻}` | 손코딩 특수 동작 목록(5장) |
|
||||
| `types` | 종류 목록 | 종류 · 옵션 · 선택지 · 기본값 · placement 등(옛 `B05_Profile_Structure_Types.json` 꼴 그대로) |
|
||||
| `종류` | `{종류 키: {기준측점?, 동작?}}` | 종류마다 덧칸 — 없으면 기본 |
|
||||
| `표시` | `{종류 키: {옵션 키: false}}` | 끈 옵션만(옛 `구조물옵션표시.json`) |
|
||||
| `그룹` | `{종류 키: [그룹]}` | 옵션 그룹(옛 `구조물옵션그룹.json` · 46장) + 형상 칸 |
|
||||
| `배치` | `{종류 키: {옵션 키: {줄, 칸, 너비}}}` | B05 · B06 좌측 「구조물 배치」 칸 자리 — 없으면 지금 차례 |
|
||||
| `규칙` | 규칙 목록 | 상위 옵션 값 → 하위 옵션(4장) |
|
||||
|
||||
## 3. 키 규칙
|
||||
|
||||
- 키 = 만들 때 자동 · 안 바뀜 · 영문 소문자 · 숫자 · `_`. 이름(`name` · `label` · 선택지 글 · 그룹 `이름`)은 자유.
|
||||
- 종류 키(`type_id`) 는 정의 안에서 하나 · 옵션 키(`key`) 는 종류 안에서 하나 · 그룹 id 는 `g` + 소문자 · 숫자.
|
||||
- 지움 = 「안 씀」(`enabled: false`) — 종류 · 옵션을 목록에서 빼지 않음.
|
||||
- 저장된 값의 키가 지금 정의에 없으면 = 지난 정의 값 — 버리지 않고 남김(오류 없음 · 계산은 없는 값으로).
|
||||
- 프로젝트 `structures.json` 에 저장 때 정의 판(`definition_version`).
|
||||
|
||||
## 4. 옵션
|
||||
|
||||
### 꼴
|
||||
|
||||
| `input` | 이름 | 값 |
|
||||
| --- | --- | --- |
|
||||
| `select` | 고르기 | `choices` 중 하나(선택지 있어야 함) |
|
||||
| `number` | 숫자 | 0 이상 수 · `unit` |
|
||||
| `text` | 글 | 글 |
|
||||
| `bool` | 예/아니오 | 참 · 거짓 |
|
||||
|
||||
- 옵션 덧칸(옛 그대로) = `label` · `choices` · `unit` · `default` · `required` · `phase`(b05 · detail) · `enabled` · `empty_means` · `default_basis` · `warn_above` · `warn_message` · `not_in_table`.
|
||||
|
||||
### 배치 칸
|
||||
|
||||
- `줄` = 몇째 줄(1부터) · `칸` = 그 줄 몇째 칸(1부터) · `너비` = 칸 몇 개 차지(1부터). 셋 다 1 이상 정수.
|
||||
|
||||
### 그룹
|
||||
|
||||
```
|
||||
{"id": "g1a2b3c", "이름": "유입구 기슭막이", "옵션": ["옵션 키", …],
|
||||
"형상": {"길이": {"받기": true, "뜻": "유입구 기슭막이 연장"}, "높이": {…}, "전": {…}, "후": {…}}}
|
||||
```
|
||||
|
||||
- 옵션 하나는 한 종류 안에서 그룹 하나에만 · 빈 그룹(옵션 0)도 그룹 · 그룹에 안 넣은 옵션 = 본체 칸.
|
||||
- `형상` = 그룹이 받을 형상 칸(길이 · 높이 · 기준 측점 전 · 후) — 칸마다 `받기`(켬끔) + `뜻`(무엇을 정하는지). 없는 칸 = 안 받음.
|
||||
|
||||
### 종류 덧칸
|
||||
|
||||
- `기준측점` = 종류 맨 위 기준 측점 칸 보이기(없으면 보임).
|
||||
- `동작` = 이 종류에 붙인 특수 동작 이름들(`동작` 목록 안에서).
|
||||
|
||||
## 5. 규칙 꼴
|
||||
|
||||
```
|
||||
{"종류": "종류 키",
|
||||
"만약": {"옵션": "상위 옵션 키", "값들": ["값", …]},
|
||||
"그러면": {"옵션": "하위 옵션 키", "선택지": ["값", …], "보이기": false, "기본값": "값"}}
|
||||
```
|
||||
|
||||
- 만약 상위 옵션 = 값들 중 하나 → 그러면 하위 옵션의 선택지 / 보이기 / 기본값(셋 중 하나 이상).
|
||||
- 상위 · 하위는 같은 종류 안 · 서로 다른 옵션 · 고르기 꼴이면 값들은 선택지 안 · 하위 `선택지` 는 고르기 꼴의 선택지 안 · `기본값` 은 (좁힌) 선택지 안.
|
||||
- 계산 · 연동 제어는 넣지 않음(48-5).
|
||||
|
||||
## 6. 동작 이름(손코딩 특수 동작)
|
||||
|
||||
| 이름 | 붙은 종류 | 하는 것 |
|
||||
| --- | --- | --- |
|
||||
| `계곡통과시설` | pipe · box_culvert · ford_pavement · ford_bridge · revetment | 관 지점 정본 시설 — 부속 옵션 서브폼 · 저장은 폼이 아는 칸만 |
|
||||
| `유입유출_묶음` | pipe | 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹 |
|
||||
| `기슭막이_한벌` | pipe · revetment | 형태 · 길이 · 높이 · 바닥 보호공 한 벌 |
|
||||
| `BOX_본체규격` | box_culvert | 본체 규격 프리셋(폭 × 높이) |
|
||||
| `날개벽` | box_culvert · ford_bridge | 날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 |
|
||||
| `월류단면` | ford_pavement · ford_bridge | 월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 · 필요 수심 미만 되돌림 |
|
||||
| `포장_물넘이_겹침` | pavement_concrete | 포장 구간이 물넘이를 품으면 나눔 · 끝만 걸치면 저장 멈춤 |
|
||||
|
||||
- 정의에서 붙이고 뗌 = `종류.{키}.동작` · 새 동작은 코딩 뒤 목록에 이름을 더함.
|
||||
|
||||
## 7. 판 · 이력
|
||||
|
||||
- 저장 = 읽어 간 `판` 을 같이 보냄 → 같으면 앞 판을 `구조물정의_이력/판_N.json` 으로 남기고 새 판 = N + 1.
|
||||
- 검사(틀리면 422 · 안 씀) = 칸 꼴 · 종류 키 · 옵션 키 겹침 · 고르기 선택지 있음 · 구조물군 있음 · 표시 · 그룹 · 배치 · 종류 덧칸 · 규칙 대상 있음.
|
||||
- 판 1 = 옛 `B05_Profile_Structure_Types.json`(37종 · 옵션 235) + `구조물옵션표시.json` + `구조물옵션그룹.json` 을 키 그대로 옮김.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 구조물 표 01 — 횡단배수 › 배관 (사용자 10-5 · 원문 그대로 · 탭 구분)
|
||||
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 컨테이너 2 컨테이너 2 옵션항목 컨테이너 3 컨테이너 3 옵션항목 컨테이너 4 컨테이너 4 옵션항목 컨테이너 5 컨테이너 5 옵션항목
|
||||
(필수)컨테이너 제목 : 관 (필수)컨테이너 제목 : 관 보호공 (유입) (필수)컨테이너 제목 : 관 보호공 (유출) (선택)컨테이너 제목 : 흙막이 추가 | 여러개일수 있음. (선택)컨테이너 제목 : 도수로/돌붙임 | 여러개일수 있음.
|
||||
횡단배수 배관 측점 No, + 측점거리 관경 800, 1000, 1200, 1500 구조 집수정, 흙막이 구조 흙막이 구조 흙막이 구조 도수로, 돌붙임
|
||||
관 길이 입력값 아님, 자동계산됨 형태 집수정 : I형, L형, ㄷ형 | 흙막이 : 돌 쌓기(찰), 돌 쌓기(메), 콘크리트, 돌망태, 통나무/목재틀, 바자 형태 (같음) 형태 (같음)
|
||||
길이 수기입력 길이 수기입력 길이 수기입력
|
||||
높이 수기입력 및 횡단도에서 제어 높이 수기입력 및 횡단도에서 제어 높이 수기입력 및 횡단도에서 제어
|
||||
기준측점 전 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) (같음) (같음)
|
||||
기준측점 후 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) (같음) (같음)
|
||||
|
||||
운영(사용자 10-5): M02 「구조물 옵션 정의」 = 이름 · 값 이름 바꿀 때만 · 구조는 사용자가 엑셀 표로 주는 대로 브레인이 구조물마다 작업 창 하나에 코드로 넣음 · 연동 계산까지 · 구조물별 계산 파일 따로 됨.
|
||||
@@ -0,0 +1,8 @@
|
||||
# 구조물 표 02 — 횡단배수 › 박스암거 (사용자 10-5 · 고친 판 · 원문 그대로)
|
||||
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 컨테이너 2 컨테이너 2 옵션항목 컨테이너 3 컨테이너 3 옵션항목
|
||||
(필수)컨테이너 제목 : 본체 (필수)유입 구조 (필수) 유출 구조
|
||||
횡단배수 박스암거 측점 No, + 측점거리 본체 규격 2.0 X 2.0, 3.0 X 3.0, 사용자 입력 구조 없음, 날개벽, 흙막이 구조 없음, 날개벽, 흙막이
|
||||
|
||||
(첫 판: 날개벽(유입) · 날개벽(유출) 설치 있음/없음 → 이 판으로 바뀜)
|
||||
사용자 참고(10-5): 컨테이너 항목별로 이전에 작업한 구조물 조합의 키값을 연결할 예정 · 연결 코드는 이미 있음.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 표 03 — 횡단배수 › 물넘이 포장 (사용자 10-5 · 원문 그대로)
|
||||
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 컨테이너 2 컨테이너 2 옵션항목 컨테이너 3 컨테이너 3 옵션항목
|
||||
(필수)컨테이너 제목 : 경사각도 (선택)컨테이너 제목 : 도수로/돌붙임
|
||||
횡단배수 물넘이 포장 측점 No, + 측점거리 경사각도 각도 수기입력 구조 도수로, 돌붙임
|
||||
@@ -0,0 +1,8 @@
|
||||
# 구조물 표 04 — 횡단배수 › 세월교 (사용자 10-5 · 원문 그대로)
|
||||
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 컨테이너 2 컨테이너 2 옵션항목 컨테이너 3 컨테이너 3 옵션항목
|
||||
(필수)컨테이너 제목 : 세월교 (필수)유입 구조 (필수) 유출 구조
|
||||
횡단배수 세월교 측점 No, + 측점거리 관경 800, 1000, 1200, 1500 구조 없음, 날개벽, 흙막이 구조 없음, 날개벽, 흙막이
|
||||
월류 폭 수기 입력
|
||||
월류 높이 월류 폭과 높이는 연동 자동계산
|
||||
수량(련) 수기 입력
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 표 05 — 횡단배수 › 노출형 횡단수로 · 개거(겉도랑) (사용자 10-5 · 원문 그대로)
|
||||
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목
|
||||
컨테이너 없는 항목 측점 하단에 구조물 조합 연결 항목 넣기
|
||||
횡단배수 노출형 횡단수로 측점 No, + 측점거리
|
||||
횡단배수 개거(겉도랑) 측점 No, + 측점거리
|
||||
@@ -0,0 +1,7 @@
|
||||
# 구조물 표 06 — 횡단배수 › 맹암거 (새 종류 · 사용자 10-5 · 원문 그대로)
|
||||
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목
|
||||
횡단배수 맹암거 측점 No, + 측점거리 (필수)컨테이너 제목 : 맹암거
|
||||
암거 높이 수기입력
|
||||
암거 경사 수기입력
|
||||
* 종단 배수의 암거와는 다른 내용임
|
||||
@@ -0,0 +1,25 @@
|
||||
# 구조물 표 07 — 안전부대용지 · 종단배수 · 계류사방 (사용자 10-5 · 원문 그대로)
|
||||
|
||||
사용자: 다음 종단배수 전체 항목은 구조물 연결 지정 없으며 현재처럼 숨김처리. 계류사방 항목도 숨김처리
|
||||
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목
|
||||
안전부대용지 대피로 측점 No, + 측점거리 (필수)컨테이너 제목 : 상세 내용
|
||||
상세 대피소, 정차/작업장, 차돌림곳
|
||||
|
||||
컨테이너 없는 항목 측점 하단에 구조물 조합 연결 항목 넣기
|
||||
안전부대용지 반사경 측점 No, + 측점거리
|
||||
안전부대용지 경계석 측점 No, + 측점거리
|
||||
안전부대용지 위험표시 측점 No, + 측점거리
|
||||
안전부대용지 국가지점번호판 측점 No, + 측점거리
|
||||
안전부대용지 취수정 측점 No, + 측점거리
|
||||
안전부대용지 준공표지판 측점 No, + 측점거리
|
||||
안전부대용지 차단기 측점 No, + 측점거리
|
||||
안전부대용지 취수정 측점 No, + 측점거리 (원문에 두 번 · 하나로 봄)
|
||||
|
||||
(덧 · 사용자 10-5)
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목
|
||||
(필수)컨테이너 제목 : 상세 내용
|
||||
안전부대용지 가드레일 측점 No, + 측점거리 형태 수기입력
|
||||
길이 수기입력
|
||||
기준측점 전 수기입력(길이 + 기준측점 전/후에 따라 연동 계산)
|
||||
기준측점 후 수기입력(길이 + 기준측점 전/후에 따라 연동 계산)
|
||||
@@ -0,0 +1,17 @@
|
||||
# 구조물 표 08 — 안전부대용지 사토장 · 토취장 · 노면공 콘크리트 포장 · 기타 (사용자 10-5 · 원문 그대로)
|
||||
|
||||
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 … 상세 설계 필수 선택
|
||||
안전부대용지 유용토운반작업장(사토장) 측점 No, + 측점거리 (필수)컨테이너 제목 : 상세 내용
|
||||
사토장 부피 수기입력
|
||||
|
||||
안전부대용지 토사채취장(토취장) 측점 No, + 측점거리 (필수)컨테이너 제목 : 상세 내용
|
||||
토취장 부피 수기입력
|
||||
|
||||
노면공 콘크리트 포장 측점 No, + 측점거리 (필수)컨테이너 제목 : 상세 내용
|
||||
포장사양 콘크리트, XXX
|
||||
높이 수기입력
|
||||
|
||||
기타 수기입력 측점 No, + 측점거리 (선택)컨테이너 제목 : 상세 내용 (상세 설계) 도수로(or 돌붙임)
|
||||
비고 수기 입력 (상세 설계) 도수로(or 돌붙임) 관보호공, 옹벽, 돌쌓기, 흙막이, 큰톨쌓기 가 같은 형상으로 보임.
|
||||
|
||||
(가드레일은 표 07 덧과 같음)
|
||||
@@ -11,21 +11,44 @@
|
||||
"담은로직": [
|
||||
{
|
||||
"로직": "GF000219",
|
||||
"별칭": null,
|
||||
"메모": "메쌓기 — 돌쌓기 인력",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000119",
|
||||
"별칭": null,
|
||||
"메모": "노체포설 — 굴착기 0.7㎥",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000137",
|
||||
"별칭": null,
|
||||
"메모": "운반 — 중기운반 트레일러(20ton)",
|
||||
"입력값": {},
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"참조": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "SP-000001"
|
||||
},
|
||||
"별칭": null,
|
||||
"메모": null,
|
||||
"수량": null
|
||||
},
|
||||
{
|
||||
"참조": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "SC-000001"
|
||||
},
|
||||
"별칭": null,
|
||||
"메모": null,
|
||||
"수량": null
|
||||
}
|
||||
],
|
||||
"자재": [],
|
||||
@@ -43,6 +66,28 @@
|
||||
"경비": 0,
|
||||
"계": 79279.668
|
||||
},
|
||||
"줄": [
|
||||
{
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": 0.195,
|
||||
"단가": 268908,
|
||||
"금액": 52437.06,
|
||||
"비목": {
|
||||
"노무비": 52437.06
|
||||
}
|
||||
},
|
||||
{
|
||||
"이름": "보통인부",
|
||||
"단위": "인",
|
||||
"수량": 0.156,
|
||||
"단가": 172068,
|
||||
"금액": 26842.608,
|
||||
"비목": {
|
||||
"노무비": 26842.608
|
||||
}
|
||||
}
|
||||
],
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
@@ -59,6 +104,20 @@
|
||||
"경비": 1012.3273551844981,
|
||||
"계": 4522.956900158043
|
||||
},
|
||||
"줄": [
|
||||
{
|
||||
"이름": "굴착기(무한궤도) 0.7㎥",
|
||||
"단위": "시간",
|
||||
"수량": 0.04122861265718408,
|
||||
"단가": 109704.31961333334,
|
||||
"금액": 4522.956900158043,
|
||||
"비목": {
|
||||
"노무비": 2433.321308321308,
|
||||
"재료비": 1077.3082366522367,
|
||||
"경비": 1012.3273551844981
|
||||
}
|
||||
}
|
||||
],
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
@@ -75,18 +134,110 @@
|
||||
"경비": 13280.479259259258,
|
||||
"계": 83943.98719017902
|
||||
},
|
||||
"줄": [
|
||||
{
|
||||
"이름": "트럭 트랙터 및 평판트레일러 20ton",
|
||||
"단위": "시간",
|
||||
"수량": 0.7670370370370371,
|
||||
"단가": 109439.28798333333,
|
||||
"금액": 83943.98719017902,
|
||||
"비목": {
|
||||
"노무비": 38181.82530864197,
|
||||
"재료비": 32481.682622277778,
|
||||
"경비": 13280.479259259258
|
||||
}
|
||||
},
|
||||
{
|
||||
"이름": "트럭 10.5ton",
|
||||
"단위": "시간",
|
||||
"수량": 0,
|
||||
"단가": 97740.38995333333,
|
||||
"금액": 0,
|
||||
"비목": {
|
||||
"노무비": 0,
|
||||
"재료비": 0,
|
||||
"경비": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"까닭": null,
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"참조": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "SP-000001"
|
||||
},
|
||||
"이름": "돌쌓기 메쌓기(인력)",
|
||||
"알약": "참조|system||SP-000001",
|
||||
"분모": "원/㎡",
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 79279.668,
|
||||
"재료비": 0,
|
||||
"경비": 0,
|
||||
"계": 79279.668
|
||||
},
|
||||
"까닭": null,
|
||||
"원로직": "GF000219",
|
||||
"줄": [
|
||||
{
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": 0.195,
|
||||
"단가": 268908,
|
||||
"금액": 52437.06,
|
||||
"비목": {
|
||||
"노무비": 52437.06
|
||||
}
|
||||
},
|
||||
{
|
||||
"이름": "보통인부",
|
||||
"단위": "인",
|
||||
"수량": 0.156,
|
||||
"단가": 172068,
|
||||
"금액": 26842.608,
|
||||
"비목": {
|
||||
"노무비": 26842.608
|
||||
}
|
||||
}
|
||||
],
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
},
|
||||
{
|
||||
"참조": {
|
||||
"층": "system",
|
||||
"주인": null,
|
||||
"키": "SC-000001"
|
||||
},
|
||||
"이름": "테스트 사전조합",
|
||||
"알약": "참조|system||SC-000001",
|
||||
"분모": null,
|
||||
"배": 1,
|
||||
"기준": {
|
||||
"노무비": 0,
|
||||
"재료비": 0,
|
||||
"경비": 0,
|
||||
"계": 0
|
||||
},
|
||||
"까닭": null,
|
||||
"안": [],
|
||||
"수량": null,
|
||||
"수량출처": "없음",
|
||||
"반영": null
|
||||
}
|
||||
],
|
||||
"자재": [],
|
||||
"기준합": {
|
||||
"노무비": 119894.81461696328,
|
||||
"노무비": 199174.48261696327,
|
||||
"재료비": 33558.99085893002,
|
||||
"경비": 14292.806614443756,
|
||||
"계": 167746.61209033706
|
||||
"계": 247026.28009033707
|
||||
},
|
||||
"반영합": {
|
||||
"노무비": 0,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"종류": "사전조합",
|
||||
"키": "SC-000001",
|
||||
"이름": "테스트 사전조합",
|
||||
"단위": null,
|
||||
"담은로직": [
|
||||
{
|
||||
"로직": "GF000297",
|
||||
"입력값": {},
|
||||
"수량": {
|
||||
"손": 1
|
||||
},
|
||||
"메모": null
|
||||
},
|
||||
{
|
||||
"로직": "GF000289",
|
||||
"입력값": {},
|
||||
"수량": {
|
||||
"손": 1
|
||||
},
|
||||
"메모": null
|
||||
}
|
||||
],
|
||||
"본뜬": null
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"종류": "단가승격",
|
||||
"키": "SP-000001",
|
||||
"이름": "돌쌓기 메쌓기(인력)",
|
||||
"단위": "원/㎡",
|
||||
"배": 1,
|
||||
"담은로직": [
|
||||
{
|
||||
"로직": "GF000219",
|
||||
"입력값": {}
|
||||
}
|
||||
],
|
||||
"본뜬": null
|
||||
}
|
||||
@@ -103,11 +103,11 @@
|
||||
"자재조합": null,
|
||||
"경로": [
|
||||
"기슭막이",
|
||||
"ㄴㄴ"
|
||||
"ㄷ2ㄷ"
|
||||
],
|
||||
"머리": [
|
||||
"기슭막이",
|
||||
"ㄴㄴ",
|
||||
"ㄷ2ㄷ",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
File diff suppressed because it is too large
Load Diff
+730
-130
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user