feat(ui_template): 항목 고르기 모달을 실서버에 이음 · 선택 입력 카드는 sub2 파일 한 벌 (PLAN 25-2d)
- 복사본 창구를 sub6 길 모양({항목} · {종류, 판, 내용} · PUT {판, 내용} · 409 message · refresh · calc)으로 — 가짜 응답 지움
- 입력 목록 · 항목 연결은 sub4 길(fetchItemInputs · readInputLinks) · 알약은 pillsOf
- 값 받기 카드를 M02_MasterTemplete_Inputs.ts 에 모드 view · value 로 더함(InputsCard 지움 · 두 벌 금지) · 셈은 _Inputs_Model
- 상태 줄에 복사본 계산 한 줄(계 · 빠진 입력) · 목록은 모달에서 한 번만 받아 들고 있음
- Structure.ts: sub2 의 선택 입력 카드를 ItemCards 의 extraCards 로 다시 얹음
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
@@ -103,3 +103,101 @@
|
||||
.m02-inputs__seat:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */
|
||||
.m02-inputval__wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: var(--m02-struct-body-min, 16rem);
|
||||
min-width: 0;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-inputval {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__places,
|
||||
.m02-inputval__missing {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-inputval__logic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__logic-name {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputval__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr);
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputval__cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputval__control {
|
||||
flex: 1 1 6rem;
|
||||
min-width: 0;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */
|
||||
.m02-inputval__value.is-auto,
|
||||
.m02-inputval__control.is-auto,
|
||||
.m02-inputval__control::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.m02-inputval__state {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-inputval__state.is-confirmed {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-inputval__error {
|
||||
flex: 1 0 100%;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-inputval__control[aria-invalid="true"] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import {
|
||||
fetchItemInputs,
|
||||
@@ -18,21 +18,28 @@ import {
|
||||
getActiveLayer,
|
||||
readInputLinks,
|
||||
saveInputLinks,
|
||||
type InputLinks,
|
||||
type InputSeat,
|
||||
type ItemInput,
|
||||
type ItemInputs,
|
||||
type ItemLogic,
|
||||
type ItemRow,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
autoText,
|
||||
choicesOf,
|
||||
countOf,
|
||||
inputKey,
|
||||
linesOf,
|
||||
pickedGroups,
|
||||
pillsOf,
|
||||
rangeOf,
|
||||
seatsOf,
|
||||
SEAT_OPTIONS,
|
||||
valueHint,
|
||||
type SeatMap,
|
||||
type ValueDoc,
|
||||
} from "./M02_MasterTemplete_Inputs_Model";
|
||||
import "./M02_MasterTemplete_Inputs.css";
|
||||
|
||||
@@ -276,3 +283,291 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro
|
||||
return []; // 서버 길이 아직 없거나 못 읽어도 시트 편집은 그대로
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ─────────────────────────────
|
||||
* 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만
|
||||
* 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님
|
||||
* (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음.
|
||||
* mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */
|
||||
|
||||
export interface InputsValueData {
|
||||
list: ItemInputs;
|
||||
link: InputLinks | null;
|
||||
values: ValueDoc;
|
||||
}
|
||||
|
||||
export interface InputsValueOptions {
|
||||
mode: "view" | "value";
|
||||
/** mode "view" — [선택 입력 수정] */
|
||||
onEdit?: () => void;
|
||||
/** mode "value" — 값이 바뀔 때마다 */
|
||||
onChange?: (values: ValueDoc) => void;
|
||||
}
|
||||
|
||||
export interface InputsValueHandle {
|
||||
root: HTMLElement;
|
||||
/** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */
|
||||
set: (data: InputsValueData | null, note?: string) => void;
|
||||
/** 지금 값(확인한 것 · 값이 든 것만) */
|
||||
getValues: () => ValueDoc;
|
||||
/** 범위 밖 값이 없나 */
|
||||
isValid: () => boolean;
|
||||
/** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
|
||||
setEditLabel: (label: string) => void;
|
||||
}
|
||||
|
||||
const VALUE_TEXT = {
|
||||
edit: "선택 입력 수정",
|
||||
none: "선택으로 이은 입력이 없음",
|
||||
auto: "자동값",
|
||||
unconfirmed: "확인 안 함",
|
||||
confirmed: "✓ 확인함",
|
||||
confirmBox: "확인",
|
||||
outOfRange: "범위 {value} 밖",
|
||||
summary: "선택 {value}",
|
||||
places: "면적산출 알약 {area} · 재료산출 알약 {material}",
|
||||
noList: "입력 목록을 못 읽음 — {value}",
|
||||
};
|
||||
|
||||
type ValueEntry = ValueDoc[string][string];
|
||||
|
||||
export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle {
|
||||
let data: InputsValueData | null = null;
|
||||
let values: ValueDoc = {};
|
||||
let invalid = 0;
|
||||
|
||||
const summary = el("span", { className: "m02-struct__no" });
|
||||
const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" });
|
||||
edit.hidden = opts.mode !== "view";
|
||||
if (opts.onEdit) edit.addEventListener("click", opts.onEdit);
|
||||
const places = el("p", { className: "m02-inputval__places" });
|
||||
const body = el("div", { className: "m02-struct__sheet m02-inputval" });
|
||||
const root = el("section", {
|
||||
className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [
|
||||
el("h3", { className: "ui-collapsible__title", text: TEXT.title }),
|
||||
el("div", { className: "m02-struct__subhead", children: [summary, edit] }),
|
||||
el("div", { className: "m02-inputval__wrap", children: [places, body] }),
|
||||
],
|
||||
});
|
||||
|
||||
const groups = (): ReturnType<typeof pickedGroups> =>
|
||||
data ? pickedGroups(data.list, data.link) : [];
|
||||
const one = (logic: string, name: string): ValueEntry =>
|
||||
values[logic]?.[name] ?? { 값: null, 확인: false };
|
||||
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
|
||||
const put = (logic: string, name: string, next: ValueEntry): void => {
|
||||
(values[logic] ??= {})[name] = next;
|
||||
};
|
||||
const autoOf = (input: ItemInput): string | number | null => {
|
||||
const auto = input.자동값;
|
||||
return typeof auto === "string" || typeof auto === "number" ? auto : null;
|
||||
};
|
||||
|
||||
const paintSummary = (): void => {
|
||||
let total = 0;
|
||||
let confirmed = 0;
|
||||
for (const { logic, inputs } of groups())
|
||||
for (const input of inputs) {
|
||||
total += 1;
|
||||
if (one(logic.로직, input.이름).확인) confirmed += 1;
|
||||
}
|
||||
summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : "";
|
||||
};
|
||||
|
||||
/** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */
|
||||
const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => {
|
||||
const stored = one(logic.로직, input.이름);
|
||||
const auto = autoOf(input);
|
||||
const state = el("span", { className: "m02-inputval__state" });
|
||||
const paintState = (): void => {
|
||||
const now = one(logic.로직, input.이름);
|
||||
state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed;
|
||||
state.classList.toggle("is-confirmed", now.확인);
|
||||
};
|
||||
if (opts.mode === "view") {
|
||||
const text = isEmpty(stored.값) ? auto : stored.값;
|
||||
const shown = el("span", {
|
||||
className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`,
|
||||
text: text === null ? "—" : String(text),
|
||||
attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {},
|
||||
});
|
||||
paintState();
|
||||
return el("div", { className: "m02-inputval__cell", children: [shown, state] });
|
||||
}
|
||||
|
||||
const emit = (): void => {
|
||||
paintState();
|
||||
paintSummary();
|
||||
opts.onChange?.(values);
|
||||
};
|
||||
const range = rangeOf(input);
|
||||
const choices = choicesOf(input);
|
||||
const control = (
|
||||
choices.length
|
||||
? el("select", { className: "ui-select ui-select--sm m02-inputval__control" })
|
||||
: el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: {
|
||||
type: range ? "number" : "text",
|
||||
...(range?.min !== undefined ? { min: String(range.min) } : {}),
|
||||
...(range?.max !== undefined ? { max: String(range.max) } : {}),
|
||||
...(range ? { step: "any" } : {}),
|
||||
placeholder: auto === null ? "" : String(auto),
|
||||
},
|
||||
})
|
||||
) as HTMLInputElement | HTMLSelectElement;
|
||||
const error = el("span", { className: "m02-inputval__error" });
|
||||
const box = el("input", {
|
||||
attrs: {
|
||||
type: "checkbox",
|
||||
title: VALUE_TEXT.confirmBox,
|
||||
"aria-label": VALUE_TEXT.confirmBox,
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
|
||||
if (control instanceof HTMLSelectElement) {
|
||||
control.append(
|
||||
el("option", {
|
||||
attrs: { value: "" },
|
||||
text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`,
|
||||
}),
|
||||
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
|
||||
);
|
||||
}
|
||||
control.value = isEmpty(stored.값) ? "" : String(stored.값);
|
||||
control.classList.toggle("is-auto", isEmpty(stored.값));
|
||||
box.checked = stored.확인;
|
||||
|
||||
const outside = (raw: string): boolean => {
|
||||
if (!range || raw.trim() === "") return false;
|
||||
const value = Number(raw);
|
||||
return (
|
||||
!Number.isFinite(value) ||
|
||||
(range.min !== undefined && value < range.min) ||
|
||||
(range.max !== undefined && value > range.max)
|
||||
);
|
||||
};
|
||||
let wasBad = false;
|
||||
const changed = (): void => {
|
||||
const raw = control.value;
|
||||
const bad = outside(raw);
|
||||
if (bad !== wasBad) invalid += bad ? 1 : -1;
|
||||
wasBad = bad;
|
||||
control.setAttribute("aria-invalid", bad ? "true" : "false");
|
||||
error.textContent = bad
|
||||
? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` })
|
||||
: "";
|
||||
control.classList.toggle("is-auto", raw === "");
|
||||
const value: string | number | null =
|
||||
raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw);
|
||||
// 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님)
|
||||
put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad });
|
||||
box.checked = raw !== "" && !bad;
|
||||
emit();
|
||||
};
|
||||
control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed);
|
||||
// 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림
|
||||
box.addEventListener("change", () => {
|
||||
const now = one(logic.로직, input.이름);
|
||||
if (box.checked && isEmpty(now.값) && auto !== null) {
|
||||
control.value = String(auto);
|
||||
control.classList.remove("is-auto");
|
||||
put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true });
|
||||
} else if (box.checked && isEmpty(now.값)) {
|
||||
box.checked = false; // 받아들일 값이 없음
|
||||
} else {
|
||||
put(logic.로직, input.이름, { ...now, 확인: box.checked });
|
||||
}
|
||||
emit();
|
||||
});
|
||||
paintState();
|
||||
return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] });
|
||||
};
|
||||
|
||||
const paint = (note?: string): void => {
|
||||
invalid = 0;
|
||||
paintSummary();
|
||||
if (!data) {
|
||||
places.textContent = "";
|
||||
body.replaceChildren(
|
||||
el("p", {
|
||||
className: "m02-struct__empty",
|
||||
text: note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const seats = [...seatsOf(data.link).values()];
|
||||
const area = seats.filter((seat) => seat === "면적").length;
|
||||
const material = seats.filter((seat) => seat === "재료").length;
|
||||
places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : "";
|
||||
const shown = groups();
|
||||
const missing = data.list.빠진로직.length
|
||||
? [
|
||||
el("p", {
|
||||
className: "m02-inputval__missing",
|
||||
text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
if (!shown.length) {
|
||||
body.replaceChildren(
|
||||
el("p", { className: "m02-struct__empty", text: VALUE_TEXT.none }),
|
||||
...missing,
|
||||
);
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...shown.map(({ logic, inputs }) =>
|
||||
el("section", {
|
||||
className: "m02-inputval__logic",
|
||||
attrs: { "data-logic": logic.로직 },
|
||||
children: [
|
||||
el("h4", {
|
||||
className: "m02-inputval__logic-name",
|
||||
text: logic.이름 || logic.로직,
|
||||
attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` },
|
||||
}),
|
||||
...inputs.map((input) =>
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
attrs: { "data-input": inputKey(logic.로직, input.이름) },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-inputval__name",
|
||||
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
|
||||
attrs: input.설명 ? { title: input.설명 } : {},
|
||||
}),
|
||||
valueCell(logic, input),
|
||||
],
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
),
|
||||
...missing,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
set: (next, note) => {
|
||||
data = next;
|
||||
values = next ? structuredClone(next.values) : {};
|
||||
paint(note);
|
||||
},
|
||||
getValues: () => {
|
||||
// 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함)
|
||||
const out: ValueDoc = {};
|
||||
for (const [logic, byName] of Object.entries(values))
|
||||
for (const [name, entry] of Object.entries(byName))
|
||||
if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry;
|
||||
return out;
|
||||
},
|
||||
isValid: () => invalid === 0,
|
||||
setEditLabel: (label) => {
|
||||
(edit.querySelector(".ui-btn__label") ?? edit).textContent = label;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/* 「선택 입력」 카드 — 로직마다 묶은 입력 줄 · 값 칸 · 확인 표시. 카드 틀은 M02_MasterTemplete_Structure.css */
|
||||
.m02-inputs__wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: var(--m02-struct-body-min, 16rem);
|
||||
min-width: 0;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputs__places,
|
||||
.m02-inputs__missing {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-inputs__logic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputs__logic-name {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputs__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr);
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputs__name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-inputs__cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-inputs__control {
|
||||
flex: 1 1 6rem;
|
||||
min-width: 0;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */
|
||||
.m02-inputs__value.is-auto,
|
||||
.m02-inputs__control.is-auto,
|
||||
.m02-inputs__control::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.m02-inputs__state {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-inputs__state.is-confirmed {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-inputs__error {
|
||||
flex: 1 0 100%;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-inputs__control[aria-invalid="true"] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_InputsCard.ts
|
||||
* 「선택 입력」 카드(계획서 25장) — 구조물 집계 항목의 일위대가 조합이 담은 단가산출 로직들의 입력을
|
||||
* **로직마다 묶어** 보이는 카드. 카드 틀은 도면 · 면적산출 · 재료산출 카드와 같음(`ItemCards`).
|
||||
*
|
||||
* mode "view" = 보기만 — 자리가 「선택」 인 입력의 값(없으면 흐린 자동값 + 「확인 안 함」)
|
||||
* mode "value" = 값 받기 — 프로젝트 복사본(고르기 = 목록 · 범위 = 숫자 · 자동값 흐림 · 확인 표시)
|
||||
* mode "link" = 자리 고르기(면적산출 알약 · 재료산출 알약 · 선택 · 안 씀) — M02 세 층 공용(sub2 · 25-2c)
|
||||
*
|
||||
* 값은 로직마다 따로 — 이름이 같아도 합치지 않음(`{로직}|{입력이름}`). 입력이 없어도 자동값은 흐린
|
||||
* 미리보기일 뿐 저장값이 아님(25-1: 확인 안 한 입력은 계산 때 「빠진 입력」 목록).
|
||||
* 자료 모양은 25-1b 약속 그대로 — 아래 타입.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { itemCard, itemNote } from "./M02_MasterTemplete_ItemCards";
|
||||
import "./M02_MasterTemplete_InputsCard.css";
|
||||
|
||||
/** 입력 목록 한 입력(25-1b) — `고르기` 는 문자열 목록 · 객체 목록 둘 다 받음 */
|
||||
export interface LogicInput {
|
||||
이름: string;
|
||||
설명?: string | null;
|
||||
단위?: string | null;
|
||||
고르기?: Array<string | number | { 값?: string | number; 이름?: string }> | null;
|
||||
범위?: [number, number] | { 최소?: number; 최대?: number; min?: number; max?: number } | null;
|
||||
자동값?: string | number | null;
|
||||
}
|
||||
|
||||
export interface InputsLogic {
|
||||
로직: string;
|
||||
이름: string;
|
||||
결과단위?: string | null;
|
||||
입력: LogicInput[];
|
||||
}
|
||||
|
||||
/** `GET /api/m02/layers/{layer}/items/{key}/inputs` */
|
||||
export interface InputsList {
|
||||
일위대가: string;
|
||||
이름: string;
|
||||
로직: InputsLogic[];
|
||||
빠진로직: string[];
|
||||
}
|
||||
|
||||
export type InputPlace = "면적" | "재료" | "선택" | null;
|
||||
|
||||
/** 항목 연결 한 줄 — 자리 면적 · 재료 = 그 시트 알약 · 선택 = 이 카드 */
|
||||
export interface LinkRow {
|
||||
로직: string;
|
||||
입력: string;
|
||||
자리: InputPlace;
|
||||
알약?: string;
|
||||
}
|
||||
|
||||
export interface LinkDoc {
|
||||
판: string;
|
||||
줄: LinkRow[];
|
||||
}
|
||||
|
||||
/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` */
|
||||
export type ValueDoc = Record<
|
||||
string,
|
||||
Record<string, { 값: string | number | null; 확인: boolean }>
|
||||
>;
|
||||
|
||||
/** 입력 하나의 키 · 알약 키(25-1b) */
|
||||
export const inputKey = (logic: string, name: string): string => `${logic}|${name}`;
|
||||
export const pillKey = (logic: string, name: string): string => `입력|${logic}|${name}`;
|
||||
|
||||
const TEXT = {
|
||||
title: "선택 입력",
|
||||
edit: "선택 입력 수정",
|
||||
none: "선택으로 이은 입력이 없음",
|
||||
missing: "없는 로직 {value}",
|
||||
auto: "자동값",
|
||||
unconfirmed: "확인 안 함",
|
||||
confirmed: "✓ 확인함",
|
||||
confirmBox: "확인",
|
||||
outOfRange: "범위 {value} 밖",
|
||||
summary: "선택 {value}",
|
||||
places: "면적산출 알약 {area} · 재료산출 알약 {material}",
|
||||
noList: "입력 목록을 못 읽음 — {value}",
|
||||
};
|
||||
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
const num = (value: unknown): number | undefined =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 */
|
||||
export function rangeOf(input: LogicInput): { min?: number; max?: number } | null {
|
||||
const raw = input.범위;
|
||||
if (Array.isArray(raw)) return { min: num(raw[0]), max: num(raw[1]) };
|
||||
if (raw && typeof raw === "object")
|
||||
return { min: num(raw.최소 ?? raw.min), max: num(raw.최대 ?? raw.max) };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 고르기 목록 → 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */
|
||||
export function choicesOf(input: LogicInput): { 값: string; 이름: string; raw: string | number }[] {
|
||||
return (input.고르기 ?? []).map((one) => {
|
||||
const raw = typeof one === "object" ? (one.값 ?? one.이름 ?? "") : one;
|
||||
return {
|
||||
값: String(raw),
|
||||
이름: typeof one === "object" ? String(one.이름 ?? raw) : String(one),
|
||||
raw,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 자리가 「선택」 인 줄만 로직 차례로 묶음 — 이 카드가 보이는 입력 */
|
||||
export function pickedGroups(
|
||||
list: InputsList,
|
||||
link: LinkDoc | null,
|
||||
): { logic: InputsLogic; inputs: LogicInput[] }[] {
|
||||
const picked = new Set(
|
||||
(link?.줄 ?? [])
|
||||
.filter((row) => row.자리 === "선택")
|
||||
.map((row) => inputKey(row.로직, row.입력)),
|
||||
);
|
||||
return list.로직
|
||||
.map((logic) => ({
|
||||
logic,
|
||||
inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))),
|
||||
}))
|
||||
.filter((group) => group.inputs.length > 0);
|
||||
}
|
||||
|
||||
export interface InputsCardData {
|
||||
list: InputsList;
|
||||
link: LinkDoc | null;
|
||||
values: ValueDoc;
|
||||
}
|
||||
|
||||
export interface InputsCardOptions {
|
||||
mode: "view" | "value";
|
||||
/** mode "view" — [선택 입력 수정] */
|
||||
onEdit?: () => void;
|
||||
/** mode "value" — 값이 바뀔 때마다 */
|
||||
onChange?: (values: ValueDoc) => void;
|
||||
}
|
||||
|
||||
export interface InputsCardHandle {
|
||||
root: HTMLElement;
|
||||
/** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */
|
||||
set: (data: InputsCardData | null, note?: string) => void;
|
||||
/** 지금 값(확인한 것 · 값이 든 것만) */
|
||||
getValues: () => ValueDoc;
|
||||
/** 범위 밖 값이 없나 */
|
||||
isValid: () => boolean;
|
||||
/** [선택 입력 수정] 글 — 아직 프로젝트로 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
|
||||
setEditLabel: (label: string) => void;
|
||||
/** 자리 「선택」 인 입력 수 · 확인한 수 */
|
||||
counts: () => { total: number; confirmed: number };
|
||||
}
|
||||
|
||||
export function createInputsCard(opts: InputsCardOptions): InputsCardHandle {
|
||||
let data: InputsCardData | null = null;
|
||||
let values: ValueDoc = {};
|
||||
let invalid = 0;
|
||||
|
||||
const summary = el("span", { className: "m02-struct__no" });
|
||||
const edit = createButton({ label: TEXT.edit, variant: "ghost", size: "sm" });
|
||||
edit.hidden = opts.mode !== "view";
|
||||
if (opts.onEdit) edit.addEventListener("click", opts.onEdit);
|
||||
const places = el("p", { className: "m02-inputs__places" });
|
||||
const body = el("div", { className: "m02-struct__sheet m02-inputs" });
|
||||
const root = itemCard(
|
||||
TEXT.title,
|
||||
el("div", {
|
||||
className: "m02-struct__subhead",
|
||||
children: [summary, edit],
|
||||
}),
|
||||
el("div", { className: "m02-inputs__wrap", children: [places, body] }),
|
||||
);
|
||||
|
||||
const groups = (): ReturnType<typeof pickedGroups> =>
|
||||
data ? pickedGroups(data.list, data.link) : [];
|
||||
const valueOf = (logic: string, name: string): { 값: string | number | null; 확인: boolean } =>
|
||||
values[logic]?.[name] ?? { 값: null, 확인: false };
|
||||
const isEmpty = (value: string | number | null): boolean => value === null || value === "";
|
||||
const store = (
|
||||
logic: string,
|
||||
name: string,
|
||||
next: { 값: string | number | null; 확인: boolean },
|
||||
) => {
|
||||
(values[logic] ??= {})[name] = next;
|
||||
};
|
||||
|
||||
const counts = (): { total: number; confirmed: number } => {
|
||||
let total = 0;
|
||||
let confirmed = 0;
|
||||
for (const { logic, inputs } of groups())
|
||||
for (const input of inputs) {
|
||||
total += 1;
|
||||
if (valueOf(logic.로직, input.이름).확인) confirmed += 1;
|
||||
}
|
||||
return { total, confirmed };
|
||||
};
|
||||
const paintSummary = (): void => {
|
||||
const { total, confirmed } = counts();
|
||||
summary.textContent = data ? `${fill(TEXT.summary, `${confirmed}/${total}`)}` : "";
|
||||
};
|
||||
|
||||
/** 값 칸 하나 — 자동값은 흐리게(placeholder · 첫 줄) */
|
||||
const valueCell = (logic: InputsLogic, input: LogicInput): HTMLElement => {
|
||||
const stored = valueOf(logic.로직, input.이름);
|
||||
const auto = input.자동값 ?? null;
|
||||
const state = el("span", { className: "m02-inputs__state" });
|
||||
const paintState = (): void => {
|
||||
const now = valueOf(logic.로직, input.이름);
|
||||
state.textContent = now.확인 ? TEXT.confirmed : TEXT.unconfirmed;
|
||||
state.classList.toggle("is-confirmed", now.확인);
|
||||
};
|
||||
if (opts.mode === "view") {
|
||||
const text = isEmpty(stored.값) ? auto : stored.값;
|
||||
const shown = el("span", {
|
||||
className: `m02-inputs__value${isEmpty(stored.값) ? " is-auto" : ""}`,
|
||||
text: text === null ? "—" : String(text),
|
||||
attrs: isEmpty(stored.값) ? { title: TEXT.auto } : {},
|
||||
});
|
||||
paintState();
|
||||
return el("div", { className: "m02-inputs__cell", children: [shown, state] });
|
||||
}
|
||||
|
||||
const emit = (): void => {
|
||||
paintState();
|
||||
paintSummary();
|
||||
opts.onChange?.(values);
|
||||
};
|
||||
const range = rangeOf(input);
|
||||
const choices = choicesOf(input);
|
||||
const control: HTMLInputElement | HTMLSelectElement = choices.length
|
||||
? el("select", { className: "ui-select ui-select--sm m02-inputs__control" })
|
||||
: el("input", {
|
||||
className: "ui-input m02-inputs__control",
|
||||
attrs: {
|
||||
type: range ? "number" : "text",
|
||||
...(range?.min !== undefined ? { min: String(range.min) } : {}),
|
||||
...(range?.max !== undefined ? { max: String(range.max) } : {}),
|
||||
...(range ? { step: "any" } : {}),
|
||||
placeholder: auto === null ? "" : String(auto),
|
||||
},
|
||||
});
|
||||
const error = el("span", { className: "m02-inputs__error" });
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", title: TEXT.confirmBox, "aria-label": TEXT.confirmBox },
|
||||
});
|
||||
|
||||
if (control instanceof HTMLSelectElement) {
|
||||
control.append(
|
||||
el("option", {
|
||||
attrs: { value: "" },
|
||||
text: auto === null ? "—" : `${TEXT.auto} ${String(auto)}`,
|
||||
}),
|
||||
...choices.map((one) => el("option", { attrs: { value: one.값 }, text: one.이름 })),
|
||||
);
|
||||
control.value = isEmpty(stored.값) ? "" : String(stored.값);
|
||||
} else {
|
||||
control.value = isEmpty(stored.값) ? "" : String(stored.값);
|
||||
}
|
||||
control.classList.toggle("is-auto", isEmpty(stored.값));
|
||||
box.checked = stored.확인;
|
||||
|
||||
const outside = (raw: string): boolean => {
|
||||
if (!range || raw.trim() === "") return false;
|
||||
const value = Number(raw);
|
||||
return (
|
||||
!Number.isFinite(value) ||
|
||||
(range.min !== undefined && value < range.min) ||
|
||||
(range.max !== undefined && value > range.max)
|
||||
);
|
||||
};
|
||||
let wasBad = false;
|
||||
const changed = (): void => {
|
||||
const raw = control.value;
|
||||
const bad = outside(raw);
|
||||
if (bad !== wasBad) invalid += bad ? 1 : -1;
|
||||
wasBad = bad;
|
||||
control.setAttribute("aria-invalid", bad ? "true" : "false");
|
||||
error.textContent = bad
|
||||
? fill(TEXT.outOfRange, `${range?.min ?? ""}~${range?.max ?? ""}`)
|
||||
: "";
|
||||
control.classList.toggle("is-auto", raw === "");
|
||||
const value: string | number | null =
|
||||
raw === ""
|
||||
? null
|
||||
: range
|
||||
? Number(raw)
|
||||
: (choices.find((one) => one.값 === raw)?.raw ?? raw);
|
||||
// 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님)
|
||||
store(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad });
|
||||
box.checked = raw !== "" && !bad;
|
||||
emit();
|
||||
};
|
||||
control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed);
|
||||
// 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림
|
||||
box.addEventListener("change", () => {
|
||||
const now = valueOf(logic.로직, input.이름);
|
||||
if (box.checked && isEmpty(now.값) && auto !== null) {
|
||||
control.value = String(auto);
|
||||
control.classList.remove("is-auto");
|
||||
store(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true });
|
||||
} else if (box.checked && isEmpty(now.값)) {
|
||||
box.checked = false; // 받아들일 값이 없음
|
||||
} else {
|
||||
store(logic.로직, input.이름, { ...now, 확인: box.checked });
|
||||
}
|
||||
emit();
|
||||
});
|
||||
paintState();
|
||||
return el("div", {
|
||||
className: "m02-inputs__cell",
|
||||
children: [control, box, state, error],
|
||||
});
|
||||
};
|
||||
|
||||
const paint = (note?: string): void => {
|
||||
invalid = 0;
|
||||
paintSummary();
|
||||
if (!data) {
|
||||
places.textContent = "";
|
||||
body.replaceChildren(itemNote(note ? fill(TEXT.noList, note) : TEXT.none));
|
||||
return;
|
||||
}
|
||||
const rows = data.link?.줄 ?? [];
|
||||
const area = rows.filter((row) => row.자리 === "면적").length;
|
||||
const material = rows.filter((row) => row.자리 === "재료").length;
|
||||
places.textContent =
|
||||
area + material
|
||||
? TEXT.places.replace("{area}", `${area}`).replace("{material}", `${material}`)
|
||||
: "";
|
||||
const shownGroups = groups();
|
||||
const missing = data.list.빠진로직.length
|
||||
? [
|
||||
el("p", {
|
||||
className: "m02-inputs__missing",
|
||||
text: fill(TEXT.missing, data.list.빠진로직.join(", ")),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
if (!shownGroups.length) {
|
||||
body.replaceChildren(itemNote(TEXT.none), ...missing);
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...shownGroups.map(({ logic, inputs }) =>
|
||||
el("section", {
|
||||
className: "m02-inputs__logic",
|
||||
attrs: { "data-logic": logic.로직 },
|
||||
children: [
|
||||
el("h4", {
|
||||
className: "m02-inputs__logic-name",
|
||||
text: logic.이름 || logic.로직,
|
||||
attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` },
|
||||
}),
|
||||
...inputs.map((input) =>
|
||||
el("div", {
|
||||
className: "m02-inputs__row",
|
||||
attrs: { "data-input": inputKey(logic.로직, input.이름) },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-inputs__name",
|
||||
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
|
||||
attrs: input.설명 ? { title: input.설명 } : {},
|
||||
}),
|
||||
valueCell(logic, input),
|
||||
],
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
),
|
||||
...missing,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
set: (next, note) => {
|
||||
data = next;
|
||||
values = next ? structuredClone(next.values) : {};
|
||||
paint(note);
|
||||
},
|
||||
getValues: () => {
|
||||
// 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함)
|
||||
const out: ValueDoc = {};
|
||||
for (const [logic, byName] of Object.entries(values))
|
||||
for (const [name, one] of Object.entries(byName))
|
||||
if (one.확인 || !isEmpty(one.값)) (out[logic] ??= {})[name] = one;
|
||||
return out;
|
||||
},
|
||||
isValid: () => invalid === 0,
|
||||
setEditLabel: (label) => {
|
||||
(edit.querySelector(".ui-btn__label") ?? edit).textContent = label;
|
||||
},
|
||||
counts,
|
||||
};
|
||||
}
|
||||
@@ -101,3 +101,51 @@ export function valueHint(input: ItemInput): string {
|
||||
|
||||
/** 자동값 글 — 없으면 빈 글 */
|
||||
export const autoText = (input: ItemInput): string => text(input.자동값);
|
||||
|
||||
/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */
|
||||
|
||||
/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */
|
||||
export type ValueDoc = Record<
|
||||
string,
|
||||
Record<string, { 값: string | number | null; 확인: boolean }>
|
||||
>;
|
||||
|
||||
const number = (value: unknown): number | undefined =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */
|
||||
export function rangeOf(input: ItemInput): { min?: number; max?: number } | null {
|
||||
const raw = input.범위;
|
||||
if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) };
|
||||
if (raw && typeof raw === "object") {
|
||||
const r = raw as Record<string, unknown>;
|
||||
return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */
|
||||
export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] {
|
||||
return (input.고르기 ?? []).map((one) => {
|
||||
const object =
|
||||
typeof one === "object" && one !== null ? (one as Record<string, unknown>) : null;
|
||||
const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number;
|
||||
return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw };
|
||||
});
|
||||
}
|
||||
|
||||
/** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */
|
||||
export function pickedGroups(
|
||||
list: ItemInputs,
|
||||
links: InputLinks | null,
|
||||
): { logic: ItemInputs["로직"][number]; inputs: ItemInput[] }[] {
|
||||
const picked = new Set(
|
||||
[...seatsOf(links)].filter(([, seat]) => seat === "선택").map(([key]) => key),
|
||||
);
|
||||
return list.로직
|
||||
.map((logic) => ({
|
||||
logic,
|
||||
inputs: logic.입력.filter((input) => picked.has(inputKey(logic.로직, input.이름))),
|
||||
}))
|
||||
.filter((group) => group.inputs.length > 0);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* 항목 고르기 모달 자료 창구 시험 헬퍼(계획서 25-2d) — `ui_template_item_picker_project.ts` 와
|
||||
* 선택 입력 카드의 순수 함수를 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다. 결과는 JSON 한 벌.
|
||||
* ① 가짜 복사본(서버 길 없음): 가져오기 · 고침 · 다시 읽기 · 마스터가 바뀜 · 새로 가져오기 · 원본 그대로
|
||||
* ② 실서버 모양(약속 길): 목록 · 읽기 · 고침 PUT · 권한 없음(403) 까닭 그대로 · 가져오기 · 새로 가져오기
|
||||
* ③ 입력 목록: 약속 길 그대로 · 없으면 M01 길로 짜 맞춤 · 가짜 연결(모두 선택)
|
||||
* ④ 카드 순수 함수: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음
|
||||
* 선택 입력 카드 셈(`M02_MasterTemplete_Inputs_Model.ts`)을 트랜스파일해 가짜 서버(fetch 대역) 위에서 돌린다.
|
||||
* 서버 모양은 sub6 복사본 길(`M02_Project_Items_Router.py`) · sub4 입력 길 그대로. 결과는 JSON 한 벌.
|
||||
* ① 폴더 이름 · 목록 {항목} · 가져오기 POST · 읽기 {종류, 판, 내용} · 고침 PUT {판, 내용}(판 · 409 · 403)
|
||||
* ② 새로 가져오기 · 계산 한 줄 · 입력 목록(한 번만 받음)
|
||||
* ③ 카드 셈: 범위 · 고르기 원래 모양 · 자리 「선택」 만 묶음
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
@@ -13,12 +13,7 @@ const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "t
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
const STUBS = {
|
||||
"@config/config_frontend": { API_BASE_URL: "/api" },
|
||||
// 카드 순수 함수만 볼 것 — 화면 부품은 안 씀
|
||||
"@ui/ui_template_elements": { el: () => ({}), createButton: () => ({}) },
|
||||
"./M02_MasterTemplete_ItemCards": { itemCard: () => ({}), itemNote: () => ({}) },
|
||||
};
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
@@ -39,228 +34,174 @@ function loadTs(file) {
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
// ── 브라우저 대역 ─────────────────────────────────────────────────────────────
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => void store.set(k, String(v)),
|
||||
};
|
||||
|
||||
/** 원본 층(시스템) — 열 rv 의 도면 · 면적 · 재료 · 판은 바뀔 수 있음 */
|
||||
const original = {
|
||||
version: "v1",
|
||||
docs: {
|
||||
structure: { 양식: "구조물도면", 도번: "SD01", 도면: { entities: [1, 2] } },
|
||||
area: { 면적번: "SA01", 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] },
|
||||
material: { 재료번: "SM01", 시트: [{ id: "s", 칸: {} }] },
|
||||
},
|
||||
};
|
||||
const originalSnapshot = () => JSON.stringify(original);
|
||||
|
||||
const reply = (status, body) => ({
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
/** 가짜 서버 — mode: "fake"(프로젝트 길 없음) | "real"(약속 길) */
|
||||
const state = { mode: "fake", readOnly: false, inputsRoute: false, calls: [], server: {} };
|
||||
/** 가짜 서버 — 복사본 폴더마다 부분 파일 · 판(글자) */
|
||||
const state = { readOnly: false, calls: [], folders: {}, inputsHits: 0 };
|
||||
const versionOf = (content) => `v${JSON.stringify(content ?? null).length}`;
|
||||
|
||||
const INPUTS = {
|
||||
일위대가: "UA000004",
|
||||
이름: "기슭막이",
|
||||
로직: [
|
||||
{
|
||||
로직: "GA1",
|
||||
이름: "로직 A",
|
||||
결과단위: "원/m",
|
||||
입력: [
|
||||
{ 이름: "지역", 고르기: ["전국평균", "서울"], 자동값: "전국평균" },
|
||||
{ 이름: "소량감률", 단위: "%", 범위: [0, 50], 자동값: 0 },
|
||||
],
|
||||
},
|
||||
{ 로직: "GA2", 이름: "로직 B", 입력: [{ 이름: "지역", 고르기: [15, 16] }] },
|
||||
],
|
||||
빠진로직: ["GX"],
|
||||
};
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const method = init.method ?? "GET";
|
||||
const body = init.body ? JSON.parse(init.body) : undefined;
|
||||
state.calls.push({ method, url, body });
|
||||
const m = /^\/api\/m02\/templates\/(structure|area|material)\/(.+)$/.exec(url);
|
||||
if (m) {
|
||||
if (/^\/api\/m02\/layers\/\w+\/items\/[^/]+\/inputs$/.test(url)) {
|
||||
state.inputsHits += 1;
|
||||
return reply(200, INPUTS);
|
||||
}
|
||||
const m = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url);
|
||||
if (!m) return reply(404, { detail: "Not Found" });
|
||||
const [, , folderEnc, partEnc] = m;
|
||||
if (!folderEnc && method === "GET")
|
||||
return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) });
|
||||
if (!folderEnc && method === "POST") {
|
||||
const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`;
|
||||
state.folders[folder] = {
|
||||
info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" },
|
||||
parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null },
|
||||
};
|
||||
return reply(200, { 폴더: folder, 만듦: true, 가져온날: "2026-09-30T01:00:00" });
|
||||
}
|
||||
const item = state.folders[decodeURIComponent(folderEnc)];
|
||||
if (!item) return reply(404, { detail: "복사본이 없음" });
|
||||
const part = partEnc && decodeURIComponent(partEnc);
|
||||
if (part === "refresh") {
|
||||
item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] };
|
||||
return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" });
|
||||
}
|
||||
if (part === "calc")
|
||||
return reply(200, {
|
||||
종류: m[1],
|
||||
이름: decodeURIComponent(m[2]),
|
||||
판: original.version,
|
||||
문서: JSON.parse(JSON.stringify(original.docs[m[1]])),
|
||||
줄: [],
|
||||
계: 12345.6,
|
||||
빠진입력: [
|
||||
{ 로직: "GA1", 입력: "지역" },
|
||||
{ 로직: "GA2", 입력: "지역" },
|
||||
],
|
||||
멈춤: ["GA1"],
|
||||
});
|
||||
if (method === "GET") {
|
||||
const content = item.parts[part] ?? null;
|
||||
return reply(200, { 종류: part, 판: versionOf(content), 내용: content });
|
||||
}
|
||||
const projects = /^\/api\/projects\/([^/]+)\/items(?:\/([^/]+)(?:\/([^/]+))?)?$/.exec(url);
|
||||
if (projects) {
|
||||
if (state.mode === "fake") return reply(404, { detail: "Not Found" });
|
||||
const [, , folder, part] = projects;
|
||||
if (!folder && method === "GET")
|
||||
return reply(
|
||||
200,
|
||||
Object.values(state.server).map((x) => x.info),
|
||||
);
|
||||
if (!folder && method === "POST") {
|
||||
const key = `${body.layer}_${body.owner ?? "none"}_${body.key}`;
|
||||
state.server[key] = {
|
||||
info: { 폴더: key, 원본: body, 원본판: original.version, 가져온날: "2026-09-29" },
|
||||
parts: {
|
||||
도면: JSON.parse(JSON.stringify(original.docs.structure)),
|
||||
면적: JSON.parse(JSON.stringify(original.docs.area)),
|
||||
재료: JSON.parse(JSON.stringify(original.docs.material)),
|
||||
},
|
||||
};
|
||||
return reply(200, state.server[key].info);
|
||||
}
|
||||
const item = state.server[decodeURIComponent(folder)];
|
||||
if (!item) return reply(404, { detail: "없는 폴더" });
|
||||
if (part === "refresh" && method === "POST") {
|
||||
item.parts.면적 = JSON.parse(JSON.stringify(original.docs.area));
|
||||
item.info.원본판 = original.version;
|
||||
return reply(200, item.info);
|
||||
}
|
||||
if (method === "GET") {
|
||||
const doc = item.parts[decodeURIComponent(part)];
|
||||
return doc === undefined ? reply(404, { detail: "부분 없음" }) : reply(200, doc);
|
||||
}
|
||||
if (method === "PUT") {
|
||||
if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" });
|
||||
item.parts[decodeURIComponent(part)] = body;
|
||||
return reply(200, { ok: true });
|
||||
}
|
||||
}
|
||||
const inputs = /^\/api\/m02\/layers\/(\w+)\/items\/([^/]+)\/inputs/.exec(url);
|
||||
if (inputs) {
|
||||
return state.inputsRoute
|
||||
? reply(200, { 일위대가: "UAREAL", 이름: "실서버", 로직: [], 빠진로직: [] })
|
||||
: reply(404, { detail: "Not Found" });
|
||||
}
|
||||
if (url.startsWith("/api/m01/combo?key=")) {
|
||||
return reply(200, {
|
||||
combo: { 이름: "기슭막이", 담은로직: [{ 로직: "GA1" }, { 로직: "GA2" }, { 로직: "GX" }] },
|
||||
});
|
||||
}
|
||||
if (url.startsWith("/api/m01/logic/auto?key=")) {
|
||||
return reply(200, { 값: { 지역: "전국평균", 소량감률: 0 } });
|
||||
}
|
||||
const logic = /^\/api\/m01\/logic\?key=(\w+)/.exec(url);
|
||||
if (logic) {
|
||||
if (logic[1] === "GX") return reply(404, { detail: "없는 로직" });
|
||||
return reply(200, {
|
||||
logic: {
|
||||
이름: `로직 ${logic[1]}`,
|
||||
결과단위: "원/m",
|
||||
입력: [
|
||||
{ 이름: "지역", 고르기: ["전국평균", "서울"] },
|
||||
{ 이름: "소량감률", 단위: "%", 범위: [0, 50] },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (method === "PUT") {
|
||||
if (state.readOnly) return reply(403, { detail: "프로젝트 편집 권한이 없음" });
|
||||
const current = versionOf(item.parts[part] ?? null);
|
||||
if (body.판 !== current)
|
||||
return reply(409, { detail: { message: "다른 곳에서 먼저 고쳤습니다", 판: current } });
|
||||
item.parts[part] = body.내용;
|
||||
return reply(200, { 종류: part, 판: versionOf(body.내용) });
|
||||
}
|
||||
return reply(404, { detail: "Not Found" });
|
||||
};
|
||||
|
||||
const project = loadTs(path.join(ROOT, "ui_template", "ui_template_item_picker_project.ts"));
|
||||
const card = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_InputsCard.ts"));
|
||||
const model = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"));
|
||||
|
||||
const row = (over = {}) => ({
|
||||
key: "SS000001",
|
||||
열: "rv",
|
||||
layer: "system",
|
||||
owner: null,
|
||||
일위대가: "UA1",
|
||||
...over,
|
||||
});
|
||||
const clone = (x) => JSON.parse(JSON.stringify(x));
|
||||
const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner: null, ...over });
|
||||
|
||||
async function main() {
|
||||
const out = {};
|
||||
|
||||
// ① 폴더 이름 — 서버 `snapshot_folder_name` 과 같은 글
|
||||
out.folders = [
|
||||
project.folderOf(row()),
|
||||
project.folderOf(row({ layer: "company", owner: "3", key: "SS000001" })),
|
||||
project.folderOf(row({ layer: "company", owner: "3" })),
|
||||
project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })),
|
||||
];
|
||||
|
||||
// ② 가짜 복사본 — 서버 길이 없을 때
|
||||
state.mode = "fake";
|
||||
const fake = project.projectItems("P-FAKE");
|
||||
const before = originalSnapshot();
|
||||
out.fake = { list: await fake.list() };
|
||||
out.fake.isFake = fake.isFake();
|
||||
const info = await fake.importItem(row());
|
||||
out.fake.info = info;
|
||||
out.fake.readBack = await fake.read(info.폴더, "면적");
|
||||
await fake.write(info.폴더, "면적", {
|
||||
...out.fake.readBack,
|
||||
시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }],
|
||||
});
|
||||
await fake.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } });
|
||||
out.fake.afterWrite = await fake.read(info.폴더, "면적");
|
||||
out.fake.listAfter = (await fake.list()).map((one) => one.폴더);
|
||||
out.fake.changedNow = await fake.masterChanged(row(), info);
|
||||
original.version = "v2";
|
||||
out.fake.changedAfterMaster = await fake.masterChanged(row(), info);
|
||||
await fake.refresh(row(), info);
|
||||
out.fake.afterRefresh = await fake.read(info.폴더, "면적");
|
||||
out.fake.valuesKept = await fake.read(info.폴더, "입력값");
|
||||
out.fake.changedAfterRefresh = await fake.masterChanged(row(), (await fake.list())[0]);
|
||||
out.fake.originalUntouched = before.replace('"v1"', '"v2"') === originalSnapshot();
|
||||
out.fake.storageKeys = [...store.keys()];
|
||||
await fake.write("없는폴더", "면적", {}).then(
|
||||
() => (out.fake.writeMissing = "성공"),
|
||||
(error) => (out.fake.writeMissing = error.message),
|
||||
const items = project.projectItems("P1");
|
||||
out.listEmpty = await items.list();
|
||||
const info = await items.importItem(row({ layer: "personal", owner: "1/3" }));
|
||||
out.info = info;
|
||||
out.importCall = state.calls.find((c) => c.method === "POST");
|
||||
out.list = (await items.list()).map((x) => x.폴더);
|
||||
|
||||
out.read = await items.read(info.폴더, "면적");
|
||||
out.readNullContent = await items.read(info.폴더, "입력값"); // 내용 null = 아직 없음
|
||||
out.readMissing = await items.read("personal_9-9_SS000009", "면적"); // 없는 폴더 404 = null
|
||||
await items.write(info.폴더, "면적", { 시트: [{ id: "s", 칸: { H10: { 값: 12345 } } }] });
|
||||
out.afterWrite = state.folders[info.폴더].parts.면적;
|
||||
// 판: 읽은 판으로 씀 · 쓴 뒤 새 판을 들고 있어 이어서 또 쓸 수 있음
|
||||
await items.write(info.폴더, "면적", { 시트: [{ id: "s", 칸: { H10: { 값: 999 } } }] });
|
||||
out.secondWrite = state.folders[info.폴더].parts.면적.시트[0].칸.H10;
|
||||
// 읽은 적 없는 부분도 쓰기 전에 판을 먼저 앎
|
||||
const getsBefore = state.calls.filter((c) => c.method === "GET").length;
|
||||
await items.write(info.폴더, "고른자재", { GA1: { 0: "MT000001" } });
|
||||
out.writeWithoutReadGets = state.calls.filter((c) => c.method === "GET").length - getsBefore;
|
||||
await items.write(info.폴더, "입력값", { GA1: { 지역: { 값: "서울", 확인: true } } });
|
||||
out.valuesSaved = state.folders[info.폴더].parts.입력값;
|
||||
|
||||
// 다른 곳에서 먼저 고침(판이 달라짐) → 409 까닭(`message`) 그대로
|
||||
state.folders[info.폴더].parts.면적 = { 시트: [], 남이: true };
|
||||
await items.write(info.폴더, "면적", {}).then(
|
||||
() => (out.stale = "성공"),
|
||||
(error) => (out.stale = error.message),
|
||||
);
|
||||
original.version = "v1";
|
||||
|
||||
// ③ 실서버 모양 — 약속 길
|
||||
state.mode = "real";
|
||||
state.calls = [];
|
||||
const real = project.projectItems("P-REAL");
|
||||
out.real = { list: await real.list(), isFake: real.isFake() };
|
||||
const rinfo = await real.importItem(row({ layer: "personal", owner: "1/3" }));
|
||||
out.real.info = rinfo;
|
||||
out.real.importCall = state.calls.find((c) => c.method === "POST");
|
||||
out.real.read = await real.read(rinfo.폴더, "면적");
|
||||
out.real.readMissing = await real.read(rinfo.폴더, "고른자재");
|
||||
await real.write(rinfo.폴더, "입력값", { L: { 이름: { 값: 1, 확인: true } } });
|
||||
out.real.putCall = state.calls.filter((c) => c.method === "PUT").map((c) => c.url);
|
||||
state.readOnly = true;
|
||||
await real.write(rinfo.폴더, "면적", {}).then(
|
||||
() => (out.real.readOnlyWrite = "성공"),
|
||||
(error) => (out.real.readOnlyWrite = error.message),
|
||||
await items.write(info.폴더, "입력값", {}).then(
|
||||
() => (out.readOnly = "성공"),
|
||||
(error) => (out.readOnly = error.message),
|
||||
);
|
||||
state.readOnly = false;
|
||||
await real.refresh(row({ layer: "personal", owner: "1/3" }), rinfo);
|
||||
out.real.refreshCall = state.calls
|
||||
|
||||
const fresh = await items.refresh(info);
|
||||
out.refresh = fresh;
|
||||
out.refreshCall = state.calls
|
||||
.filter((c) => /refresh$/.test(c.url))
|
||||
.map((c) => `${c.method} ${c.url}`);
|
||||
out.real.changedFlag = await real.masterChanged(row(), { ...rinfo, 마스터바뀜: true });
|
||||
out.calc = await items.calc(info.폴더);
|
||||
out.calcMissing = await items.calc("없는폴더");
|
||||
|
||||
// ④ 입력 목록
|
||||
state.inputsRoute = false;
|
||||
const madeUp = await project.loadInputs(row({ key: "SS000009" }));
|
||||
out.inputs = {
|
||||
fake: madeUp.fake,
|
||||
logics: madeUp.list.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]),
|
||||
missing: madeUp.list.빠진로직,
|
||||
auto: madeUp.list.로직[0].입력.map((i) => i.자동값),
|
||||
link: project.fakeLink(madeUp.list).줄.length,
|
||||
linkPlaces: [...new Set(project.fakeLink(madeUp.list).줄.map((r) => r.자리))],
|
||||
};
|
||||
state.inputsRoute = true;
|
||||
const realInputs = await project.loadInputs(row({ key: "SS000010" }));
|
||||
out.inputs.real = { fake: realInputs.fake, name: realInputs.list.이름 };
|
||||
// 입력 목록 — 같은 항목은 한 번만 받음
|
||||
const first = await project.loadInputs(row());
|
||||
await project.loadInputs(row());
|
||||
out.inputsHits = state.inputsHits;
|
||||
out.inputsLogics = first.로직.map((l) => [l.로직, l.입력.map((i) => i.이름)]);
|
||||
|
||||
// ⑤ 카드 순수 함수
|
||||
out.card = {
|
||||
// 카드 셈
|
||||
out.model = {
|
||||
range: [
|
||||
card.rangeOf({ 이름: "a", 범위: [0, 50] }),
|
||||
card.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }),
|
||||
card.rangeOf({ 이름: "c" }),
|
||||
model.rangeOf({ 이름: "a", 범위: [0, 50] }),
|
||||
model.rangeOf({ 이름: "b", 범위: { 최소: 1, 최대: 2 } }),
|
||||
model.rangeOf({ 이름: "c" }),
|
||||
],
|
||||
choices: card.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }),
|
||||
keys: [card.inputKey("GA1", "지역"), card.pillKey("GA1", "지역")],
|
||||
groups: card
|
||||
.pickedGroups(madeUp.list, {
|
||||
choices: model.choicesOf({ 이름: "d", 고르기: [15, "16", { 값: 18, 이름: "열여덟" }] }),
|
||||
groups: model
|
||||
.pickedGroups(first, {
|
||||
판: "x",
|
||||
줄: [
|
||||
{ 로직: "GA1", 입력: "지역", 자리: "선택" },
|
||||
{ 로직: "GA1", 입력: "소량감률", 자리: "면적" },
|
||||
{ 로직: "GA2", 입력: "소량감률", 자리: "선택" },
|
||||
{ 로직: "GA2", 입력: "지역", 자리: "선택" },
|
||||
],
|
||||
})
|
||||
.map((g) => [g.logic.로직, g.inputs.map((i) => i.이름)]),
|
||||
noLink: card.pickedGroups(madeUp.list, null).length,
|
||||
noLink: model.pickedGroups(first, null).length,
|
||||
pills: model.pillsOf(
|
||||
first,
|
||||
{ 판: "x", 줄: [{ 로직: "GA1", 입력: "소량감률", 자리: "면적" }] },
|
||||
"면적",
|
||||
),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""항목 고르기 모달 상세(계획서 25-2d) — 자료 창구 · 선택 입력 카드 · 한 벌 지킴.
|
||||
"""항목 고르기 모달 상세(계획서 25-2d) — 복사본 창구 · 선택 입력 카드 셈 · 한 벌 지킴.
|
||||
|
||||
① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(가짜 서버 fetch 대역) 본다:
|
||||
서버 길이 없을 때의 가짜 복사본(가져오기 · 고침 · 마스터가 바뀜 · 새로 가져오기 · **원본 그대로**) ·
|
||||
약속 길(목록 · 읽기 · PUT · 403 까닭 그대로 · 가져오기 · 새로 가져오기) · 입력 목록(약속 길 · M01 로 짜 맞춤).
|
||||
① 프로젝트 복사본 창구(`ui_template_item_picker_project.ts`)를 Node 로 돌려(sub6 길 모양의 가짜 서버 fetch 대역)
|
||||
본다: 목록 `{항목}` · 가져오기 POST · 읽기 `{종류, 판, 내용}` · 고침 PUT `{판, 내용}`(판 · 409 · 403 까닭 그대로) ·
|
||||
새로 가져오기 · 계산 한 줄 · 입력 목록 한 번만 받음 · 카드 셈(범위 · 고르기 원래 모양 · 자리 「선택」).
|
||||
② 모달이 카드 · 편집기를 M02 와 같은 한 벌로 쓰는지, 원본 층에 쓰는 길을 안 부르는지 소스로 지킨다.
|
||||
"""
|
||||
|
||||
@@ -43,75 +43,62 @@ def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out):
|
||||
{"layer": "personal", "owner": "3/12", "key": "SS000002"},
|
||||
]
|
||||
assert out["folders"] == [snapshot_folder_name(row) for row in rows]
|
||||
assert out["info"]["폴더"] == snapshot_folder_name(
|
||||
{"layer": "personal", "owner": "1/3", "key": "SS000001"}
|
||||
)
|
||||
|
||||
|
||||
def test_가짜_복사본_가져와_고치고_다시_읽으면_고친_값(out):
|
||||
fake = out["fake"]
|
||||
assert fake["isFake"] is True and fake["list"] == [] # 서버 길이 없음 → 가짜
|
||||
assert fake["info"]["폴더"] == "system_none_SS000001"
|
||||
assert fake["readBack"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 가져온 그대로
|
||||
assert fake["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}} # 고침이 남음
|
||||
assert fake["listAfter"] == ["system_none_SS000001"]
|
||||
|
||||
|
||||
def test_가짜_복사본을_고쳐도_원본은_그대로(out):
|
||||
assert out["fake"]["originalUntouched"] is True
|
||||
assert out["fake"]["storageKeys"] == ["aislo.itemsFake.P-FAKE"] # 브라우저 저장소 한 칸만
|
||||
|
||||
|
||||
def test_마스터가_바뀌면_표시_새로_가져오면_원본으로_덮고_넣은_값은_남김(out):
|
||||
fake = out["fake"]
|
||||
assert fake["changedNow"] is False
|
||||
assert fake["changedAfterMaster"] is True
|
||||
assert fake["afterRefresh"]["시트"][0]["칸"] == {"A1": {"값": 1}} # 고침이 원본으로 덮임
|
||||
assert fake["valuesKept"]["GA1"]["지역"] == {
|
||||
"값": "서울",
|
||||
"확인": True,
|
||||
} # 사용자가 넣은 값은 남음
|
||||
assert fake["changedAfterRefresh"] is False
|
||||
|
||||
|
||||
def test_안_가져온_항목에_쓰면_까닭을_던짐(out):
|
||||
assert out["fake"]["writeMissing"] == "프로젝트에 가져오지 않은 항목"
|
||||
|
||||
|
||||
def test_약속_길_목록_읽기_고침_가져오기_새로_가져오기(out):
|
||||
real = out["real"]
|
||||
assert real["isFake"] is False # 길이 있으면 가짜를 안 탐
|
||||
assert real["importCall"] == {
|
||||
def test_가져오기는_POST_원본_참조_목록은_항목(out):
|
||||
assert out["listEmpty"] == []
|
||||
assert out["importCall"] == {
|
||||
"method": "POST",
|
||||
"url": "/api/projects/P-REAL/items",
|
||||
"url": "/api/projects/P1/items",
|
||||
"body": {"layer": "personal", "owner": "1/3", "key": "SS000001"},
|
||||
}
|
||||
assert real["readMissing"] is None # 부분이 없음 = null
|
||||
assert len(real["putCall"]) == 1 and real["putCall"][0].endswith("/%EC%9E%85%EB%A0%A5%EA%B0%92")
|
||||
assert real["refreshCall"][0].startswith("POST /api/projects/P-REAL/items/")
|
||||
assert real["refreshCall"][0].endswith("/refresh")
|
||||
assert real["changedFlag"] is True # 실서버는 `마스터바뀜` 을 그대로 씀
|
||||
assert out["list"] == [out["info"]["폴더"]]
|
||||
|
||||
|
||||
def test_권한_없음은_서버_까닭_그대로(out):
|
||||
assert out["real"]["readOnlyWrite"] == "프로젝트 편집 권한이 없음"
|
||||
def test_읽기는_내용만_돌려주고_없으면_null(out):
|
||||
assert out["read"]["시트"][0]["칸"] == {"A1": {"값": 1}}
|
||||
assert out["readNullContent"] is None # 파일이 아직 없음(내용 null)
|
||||
assert out["readMissing"] is None # 없는 폴더 404
|
||||
|
||||
|
||||
def test_입력_목록_약속_길이_있으면_그대로_없으면_M01_로_짜_맞춤(out):
|
||||
inputs = out["inputs"]
|
||||
assert inputs["real"] == {"fake": False, "name": "실서버"}
|
||||
assert inputs["fake"] is True
|
||||
# 로직마다 갈라 — 이름이 같아도 합치지 않음 · 없는 로직은 빠진로직
|
||||
assert inputs["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역", "소량감률"]]]
|
||||
assert inputs["missing"] == ["GX"]
|
||||
assert inputs["auto"] == ["전국평균", 0]
|
||||
assert inputs["link"] == 4 and inputs["linkPlaces"] == ["선택"] # 가짜 연결 = 모두 선택
|
||||
def test_고침은_읽은_판으로_쓰고_쓴_뒤_새_판을_들고_있음(out):
|
||||
assert out["afterWrite"]["시트"][0]["칸"] == {"H10": {"값": 12345}}
|
||||
assert out["secondWrite"] == {"값": 999} # 이어서 또 써도 판이 맞음
|
||||
assert out["writeWithoutReadGets"] == 1 # 읽은 적 없는 부분은 쓰기 전에 판을 한 번 읽음
|
||||
assert out["valuesSaved"]["GA1"]["지역"] == {"값": "서울", "확인": True}
|
||||
|
||||
|
||||
def test_선택_입력_카드_순수_함수(out):
|
||||
card = out["card"]
|
||||
assert card["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None]
|
||||
assert [c["raw"] for c in card["choices"]] == [15, "16", 18] # 저장은 원래 모양
|
||||
assert card["keys"] == ["GA1|지역", "입력|GA1|지역"] # 25-1b 키 모양
|
||||
assert card["groups"] == [["GA1", ["지역"]], ["GA2", ["소량감률"]]] # 자리 「선택」 만
|
||||
assert card["noLink"] == 0 # 연결이 없으면 아무것도 안 보임
|
||||
def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로(out):
|
||||
assert out["stale"] == "다른 곳에서 먼저 고쳤습니다"
|
||||
assert out["readOnly"] == "프로젝트 편집 권한이 없음"
|
||||
|
||||
|
||||
def test_새로_가져오기와_계산_한_줄(out):
|
||||
assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/")
|
||||
assert out["refreshCall"][0].endswith("/refresh")
|
||||
assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판"
|
||||
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1}
|
||||
assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음
|
||||
|
||||
|
||||
def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out):
|
||||
assert out["inputsHits"] == 1
|
||||
assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
|
||||
|
||||
|
||||
def test_선택_입력_카드_셈(out):
|
||||
model = out["model"]
|
||||
assert model["range"] == [{"min": 0, "max": 50}, {"min": 1, "max": 2}, None]
|
||||
assert [c["raw"] for c in model["choices"]] == [15, "16", 18] # 저장은 원래 모양
|
||||
assert model["groups"] == [
|
||||
["GA1", ["지역"]],
|
||||
["GA2", ["지역"]],
|
||||
] # 같은 이름도 로직마다 · 「선택」 만
|
||||
assert model["noLink"] == 0 # 연결이 없으면 아무것도 안 보임
|
||||
assert model["pills"][0]["키"] == "입력|GA1|소량감률" # 25-1b 알약 키
|
||||
|
||||
|
||||
def test_모달은_M02_와_같은_카드_한_벌을_씀():
|
||||
@@ -128,6 +115,17 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀():
|
||||
assert cards.count("mountDrawingTemplate(") == 1
|
||||
|
||||
|
||||
def test_선택_입력_카드는_sub2_파일_한_벌(out):
|
||||
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일)."""
|
||||
assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists()
|
||||
inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8")
|
||||
assert "export function createInputsCard(" in inputs # M02 자리 고르기(link)
|
||||
assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value)
|
||||
for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"):
|
||||
text = (UI / name).read_text(encoding="utf-8")
|
||||
assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name
|
||||
|
||||
|
||||
def test_모달은_원본_층에_쓰는_길을_안_부름():
|
||||
"""마스터 · 회사 · 개인 원본은 안 바뀜 — 저장 길은 프로젝트 복사본(`items.write`)뿐."""
|
||||
for name in (
|
||||
@@ -141,6 +139,7 @@ def test_모달은_원본_층에_쓰는_길을_안_부름():
|
||||
"deleteTemplate(",
|
||||
"deleteLayerTemplate(",
|
||||
"copyToPersonal(",
|
||||
"saveInputLinks(",
|
||||
):
|
||||
assert forbidden not in text, f"{name} 이 {forbidden} 를 부름"
|
||||
edit = (UI / "ui_template_item_picker_edit.ts").read_text(encoding="utf-8")
|
||||
@@ -161,6 +160,7 @@ def test_소스가_700줄을_넘지_않음():
|
||||
*sorted(UI.glob("ui_template_item_picker*.ts")),
|
||||
M02 / "M02_MasterTemplete_ItemCards.ts",
|
||||
M02 / "M02_MasterTemplete_ItemSource.ts",
|
||||
M02 / "M02_MasterTemplete_InputsCard.ts",
|
||||
M02 / "M02_MasterTemplete_Inputs.ts",
|
||||
M02 / "M02_MasterTemplete_Inputs_Model.ts",
|
||||
):
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_detail.ts
|
||||
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 ·
|
||||
* 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_InputsCard`)을 늦게 받아 씀
|
||||
* 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀
|
||||
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
|
||||
*
|
||||
* 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만).
|
||||
@@ -10,14 +10,15 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
|
||||
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
readInputLinks,
|
||||
readTemplate,
|
||||
type InputLinks,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
|
||||
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
|
||||
import type {
|
||||
InputsCardHandle,
|
||||
LinkDoc,
|
||||
ValueDoc,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard";
|
||||
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs";
|
||||
import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model";
|
||||
import {
|
||||
folderLines,
|
||||
itemName,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
type PickerRow,
|
||||
} from "./ui_template_item_picker_data";
|
||||
import {
|
||||
fakeLink,
|
||||
folderOf,
|
||||
loadInputs,
|
||||
PART_OF,
|
||||
@@ -72,7 +72,8 @@ const TEXT = {
|
||||
importFailed: "프로젝트로 가져오지 못함 — {value}",
|
||||
refreshFailed: "새로 가져오지 못함 — {value}",
|
||||
listFailed: "프로젝트 항목을 못 읽음 — {value}",
|
||||
fake: "임시 저장(서버 길 준비 중)",
|
||||
calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개",
|
||||
calcStuck: " · 멈춘 로직 {stuck}개",
|
||||
loading: "읽는 중…",
|
||||
noCopy: "복사본에 없음",
|
||||
combo: "자재조합",
|
||||
@@ -81,7 +82,7 @@ const TEXT = {
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
const dateOf = (iso?: string): string => (iso ? iso.slice(0, 10) : "");
|
||||
const dateOf = (iso?: string | null): string => (iso ? iso.slice(0, 10) : "");
|
||||
|
||||
export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
const root = el("div", { className: "ui-item-picker__detail" });
|
||||
@@ -94,7 +95,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
let seq = 0;
|
||||
let infos: ProjectItemInfo[] | null = null;
|
||||
let cards: ItemCardsHandle | null = null;
|
||||
let inputs: InputsCardHandle | null = null;
|
||||
let inputs: InputsValueHandle | null = null;
|
||||
let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() };
|
||||
let info: ProjectItemInfo | null = null;
|
||||
const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null;
|
||||
@@ -149,11 +150,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
if (!info) nodes.push(TEXT.notImported);
|
||||
else {
|
||||
nodes.push(fill(TEXT.imported, dateOf(info.가져온날)));
|
||||
if (items.isFake())
|
||||
nodes.push(el("span", { className: "ui-item-picker__fake", text: TEXT.fake }));
|
||||
const changed = await items.masterChanged(row, info).catch(() => false);
|
||||
if (mine !== seq) return;
|
||||
if (changed) {
|
||||
if (info.마스터바뀜) {
|
||||
const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" });
|
||||
button.addEventListener("click", () => void refresh(row));
|
||||
nodes.push(
|
||||
@@ -164,13 +161,29 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
}
|
||||
status.replaceChildren(...nodes);
|
||||
status.hidden = false;
|
||||
if (info) void paintCalc(info, mine);
|
||||
};
|
||||
|
||||
/** 복사본만으로 계산한 계 · 빠진 입력을 한 줄로(값을 넣고 돌아오면 달라짐) */
|
||||
async function paintCalc(from: ProjectItemInfo, mine: number): Promise<void> {
|
||||
const got = await items?.calc(from.폴더).catch(() => null);
|
||||
if (!got || mine !== seq) return;
|
||||
const total = Math.round(got.total).toLocaleString();
|
||||
status.append(
|
||||
el("span", {
|
||||
className: "ui-item-picker__calc",
|
||||
text:
|
||||
TEXT.calc.replace("{total}", total).replace("{gaps}", String(got.gaps)) +
|
||||
(got.stuck ? TEXT.calcStuck.replace("{stuck}", String(got.stuck)) : ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function refresh(row: PickerRow): Promise<void> {
|
||||
if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return;
|
||||
try {
|
||||
await items.refresh(row, info);
|
||||
infos = null;
|
||||
const fresh = await items.refresh(info);
|
||||
infos = (infos ?? []).map((one) => (one.폴더 === fresh.폴더 ? fresh : one));
|
||||
show(row);
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.refreshFailed, why(error)), "error");
|
||||
@@ -184,7 +197,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
if (!info) {
|
||||
try {
|
||||
info = await items.importItem(row);
|
||||
infos = null;
|
||||
infos = [...(infos ?? []), info];
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.importFailed, why(error)), "error");
|
||||
return;
|
||||
@@ -196,12 +209,15 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
async function paintInputs(row: PickerRow, mine: number): Promise<void> {
|
||||
if (!inputs) return;
|
||||
try {
|
||||
const { list, fake } = await loadInputs(row);
|
||||
let link: LinkDoc | null = fake ? fakeLink(list) : null;
|
||||
const list = await loadInputs(row);
|
||||
let link: InputLinks | null;
|
||||
let values: ValueDoc = {};
|
||||
if (info && items) {
|
||||
link = ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? link;
|
||||
// 복사본 것(가져온 때 그대로) — 값도 복사본에서
|
||||
link = (await items.read(info.폴더, "입력연결")) as InputLinks | null;
|
||||
values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {};
|
||||
} else {
|
||||
link = await readInputLinks(layerOf(row), row.key).catch(() => null);
|
||||
}
|
||||
if (mine === seq) inputs.set({ list, link, values });
|
||||
} catch (error) {
|
||||
@@ -213,9 +229,9 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
if (cards && inputs) return;
|
||||
const [cardModule, inputModule] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"),
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"),
|
||||
]);
|
||||
inputs = inputModule.createInputsCard({ mode: "view", onEdit: () => void edit("inputs") });
|
||||
inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") });
|
||||
cards = cardModule.createItemCards({
|
||||
source: () => source,
|
||||
editable: () => !!items,
|
||||
|
||||
@@ -11,15 +11,12 @@ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_el
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing";
|
||||
import type {
|
||||
InputsCardHandle,
|
||||
LinkDoc,
|
||||
ValueDoc,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard";
|
||||
import type { StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs";
|
||||
import { pillsOf, type ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model";
|
||||
import type { InputLinks, StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type { EditRequest } from "./ui_template_item_picker_detail";
|
||||
import { itemName } from "./ui_template_item_picker_data";
|
||||
import { fakeLink, loadInputs, PART_OF } from "./ui_template_item_picker_project";
|
||||
import { loadInputs, PART_OF } from "./ui_template_item_picker_project";
|
||||
|
||||
export interface EditView {
|
||||
root: HTMLElement;
|
||||
@@ -39,7 +36,8 @@ const TEXT = {
|
||||
loadFailed: "복사본을 못 읽음 — {value}",
|
||||
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
|
||||
outOfRange: "범위 밖 값이 있어 저장하지 못함",
|
||||
noAreaPill: "면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸",
|
||||
noAreaPill:
|
||||
"면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸(마스터 · 회사 · 개인 층에서)",
|
||||
drawing: "도면",
|
||||
area: "면적산출",
|
||||
material: "재료산출",
|
||||
@@ -57,7 +55,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView {
|
||||
let doc: unknown = null;
|
||||
let drawing: DrawingTemplateHandle | null = null;
|
||||
let sheet: SpreadsheetHandle | null = null;
|
||||
let card: InputsCardHandle | null = null;
|
||||
let card: InputsValueHandle | null = null;
|
||||
|
||||
const word = {
|
||||
structure: TEXT.drawing,
|
||||
@@ -97,36 +95,29 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView {
|
||||
body.replaceChildren(el("p", { className: "ui-item-picker__hint", text }));
|
||||
};
|
||||
|
||||
/** 면적산출 알약 — 항목 연결에서 자리가 「면적」 인 입력(25-1b) */
|
||||
async function areaLinks(): Promise<{ links: LinkOptions; notice?: string }> {
|
||||
const { list, fake } = await loadInputs(row);
|
||||
const link =
|
||||
((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ??
|
||||
(fake ? fakeLink(list) : null);
|
||||
const 항목 = (link?.줄 ?? [])
|
||||
.filter((one) => one.자리 === "면적")
|
||||
.map((one) => ({
|
||||
키: one.알약 ?? `입력|${one.로직}|${one.입력}`,
|
||||
이름: one.입력,
|
||||
단위:
|
||||
list.로직.find((logic) => logic.로직 === one.로직)?.입력.find((x) => x.이름 === one.입력)
|
||||
?.단위 ?? "",
|
||||
}));
|
||||
return 항목.length
|
||||
? { links: { 항목, 모드: "입력" } }
|
||||
: { links: { 항목: [], 모드: "입력" }, notice: TEXT.noAreaPill };
|
||||
/** 복사본의 항목 연결 — 자리가 면적 · 재료인 입력이 그 시트 알약(25-1b · 키 모양은 `pillsOf`) */
|
||||
const linkOf = async (): Promise<InputLinks | null> =>
|
||||
(await items.read(info.폴더, "입력연결")) as InputLinks | null;
|
||||
|
||||
async function sheetLinks(kindOf: "area" | "material"): Promise<LinkOptions> {
|
||||
const [list, link, { searchMaterials }] = await Promise.all([
|
||||
loadInputs(row),
|
||||
linkOf(),
|
||||
import("../M01_MasterData/M01_MasterData_Api_Fetch"),
|
||||
]);
|
||||
const 항목 = pillsOf(list, link, kindOf === "area" ? "면적" : "재료");
|
||||
if (kindOf === "area") {
|
||||
if (!항목.length) note.textContent = TEXT.noAreaPill;
|
||||
return { 항목, 모드: "입력" };
|
||||
}
|
||||
return { 항목, 모드: "자재", 자재찾기: searchMaterials };
|
||||
}
|
||||
|
||||
async function mountSheet(kindOf: "area" | "material"): Promise<void> {
|
||||
const [{ createSpreadsheet }, pills, { searchMaterials }] = await Promise.all([
|
||||
const [{ createSpreadsheet }, links] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
kindOf === "area"
|
||||
? areaLinks()
|
||||
: Promise.resolve({ links: undefined as LinkOptions | undefined, notice: undefined }),
|
||||
import("../M01_MasterData/M01_MasterData_Api_Fetch"),
|
||||
sheetLinks(kindOf),
|
||||
]);
|
||||
const links: LinkOptions = pills.links ?? { 항목: [], 모드: "자재", 자재찾기: searchMaterials };
|
||||
if (pills.notice) note.textContent = pills.notice;
|
||||
const host = el("div", { className: "ui-item-picker__edit-sheet" });
|
||||
body.replaceChildren(host);
|
||||
sheet = createSpreadsheet(host, doc as Workbook, {
|
||||
@@ -155,16 +146,14 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView {
|
||||
}
|
||||
|
||||
async function mountInputs(): Promise<void> {
|
||||
const [{ createInputsCard }, loaded] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"),
|
||||
const [{ createInputsValueCard }, list, link] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"),
|
||||
loadInputs(row),
|
||||
linkOf(),
|
||||
]);
|
||||
const link =
|
||||
((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ??
|
||||
(loaded.fake ? fakeLink(loaded.list) : null);
|
||||
const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {};
|
||||
card = createInputsCard({ mode: "value", onChange: () => setDirty(true) });
|
||||
card.set({ list: loaded.list, link, values });
|
||||
card = createInputsValueCard({ mode: "value", onChange: () => setDirty(true) });
|
||||
card.set({ list, link, values });
|
||||
body.replaceChildren(
|
||||
el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }),
|
||||
);
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_project.ts
|
||||
* 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 원본 층 · 입력 목록을 한 곳에서 부름.
|
||||
* 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름.
|
||||
*
|
||||
* 프로젝트 복사본 GET /api/projects/{id}/items 목록
|
||||
* GET|PUT /api/projects/{id}/items/{폴더}/{부분} 읽기 · 고침(sub6 · 25-1b 약속)
|
||||
* POST /api/projects/{id}/items 미리 가져오기(body = 원본 참조)
|
||||
* POST /api/projects/{id}/items/{폴더}/refresh 새로 가져오기
|
||||
* 입력 목록 GET /api/m02/layers/{층}/items/{키}/inputs?owner= (sub4 · 25-2a)
|
||||
*
|
||||
* ⚠ 이음 자리 — 위 길이 서버에 아직 없으면(`{"detail":"Not Found"}`) **약속 모양의 가짜 응답**으로 동작함:
|
||||
* 복사본 = 브라우저 저장소(`aislo.itemsFake.{프로젝트}`)에 원본을 복사해 두고 거기서 읽고 고침 ·
|
||||
* 입력 목록 = M01 조합 · 로직 길로 짜 맞춤 · 항목 연결 = 「모든 입력이 선택」(가짜 기본값).
|
||||
* 실서버가 오면 같은 함수가 실서버 응답을 그대로 씀(가짜 분기는 길이 있으면 안 탐) — `FAKE` 로 찾음.
|
||||
* 프로젝트 복사본(sub6 · 25-2b)
|
||||
* GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, …}]}
|
||||
* POST /api/projects/{id}/items {layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날}
|
||||
* GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409)
|
||||
* POST …/{폴더}/refresh 새로 가져오기(입력값은 같은 키면 살림)
|
||||
* POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력)
|
||||
* 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks`
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { readTemplate, type Kind } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type {
|
||||
InputsList,
|
||||
LinkDoc,
|
||||
LogicInput,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard";
|
||||
import {
|
||||
fetchItemInputs,
|
||||
type ItemInputs,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
|
||||
|
||||
/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */
|
||||
@@ -36,27 +31,49 @@ export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = {
|
||||
|
||||
export interface ProjectItemInfo {
|
||||
폴더: string;
|
||||
이름?: string | null;
|
||||
원본: { layer: string; owner: string | null; key: string; name?: string };
|
||||
원본판?: string;
|
||||
가져온날?: string;
|
||||
원본판?: string | null;
|
||||
가져온날?: string | null;
|
||||
/** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */
|
||||
마스터바뀜?: boolean;
|
||||
원본없음?: boolean;
|
||||
구조물?: string[];
|
||||
고침?: boolean;
|
||||
}
|
||||
|
||||
/** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */
|
||||
export const folderOf = (row: PickerRow): string =>
|
||||
`${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`;
|
||||
|
||||
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */
|
||||
export interface ProjectCalc {
|
||||
total: number;
|
||||
gaps: number;
|
||||
stuck: number;
|
||||
}
|
||||
|
||||
interface Reply {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
const reasonOf = (reply: Reply): string => {
|
||||
|
||||
/** 서버가 막은 까닭 그대로 — 문장 · `{message}`(409) · 입력 검사 목록(`[{msg}]`) */
|
||||
function reasonOf(reply: Reply): string {
|
||||
const detail = (reply.body as { detail?: unknown } | null)?.detail;
|
||||
return typeof detail === "string" && detail ? detail : `HTTP ${reply.status}`;
|
||||
};
|
||||
if (typeof detail === "string" && detail) return detail;
|
||||
const message = (detail as { message?: unknown } | null)?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
if (Array.isArray(detail)) {
|
||||
const lines = detail
|
||||
.map((one) => String((one as { msg?: unknown })?.msg ?? ""))
|
||||
.filter(Boolean);
|
||||
if (lines.length) return lines.join(" · ");
|
||||
}
|
||||
return `HTTP ${reply.status}`;
|
||||
}
|
||||
|
||||
async function call(path: string, init: RequestInit = {}): Promise<Reply> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
@@ -67,224 +84,105 @@ async function call(path: string, init: RequestInit = {}): Promise<Reply> {
|
||||
return { status: response.status, body: await response.json().catch(() => null) };
|
||||
}
|
||||
|
||||
/** 길 자체가 없음(서버가 아직 안 만듦) — 「문서가 없음」의 404 와 갈라 봄 */
|
||||
const routeMissing = (reply: Reply): boolean =>
|
||||
reply.status === 404 && (reply.body as { detail?: unknown } | null)?.detail === "Not Found";
|
||||
|
||||
const send = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/* ── 가짜 복사본(FAKE) — 브라우저 저장소 ─────────────────────────────────────── */
|
||||
interface FakeItem {
|
||||
info: ProjectItemInfo;
|
||||
parts: Partial<Record<ProjectPart, unknown>>;
|
||||
}
|
||||
type FakeDb = Record<string, FakeItem>;
|
||||
const memory = new Map<string, FakeDb>();
|
||||
const storeKey = (projectId: string): string => `aislo.itemsFake.${projectId}`;
|
||||
|
||||
function fakeLoad(projectId: string): FakeDb {
|
||||
const kept = memory.get(projectId);
|
||||
if (kept) return kept;
|
||||
try {
|
||||
const raw = localStorage.getItem(storeKey(projectId));
|
||||
const db = raw ? (JSON.parse(raw) as FakeDb) : {};
|
||||
memory.set(projectId, db);
|
||||
return db;
|
||||
} catch {
|
||||
const db: FakeDb = {};
|
||||
memory.set(projectId, db);
|
||||
return db;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSave(projectId: string): void {
|
||||
try {
|
||||
localStorage.setItem(storeKey(projectId), JSON.stringify(fakeLoad(projectId)));
|
||||
} catch {
|
||||
// 저장소가 꽉 차도 이 탭에서는 메모리 값으로 계속 됨
|
||||
}
|
||||
}
|
||||
|
||||
/** 원본 층에서 도면 · 면적 · 재료 문서를 읽고 판을 이은 글 — 복사본 `원본판` */
|
||||
async function readOriginals(
|
||||
row: PickerRow,
|
||||
): Promise<{ parts: FakeItem["parts"]; version: string }> {
|
||||
const kinds: ("structure" | "area" | "material")[] = ["structure", "area", "material"];
|
||||
const got = await Promise.all(
|
||||
kinds.map((kind) => readTemplate(layerOf(row), kind as Kind, row.열, null).catch(() => null)),
|
||||
);
|
||||
const parts: FakeItem["parts"] = {};
|
||||
kinds.forEach((kind, index) => {
|
||||
if (got[index]) parts[PART_OF[kind]] = structuredClone(got[index]!.문서);
|
||||
});
|
||||
return { parts, version: got.map((one) => one?.판 ?? "").join("|") };
|
||||
}
|
||||
const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
export interface ProjectItems {
|
||||
/** 프로젝트에 가져온 항목 목록 */
|
||||
/** 프로젝트에 가져온 항목 목록 — 서버가 항목당 0.5초쯤 걸려 모달이 열 때 한 번만 부르고 들고 있음 */
|
||||
list: () => Promise<ProjectItemInfo[]>;
|
||||
/** 복사본 부분 하나 — 없으면 null */
|
||||
/** 복사본 부분 하나(`내용`) — 없으면 null · 판은 고칠 때를 위해 들고 있음 */
|
||||
read: (folder: string, part: ProjectPart) => Promise<unknown | null>;
|
||||
/** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판 …) */
|
||||
/** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */
|
||||
write: (folder: string, part: ProjectPart, doc: unknown) => Promise<void>;
|
||||
/** 프로젝트로 미리 가져옴 */
|
||||
importItem: (row: PickerRow) => Promise<ProjectItemInfo>;
|
||||
/** 새로 가져옴 — 복사본을 원본으로 덮음 */
|
||||
refresh: (row: PickerRow, info: ProjectItemInfo) => Promise<ProjectItemInfo>;
|
||||
/** 가져온 뒤 원본이 바뀌었나 */
|
||||
masterChanged: (row: PickerRow, info: ProjectItemInfo) => Promise<boolean>;
|
||||
/** 가짜로 동작 중인가(서버 길이 아직 없음) */
|
||||
isFake: () => boolean;
|
||||
/** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */
|
||||
refresh: (info: ProjectItemInfo) => Promise<ProjectItemInfo>;
|
||||
/** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */
|
||||
calc: (folder: string) => Promise<ProjectCalc | null>;
|
||||
}
|
||||
|
||||
const clients = new Map<string, ProjectItems>();
|
||||
|
||||
/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(가짜 판정 · 목록 재사용) */
|
||||
/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(고칠 때 쓸 판을 들고 있음) */
|
||||
export function projectItems(projectId: string): ProjectItems {
|
||||
const kept = clients.get(projectId);
|
||||
if (kept) return kept;
|
||||
let fake = false;
|
||||
const base = `/projects/${enc(projectId)}/items`;
|
||||
const versions = new Map<string, string | null>();
|
||||
const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`;
|
||||
|
||||
const client: ProjectItems = {
|
||||
isFake: () => fake,
|
||||
list: async () => {
|
||||
if (!fake) {
|
||||
const reply = await call(base);
|
||||
if (reply.status === 200 && Array.isArray(reply.body))
|
||||
return reply.body as ProjectItemInfo[];
|
||||
if (!routeMissing(reply)) throw new Error(reasonOf(reply));
|
||||
fake = true; // FAKE — 서버 길이 아직 없음
|
||||
}
|
||||
return Object.values(fakeLoad(projectId)).map((item) => item.info);
|
||||
const reply = await call(base);
|
||||
const rows = (reply.body as { 항목?: ProjectItemInfo[] } | null)?.항목;
|
||||
if (reply.status !== 200 || !Array.isArray(rows)) throw new Error(reasonOf(reply));
|
||||
return rows;
|
||||
},
|
||||
read: async (folder, part) => {
|
||||
if (!fake) {
|
||||
const reply = await call(`${base}/${enc(folder)}/${enc(part)}`);
|
||||
if (reply.status === 200) return reply.body;
|
||||
if (reply.status === 404) return null;
|
||||
throw new Error(reasonOf(reply));
|
||||
}
|
||||
return fakeLoad(projectId)[folder]?.parts[part] ?? null;
|
||||
const reply = await call(at(folder, part));
|
||||
if (reply.status === 404) return null;
|
||||
if (reply.status !== 200) throw new Error(reasonOf(reply));
|
||||
const got = reply.body as { 판?: string | null; 내용?: unknown };
|
||||
versions.set(`${folder}/${part}`, got.판 ?? null);
|
||||
return got.내용 ?? null;
|
||||
},
|
||||
write: async (folder, part, doc) => {
|
||||
if (!fake) {
|
||||
const reply = await call(`${base}/${enc(folder)}/${enc(part)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
if (reply.status >= 400) throw new Error(reasonOf(reply));
|
||||
return;
|
||||
}
|
||||
const item = fakeLoad(projectId)[folder];
|
||||
if (!item) throw new Error("프로젝트에 가져오지 않은 항목");
|
||||
item.parts[part] = structuredClone(doc);
|
||||
fakeSave(projectId);
|
||||
const key = `${folder}/${part}`;
|
||||
if (!versions.has(key)) await client.read(folder, part); // 고칠 판을 먼저 앎
|
||||
const reply = await call(at(folder, part), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ 판: versions.get(key) ?? null, 내용: doc }),
|
||||
});
|
||||
if (reply.status >= 400) throw new Error(reasonOf(reply));
|
||||
versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null);
|
||||
},
|
||||
importItem: async (row) => {
|
||||
const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key };
|
||||
if (!fake) {
|
||||
const reply = await call(base, send(ref));
|
||||
if (reply.status < 300) return reply.body as ProjectItemInfo;
|
||||
if (!routeMissing(reply)) throw new Error(reasonOf(reply));
|
||||
fake = true; // FAKE
|
||||
}
|
||||
const { parts, version } = await readOriginals(row);
|
||||
const info: ProjectItemInfo = {
|
||||
폴더: folderOf(row),
|
||||
원본: { ...ref, name: row.종류 },
|
||||
원본판: version,
|
||||
가져온날: new Date().toISOString(),
|
||||
const reply = await call(base, post(ref));
|
||||
if (reply.status >= 300) throw new Error(reasonOf(reply));
|
||||
const got = reply.body as { 폴더: string; 가져온날?: string | null };
|
||||
return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false };
|
||||
},
|
||||
refresh: async (info) => {
|
||||
const reply = await call(`${base}/${enc(info.폴더)}/refresh`, post({}));
|
||||
if (reply.status >= 300) throw new Error(reasonOf(reply));
|
||||
for (const key of [...versions.keys()])
|
||||
if (key.startsWith(`${info.폴더}/`)) versions.delete(key);
|
||||
inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음
|
||||
const got = reply.body as { 가져온날?: string | null; 원본판?: string | null };
|
||||
return {
|
||||
...info,
|
||||
가져온날: got.가져온날,
|
||||
원본판: got.원본판,
|
||||
마스터바뀜: false,
|
||||
고침: false,
|
||||
};
|
||||
fakeLoad(projectId)[info.폴더] = { info, parts };
|
||||
fakeSave(projectId);
|
||||
return info;
|
||||
},
|
||||
refresh: async (row, info) => {
|
||||
if (!fake) {
|
||||
const reply = await call(`${base}/${enc(info.폴더)}/refresh`, send({}));
|
||||
if (reply.status >= 400) throw new Error(reasonOf(reply));
|
||||
return reply.body as ProjectItemInfo;
|
||||
}
|
||||
// 원본에서 온 문서(도면 · 면적 · 재료)만 새로 덮음 — 사용자가 넣은 값(입력값 · 고른자재)은 그대로
|
||||
const kept = fakeLoad(projectId)[info.폴더]?.parts;
|
||||
const fresh = await client.importItem(row);
|
||||
const item = fakeLoad(projectId)[fresh.폴더];
|
||||
for (const part of ["입력값", "고른자재"] as const)
|
||||
if (kept?.[part] !== undefined && item) item.parts[part] = kept[part];
|
||||
fakeSave(projectId);
|
||||
return fresh;
|
||||
},
|
||||
masterChanged: async (row, info) => {
|
||||
if (!fake) return info.마스터바뀜 === true;
|
||||
return (await readOriginals(row)).version !== (info.원본판 ?? "");
|
||||
calc: async (folder) => {
|
||||
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
|
||||
if (reply.status !== 200) return null;
|
||||
const got = reply.body as { 계?: number; 빠진입력?: unknown[]; 멈춤?: unknown[] };
|
||||
return {
|
||||
total: Number(got.계 ?? 0),
|
||||
gaps: got.빠진입력?.length ?? 0,
|
||||
stuck: got.멈춤?.length ?? 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
clients.set(projectId, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/* ── 입력 목록(원본 층 기준) — 25-1b 약속 길 · 없으면 FAKE ─────────────────────── */
|
||||
const inputsCache = new Map<string, Promise<{ list: InputsList; fake: boolean }>>();
|
||||
/* ── 입력 목록(원본 층 기준) — 같은 항목은 한 번만 받음 ───────────────────────── */
|
||||
const inputsCache = new Map<string, Promise<ItemInputs>>();
|
||||
|
||||
async function fakeInputs(row: PickerRow): Promise<InputsList> {
|
||||
const combo = row.일위대가 ?? "";
|
||||
const got = await call(`/m01/combo?key=${enc(combo)}`);
|
||||
const header = (got.body as { combo?: { 이름?: string; 담은로직?: { 로직: string }[] } } | null)
|
||||
?.combo;
|
||||
if (got.status !== 200 || !header) throw new Error(reasonOf(got));
|
||||
const list: InputsList = { 일위대가: combo, 이름: header.이름 ?? combo, 로직: [], 빠진로직: [] };
|
||||
for (const one of header.담은로직 ?? []) {
|
||||
const [logic, auto] = await Promise.all([
|
||||
call(`/m01/logic?key=${enc(one.로직)}`),
|
||||
call(`/m01/logic/auto?key=${enc(one.로직)}`),
|
||||
]);
|
||||
const row = (
|
||||
logic.body as {
|
||||
logic?: { 이름?: string; 결과단위?: string; 입력?: (LogicInput & { 비고?: string })[] };
|
||||
} | null
|
||||
)?.logic;
|
||||
if (logic.status !== 200 || !row) {
|
||||
list.빠진로직.push(one.로직);
|
||||
continue;
|
||||
}
|
||||
const values = (auto.body as { 값?: Record<string, string | number> } | null)?.값 ?? {};
|
||||
list.로직.push({
|
||||
로직: one.로직,
|
||||
이름: row.이름 ?? one.로직,
|
||||
결과단위: row.결과단위 ?? null,
|
||||
입력: (row.입력 ?? []).map((input) => ({ ...input, 자동값: values[input.이름] ?? null })),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 원본 항목의 입력 목록 — 25-2a 길 → 없으면 가짜 */
|
||||
export function loadInputs(row: PickerRow): Promise<{ list: InputsList; fake: boolean }> {
|
||||
/** 원본 항목의 입력 목록(로직마다 갈라 · 합치지 않음) — 못 받으면 다음에 다시 */
|
||||
export function loadInputs(row: PickerRow): Promise<ItemInputs> {
|
||||
const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
|
||||
const kept = inputsCache.get(id);
|
||||
if (kept) return kept;
|
||||
const made = (async () => {
|
||||
const owner = ownerOf(row);
|
||||
const reply = await call(
|
||||
`/m02/layers/${layerOf(row)}/items/${enc(row.key)}/inputs${owner ? `?owner=${enc(owner)}` : ""}`,
|
||||
);
|
||||
if (reply.status === 200) return { list: reply.body as InputsList, fake: false };
|
||||
if (!routeMissing(reply)) throw new Error(reasonOf(reply));
|
||||
return { list: await fakeInputs(row), fake: true }; // FAKE
|
||||
})();
|
||||
const made = fetchItemInputs(layerOf(row), row.key);
|
||||
inputsCache.set(id, made);
|
||||
made.catch(() => inputsCache.delete(id)); // 못 받으면 다음에 다시
|
||||
made.catch(() => inputsCache.delete(id));
|
||||
return made;
|
||||
}
|
||||
|
||||
/** 가짜 항목 연결 — 모든 입력이 「선택」(실서버가 오면 그 파일을 씀) */
|
||||
export function fakeLink(list: InputsList): LinkDoc {
|
||||
return {
|
||||
판: "fake",
|
||||
줄: list.로직.flatMap((logic) =>
|
||||
logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user