feat(M02): 구조물집계표 넷째 카드 「선택 입력」 — 로직마다 입력 목록 · 자리 고르기(면적 · 재료 · 선택 · 안 씀) · 알약 줄에 뜸 (PLAN 25-2c)
- 카드 = 로직 이름 · 결과단위 묶음 아래 입력 줄(이름 · 설명 · 단위 · 고르기/범위 · 자동값 흐리게) + 자리 상자 · 처음은 모두 안 씀 - 저장 = 집계표 [저장]에 이어 PUT input-links(자동저장 없음) · 읽기만 층은 상자 잠금 - 면적 · 재료로 고른 입력은 그 시트 알약 줄에 「로직 이름 · 입력 이름」 · 자재 모드에서도 [목록에서 빼기] 막음(고정) - 알약 줄 시험을 합계 줄 아래 자리에 맞춤(25-0 뒤 어긋난 것) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
@@ -35,6 +35,8 @@ export interface LinkItem {
|
||||
단위?: string;
|
||||
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
|
||||
자재?: LinkMaterial;
|
||||
/** 호스트가 정한 알약 — 자재 모드에서도 [목록에서 빼기]가 안 뜸(예: 선택 입력 카드가 자리를 고른 입력) */
|
||||
고정?: boolean;
|
||||
}
|
||||
|
||||
export interface LinkOptions {
|
||||
@@ -208,7 +210,7 @@ export function mountLinks(
|
||||
};
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키);
|
||||
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
|
||||
if (ctx.readOnly || !(hit || inList)) return;
|
||||
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
|
||||
menu.open(e.clientX - o.left, e.clientY - o.top, [
|
||||
|
||||
@@ -333,3 +333,73 @@ export const fetchSources = (id: string): Promise<unknown> => call(`/projects/${
|
||||
/** 설계값을 채운 표 문서 — 서버가 그때그때 채움 · 저장 안 함 */
|
||||
export const readFilled = (id: string, name: string): Promise<TemplateDoc> =>
|
||||
call(`/projects/${enc(id)}/tables/${enc(name)}/filled`);
|
||||
|
||||
/* --- 선택 입력 (PLAN 25-1b) — 항목(집계표 열 하나) 일위대가의 로직별 입력 목록 · 입력 자리 연결 ---
|
||||
* 길은 층 항목 길(`/layers/{층}/items/{SS 키}/…`) · 층 폴더 저장은 연결만(값은 안 받음). */
|
||||
|
||||
/** 층 항목 목록 한 줄 — 열 id ↔ SS 키 */
|
||||
export interface ItemRow {
|
||||
key: string;
|
||||
열: string;
|
||||
일위대가: string | null;
|
||||
}
|
||||
|
||||
/** 로직의 입력 하나 — 고르기(선택지) · 범위 · 자동값은 보이기만 */
|
||||
export interface ItemInput {
|
||||
이름: string;
|
||||
설명?: string;
|
||||
단위?: string;
|
||||
고르기?: unknown[];
|
||||
범위?: unknown;
|
||||
자동값?: unknown;
|
||||
}
|
||||
|
||||
export interface ItemLogic {
|
||||
로직: string;
|
||||
이름: string;
|
||||
결과단위?: string;
|
||||
입력: ItemInput[];
|
||||
}
|
||||
|
||||
export interface ItemInputs {
|
||||
일위대가: string | null;
|
||||
이름?: string;
|
||||
로직: ItemLogic[];
|
||||
빠진로직: string[];
|
||||
}
|
||||
|
||||
/** 입력 자리 — 면적산출 알약 · 재료산출 알약 · 선택 컨테이너 · 안 씀(null) */
|
||||
export type InputSeat = "면적" | "재료" | "선택";
|
||||
|
||||
export interface InputLine {
|
||||
로직: string;
|
||||
입력: string;
|
||||
자리: InputSeat | null;
|
||||
알약?: string;
|
||||
}
|
||||
|
||||
export interface InputLinks {
|
||||
판: string | null;
|
||||
줄: InputLine[];
|
||||
}
|
||||
|
||||
const itemPath = (layer: Layer, key: string, tail: string): string =>
|
||||
`/layers/${layer}/items/${enc(key)}/${tail}`;
|
||||
|
||||
export const fetchItemRows = (layer: Layer): Promise<ItemRow[]> =>
|
||||
call<ItemRow[]>(`/items?layer=${layer}`);
|
||||
|
||||
export const fetchItemInputs = (layer: Layer, key: string): Promise<ItemInputs> =>
|
||||
call(itemPath(layer, key, "inputs"));
|
||||
|
||||
export const readInputLinks = (layer: Layer, key: string): Promise<InputLinks> =>
|
||||
call(itemPath(layer, key, "input-links"));
|
||||
|
||||
/** 판이 다르면 409(`StaleError`) · 없는 키는 422 */
|
||||
export const saveInputLinks = (
|
||||
layer: Layer,
|
||||
key: string,
|
||||
판: string | null,
|
||||
줄: InputLine[],
|
||||
): Promise<{ 판: string }> =>
|
||||
call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 + 줄마다 자리 고르기. 색은 테마 토큰. */
|
||||
|
||||
.m02-inputs__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
align-items: baseline;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__count {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 카드 본문이 남는 높이를 채우고 넘치면 안에서 스크롤 */
|
||||
.m02-inputs__body {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-height: var(--m02-struct-body-min);
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m02-inputs__logic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-inputs__logic-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputs__logic-title small,
|
||||
.m02-inputs__row small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.m02-inputs__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.m02-inputs__info {
|
||||
display: flex;
|
||||
flex: 1 1 12rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px var(--spacing-8);
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputs__name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__desc,
|
||||
.m02-inputs__hint {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* 자동값 — 흐리게(입력 값이 아니라 보기용 첫 선택지) */
|
||||
.m02-inputs__auto {
|
||||
flex-basis: 100%;
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.m02-inputs__seat {
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
padding: 4px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-inputs__seat:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Inputs.ts
|
||||
* 구조물집계표 아래 넷째 카드 「선택 입력」(PLAN 25-2c) — 고른 열(항목) 일위대가의 로직마다 묶어
|
||||
* 입력 목록을 보이고, 입력마다 자리(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀)를 사용자가 고름.
|
||||
*
|
||||
* · 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음 · 이름이 같은 입력도 로직마다 따로.
|
||||
* · 마스터 층에서는 값을 받지 않음 — 자리 연결만(값은 프로젝트 복사본 몫 · 25-2b).
|
||||
* · 저장은 집계표 [저장]과 같은 단추 흐름 — 여기는 고른 것을 초안으로 들고 있다가 `save()` 가 서버로 보냄
|
||||
* (자동저장 없음). 고름이 바뀌면 `onDirty` 로 메인에 알림(떠날 때 물음).
|
||||
* · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import {
|
||||
fetchItemInputs,
|
||||
fetchItemRows,
|
||||
getActiveLayer,
|
||||
readInputLinks,
|
||||
saveInputLinks,
|
||||
type InputSeat,
|
||||
type ItemInputs,
|
||||
type ItemRow,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
autoText,
|
||||
countOf,
|
||||
inputKey,
|
||||
linesOf,
|
||||
pillsOf,
|
||||
seatsOf,
|
||||
SEAT_OPTIONS,
|
||||
valueHint,
|
||||
type SeatMap,
|
||||
} from "./M02_MasterTemplete_Inputs_Model";
|
||||
import "./M02_MasterTemplete_Inputs.css";
|
||||
|
||||
const TEXT = {
|
||||
title: "선택 입력",
|
||||
count: "로직 {logics} · 입력 {inputs}",
|
||||
chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}",
|
||||
auto: "자동값",
|
||||
noItem: "집계표를 먼저 저장하면 입력 목록이 나옴",
|
||||
noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름",
|
||||
unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴",
|
||||
noLogic: "이 일위대가에 담긴 로직이 없음",
|
||||
missing: "빠진 로직 {value}",
|
||||
failed: "입력 목록을 못 받음 — {value}",
|
||||
loading: "입력 목록을 받는 중…",
|
||||
seat: "자리",
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""));
|
||||
|
||||
/** 열 하나의 초안 — 서버 목록 · 판 · 고른 자리 · 바뀜 */
|
||||
interface Draft {
|
||||
key: string;
|
||||
판: string | null;
|
||||
list: ItemInputs;
|
||||
seats: SeatMap;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export interface InputsCardOptions {
|
||||
/** 이 층에서 고칠 수 있나 — 아니면 자리 고르기가 잠김 */
|
||||
isAdmin: boolean;
|
||||
/** 고름이 바뀜 — 메인의 「저장 안 한 고침」 표시 */
|
||||
onDirty: () => void;
|
||||
}
|
||||
|
||||
export interface InputsCardHandle {
|
||||
root: HTMLElement;
|
||||
/** 고른 열 — `combo` = 집계표에 지금 고른 일위대가(저장 전일 수 있음) · null 이면 카드 비움 */
|
||||
show: (colId: string | null, combo: string | null) => Promise<void>;
|
||||
/** 고른 게 저장 안 됐나 */
|
||||
dirty: () => boolean;
|
||||
/** 바뀐 열마다 서버에 저장 — 실패하면 던짐(판이 다르면 `StaleError`) */
|
||||
save: () => Promise<void>;
|
||||
/** 항목 목록(열 ↔ 키)을 다시 받게 함 — 집계표 [저장] 뒤 */
|
||||
invalidate: () => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createInputsCard(opts: InputsCardOptions): InputsCardHandle {
|
||||
const drafts = new Map<string, Draft>();
|
||||
let rows: Promise<ItemRow[]> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const summary = el("div", { className: "m02-inputs__summary" });
|
||||
const body = el("div", { className: "m02-inputs__body" });
|
||||
const root = el("section", {
|
||||
className: "m02-struct__card m02-inputs ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [el("h3", { className: "ui-collapsible__title", text: TEXT.title }), summary, body],
|
||||
});
|
||||
|
||||
const note = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text });
|
||||
const clear = (): void => {
|
||||
summary.replaceChildren();
|
||||
body.replaceChildren();
|
||||
};
|
||||
|
||||
const itemRows = (): Promise<ItemRow[]> =>
|
||||
(rows ??= fetchItemRows(getActiveLayer()).catch((error: unknown) => {
|
||||
rows = null;
|
||||
throw error;
|
||||
}));
|
||||
|
||||
function paintSummary(draft: Draft): void {
|
||||
const count = countOf(draft.list);
|
||||
// 목록에 있는 입력의 자리만 셈 — 서버 목록에서 빠진 입력의 옛 고름은 안 셈(저장 때도 빠짐)
|
||||
const listed = new Set(
|
||||
draft.list.로직.flatMap((l) => l.입력.map((i) => inputKey(l.로직, i.이름))),
|
||||
);
|
||||
const seats = [...draft.seats].filter(([key]) => listed.has(key)).map(([, seat]) => seat);
|
||||
const by = (seat: InputSeat): number => seats.filter((s) => s === seat).length;
|
||||
summary.replaceChildren(
|
||||
el("span", { className: "m02-inputs__count", text: fill(TEXT.count, count) }),
|
||||
el("span", {
|
||||
className: "m02-inputs__chosen",
|
||||
text: fill(TEXT.chosen, { area: by("면적"), material: by("재료"), pick: by("선택") }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function paint(draft: Draft): void {
|
||||
paintSummary(draft);
|
||||
const groups = draft.list.로직.map((logic) =>
|
||||
el("div", {
|
||||
className: "m02-inputs__logic",
|
||||
attrs: { "data-logic": logic.로직 },
|
||||
children: [
|
||||
el("h4", {
|
||||
className: "m02-inputs__logic-title",
|
||||
children: [
|
||||
el("span", { text: logic.이름 }),
|
||||
...(logic.결과단위 ? [el("small", { text: logic.결과단위 })] : []),
|
||||
el("small", { className: "m02-inputs__logic-key", text: logic.로직 }),
|
||||
],
|
||||
}),
|
||||
...logic.입력.map((input) => {
|
||||
const key = inputKey(logic.로직, input.이름);
|
||||
const select = el("select", {
|
||||
className: "m02-inputs__seat",
|
||||
attrs: { "aria-label": `${logic.이름} ${input.이름} ${TEXT.seat}` },
|
||||
children: SEAT_OPTIONS.map((o) =>
|
||||
el("option", { text: o.label, attrs: { value: o.value } }),
|
||||
),
|
||||
}) as HTMLSelectElement;
|
||||
select.value = draft.seats.get(key) ?? "";
|
||||
select.disabled = !opts.isAdmin;
|
||||
select.addEventListener("change", () => {
|
||||
if (select.value) draft.seats.set(key, select.value as InputSeat);
|
||||
else draft.seats.delete(key);
|
||||
draft.dirty = true;
|
||||
paintSummary(draft);
|
||||
opts.onDirty();
|
||||
});
|
||||
const hint = valueHint(input);
|
||||
const auto = autoText(input);
|
||||
return el("div", {
|
||||
className: "m02-inputs__row",
|
||||
attrs: { "data-input": input.이름 },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-inputs__info",
|
||||
children: [
|
||||
el("span", { className: "m02-inputs__name", text: input.이름 }),
|
||||
...(input.단위 ? [el("small", { text: input.단위 })] : []),
|
||||
...(input.설명
|
||||
? [el("small", { className: "m02-inputs__desc", text: input.설명 })]
|
||||
: []),
|
||||
...(hint ? [el("small", { className: "m02-inputs__hint", text: hint })] : []),
|
||||
...(auto
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-inputs__auto",
|
||||
text: `${TEXT.auto} ${auto}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
select,
|
||||
],
|
||||
});
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const missing = draft.list.빠진로직;
|
||||
body.replaceChildren(
|
||||
...(groups.length ? groups : [note(TEXT.noLogic)]),
|
||||
...(missing.length ? [note(fill(TEXT.missing, { value: missing.join(", ") }))] : []),
|
||||
);
|
||||
}
|
||||
|
||||
async function show(colId: string | null, combo: string | null): Promise<void> {
|
||||
const mine = ++seq;
|
||||
clear();
|
||||
if (!colId) return;
|
||||
const kept = drafts.get(colId);
|
||||
if (kept) return paint(kept);
|
||||
body.replaceChildren(note(TEXT.loading));
|
||||
try {
|
||||
const layer: Layer = getActiveLayer();
|
||||
const row = (await itemRows()).find((r) => r.열 === colId);
|
||||
if (mine !== seq) return;
|
||||
if (!row) return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noItem));
|
||||
if (!row.일위대가)
|
||||
return void body.replaceChildren(note(combo ? TEXT.unsavedCombo : TEXT.noCombo));
|
||||
const [list, links] = await Promise.all([
|
||||
fetchItemInputs(layer, row.key),
|
||||
readInputLinks(layer, row.key),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (!list.일위대가) return void body.replaceChildren(note(TEXT.noCombo));
|
||||
const draft: Draft = {
|
||||
key: row.key,
|
||||
판: links.판,
|
||||
list,
|
||||
seats: seatsOf(links),
|
||||
dirty: false,
|
||||
};
|
||||
drafts.set(colId, draft);
|
||||
paint(draft);
|
||||
} catch (error) {
|
||||
if (mine === seq) body.replaceChildren(note(fill(TEXT.failed, { value: why(error) })));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
show,
|
||||
dirty: () => [...drafts.values()].some((d) => d.dirty),
|
||||
save: async () => {
|
||||
const layer = getActiveLayer();
|
||||
for (const draft of drafts.values()) {
|
||||
if (!draft.dirty) continue;
|
||||
const got = await saveInputLinks(
|
||||
layer,
|
||||
draft.key,
|
||||
draft.판,
|
||||
linesOf(draft.list, draft.seats),
|
||||
);
|
||||
draft.판 = got.판;
|
||||
draft.dirty = false;
|
||||
}
|
||||
},
|
||||
invalidate: () => {
|
||||
rows = null;
|
||||
for (const [id, draft] of drafts) if (!draft.dirty) drafts.delete(id); // 서버 것으로 다시 읽음
|
||||
},
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 열의 면적산출 · 재료산출 편집 화면 알약 — 자리를 그 시트로 고른 입력(프로젝트 층은 25-2b 몫이라 없음) */
|
||||
export async function inputPills(column: string, seat: "면적" | "재료"): Promise<LinkItem[]> {
|
||||
const layer = getActiveLayer();
|
||||
if (layer === "project") return [];
|
||||
try {
|
||||
const row = (await fetchItemRows(layer)).find((r) => r.열 === column);
|
||||
if (!row?.일위대가) return [];
|
||||
const [list, links] = await Promise.all([
|
||||
fetchItemInputs(layer, row.key),
|
||||
readInputLinks(layer, row.key),
|
||||
]);
|
||||
return pillsOf(list, links, seat);
|
||||
} catch {
|
||||
return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Inputs_Model.ts
|
||||
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글.
|
||||
* 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` ·
|
||||
* 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type {
|
||||
InputLine,
|
||||
InputLinks,
|
||||
InputSeat,
|
||||
ItemInput,
|
||||
ItemInputs,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 화면 글 ↔ 자리 — 「안 씀」 = 빈 글 */
|
||||
export const SEAT_OPTIONS: { value: "" | InputSeat; label: string }[] = [
|
||||
{ value: "", label: "안 씀" },
|
||||
{ value: "면적", label: "면적산출 알약" },
|
||||
{ value: "재료", label: "재료산출 알약" },
|
||||
{ value: "선택", label: "선택" },
|
||||
];
|
||||
|
||||
export const inputKey = (logic: string, name: string): string => `${logic}|${name}`;
|
||||
export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`;
|
||||
|
||||
/** 자리 고름 표 — 키 = 입력 키 · 값 = 자리(안 쓰면 키 없음) */
|
||||
export type SeatMap = Map<string, InputSeat>;
|
||||
|
||||
export function seatsOf(links: InputLinks | null): SeatMap {
|
||||
const out: SeatMap = new Map();
|
||||
for (const line of links?.줄 ?? [])
|
||||
if (line.자리) out.set(inputKey(line.로직, line.입력), line.자리);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저장할 줄 — 자리를 고른 것만(없는 것 = 안 씀) · 면적 · 재료는 알약 키를 같이 */
|
||||
export function linesOf(list: ItemInputs, seats: SeatMap): InputLine[] {
|
||||
const out: InputLine[] = [];
|
||||
for (const logic of list.로직)
|
||||
for (const input of logic.입력) {
|
||||
const seat = seats.get(inputKey(logic.로직, input.이름));
|
||||
if (!seat) continue;
|
||||
out.push({
|
||||
로직: logic.로직,
|
||||
입력: input.이름,
|
||||
자리: seat,
|
||||
...(seat === "선택" ? {} : { 알약: pillKey(logic.로직, input.이름) }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const countOf = (list: ItemInputs): { logics: number; inputs: number } => ({
|
||||
logics: list.로직.length,
|
||||
inputs: list.로직.reduce((sum, logic) => sum + logic.입력.length, 0),
|
||||
});
|
||||
|
||||
/** 그 시트(면적산출 · 재료산출)의 알약 — 고른 자리가 그 시트인 입력만 · 「로직 이름 · 입력 이름」 */
|
||||
export function pillsOf(
|
||||
list: ItemInputs,
|
||||
links: InputLinks | null,
|
||||
seat: "면적" | "재료",
|
||||
): LinkItem[] {
|
||||
const seats = seatsOf(links);
|
||||
const out: LinkItem[] = [];
|
||||
for (const logic of list.로직)
|
||||
for (const input of logic.입력) {
|
||||
if (seats.get(inputKey(logic.로직, input.이름)) !== seat) continue;
|
||||
out.push({
|
||||
키: pillKey(logic.로직, input.이름),
|
||||
이름: `${logic.이름} · ${input.이름}`,
|
||||
...(input.단위 ? { 단위: input.단위 } : {}),
|
||||
고정: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const text = (value: unknown): string =>
|
||||
value === null || value === undefined
|
||||
? ""
|
||||
: typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
|
||||
/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */
|
||||
export function valueHint(input: ItemInput): string {
|
||||
if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / ");
|
||||
const range = input.범위;
|
||||
if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`;
|
||||
if (range && typeof range === "object") {
|
||||
const r = range as Record<string, unknown>;
|
||||
const lo = r.최소 ?? r.min;
|
||||
const hi = r.최대 ?? r.max;
|
||||
if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`;
|
||||
}
|
||||
return text(range);
|
||||
}
|
||||
|
||||
/** 자동값 글 — 없으면 빈 글 */
|
||||
export const autoText = (input: ItemInput): string => text(input.자동값);
|
||||
@@ -13,7 +13,7 @@
|
||||
display: grid;
|
||||
flex: 1 1 0; /* 메인 칸(창 높이)에서 표 아래 남은 높이를 다 차지 */
|
||||
grid-auto-rows: minmax(auto, 1fr); /* 카드 셋 같은 높이 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
padding-top: var(--spacing-12);
|
||||
border-top: 1px solid var(--color-border);
|
||||
min-width: 0;
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type DrawingTemplateDoc,
|
||||
type DrawingTemplateHandle,
|
||||
} from "./M02_MasterTemplete_Drawing";
|
||||
import { createInputsCard, inputPills, type InputsCardHandle } from "./M02_MasterTemplete_Inputs";
|
||||
import "./M02_MasterTemplete_Structure.css";
|
||||
|
||||
/** 구조물 도면이 걸리는 집계표 이름 — 서버 `M02_MasterTemplete_Store.STRUCTURE_TABLE` 과 같은 글 */
|
||||
@@ -207,8 +208,12 @@ export async function pillsFor(
|
||||
kind: SheetKind,
|
||||
column: string,
|
||||
): Promise<{ links: LinkOptions; notice?: string }> {
|
||||
if (kind === "material") return { links: { 항목: [], 모드: "자재", 자재찾기: searchMaterials } };
|
||||
const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice });
|
||||
if (kind === "material") {
|
||||
const own = await inputPills(column, "재료");
|
||||
return { links: { 항목: own, 모드: "자재", 자재찾기: searchMaterials } };
|
||||
}
|
||||
const own = await inputPills(column, "면적"); // 선택 입력 카드가 면적산출로 고른 입력
|
||||
const none = (notice: string) => ({ links: { 항목: own, 모드: "입력" as const }, notice });
|
||||
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
|
||||
() => null,
|
||||
);
|
||||
@@ -217,7 +222,7 @@ export async function pillsFor(
|
||||
if (!combo) return none(TEXT.noPrice);
|
||||
try {
|
||||
const got = await fetchComboInputs(combo);
|
||||
const 항목 = got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 }));
|
||||
const 항목 = [...got.물량.map((q) => ({ 키: q.키, 이름: q.이름, 단위: q.단위 })), ...own];
|
||||
return 항목.length ? { links: { 항목, 모드: "입력" } } : none(fill(TEXT.noInputs, combo));
|
||||
} catch (error) {
|
||||
return none(fill(TEXT.inputsFailed, why(error)));
|
||||
@@ -232,6 +237,8 @@ export interface StructurePanelOptions {
|
||||
onEdit: (column: string, label: string, kind: EditKind) => void;
|
||||
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 「선택 입력」 카드에서 자리를 골랐음 — 저장 안 한 고침 표시 */
|
||||
onDirty?: () => void;
|
||||
}
|
||||
|
||||
export interface StructurePanelHandle {
|
||||
@@ -240,6 +247,8 @@ export interface StructurePanelHandle {
|
||||
numbers: () => ColumnNumbers;
|
||||
/** 번호 목록 다시 받기 */
|
||||
reload: () => Promise<void>;
|
||||
/** 「선택 입력」 카드에서 고른 자리를 서버에 저장 — 집계표 [저장] 뒤에 부름(실패하면 던짐) */
|
||||
saveInputs: () => Promise<void>;
|
||||
/** 고른 열이 바뀜 — 같은 열이면 이름만 새로(CAD 를 다시 안 띄움) */
|
||||
show: (colId: string | null, columns: StructureColumn[]) => void;
|
||||
/** 보던 열을 다시 읽음 — 집계표 [저장] 뒤(서버가 새 열 파일 · 번호를 만듦) */
|
||||
@@ -311,11 +320,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
};
|
||||
};
|
||||
const sheets = [sheetCard("area"), sheetCard("material")];
|
||||
const inputs: InputsCardHandle = createInputsCard({
|
||||
isAdmin: opts.isAdmin,
|
||||
onDirty: () => opts.onDirty?.(),
|
||||
});
|
||||
|
||||
const root = el("div", {
|
||||
className: "m02-struct ui-sidebar-panel",
|
||||
attrs: { hidden: "" },
|
||||
children: [infoCard, ...sheets.map((c) => c.root)],
|
||||
children: [infoCard, ...sheets.map((c) => c.root), inputs.root],
|
||||
});
|
||||
attachCollapsible(root);
|
||||
|
||||
@@ -397,6 +410,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
root.hidden = true;
|
||||
column = null;
|
||||
doc = null;
|
||||
void inputs.show(null, null);
|
||||
return;
|
||||
}
|
||||
root.hidden = false;
|
||||
@@ -427,6 +441,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
c.number = String(book?.[PARTS[c.kind].field] ?? "");
|
||||
});
|
||||
paintBody(mine);
|
||||
// 집계표에 지금 고른 일위대가(저장 전일 수 있음) — 카드가 「먼저 저장」 안내를 고름
|
||||
void inputs.show(col.id, (col as { 일위대가?: string | null }).일위대가 ?? null);
|
||||
}
|
||||
|
||||
/** 글 열(NO · 측점 · 비고)은 구조물이 아니라 컨테이너를 닫음 */
|
||||
@@ -516,7 +532,9 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
numbers: () => numbers,
|
||||
reload: async () => {
|
||||
numbers = await fetchStructureNumbers().catch(() => ({}));
|
||||
inputs.invalidate(); // 새 열 · 바뀐 일위대가를 서버 항목 목록에서 다시 찾게
|
||||
},
|
||||
saveInputs: () => inputs.save(),
|
||||
show,
|
||||
refresh: (columns) => {
|
||||
const at = column;
|
||||
@@ -527,6 +545,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
drop();
|
||||
inputs.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type StructureColumn,
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { inputPills } from "./M02_MasterTemplete_Inputs";
|
||||
import { buildComboBar } from "./M02_MasterTemplete_Combo";
|
||||
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
|
||||
import {
|
||||
@@ -197,6 +198,7 @@ export function createMain(
|
||||
const made = createStructurePanel({
|
||||
isAdmin,
|
||||
confirmLeave,
|
||||
onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장
|
||||
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
|
||||
onEdit: (column, label, kind) => {
|
||||
void open({ layer, projectId: null, kind, name: column, label });
|
||||
@@ -221,16 +223,17 @@ export function createMain(
|
||||
try {
|
||||
const master = at.layer !== "project" && kind === "material";
|
||||
if (master && !matState) matState = await loadMaterialState(at.name);
|
||||
const [{ createSpreadsheet }, pills] = await Promise.all([
|
||||
const [{ createSpreadsheet }, pills, own] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
master ? null : pillsFor(kind, at.name),
|
||||
master ? inputPills(at.name, "재료") : [], // 「선택 입력」 카드가 재료산출로 고른 입력
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (pills?.notice) showNotice([pills.notice]);
|
||||
const links: LinkOptions =
|
||||
pills?.links ??
|
||||
({
|
||||
항목: toPills(pillItems ?? matState?.combo?.담은자재 ?? []),
|
||||
항목: [...toPills(pillItems ?? matState?.combo?.담은자재 ?? []), ...own],
|
||||
모드: "자재",
|
||||
자재찾기: searchMaterials,
|
||||
// 알약 막대에서 더하기 · 빼기 — 옆 판 표를 같은 목록으로
|
||||
@@ -468,6 +471,7 @@ export function createMain(
|
||||
await panel.reload();
|
||||
sheet.recalc();
|
||||
panel.refresh(sheet.getDoc()?.열 ?? []);
|
||||
await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서
|
||||
}
|
||||
showNotice([]);
|
||||
showToast(L("M02_Saved"), "success");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* M02 「선택 입력」 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`) 단위검증 헬퍼 —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 낸다. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
// 같은 이름(높이)의 입력이 두 로직에 있음
|
||||
const list = {
|
||||
일위대가: "UA000001",
|
||||
로직: [
|
||||
{
|
||||
로직: "GC1",
|
||||
이름: "돌쌓기",
|
||||
입력: [
|
||||
{ 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 },
|
||||
{ 이름: "종류", 고르기: ["막돌", "견치돌"] },
|
||||
],
|
||||
},
|
||||
{ 로직: "GC2", 이름: "타설", 입력: [{ 이름: "높이", 범위: { 최소: 0, 최대: 3 } }, { 이름: "배합" }] },
|
||||
],
|
||||
빠진로직: [],
|
||||
};
|
||||
const seats = m.seatsOf({
|
||||
판: "v1",
|
||||
줄: [
|
||||
{ 로직: "GC1", 입력: "높이", 자리: "면적", 알약: "입력|GC1|높이" },
|
||||
{ 로직: "GC2", 입력: "높이", 자리: "면적" },
|
||||
{ 로직: "GC2", 입력: "배합", 자리: "재료" },
|
||||
{ 로직: "GC1", 입력: "종류", 자리: null },
|
||||
{ 로직: "GC9", 입력: "없는것", 자리: "선택" },
|
||||
],
|
||||
});
|
||||
const links = { 판: "v1", 줄: [...[...seats].map(([k, 자리]) => ({ 로직: k.split("|")[0], 입력: k.split("|")[1], 자리 }))] };
|
||||
const pick = new Map(seats);
|
||||
pick.set(m.inputKey("GC1", "종류"), "선택");
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
keys: [m.inputKey("GC1", "높이"), m.pillKey("GC2", "높이")],
|
||||
seatKeys: [...seats.keys()],
|
||||
count: m.countOf(list),
|
||||
area: m.pillsOf(list, links, "면적").map((p) => [p.키, p.이름, p.단위 ?? null, p.고정]),
|
||||
material: m.pillsOf(list, links, "재료").map((p) => [p.키, p.이름]),
|
||||
lines: m.linesOf(list, pick),
|
||||
none: m.linesOf(list, new Map()),
|
||||
hints: list.로직.flatMap((l) => l.입력).map((i) => [m.valueHint(i), m.autoText(i)]),
|
||||
seatOptions: m.SEAT_OPTIONS.map((o) => o.label),
|
||||
}),
|
||||
);
|
||||
@@ -113,13 +113,13 @@ def test_links_follow_rows_cols(r):
|
||||
assert r["links"] == ["L:B7,W:D5", "L:B7", "L:B7,W:D5", "L:B5,W:D3", "D1"]
|
||||
|
||||
|
||||
def test_links_bar_sits_under_sheet():
|
||||
# 알약 막대 = 시트 아래 · 상태줄 위 · 자재 고르기 창은 막대 위로 엶
|
||||
def test_links_bar_sits_under_statusbar():
|
||||
# 알약 막대 = 시트 · 합계 줄(상태줄) 아래 맨 끝 · 자재 고르기 창은 막대 위로 엶
|
||||
with open(
|
||||
os.path.join(HERE, "../../../A00_Common/spreadsheet/spreadsheet_links.ts"), encoding="utf-8"
|
||||
) as f:
|
||||
src = f.read()
|
||||
assert 'insertBefore(bar, ctx.root.querySelector(":scope > .ss-statusbar"))' in src
|
||||
assert 'ctx.root.querySelector(":scope > .ss-statusbar")?.after(bar)' in src
|
||||
assert "pick.style.bottom = `${o.bottom - bar.getBoundingClientRect().top + 4}px`" in src
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M02 「선택 입력」 카드(PLAN 25-2c) 셈 회귀검증 — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록.
|
||||
|
||||
같은 이름의 입력도 로직마다 따로(키 `{로직}|{이름}`) · 처음은 모두 「안 씀」(저장 줄 없음) ·
|
||||
면적 · 재료로 고른 입력만 그 시트 알약(`입력|{로직}|{이름}` · 「로직 이름 · 입력 이름」).
|
||||
Node 헬퍼(`helper_m02_inputs.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_inputs.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_키_모양(results):
|
||||
assert results["keys"] == ["GC1|높이", "입력|GC2|높이"]
|
||||
|
||||
|
||||
def test_자리는_고른_것만_안_쓰면_없음(results):
|
||||
assert results["seatKeys"] == ["GC1|높이", "GC2|높이", "GC2|배합", "GC9|없는것"]
|
||||
assert results["none"] == [] # 처음은 모두 안 씀 — 저장할 줄 없음
|
||||
|
||||
|
||||
def test_로직_수_입력_수(results):
|
||||
assert results["count"] == {"logics": 2, "inputs": 4}
|
||||
|
||||
|
||||
def test_같은_이름_입력도_로직마다_따로_알약(results):
|
||||
# 높이가 두 로직에 있어도 알약 둘 · 이름은 「로직 이름 · 입력 이름」 · 목록에서 못 뺌(고정)
|
||||
assert results["area"] == [
|
||||
["입력|GC1|높이", "돌쌓기 · 높이", "m", True],
|
||||
["입력|GC2|높이", "타설 · 높이", None, True],
|
||||
]
|
||||
assert results["material"] == [["입력|GC2|배합", "타설 · 배합"]]
|
||||
|
||||
|
||||
def test_저장_줄은_고른_것만_알약_키_함께(results):
|
||||
assert results["lines"] == [
|
||||
{"로직": "GC1", "입력": "높이", "자리": "면적", "알약": "입력|GC1|높이"},
|
||||
{"로직": "GC1", "입력": "종류", "자리": "선택"}, # 선택은 알약 없음
|
||||
{"로직": "GC2", "입력": "높이", "자리": "면적", "알약": "입력|GC2|높이"},
|
||||
{"로직": "GC2", "입력": "배합", "자리": "재료", "알약": "입력|GC2|배합"},
|
||||
]
|
||||
|
||||
|
||||
def test_고르기_범위_자동값_글(results):
|
||||
assert results["hints"] == [
|
||||
["0.5 ~ 5", "1.2"],
|
||||
["막돌 / 견치돌", ""],
|
||||
["0 ~ 3", ""],
|
||||
["", ""],
|
||||
]
|
||||
assert results["seatOptions"] == ["안 씀", "면적산출 알약", "재료산출 알약", "선택"]
|
||||
|
||||
|
||||
def test_카드는_집계표_카드_옆_넷째_그리고_700줄_안():
|
||||
page = os.path.join(ROOT, "M02_MasterTemplete")
|
||||
src = open(os.path.join(page, "M02_MasterTemplete_Structure.ts"), encoding="utf-8").read()
|
||||
assert "inputs.root" in src and "createInputsCard" in src
|
||||
for name in os.listdir(page):
|
||||
if name.endswith((".ts", ".py")):
|
||||
n = len(open(os.path.join(page, name), encoding="utf-8").read().splitlines())
|
||||
assert n <= 700, (name, n)
|
||||
Reference in New Issue
Block a user