Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_7
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김).
|
||||
|
||||
담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은
|
||||
그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 =
|
||||
`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과
|
||||
옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
|
||||
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
|
||||
|
||||
|
||||
def _num(value: Any) -> Any:
|
||||
"""Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지."""
|
||||
if isinstance(value, Decimal):
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
if isinstance(value, list):
|
||||
return [_num(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _num(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
|
||||
"""([로직 줄 …], 빠진 로직 키) — 키 차례 그대로."""
|
||||
whole = m01.loaded()[1]
|
||||
found, missing = [], []
|
||||
for code in codes:
|
||||
try:
|
||||
found.append(whole.logic(code))
|
||||
except combo_store.cm.mf.FormulaError:
|
||||
missing.append(code)
|
||||
return found, missing
|
||||
|
||||
|
||||
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
|
||||
"""(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], [])."""
|
||||
if not ua:
|
||||
return None, [], []
|
||||
row = combos.find(str(ua)) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
|
||||
if row is None:
|
||||
return None, [], []
|
||||
found, missing = _logics(combo_store.mcb.logic_keys(row))
|
||||
return row, found, missing
|
||||
|
||||
|
||||
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
|
||||
"""로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`."""
|
||||
whole = m01.loaded()[1]
|
||||
out = []
|
||||
for logic in logics:
|
||||
auto = m01.mau.values(whole, logic)
|
||||
specs = []
|
||||
for spec in logic.get("입력") or []:
|
||||
one = {k: spec[k] for k in _SPEC if k in spec}
|
||||
one.setdefault("설명", None)
|
||||
one.setdefault("단위", None)
|
||||
specs.append({**one, "자동값": auto.get(spec["이름"])})
|
||||
out.append(
|
||||
{
|
||||
"로직": logic.get("키"),
|
||||
"이름": logic.get("이름"),
|
||||
"결과단위": logic.get("결과단위"),
|
||||
"입력": specs,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def logic_inputs(codes: list[str]) -> dict[str, Any]:
|
||||
"""일위대가 입력 모달 — 로직 키들의 입력 목록 `{로직: [...], 빠진로직}`(키 차례 · 겹침 한 번)"""
|
||||
found, missing = _logics(list(dict.fromkeys(c for c in codes if c)))
|
||||
return _num({"로직": _listed(found), "빠진로직": missing})
|
||||
|
||||
|
||||
def inputs_of(ua: str | None) -> dict[str, Any]:
|
||||
"""옛 선택 입력 목록 — `{일위대가, 이름, 로직: [...], 빠진로직}` · 일위대가 없으면 로직 없음."""
|
||||
row, logics, missing = combo_logics(ua)
|
||||
return _num(
|
||||
{
|
||||
"일위대가": ua or None,
|
||||
"이름": row.get("이름") if row else None,
|
||||
"로직": _listed(logics),
|
||||
"빠진로직": missing,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
/* 넣을 값 칸(39-3 · 옛 선택 입력 CSS 에서 옮김) — 프로젝트 값 카드 · 일위대가 입력 모달이 같이 씀. 색은 테마 토큰. */
|
||||
|
||||
/* ── 프로젝트에서 값 받기(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);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/* =============================================================================
|
||||
* M02_Labor_Values.ts
|
||||
* 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌:
|
||||
* 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder).
|
||||
* · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀)
|
||||
* · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
|
||||
import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model";
|
||||
import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model";
|
||||
import "./M02_Labor_Values.css";
|
||||
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""));
|
||||
|
||||
const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" };
|
||||
|
||||
const autoValue = (input: ItemInput): string | number | null => {
|
||||
const auto = input.자동값;
|
||||
return typeof auto === "string" || typeof auto === "number" ? auto : null;
|
||||
};
|
||||
|
||||
export interface ValueControl {
|
||||
control: HTMLInputElement | HTMLSelectElement;
|
||||
/** 범위 밖 글(빈 글이면 비움) */
|
||||
error: HTMLElement;
|
||||
/** 지금 범위 밖인가 — 부르는 쪽이 셈(값 카드 「막힌 칸 수」) */
|
||||
wasBad: boolean;
|
||||
}
|
||||
|
||||
/** 값 칸 하나 — `value` = 저장 값(null = 자동값) · 바뀔 때마다 `onChange(저장 값, 범위 밖)` */
|
||||
export function createValueControl(
|
||||
input: ItemInput,
|
||||
value: string | number | null,
|
||||
onChange: (value: string | number | null, bad: boolean) => void,
|
||||
): ValueControl {
|
||||
const auto = autoValue(input);
|
||||
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;
|
||||
if (control instanceof HTMLSelectElement) {
|
||||
control.append(
|
||||
el("option", {
|
||||
attrs: { value: "" },
|
||||
text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`,
|
||||
}),
|
||||
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
|
||||
);
|
||||
}
|
||||
const empty = value === null || value === "";
|
||||
control.value = empty ? "" : String(value);
|
||||
control.classList.toggle("is-auto", empty);
|
||||
const error = el("span", { className: "m02-inputval__error" });
|
||||
const out: ValueControl = { control, error, wasBad: false };
|
||||
control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", () => {
|
||||
const raw = control.value;
|
||||
const bad = isOutside(input, raw);
|
||||
control.setAttribute("aria-invalid", bad ? "true" : "false");
|
||||
error.textContent = bad
|
||||
? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` })
|
||||
: "";
|
||||
control.classList.toggle("is-auto", raw === "");
|
||||
onChange(storedOf(input, raw), bad);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 프로젝트 복사본에서 값 받기(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, retry?: () => void) => void;
|
||||
/** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */
|
||||
loading: () => () => void;
|
||||
/** 지금 값(확인한 것 · 값이 든 것만) */
|
||||
getValues: () => ValueDoc;
|
||||
/** 범위 밖 값이 없나 */
|
||||
isValid: () => boolean;
|
||||
/** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */
|
||||
setEditLabel: (label: string) => void;
|
||||
}
|
||||
|
||||
const VALUE_TEXT = {
|
||||
title: "선택 입력",
|
||||
missing: "빠진 로직 {value}",
|
||||
edit: "선택 입력 수정",
|
||||
none: "선택으로 이은 입력이 없음",
|
||||
auto: "자동값",
|
||||
unconfirmed: "확인 안 함",
|
||||
confirmed: "✓ 확인함",
|
||||
confirmBox: "확인",
|
||||
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" });
|
||||
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에
|
||||
const shell = cardShell({
|
||||
title: VALUE_TEXT.title,
|
||||
sub: [summary],
|
||||
body: el("div", { className: "m02-inputval__wrap", children: [places, body] }),
|
||||
stateHost: body,
|
||||
});
|
||||
shell.tools.append(edit);
|
||||
const root = shell.root;
|
||||
|
||||
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 = autoValue;
|
||||
|
||||
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 field = createValueControl(input, stored.값, (value, bad) => {
|
||||
if (bad !== field.wasBad) invalid += bad ? 1 : -1;
|
||||
field.wasBad = bad;
|
||||
// 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님)
|
||||
put(logic.로직, input.이름, { 값: bad ? null : value, 확인: value !== null && !bad });
|
||||
box.checked = value !== null && !bad;
|
||||
emit();
|
||||
});
|
||||
const { control, error } = field;
|
||||
const box = el("input", {
|
||||
attrs: {
|
||||
type: "checkbox",
|
||||
title: VALUE_TEXT.confirmBox,
|
||||
"aria-label": VALUE_TEXT.confirmBox,
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
box.checked = stored.확인;
|
||||
// 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림
|
||||
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, retry?: () => void): void => {
|
||||
invalid = 0;
|
||||
paintSummary();
|
||||
if (!data) {
|
||||
places.textContent = "";
|
||||
if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry);
|
||||
return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none);
|
||||
}
|
||||
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(VALUE_TEXT.missing, { value: data.list.빠진로직.join(", ") }),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
if (!shown.length) {
|
||||
body.replaceChildren(cardNote(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, retry) => {
|
||||
data = next;
|
||||
values = next ? structuredClone(next.values) : {};
|
||||
paint(note, retry);
|
||||
},
|
||||
loading: () => shell.loading(),
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* =============================================================================
|
||||
* M02_Labor_Values_Model.ts
|
||||
* 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김.
|
||||
* 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 ·
|
||||
* 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ItemInput } from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
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.자동값);
|
||||
|
||||
/** `입력값.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 isOutside(input: ItemInput, raw: string): boolean {
|
||||
const range = rangeOf(input);
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/** 칸 글 → 저장 값 — 빈 글 = null(자동값) · 범위 = 수 · 고르기 = 원래 모양 · 그 밖 = 글 */
|
||||
export function storedOf(input: ItemInput, raw: string): string | number | null {
|
||||
if (raw === "") return null;
|
||||
if (rangeOf(input)) return Number(raw);
|
||||
return choicesOf(input).find((c) => c.값 === raw)?.raw ?? raw;
|
||||
}
|
||||
|
||||
/* ── 일위대가 입력값(39-3) — 로직 한 줄의 `{입력 이름: 값}` ───────────────────────────── */
|
||||
|
||||
export type LaborValues = Record<string, string | number>;
|
||||
|
||||
/** 로직 입력 목록에 있는 이름 · 빈 값 아닌 것만 남김 + 사라진 이름(로직 입력이 바뀌어 버려질 값) */
|
||||
export function keepKnown(
|
||||
inputs: ItemInput[],
|
||||
values: LaborValues | null | undefined,
|
||||
): { kept: LaborValues; lost: string[] } {
|
||||
const names = new Set(inputs.map((i) => i.이름));
|
||||
const kept: LaborValues = {};
|
||||
const lost: string[] = [];
|
||||
for (const [name, value] of Object.entries(values ?? {})) {
|
||||
if (!names.has(name)) lost.push(name);
|
||||
else if (value !== "" && value !== null && value !== undefined) kept[name] = value;
|
||||
}
|
||||
return { kept, lost };
|
||||
}
|
||||
|
||||
/** 줄 아래 작은 글 — 「지역 서울 · 규격 25mm」(넣은 값만 · 입력 차례) + 빈 값 수(자동값도 없는 입력) */
|
||||
export function valueLine(
|
||||
inputs: ItemInput[],
|
||||
values: LaborValues | null | undefined,
|
||||
): { text: string; empty: number } {
|
||||
const parts: string[] = [];
|
||||
let empty = 0;
|
||||
for (const input of inputs) {
|
||||
const value = values?.[input.이름];
|
||||
if (value !== undefined && value !== "") {
|
||||
parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`);
|
||||
} else if (autoText(input) === "") empty += 1;
|
||||
}
|
||||
return { text: parts.join(" · "), empty };
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
"""M02 층 권한 한 벌 — 나무 · 항목(`Router_Items`) · 양식 · 남의 층 목록(`Router_Layers`)이 함께 씀
|
||||
(PLAN 27-1c). 화면 쪽 짝 = `M02_MasterTemplete_Layer.ts` 의 `accessFor`.
|
||||
|
||||
「회사 C 를 맡음」(`manages` · 39-6) = SYSTEM_ADMIN · 또는 C 의 회사 관리자(ADMIN · 마스터).
|
||||
system 읽기 = 로그인 누구나 · 수정 = SYSTEM_ADMIN
|
||||
company {C} 자기 회사 = 읽기 누구나 · 수정 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) ·
|
||||
남의 회사 = SYSTEM_ADMIN 읽기만
|
||||
personal {C}/{U} 본인 = 읽기 · 수정 · 같은 회사 남 = 회사 관리자 읽기만 ·
|
||||
다른 회사 = SYSTEM_ADMIN 읽기만
|
||||
owner 글 없음 = 자기 층 · 남의 층은 그 층 폴더가 있을 때만(없으면 404) ·
|
||||
읽기로 남의 층에 뼈대를 안 만듦.
|
||||
company {C} 읽기 = C 사람 · SYSTEM_ADMIN · 수정 = C 를 맡은 사람
|
||||
personal {C}/{U} 본인 = 읽기 · 수정 · 남 = C 를 맡은 사람만 읽기 · 수정(일반 = 403)
|
||||
owner 글 없음 = 자기 층 · 남의 층 읽기는 그 층 폴더가 있을 때만(없으면 404) ·
|
||||
읽기로 남의 층에 뼈대를 안 만듦(쓰기는 부르는 쪽이 소속을 본 뒤 `folder(…, create=True)`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,6 +32,14 @@ def is_company_admin(session: dict[str, Any]) -> bool:
|
||||
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
|
||||
|
||||
|
||||
def manages(session: dict[str, Any], company: Any) -> bool:
|
||||
"""회사 `company` 를 맡음 — 그 회사 층 · 그 회사 모든 개인 층을 보고 고침."""
|
||||
if is_system_admin(session):
|
||||
return True
|
||||
own = session.get("company_id")
|
||||
return own is not None and str(own) == str(company) and is_company_admin(session)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Grant:
|
||||
"""층 한 칸의 권한 — `owner` = 층 주인 글(시스템 None) · `mine` = 자기 층(뼈대 만들어도 됨)."""
|
||||
@@ -63,15 +70,14 @@ def grant(session: dict[str, Any], layer: str, owner: str | None = None) -> Gran
|
||||
raise HTTPException(status_code=409, detail=NO_COMPANY)
|
||||
if not _SHAPE[layer].match(who):
|
||||
raise HTTPException(status_code=400, detail=f"층 주인 모양이 틀림 「{who}」")
|
||||
company = who.split("/")[0]
|
||||
if who == own:
|
||||
return Grant(layer, who, layer == "personal" or is_company_admin(session), True)
|
||||
same_company = who.split("/")[0] == str(session.get("company_id"))
|
||||
colleague = layer == "personal" and same_company and is_company_admin(session)
|
||||
if not (is_system_admin(session) or colleague):
|
||||
return Grant(layer, who, layer == "personal" or manages(session, company), True)
|
||||
if not manages(session, company):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표는 볼 수 없음"
|
||||
)
|
||||
return Grant(layer, who, False, False)
|
||||
return Grant(layer, who, True, False)
|
||||
|
||||
|
||||
def require(
|
||||
@@ -82,16 +88,15 @@ def require(
|
||||
if write and not got.write:
|
||||
if layer == "system":
|
||||
detail = "마스터 템플릿은 시스템 관리자만 고침."
|
||||
elif got.mine:
|
||||
detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀."
|
||||
else:
|
||||
detail = "남의 층은 읽기만."
|
||||
detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀."
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
return got
|
||||
|
||||
|
||||
def folder(got: Grant) -> Path:
|
||||
"""회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함(부르는 쪽은 자기 층만 뼈대를 만듦)."""
|
||||
def folder(got: Grant, create: bool = False) -> Path:
|
||||
"""회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함 · `create` = 쓰기(부르는 쪽이 소속을
|
||||
본 뒤 뼈대를 만듦)."""
|
||||
if got.owner is None:
|
||||
raise HTTPException(status_code=409, detail=NO_COMPANY)
|
||||
try:
|
||||
@@ -102,6 +107,6 @@ def folder(got: Grant) -> Path:
|
||||
path = layers.personal_dir(company, user)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error
|
||||
if not got.mine and not path.is_dir():
|
||||
if not (got.mine or create) and not path.is_dir():
|
||||
raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음")
|
||||
return path
|
||||
|
||||
@@ -9,7 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure ·
|
||||
* 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) */
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "material";
|
||||
export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "labor";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -510,9 +510,11 @@ export const pasteTree = (
|
||||
export interface LayerOwner {
|
||||
owner: string;
|
||||
name: string;
|
||||
/** 개인 = 집계표가 아직 없음(사람 나무 「비어 있음」 · 39-6) */
|
||||
비어있음?: boolean;
|
||||
}
|
||||
|
||||
/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */
|
||||
/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사를 맡은 사람 · 회사 사람 전부(나 맨 위) */
|
||||
export const fetchOwners = (
|
||||
layer: "company" | "personal",
|
||||
company: string | null = null,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_AreaLink.ts
|
||||
* 면적산출 상세 — 칸 지정 모달(PLAN 39-4 D5). 오른쪽 패널의 담은 로직 · 자재 줄을 누르면 엶.
|
||||
* 그 줄 알약 키(로직 물량 「이름|단위」 · 자재 「자재|MT키」)를 면적산출 칸에 잇기 · 옮기기 · 풀기.
|
||||
* 정본 = 면적산출 문서 `연결` 한 곳 — 스프레드시트 손잡이 `setLink`(알약 누름과 같은 규칙 · 되돌리기 됨)라
|
||||
* 아래 알약 줄 · 칸 세모 · 패널 수량이 같이 따라감. 손 입력 수량은 일위대가 몫(여기 없음) · 저장은 [저장].
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
|
||||
const TEXT = {
|
||||
title: "칸 지정 — {value}",
|
||||
close: "닫기",
|
||||
now: "지금 연결",
|
||||
none: "연결 없음",
|
||||
picked: "지금 고른 칸으로 ({value})",
|
||||
address: "칸 주소",
|
||||
hint: "예 B6",
|
||||
link: "잇기",
|
||||
unlink: "풀기",
|
||||
bad: "칸 주소가 틀림 — 예 B6",
|
||||
readOnly: "읽기만 — 고칠 수 없음",
|
||||
};
|
||||
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
export interface AreaLinkOptions {
|
||||
/** 줄 이름(모달 제목) */
|
||||
label: string;
|
||||
/** 알약 키 */
|
||||
key: string;
|
||||
/** 연결에 같이 적을 이름 · 단위 · 자재 */
|
||||
info: LinkInfo;
|
||||
sheet: SpreadsheetHandle;
|
||||
/** 시트에서 지금 고른 칸(A1) */
|
||||
picked: () => string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */
|
||||
function linkedText(opts: AreaLinkOptions): string | null {
|
||||
const link = opts.sheet
|
||||
.getDoc()
|
||||
.시트.flatMap((s) => s.연결 ?? [])
|
||||
.find((l) => l.키 === opts.key);
|
||||
if (!link) return null;
|
||||
const value = opts.sheet.getLinkedValues()[opts.key];
|
||||
return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim();
|
||||
}
|
||||
|
||||
export function openAreaLink(opts: AreaLinkOptions): void {
|
||||
const { close } = openModal({
|
||||
title: fill(TEXT.title, opts.label),
|
||||
closeLabel: TEXT.close,
|
||||
dialogClass: "m02-modal m02-area-link",
|
||||
mount: (body) => {
|
||||
const now = el("p", { className: "m02-area-link__now" });
|
||||
const paint = (): void => {
|
||||
const text = linkedText(opts);
|
||||
now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`;
|
||||
unlink.disabled = opts.readOnly || !text;
|
||||
};
|
||||
const done = (cell: string | null): boolean => {
|
||||
if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false;
|
||||
close();
|
||||
return true;
|
||||
};
|
||||
const here = opts.picked();
|
||||
const pick = createButton({
|
||||
label: fill(TEXT.picked, here),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => void done(here),
|
||||
});
|
||||
const address = createInputField({ label: TEXT.address, placeholder: TEXT.hint });
|
||||
const link = createButton({
|
||||
label: TEXT.link,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
if (!done(address.input.value)) address.setError(TEXT.bad);
|
||||
},
|
||||
});
|
||||
const unlink = createButton({
|
||||
label: TEXT.unlink,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void done(null),
|
||||
});
|
||||
address.input.addEventListener("input", () => address.setError(undefined));
|
||||
address.input.addEventListener("keydown", (ev) => ev.key === "Enter" && link.click());
|
||||
pick.disabled = link.disabled = address.input.disabled = opts.readOnly;
|
||||
paint();
|
||||
body.append(
|
||||
now,
|
||||
...(opts.readOnly
|
||||
? [el("p", { className: "m02-struct__empty", text: TEXT.readOnly })]
|
||||
: []),
|
||||
el("div", { className: "m02-area-link__row", children: [pick, unlink] }),
|
||||
el("div", { className: "m02-area-link__row", children: [address.root, link] }),
|
||||
);
|
||||
if (!opts.readOnly) address.input.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -190,12 +190,6 @@ def pills(key: str) -> dict[str, Any]:
|
||||
return combo_store.combo_pills(_find(_load()[0], key))
|
||||
|
||||
|
||||
def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]:
|
||||
"""저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키."""
|
||||
row = _find(_load()[0], combo) if isinstance(combo, str) else combo
|
||||
return _m01(combo_store.combo_preview, row, inputs)
|
||||
|
||||
|
||||
# ── 쓰기(회사 · 개인) ──────────────────────────────────────────────────
|
||||
def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]:
|
||||
"""서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든)."""
|
||||
@@ -289,6 +283,21 @@ def edit(key: str, row: Any, version: str) -> dict[str, Any]:
|
||||
return {"키": key, "판": _save(doc, version), "combo": fixed}
|
||||
|
||||
|
||||
def drop_practice(version: str) -> dict[str, Any]:
|
||||
"""실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 ·
|
||||
구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만."""
|
||||
if heads()[-1] != MASTER:
|
||||
raise store.StoreError(400, "실무문서는 마스터 층에만 있음")
|
||||
doc, have = _load()
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
keys = [str(r.get("키")) for r in doc.get("줄") or []]
|
||||
_refuse_used(keys)
|
||||
if keys:
|
||||
_m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have)
|
||||
return {**listing(), "지운조합": keys}
|
||||
|
||||
|
||||
def delete(key: str, version: str) -> dict[str, Any]:
|
||||
"""지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음)."""
|
||||
if heads()[-1] == MASTER:
|
||||
|
||||
@@ -103,101 +103,3 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -14,30 +14,18 @@ M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) ·
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Labor_Inputs import _num, combo_logics
|
||||
|
||||
PLACES = ("면적", "재료", "선택", None)
|
||||
PILL_HEAD = "입력"
|
||||
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
|
||||
|
||||
|
||||
def _num(value: Any) -> Any:
|
||||
"""Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지."""
|
||||
if isinstance(value, Decimal):
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
if isinstance(value, list):
|
||||
return [_num(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _num(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def pill_key(logic: str, name: str) -> str:
|
||||
@@ -56,55 +44,10 @@ def _combo_row(ua: str) -> dict[str, Any] | None:
|
||||
return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c)
|
||||
|
||||
|
||||
def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]:
|
||||
"""(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], [])."""
|
||||
if not ua:
|
||||
return None, [], []
|
||||
row = _combo_row(str(ua))
|
||||
if row is None:
|
||||
return None, [], []
|
||||
whole = m01.loaded()[1]
|
||||
found, missing = [], []
|
||||
for one in combo_store.mcb.items(row):
|
||||
code = str(one.get("로직") or "") if isinstance(one, dict) else ""
|
||||
try:
|
||||
found.append(whole.logic(code))
|
||||
except combo_store.cm.mf.FormulaError:
|
||||
missing.append(code)
|
||||
return row, found, missing
|
||||
|
||||
|
||||
def inputs(key: str) -> dict[str, Any]:
|
||||
"""25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`.
|
||||
일위대가 없는 항목은 로직 빈 목록."""
|
||||
ua = _item(key).get("일위대가")
|
||||
row, logics, missing = combo_logics(ua)
|
||||
whole = m01.loaded()[1]
|
||||
out = []
|
||||
for logic in logics:
|
||||
auto = m01.mau.values(whole, logic)
|
||||
specs = []
|
||||
for spec in logic.get("입력") or []:
|
||||
one = {k: spec[k] for k in _SPEC if k in spec}
|
||||
one.setdefault("설명", None)
|
||||
one.setdefault("단위", None)
|
||||
specs.append({**one, "자동값": auto.get(spec["이름"])})
|
||||
out.append(
|
||||
{
|
||||
"로직": logic.get("키"),
|
||||
"이름": logic.get("이름"),
|
||||
"결과단위": logic.get("결과단위"),
|
||||
"입력": specs,
|
||||
}
|
||||
)
|
||||
return _num(
|
||||
{
|
||||
"일위대가": ua or None,
|
||||
"이름": row.get("이름") if row else None,
|
||||
"로직": out,
|
||||
"빠진로직": missing,
|
||||
}
|
||||
)
|
||||
일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌)."""
|
||||
return labor_inputs.inputs_of(_item(key).get("일위대가"))
|
||||
|
||||
|
||||
# ── 항목 연결 파일 ─────────────────────────────────────
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import {
|
||||
fetchItemInputs,
|
||||
@@ -18,28 +18,21 @@ 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 { cardNote, cardShell } from "./M02_MasterTemplete_CardShell";
|
||||
import "./M02_MasterTemplete_Inputs.css";
|
||||
@@ -288,286 +281,10 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 프로젝트 복사본에서 값 받기(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, retry?: () => void) => void;
|
||||
/** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */
|
||||
loading: () => () => 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" });
|
||||
// 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에
|
||||
const shell = cardShell({
|
||||
title: TEXT.title,
|
||||
sub: [summary],
|
||||
body: el("div", { className: "m02-inputval__wrap", children: [places, body] }),
|
||||
stateHost: body,
|
||||
});
|
||||
shell.tools.append(edit);
|
||||
const root = shell.root;
|
||||
|
||||
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, retry?: () => void): void => {
|
||||
invalid = 0;
|
||||
paintSummary();
|
||||
if (!data) {
|
||||
places.textContent = "";
|
||||
if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry);
|
||||
return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none);
|
||||
}
|
||||
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(cardNote(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, retry) => {
|
||||
data = next;
|
||||
values = next ? structuredClone(next.values) : {};
|
||||
paint(note, retry);
|
||||
},
|
||||
loading: () => shell.loading(),
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */
|
||||
export {
|
||||
createInputsValueCard,
|
||||
type InputsValueData,
|
||||
type InputsValueHandle,
|
||||
type InputsValueOptions,
|
||||
} from "./M02_Labor_Values";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Inputs_Model.ts
|
||||
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글.
|
||||
* 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록.
|
||||
* 값 글 · 값 칸 규칙은 `M02_Labor_Values_Model.ts`(일위대가 입력 모달과 한 벌 · 여기서 다시 내보냄).
|
||||
* 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` ·
|
||||
* 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음.
|
||||
* ========================================================================== */
|
||||
@@ -78,62 +79,11 @@ export function pillsOf(
|
||||
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.자동값);
|
||||
/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */
|
||||
export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model";
|
||||
|
||||
/* ── 프로젝트에서 값 받기(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,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type {
|
||||
LinkOptions,
|
||||
SpreadsheetHandle,
|
||||
@@ -62,6 +63,8 @@ const TEXT = {
|
||||
readFailed: "{value} 파일을 못 읽음 — {why}",
|
||||
discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?",
|
||||
sheetHelp: "단축키 — 칸을 고르고 바로 침",
|
||||
pills: "알약",
|
||||
pillsTip: "아래 알약 줄 보임 · 숨김",
|
||||
};
|
||||
|
||||
/** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */
|
||||
@@ -221,6 +224,20 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
return card;
|
||||
};
|
||||
const sheets = [sheetCard("area"), sheetCard("material")];
|
||||
// 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임)
|
||||
const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" });
|
||||
pills.title = TEXT.pillsTip;
|
||||
const paintPills = (shown: boolean): void => {
|
||||
pills.setAttribute("aria-pressed", String(shown));
|
||||
sheets[0].host.classList.toggle("is-pills-off", !shown);
|
||||
};
|
||||
pills.addEventListener("click", () => {
|
||||
const shown = pills.getAttribute("aria-pressed") !== "true";
|
||||
writeStateRaw("m02-card-pills", String(shown));
|
||||
paintPills(shown);
|
||||
});
|
||||
paintPills(readStateRaw("m02-card-pills") === "true");
|
||||
sheets[0].shell.tools.prepend(pills);
|
||||
const cards: Card[] = [info, ...sheets];
|
||||
const defaults: Record<EditKind, string> = {
|
||||
structure: TEXT.edit,
|
||||
@@ -244,6 +261,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
c.saver.button.hidden = c.help.hidden = !can;
|
||||
c.edit.hidden = !opts.editable();
|
||||
}
|
||||
pills.hidden = !sheetEdit;
|
||||
};
|
||||
|
||||
const drop = (): void => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemList.ts
|
||||
* 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나
|
||||
* 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표
|
||||
* (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음).
|
||||
* 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다.
|
||||
* 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, el } from "@ui/ui_template_elements";
|
||||
import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Style.css";
|
||||
import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
|
||||
/** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */
|
||||
export type ListKind = "structure" | "area" | "labor" | "item";
|
||||
|
||||
/** 나무 항목 줄 + 목록 칸(서버 `M02_MasterTemplete_Items._list_extras`) */
|
||||
export interface ItemRow extends TreeItem {
|
||||
도번?: string | null;
|
||||
면적번?: string | null;
|
||||
수정일?: Partial<Record<"structure" | "area" | "labor", string>>;
|
||||
labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null;
|
||||
}
|
||||
|
||||
export interface Col<R> {
|
||||
head: string;
|
||||
get: (row: R) => string;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
search: "찾기",
|
||||
none: "항목이 없음",
|
||||
noMatch: "맞는 항목이 없음",
|
||||
loading: "불러오는 중",
|
||||
failed: "목록을 못 받음",
|
||||
};
|
||||
|
||||
const SIZE = 30;
|
||||
const DASH = "—";
|
||||
const day = (iso?: string): string => (iso ? iso.slice(0, 10) : "");
|
||||
const money = (value: number | null | undefined): string =>
|
||||
typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH;
|
||||
/** 가장 늦게 고친 날 — 항목 통째 */
|
||||
const latest = (row: ItemRow): string =>
|
||||
day(
|
||||
Object.values(row.수정일 ?? {})
|
||||
.sort()
|
||||
.pop(),
|
||||
);
|
||||
|
||||
const NAME: Col<ItemRow> = { head: "이름", get: (r) => leafName(r) };
|
||||
const FOLDER: Col<ItemRow> = { head: "폴더", get: (r) => folderOf(r).join(" / ") };
|
||||
const UNIT: Col<ItemRow> = { head: "단위", get: (r) => r.단위 ?? "" };
|
||||
const changed = (part: "structure" | "area" | "labor"): Col<ItemRow> => ({
|
||||
head: "고친 날",
|
||||
get: (r) => day(r.수정일?.[part]),
|
||||
});
|
||||
|
||||
/** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */
|
||||
export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
|
||||
structure: [
|
||||
{ head: "도번", get: (r) => r.도번 || DASH },
|
||||
NAME,
|
||||
FOLDER,
|
||||
UNIT,
|
||||
changed("structure"),
|
||||
],
|
||||
area: [{ head: "면적번", get: (r) => r.면적번 || DASH }, NAME, FOLDER, UNIT, changed("area")],
|
||||
labor: [
|
||||
{ head: "일위대가번", get: (r) => r.labor?.번호 || DASH },
|
||||
NAME,
|
||||
FOLDER,
|
||||
UNIT,
|
||||
{ head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
|
||||
{ head: "합계", get: (r) => money(r.labor?.합계) },
|
||||
changed("labor"),
|
||||
],
|
||||
item: [
|
||||
NAME,
|
||||
FOLDER,
|
||||
UNIT,
|
||||
{ head: "도번", get: (r) => r.도번 || DASH },
|
||||
{ head: "일위대가번", get: (r) => r.labor?.번호 || DASH },
|
||||
{ head: "면적번", get: (r) => r.면적번 || DASH },
|
||||
{ head: "고친 날", get: latest },
|
||||
],
|
||||
};
|
||||
|
||||
/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */
|
||||
export function pageOf<R>(
|
||||
rows: R[],
|
||||
cols: Col<R>[],
|
||||
query: string,
|
||||
page: number,
|
||||
size = SIZE,
|
||||
): { shown: R[]; page: number; total: number } {
|
||||
const hits = rows.filter((r) =>
|
||||
matchText(
|
||||
cols.map((c) => c.get(r)),
|
||||
query,
|
||||
),
|
||||
);
|
||||
const last = Math.max(1, Math.ceil(hits.length / size));
|
||||
const at = Math.min(Math.max(1, page), last);
|
||||
return { shown: hits.slice((at - 1) * size, at * size), page: at, total: hits.length };
|
||||
}
|
||||
|
||||
/** 한 층 항목 — `owner` = 남의 층(없으면 자기 층) */
|
||||
export const loadItems = async (layer: Layer, owner: string | null): Promise<ItemRow[]> =>
|
||||
(await fetchTree(layer, owner)).items as ItemRow[];
|
||||
|
||||
/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */
|
||||
export function buildAllItems(
|
||||
kind: ListKind,
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
onOpen: (row: ItemRow) => void,
|
||||
): HTMLElement {
|
||||
const list = buildItemList();
|
||||
loadItems(layer, owner).then(
|
||||
(rows) => list.show(rows, ITEM_COLS[kind], onOpen),
|
||||
(error: unknown) =>
|
||||
list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
return list.root;
|
||||
}
|
||||
|
||||
export interface ItemListHandle {
|
||||
root: HTMLElement;
|
||||
/** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */
|
||||
show: <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void) => void;
|
||||
/** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */
|
||||
message: (text?: string) => void;
|
||||
}
|
||||
|
||||
export function buildItemList(): ItemListHandle {
|
||||
let draw = (): void => {};
|
||||
let page = 1;
|
||||
const searchField = createInputField({ type: "search", placeholder: TEXT.search });
|
||||
const search = searchField.input;
|
||||
const grid = el("div", { className: "m01-master__grid-wrap" });
|
||||
let pager = buildPager(0, SIZE, 1, () => {});
|
||||
const note = (text: string): void => {
|
||||
grid.replaceChildren(el("p", { className: "m01-master__empty", text }));
|
||||
};
|
||||
|
||||
const show = <R>(rows: R[], cols: Col<R>[], onPick: (row: R) => void): void => {
|
||||
page = 1;
|
||||
draw = (): void => {
|
||||
const got = pageOf(rows, cols, search.value, page);
|
||||
page = got.page;
|
||||
const next = buildPager(got.total, SIZE, page, (p) => {
|
||||
page = p;
|
||||
draw();
|
||||
});
|
||||
pager.replaceWith(next);
|
||||
pager = next;
|
||||
if (!got.shown.length) return note(rows.length ? TEXT.noMatch : TEXT.none);
|
||||
const body = got.shown.map((row) => {
|
||||
const tr = el("tr", {
|
||||
className: "m01-master__table-row",
|
||||
children: cols.map((c) => {
|
||||
const text = c.get(row);
|
||||
return el("td", { text, attrs: { title: text } });
|
||||
}),
|
||||
});
|
||||
tr.addEventListener("click", () => onPick(row));
|
||||
return tr;
|
||||
});
|
||||
grid.replaceChildren(
|
||||
el("table", {
|
||||
className: "m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: cols.map((c) => el("th", { text: c.head })) })],
|
||||
}),
|
||||
el("tbody", { children: body }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
draw();
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
page = 1;
|
||||
draw();
|
||||
});
|
||||
const root = el("div", {
|
||||
className: "m02-itemlist",
|
||||
children: [buildHeadFilters([searchField.root, pager]), grid],
|
||||
});
|
||||
// 처음 = 빈 표 자리(줄이 오면 `show`)
|
||||
note(TEXT.loading);
|
||||
return { root, show, message: (text = TEXT.loading) => note(text) };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* 가져올 곳 고르기(PLAN 39-5) — 탭 줄 + 목록 · 창이면 목록이 넓게 */
|
||||
.m02-itempick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-itempick .m01-master__grid-wrap {
|
||||
max-height: min(60vh, calc(100vh - 300px));
|
||||
}
|
||||
|
||||
.ui-modal.m02-itempick__dialog {
|
||||
width: min(960px, 94vw);
|
||||
}
|
||||
|
||||
/* 두 단추 화면 — 새 구조물 조합 · 빈 카드 */
|
||||
.m02-itempick__two {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-itempick__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-itempick__buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemPick.ts
|
||||
* 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`).
|
||||
* 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀.
|
||||
* 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈
|
||||
* (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로.
|
||||
* 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
buildItemList,
|
||||
ITEM_COLS,
|
||||
loadItems,
|
||||
type Col,
|
||||
type ItemRow,
|
||||
type ListKind,
|
||||
} from "./M02_MasterTemplete_ItemList";
|
||||
import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { ownOwner, readableSources, type Source } from "./M02_MasterTemplete_Layer";
|
||||
import { leafName } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
import "./M02_MasterTemplete_TreePanel.css";
|
||||
import "./M02_MasterTemplete_ItemPick.css";
|
||||
|
||||
const TEXT = {
|
||||
close: "닫기",
|
||||
person: "사람",
|
||||
noPeople: "다른 사람이 없음",
|
||||
empty: "비어 있음",
|
||||
failed: "목록을 못 받음 — {value}",
|
||||
makeNew: "새로 만들기",
|
||||
makeCopy: "복사 생성",
|
||||
};
|
||||
|
||||
/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */
|
||||
export interface From {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
label: string;
|
||||
practice: boolean;
|
||||
}
|
||||
|
||||
export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary };
|
||||
|
||||
/** 고른 것 이름 — 확인 글에 씀 */
|
||||
export const pickedName = (p: Picked): string =>
|
||||
"row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`;
|
||||
|
||||
const PRACTICE_COLS: Col<ComboSummary>[] = [
|
||||
{ head: "키", get: (c) => c.키 },
|
||||
{ head: "이름", get: (c) => c.이름 },
|
||||
{ head: "구분", get: (c) => c.구분 ?? "" },
|
||||
{ head: "상세구분", get: (c) => c.상세구분 ?? "" },
|
||||
{ head: "단위", get: (c) => c.단위 ?? "" },
|
||||
{ head: "담은 로직 수", get: (c) => String(c.count) },
|
||||
];
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
/** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */
|
||||
const empty = (o: { owner: string }): boolean => !!(o as { 비어있음?: boolean }).비어있음;
|
||||
|
||||
export interface PickerOptions {
|
||||
user: SessionUser | null;
|
||||
/** 볼 회사(`viewCompany`) */
|
||||
company: string | null;
|
||||
/** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */
|
||||
kind: ListKind;
|
||||
/** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */
|
||||
skip?: (from: From, row: ItemRow) => boolean;
|
||||
onPick: (picked: Picked) => void;
|
||||
}
|
||||
|
||||
/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */
|
||||
export function buildPicker(opt: PickerOptions): { root: HTMLElement } {
|
||||
const { tabs, first } = readableSources(opt.user, opt.company, {
|
||||
practice: opt.kind === "labor",
|
||||
});
|
||||
const list = buildItemList();
|
||||
const people = createSelectField({ options: [], compact: true, label: TEXT.person });
|
||||
people.root.hidden = true;
|
||||
let owners: { owner: string; name: string }[] | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const buttons = tabs.map((tab) => {
|
||||
const b = el("button", {
|
||||
className: "m02-layers__btn",
|
||||
text: tab.label,
|
||||
attrs: { type: "button", "data-source": tab.id },
|
||||
});
|
||||
b.addEventListener("click", () => void show(tab));
|
||||
return b;
|
||||
});
|
||||
|
||||
async function show(tab: Source): Promise<void> {
|
||||
const mine = ++seq;
|
||||
buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id));
|
||||
people.root.hidden = tab.id !== "people";
|
||||
list.message();
|
||||
try {
|
||||
if (tab.id === "practice") {
|
||||
const combos = await fetchCombos();
|
||||
if (mine !== seq) return;
|
||||
const from = { layer: tab.layer, owner: null, label: tab.label, practice: true };
|
||||
return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo }));
|
||||
}
|
||||
let owner = tab.owner;
|
||||
let label = tab.label;
|
||||
if (tab.id === "people") {
|
||||
// 나는 「개인」 탭 — 뺌 · 빈 사람은 「비어 있음」 · 처음 = 첫 빈 아닌 사람
|
||||
const me = ownOwner("personal", opt.user);
|
||||
owners ??= (await fetchOwners("personal", opt.company)).filter((o) => o.owner !== me);
|
||||
if (mine !== seq) return;
|
||||
if (!owners.length) return list.message(TEXT.noPeople);
|
||||
if (!people.select.value) {
|
||||
people.setOptions(
|
||||
owners.map((o) => ({
|
||||
value: o.owner,
|
||||
text: empty(o) ? `${o.name} · ${TEXT.empty}` : o.name,
|
||||
})),
|
||||
(owners.find((o) => !empty(o)) ?? owners[0]).owner,
|
||||
);
|
||||
}
|
||||
owner = people.select.value;
|
||||
const who = owners.find((o) => o.owner === owner);
|
||||
label = `${tab.label} · ${who?.name ?? owner}`;
|
||||
if (who && empty(who)) return list.show([], ITEM_COLS[opt.kind], () => {}); // 층 폴더 없음
|
||||
}
|
||||
const rows = await loadItems(tab.layer, owner);
|
||||
if (mine !== seq) return;
|
||||
const from = { layer: tab.layer, owner, label, practice: false };
|
||||
list.show(
|
||||
rows.filter((row) => !opt.skip?.(from, row)),
|
||||
ITEM_COLS[opt.kind],
|
||||
(row) => opt.onPick({ from, row }),
|
||||
);
|
||||
} catch (error) {
|
||||
if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error)));
|
||||
}
|
||||
}
|
||||
|
||||
people.select.addEventListener("change", () => {
|
||||
const tab = tabs.find((t) => t.id === "people");
|
||||
if (tab) void show(tab);
|
||||
});
|
||||
const start = tabs.find((t) => t.id === first) ?? tabs[0];
|
||||
if (start) void show(start);
|
||||
return {
|
||||
root: el("div", {
|
||||
className: "m02-itempick",
|
||||
children: [
|
||||
el("div", { className: "m02-layers__row", children: [...buttons, people.root] }),
|
||||
list.root,
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TwoWayOptions {
|
||||
/** 위 안내 한 줄 */
|
||||
note: string;
|
||||
/** [새로 만들기] — 지금 길(창 · 빈 문서) */
|
||||
onNew: () => void;
|
||||
/** [복사 생성] 뒤 고를 목록 */
|
||||
picker: Omit<PickerOptions, "onPick">;
|
||||
/** 줄 누름 → 확인 글(취소면 그대로 고름) */
|
||||
ask: (picked: Picked) => string;
|
||||
/** 확인 뒤 — 복사(실패 알림은 부르는 쪽) */
|
||||
onCopy: (picked: Picked) => Promise<void>;
|
||||
}
|
||||
|
||||
/** [새로 만들기] [복사 생성] 두 단추(39-5 · 답 2) — 복사 생성을 누르면 같은 칸 아래 탭 + 목록 */
|
||||
export function buildTwoWay(opt: TwoWayOptions): HTMLElement {
|
||||
const fresh = createButton({ label: TEXT.makeNew, variant: "filled" });
|
||||
const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" });
|
||||
const below = el("div", { className: "m02-itempick__below" });
|
||||
let busy = false;
|
||||
fresh.addEventListener("click", opt.onNew);
|
||||
copy.addEventListener("click", () => {
|
||||
if (below.firstChild) return;
|
||||
copy.classList.add("is-active");
|
||||
const picker = buildPicker({
|
||||
...opt.picker,
|
||||
onPick: (picked) =>
|
||||
void (async () => {
|
||||
if (busy || !(await showConfirmDialog(opt.ask(picked)))) return;
|
||||
busy = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await opt.onCopy(picked);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
})(),
|
||||
});
|
||||
below.append(picker.root);
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-itempick__two",
|
||||
children: [
|
||||
el("p", { className: "m02-itempick__note", text: opt.note }),
|
||||
el("div", { className: "m02-itempick__buttons", children: [fresh, copy] }),
|
||||
below,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */
|
||||
export function pickFrom(
|
||||
opt: Omit<PickerOptions, "onPick"> & { title: string; ask?: (picked: Picked) => string },
|
||||
): Promise<Picked | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: Picked | null): void => {
|
||||
if (!done) resolve(value);
|
||||
done = true;
|
||||
};
|
||||
openModal({
|
||||
title: opt.title,
|
||||
closeLabel: TEXT.close,
|
||||
dialogClass: "m02-modal m02-itempick__dialog",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
const picker = buildPicker({
|
||||
...opt,
|
||||
onPick: (picked) =>
|
||||
void (async () => {
|
||||
if (opt.ask && !(await showConfirmDialog(opt.ask(picked)))) return;
|
||||
finish(picked);
|
||||
close();
|
||||
})(),
|
||||
});
|
||||
body.append(picker.root);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -85,10 +86,47 @@ def items(folder: Path) -> list[dict[str, Any]]:
|
||||
k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번", "재료번")
|
||||
},
|
||||
}
|
||||
out.append({"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}, "names": names})
|
||||
out.append(
|
||||
{
|
||||
"key": r.get("키"),
|
||||
**{k: r.get(k) for k in ITEM_FIELDS},
|
||||
"names": names,
|
||||
**_list_extras(Path(folder), str(r.get("열"))),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _list_extras(folder: Path, col: str) -> dict[str, Any]:
|
||||
"""목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의
|
||||
번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음."""
|
||||
stamps: dict[str, str] = {}
|
||||
for kind in ("structure", "area", "labor"):
|
||||
path = folder / kind / f"{col}.json"
|
||||
if path.is_file():
|
||||
stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
labor = None
|
||||
path = folder / "labor" / f"{col}.json"
|
||||
if path.is_file():
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except ValueError:
|
||||
doc = {}
|
||||
total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계")
|
||||
try:
|
||||
total = None if total is None or isinstance(total, bool) else float(total)
|
||||
except (TypeError, ValueError):
|
||||
total = None # 수 아닌 값(옛 꼴)은 「—」
|
||||
labor = {
|
||||
"번호": doc.get("일위대가번"),
|
||||
"로직수": len(doc.get("담은로직") or []),
|
||||
"합계": total,
|
||||
}
|
||||
return {"수정일": stamps, "labor": labor}
|
||||
|
||||
|
||||
def _fresh_id(want: str, used: set[str]) -> str:
|
||||
out, n = want, 2
|
||||
while out in used:
|
||||
|
||||
@@ -21,3 +21,118 @@
|
||||
.m02-labor__list tbody tr:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */
|
||||
.m02-labrow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-labrow__row.is-open {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-labrow__row.is-open:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-labrow__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.m02-labrow__key,
|
||||
.m02-labrow__note,
|
||||
.m02-labrow__muted {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-labrow__empty {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow__money,
|
||||
.m02-labrow__num {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-labrow__money {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.m02-labrow__base {
|
||||
display: block;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labrow__applied {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m02-labrow__sum td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-labrow__move {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 입력 모달 */
|
||||
.m02-labin {
|
||||
width: min(40rem, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.m02-labin__qty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-labin__qty-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-labin__muted {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labin__lost {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labin__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--spacing-12);
|
||||
}
|
||||
|
||||
/* 보기 갈래(면적산출 오른쪽 패널 · 일위대가 카드) */
|
||||
.m02-labview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-labview__title {
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor.ts
|
||||
* 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추
|
||||
* (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` ·
|
||||
* 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`Labor_Edit`).
|
||||
* 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
editComboTree,
|
||||
editComboTreeMany,
|
||||
fetchComboList,
|
||||
pasteCombos,
|
||||
type ComboList,
|
||||
type ComboTreeEdit,
|
||||
type LaborScope,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit";
|
||||
import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
|
||||
|
||||
/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */
|
||||
function treeError(error: unknown): Error {
|
||||
if (!(error instanceof ApiError))
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
const d = error.detail as { errors?: string[]; stale?: unknown } | string | null;
|
||||
const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`;
|
||||
return error.status === 409 ? new StaleError(text) : new Error(text);
|
||||
}
|
||||
|
||||
export interface LaborLibraryOptions {
|
||||
scope: LaborScope;
|
||||
canEdit: boolean;
|
||||
/** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */
|
||||
copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[];
|
||||
/** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */
|
||||
reveal: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LaborLibrary {
|
||||
/** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */
|
||||
root: HTMLElement;
|
||||
/** 좌측 패널 나무 */
|
||||
tree: HTMLElement;
|
||||
/** 나무 맨 위 더하기 「+」(층 단추 옆) */
|
||||
add: HTMLElement;
|
||||
/** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */
|
||||
back: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
/** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */
|
||||
tools: HTMLElement | null;
|
||||
/** 메인 칸 「저장 안 한 고침」 손잡이 */
|
||||
bind: (setDirty: (dirty: boolean) => void) => void;
|
||||
}
|
||||
|
||||
export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
|
||||
const { scope } = opt;
|
||||
let setDirty: (dirty: boolean) => void = () => {};
|
||||
let keys = new Set<string>(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나
|
||||
const took = (list: ComboList): ComboList => {
|
||||
keys = new Set(list.줄.map((c) => c.키));
|
||||
editor.setList(list.줄);
|
||||
return list;
|
||||
};
|
||||
const editor = createLaborEditor({
|
||||
scope,
|
||||
canEdit: opt.canEdit,
|
||||
onDirty: (dirty) => setDirty(dirty),
|
||||
onSaved: async (key) => {
|
||||
await tree.refresh();
|
||||
tree.select(key);
|
||||
},
|
||||
});
|
||||
const tree = createTreePanel({
|
||||
layer: scope.layer,
|
||||
owner: scope.owner,
|
||||
canEdit: opt.canEdit,
|
||||
maxDepth: 2,
|
||||
item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) },
|
||||
api: {
|
||||
fetch: async () => {
|
||||
return toView(took(await fetchComboList(scope)));
|
||||
},
|
||||
edit: async (_layer, edit, 판) => {
|
||||
const body = { ...edit, 판 } as ComboTreeEdit;
|
||||
const got = await editComboTree(body, scope).catch((error: unknown) => {
|
||||
throw treeError(error);
|
||||
});
|
||||
return toView(took(got), noteOf(edit, got.지운조합));
|
||||
},
|
||||
// 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장
|
||||
many: async (_layer, body, 판) => {
|
||||
const ops = body.대상.map((ref) => ({
|
||||
op: body.op,
|
||||
...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }),
|
||||
...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}),
|
||||
}));
|
||||
const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => {
|
||||
throw treeError(error);
|
||||
});
|
||||
return toView(took(got), { op: body.op, 지운항목: got.지운조합 });
|
||||
},
|
||||
// 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로
|
||||
paste: async (layer, owner, clip, dest) => {
|
||||
const to = { layer, owner } as LaborScope;
|
||||
const pick = {
|
||||
키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])),
|
||||
폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])),
|
||||
};
|
||||
const got = await fetchComboList(to)
|
||||
.then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to))
|
||||
.catch((error: unknown) => {
|
||||
throw treeError(error);
|
||||
});
|
||||
const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null);
|
||||
return toView(took(here ? got : await fetchComboList(scope)));
|
||||
},
|
||||
},
|
||||
clipKind: "일위대가",
|
||||
copyTo: opt.copyTo,
|
||||
onPick: async (item) => {
|
||||
await opt.reveal();
|
||||
tree.select(item.key);
|
||||
void editor.open(item.key);
|
||||
},
|
||||
// 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로)
|
||||
settle: async () => {
|
||||
if (!editor.isDirty()) return "clean";
|
||||
if (!(await showConfirmDialog(tc("BackAsk")))) return false;
|
||||
editor.showList();
|
||||
return "changed";
|
||||
},
|
||||
// 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록
|
||||
onChanged: async () => {
|
||||
const open = editor.current();
|
||||
if (!open) return;
|
||||
if (keys.has(open)) await editor.open(open);
|
||||
else editor.showList();
|
||||
},
|
||||
});
|
||||
// 찾기 칸 하나(PLAN 29-6) — 나무와 조합 목록을 같은 글 · 같은 규칙으로 거름
|
||||
tree.search
|
||||
.querySelector("input")
|
||||
?.addEventListener("input", (event) =>
|
||||
editor.setQuery((event.target as HTMLInputElement).value),
|
||||
);
|
||||
return {
|
||||
root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }),
|
||||
tree: tree.root,
|
||||
add: tree.add,
|
||||
back: async () => {
|
||||
await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움
|
||||
if (editor.current() === null) return;
|
||||
if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
|
||||
editor.showList();
|
||||
},
|
||||
refresh: async () => {
|
||||
const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6)
|
||||
try {
|
||||
await tree.refresh();
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
tools: editor.newButton,
|
||||
bind: (fn) => (setDirty = fn),
|
||||
};
|
||||
}
|
||||
|
||||
export interface LaborSectionOptions {
|
||||
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
|
||||
show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
|
||||
onOpen: () => void;
|
||||
/** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */
|
||||
make: (reveal: () => Promise<void>) => LaborLibrary;
|
||||
/** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */
|
||||
bar?: (add: HTMLElement, back: () => void) => HTMLElement;
|
||||
/** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */
|
||||
wrap?: (tree: HTMLElement) => HTMLElement;
|
||||
}
|
||||
|
||||
export interface LaborSection {
|
||||
title: string;
|
||||
body: HTMLElement;
|
||||
open: () => Promise<void>;
|
||||
/** 다른 화면을 열었음 — 고름 표시만 지움 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */
|
||||
export function buildLaborSection(opt: LaborSectionOptions): LaborSection {
|
||||
let opened = false;
|
||||
const mark = (on: boolean): void => {
|
||||
opened = on;
|
||||
};
|
||||
|
||||
async function open(): Promise<void> {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
library.bind(opt.show(tc("Library"), library.root, library.tools));
|
||||
opt.onOpen();
|
||||
mark(true);
|
||||
await library.refresh();
|
||||
}
|
||||
// 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음
|
||||
const library = opt.make(open);
|
||||
void library.refresh();
|
||||
const body = el("div", {
|
||||
className: "m02-side__items ui-sidebar-list",
|
||||
children: [
|
||||
...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []),
|
||||
opt.wrap ? opt.wrap(library.tree) : library.tree,
|
||||
],
|
||||
});
|
||||
return { title: tc("Title"), body, open, clear: () => mark(false) };
|
||||
}
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
type LogicSummary,
|
||||
type SubBrief,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
getActiveLayer,
|
||||
getActiveOwner,
|
||||
type ItemLogic,
|
||||
type Layer,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */
|
||||
export interface ComboLogic {
|
||||
@@ -56,28 +62,6 @@ export interface ComboOne {
|
||||
combo: Combo;
|
||||
줄: ComboLine[];
|
||||
}
|
||||
export interface PreviewLine {
|
||||
차례: number;
|
||||
로직: string;
|
||||
이름: string;
|
||||
ok: boolean;
|
||||
까닭?: string;
|
||||
갈래?: string;
|
||||
결과?: number;
|
||||
노무비?: number;
|
||||
재료비?: number;
|
||||
경비?: number;
|
||||
계?: number;
|
||||
}
|
||||
export interface PreviewAnswer {
|
||||
줄: PreviewLine[];
|
||||
노무비: number;
|
||||
재료비: number;
|
||||
경비: number;
|
||||
계: number;
|
||||
멈춤: string[];
|
||||
}
|
||||
|
||||
/** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */
|
||||
export interface LaborScope {
|
||||
layer: Exclude<Layer, "project">;
|
||||
@@ -227,13 +211,6 @@ export const pasteCombos = (
|
||||
판,
|
||||
});
|
||||
|
||||
/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */
|
||||
export const previewCombo = (
|
||||
combo: Combo | string,
|
||||
inputs: Record<string, Record<string, unknown>>,
|
||||
scope: LaborScope = SYSTEM_SCOPE,
|
||||
): Promise<PreviewAnswer> => request(at(scope, "/preview"), "POST", { combo, inputs });
|
||||
|
||||
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
|
||||
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
|
||||
request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics);
|
||||
@@ -248,3 +225,117 @@ export const fetchLibAuto = (key: string): Promise<Record<string, unknown>> =>
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic-lib/auto?key=${enc(key)}`)
|
||||
.then((d) => d.값 ?? {})
|
||||
.catch(() => ({}));
|
||||
|
||||
/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */
|
||||
|
||||
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
|
||||
export type LaborQty = { 손: number } | null;
|
||||
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */
|
||||
export interface LaborRow {
|
||||
로직: string;
|
||||
메모: string | null;
|
||||
입력값: Record<string, string | number>;
|
||||
수량: LaborQty;
|
||||
}
|
||||
export interface LaborMat {
|
||||
코드: string;
|
||||
이름: string | null;
|
||||
규격: string | null;
|
||||
단위: string | null;
|
||||
수량: LaborQty;
|
||||
}
|
||||
export interface LaborDoc {
|
||||
종류: "일위대가";
|
||||
판?: number;
|
||||
열: string;
|
||||
일위대가번?: string;
|
||||
단위: string | null;
|
||||
본뜬?: { 층: string; 주인: string | null; 키: string } | null;
|
||||
담은로직: LaborRow[];
|
||||
자재: LaborMat[];
|
||||
계산값?: unknown;
|
||||
}
|
||||
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
|
||||
export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
const d = (doc && typeof doc === "object" ? doc : {}) as Partial<LaborDoc>;
|
||||
return {
|
||||
...d,
|
||||
종류: "일위대가",
|
||||
열: d.열 ?? column,
|
||||
단위: d.단위 ?? null,
|
||||
담은로직: (d.담은로직 ?? []).map((r) => ({
|
||||
로직: r.로직,
|
||||
메모: r.메모 ?? null,
|
||||
입력값: r.입력값 ?? {},
|
||||
수량: r.수량 ?? null,
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
이름: m.이름 ?? null,
|
||||
규격: m.규격 ?? null,
|
||||
단위: m.단위 ?? null,
|
||||
수량: m.수량 ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 면적산출 문서의 알약 → {칸, 값}(식 칸 = 서버 `계산값` · 그 밖 = 칸 값 · 수만) — 서버 `area_values` 와 같은 규칙 */
|
||||
export function areaLinks(area: unknown): Map<string, { 칸: string; 값: number | null }> {
|
||||
const out = new Map<string, { 칸: string; 값: number | null }>();
|
||||
const doc = (area && typeof area === "object" ? area : {}) as {
|
||||
시트?: {
|
||||
id?: string;
|
||||
칸?: Record<string, { 값?: unknown }>;
|
||||
연결?: { 키?: string; 칸?: string }[];
|
||||
}[];
|
||||
계산값?: Record<string, Record<string, { 수?: unknown }>>;
|
||||
};
|
||||
for (const sheet of doc.시트 ?? []) {
|
||||
const solved = doc.계산값?.[sheet.id ?? ""] ?? {};
|
||||
for (const link of sheet.연결 ?? []) {
|
||||
if (!link.키 || !link.칸) continue;
|
||||
const raw = solved[link.칸]?.수 ?? sheet.칸?.[link.칸]?.값;
|
||||
const n =
|
||||
raw === null || raw === undefined || raw === ""
|
||||
? NaN
|
||||
: Number(String(raw).replace(/,/g, ""));
|
||||
out.set(link.키, { 칸: link.칸, 값: Number.isFinite(n) ? n : null });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** calc 답 모양 · 수량 · 반영 짝 셈 = `M02_MasterTemplete_Labor_Calc.ts`(sub7 · ↔ `M02_Labor_Doc.py`) */
|
||||
export type {
|
||||
CalcLine as LaborCalcLine,
|
||||
CalcMaterial as LaborCalcMat,
|
||||
LaborCalc,
|
||||
Money as LaborCost,
|
||||
} from "./M02_MasterTemplete_Labor_Calc";
|
||||
|
||||
/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */
|
||||
const laborAt = (layer: Layer, column: string, tail: string): string => {
|
||||
const owner = layer === getActiveLayer() ? getActiveOwner() : null;
|
||||
const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : "";
|
||||
return `/layers/${layer}/labor/${enc(column)}${tail}${q}`;
|
||||
};
|
||||
|
||||
/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = {알약 키: 칸 값} 바꿔 끼우기 */
|
||||
export const calcLabor = (
|
||||
layer: Layer,
|
||||
column: string,
|
||||
doc: LaborDoc | null,
|
||||
수량?: Record<string, number>,
|
||||
): Promise<LaborCalc & { 알약: unknown[] }> =>
|
||||
request(laborAt(layer, column, "/calc"), "POST", {
|
||||
...(doc ? { doc } : {}),
|
||||
...(수량 ? { 수량 } : {}),
|
||||
});
|
||||
|
||||
/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */
|
||||
export const fetchLogicInputs = (
|
||||
keys: string[],
|
||||
): Promise<{ 로직: ItemLogic[]; 빠진로직: string[] }> =>
|
||||
keys.length
|
||||
? request(`/logic-lib/inputs?keys=${enc(keys.join(","))}`)
|
||||
: Promise.resolve({ 로직: [], 빠진로직: [] });
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Detail.ts
|
||||
* 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임
|
||||
* (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램.
|
||||
* · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장]
|
||||
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
||||
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
||||
import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import {
|
||||
areaLinks,
|
||||
calcLabor,
|
||||
fetchLogicInputs,
|
||||
normalizeLabor,
|
||||
type LaborCalc,
|
||||
type LaborDoc,
|
||||
type LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import {
|
||||
buildLaborDiagram,
|
||||
diagramTitle,
|
||||
renderComboDiagram,
|
||||
} from "./M02_MasterTemplete_Labor_Diagram";
|
||||
import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { keepKnown } from "./M02_Labor_Values_Model";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
|
||||
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
export interface LaborDetailOptions {
|
||||
layer: Layer;
|
||||
column: string;
|
||||
readOnly: boolean;
|
||||
/** 저장 안 한 고침이 생기거나 사라짐 */
|
||||
onChanged: (dirty: boolean) => void;
|
||||
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */
|
||||
goArea: (() => void) | null;
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
getDoc: () => LaborDoc;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function mountLaborDetail(
|
||||
host: HTMLElement,
|
||||
doc: unknown,
|
||||
opt: LaborDetailOptions,
|
||||
): LaborDetailHandle {
|
||||
const cur = normalizeLabor(doc, opt.column);
|
||||
const saved = JSON.stringify(cur);
|
||||
const edit = !opt.readOnly;
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let calc: LaborCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
let alive = true;
|
||||
/** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */
|
||||
let links = new Map<string, { 칸: string; 값: number | null }>();
|
||||
const cells = (): Map<string, string> => new Map([...links].map(([k, v]) => [k, v.칸]));
|
||||
const given = (): Record<string, number> =>
|
||||
Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]])));
|
||||
|
||||
const logicHost = el("div");
|
||||
const matHost = el("div");
|
||||
const dgHost = el("div");
|
||||
const dgBox = el("details", {
|
||||
className: "m01lab__box m01-logic__section m02-labor__dg",
|
||||
attrs: { open: "" },
|
||||
children: [el("summary", { text: diagramTitle() }), dgHost],
|
||||
});
|
||||
|
||||
const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key);
|
||||
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
|
||||
|
||||
const drawDg = (): void => {
|
||||
if (!dgBox.open) return;
|
||||
const data = buildLaborDiagram({
|
||||
rows: cur.담은로직,
|
||||
logics: [...logics.values()],
|
||||
calc,
|
||||
mats: cur.자재.length,
|
||||
});
|
||||
renderComboDiagram(dgHost, data, {
|
||||
focusInput: (key) => {
|
||||
const i = cur.담은로직.findIndex((r) => r.로직 === key);
|
||||
if (i >= 0) openRow(i);
|
||||
},
|
||||
// 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard)
|
||||
openLogic: (key) => {
|
||||
setPendingLogicKey(key);
|
||||
navigateTo(ROUTES.M01_MASTER_DATA);
|
||||
},
|
||||
});
|
||||
};
|
||||
dgBox.addEventListener("toggle", drawDg);
|
||||
|
||||
const draw = (): void => {
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
|
||||
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
|
||||
edit ? { open: openRow, move, remove: removeRow } : { open: openRow },
|
||||
),
|
||||
);
|
||||
matHost.replaceChildren(
|
||||
matTable(
|
||||
{ mats: cur.자재, calc, fail, cells: cells() },
|
||||
edit ? { open: openMat, remove: removeMat } : { open: openMat },
|
||||
),
|
||||
);
|
||||
drawDg();
|
||||
};
|
||||
|
||||
/** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */
|
||||
const recalc = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(async () => {
|
||||
const mine = ++seq;
|
||||
calc = null;
|
||||
fail = null;
|
||||
draw();
|
||||
try {
|
||||
const got = await calcLabor(opt.layer, opt.column, cur);
|
||||
if (!alive || mine !== seq) return;
|
||||
calc = got;
|
||||
} catch (error) {
|
||||
if (!alive || mine !== seq) return;
|
||||
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
|
||||
}
|
||||
draw();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
/** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */
|
||||
const changed = (qtyOnly = false): void => {
|
||||
opt.onChanged(JSON.stringify(cur) !== saved);
|
||||
if (qtyOnly && calc) {
|
||||
calc = settle(calc, cur, given());
|
||||
draw();
|
||||
} else recalc();
|
||||
};
|
||||
|
||||
/** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */
|
||||
const linkedText = (pill: string | null | undefined, unit: string): string | null => {
|
||||
const at = pill ? links.get(pill) : undefined;
|
||||
if (!at) return null;
|
||||
return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`;
|
||||
};
|
||||
|
||||
/** 아직 안 받은 로직 입력 목록만 받음 */
|
||||
const loadLogics = async (): Promise<void> => {
|
||||
const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
|
||||
if (!want.length) return;
|
||||
try {
|
||||
const got = await fetchLogicInputs(want);
|
||||
for (const l of got.로직) logics.set(l.로직, l);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
function move(i: number, d: number): void {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= cur.담은로직.length) return;
|
||||
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
|
||||
changed();
|
||||
}
|
||||
function removeRow(i: number): void {
|
||||
cur.담은로직.splice(i, 1);
|
||||
changed();
|
||||
}
|
||||
function removeMat(i: number): void {
|
||||
cur.자재.splice(i, 1);
|
||||
changed();
|
||||
}
|
||||
|
||||
function openRow(i: number): void {
|
||||
const row: LaborRow = cur.담은로직[i];
|
||||
const logic = logics.get(row.로직) ?? null;
|
||||
const line = lineOf(row.로직);
|
||||
openLaborInput({
|
||||
title: `${row.로직} ${logic?.이름 ?? ""}`.trim(),
|
||||
logic,
|
||||
values: row.입력값,
|
||||
memo: row.메모,
|
||||
seat: {
|
||||
qty: row.수량,
|
||||
linked: linkedText(line?.알약, line?.분모 ?? ""),
|
||||
unit: line?.분모 ?? "",
|
||||
},
|
||||
readOnly: !edit,
|
||||
goArea: opt.goArea,
|
||||
done: (next) => {
|
||||
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
|
||||
const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값);
|
||||
row.입력값 = values;
|
||||
row.메모 = next.메모;
|
||||
row.수량 = next.수량;
|
||||
changed(qtyOnly);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openMat(i: number): void {
|
||||
const mat = cur.자재[i];
|
||||
const line = matOf(mat.코드);
|
||||
openQtyModal(
|
||||
`${mat.코드} ${mat.이름 ?? ""}`.trim(),
|
||||
{
|
||||
qty: mat.수량,
|
||||
linked: linkedText(line?.알약, mat.단위 ?? ""),
|
||||
unit: mat.단위 ?? "",
|
||||
},
|
||||
!edit,
|
||||
opt.goArea,
|
||||
(qty) => {
|
||||
mat.수량 = qty;
|
||||
changed(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const addLogic = createButton({
|
||||
label: tc("AddLogic"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) => {
|
||||
if (cur.담은로직.some((r) => r.로직 === x.키))
|
||||
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
|
||||
cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null });
|
||||
void loadLogics().then(() => changed());
|
||||
}),
|
||||
});
|
||||
const addMat = createButton({
|
||||
label: tc("Labor_AddMat"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
pickMaterial((m) => {
|
||||
if (cur.자재.some((x) => x.코드 === m.코드))
|
||||
return showToast(
|
||||
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
|
||||
"error",
|
||||
);
|
||||
cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null });
|
||||
changed();
|
||||
}),
|
||||
});
|
||||
|
||||
const unit = createInputField({ type: "text", label: tc("Labor_Unit") });
|
||||
unit.input.value = cur.단위 ?? "";
|
||||
unit.input.readOnly = !edit;
|
||||
unit.input.maxLength = 20;
|
||||
unit.input.setAttribute("data-labor-field", "단위");
|
||||
unit.input.addEventListener("input", () => {
|
||||
cur.단위 = unit.input.value.trim() || null;
|
||||
opt.onChanged(JSON.stringify(cur) !== saved);
|
||||
});
|
||||
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
|
||||
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
|
||||
origin.input.readOnly = true;
|
||||
|
||||
const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [el("h3", { text: title }), ...(edit && button ? [button] : [])],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
});
|
||||
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-logic__editor m01-combo m02-labor__edit",
|
||||
attrs: { "data-labor": opt.column },
|
||||
children: [
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })],
|
||||
}),
|
||||
section(tc("Logics_Title"), addLogic, logicHost),
|
||||
section(tc("Labor_Mats"), addMat, matHost),
|
||||
dgBox,
|
||||
],
|
||||
}),
|
||||
);
|
||||
draw();
|
||||
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
|
||||
void Promise.all([
|
||||
loadLogics(),
|
||||
readTemplate(opt.layer, "area", opt.column, null).then(
|
||||
(got) => (links = areaLinks(got.문서)),
|
||||
() => undefined,
|
||||
),
|
||||
]).then(() => {
|
||||
if (alive) recalc();
|
||||
});
|
||||
|
||||
return {
|
||||
getDoc: () => cur,
|
||||
destroy: () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
host.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -309,3 +309,13 @@
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 39-3 — 자동값 알약(넣은 값 없음 · 기울임) · 자재목록 카드(누름 없음) */
|
||||
.m02-labordg__pill.is-auto .m02-labordg__pill-value {
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-labordg__card.is-material {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Diagram.ts
|
||||
* 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값)
|
||||
* 조합은 계산하지 않음 — 미리 보기 결과를 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색)
|
||||
* 값은 미리 보기와 한 벌(`buildDiagramData` 로 미리 보기가 넘김) · 글 [한, 영] 짝은 아래 TEXT
|
||||
* 다이어그램은 계산하지 않음 — 일위대가 calc 답을 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색)
|
||||
* 값 = 저장된 줄 입력값 + calc 답(수량 있으면 반영 · 없으면 기준 · 카드에 표기 · `buildLaborDiagram` · 39-3) ·
|
||||
* 자재목록은 카드 하나로 재료비 칸에 이음 · 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -29,7 +30,9 @@ const TEXT = {
|
||||
"누르면 이 로직을 단가산출 로직 화면에서 엶",
|
||||
"Open this logic in the unit-price logic screen",
|
||||
],
|
||||
Goto: ["미리 보기 입력 칸으로 감", "Go to this input in the preview"],
|
||||
Goto: ["누르면 이 로직 입력 모달을 엶", "Open this logic's inputs"],
|
||||
Mats: ["자재목록", "Materials"],
|
||||
Auto: ["자동값", "auto"],
|
||||
Same: ["같은 이름의 넣을 값", "Same input name"],
|
||||
Won: ["원", "KRW"],
|
||||
Title: ["다이어그램", "Diagram"],
|
||||
@@ -46,8 +49,10 @@ type Cost = (typeof COST_BINS)[number];
|
||||
export interface DiagramInput {
|
||||
name: string;
|
||||
unit: string;
|
||||
/** 미리 보기에 넣은 값 — 빈 글 = 값 없음 */
|
||||
/** 넣은 값 — 빈 글 = 값 없음 */
|
||||
value: string;
|
||||
/** 넣은 값이 없어 자동값을 보임 */
|
||||
auto?: boolean;
|
||||
}
|
||||
|
||||
/** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */
|
||||
@@ -64,6 +69,10 @@ export interface DiagramLogic {
|
||||
unit: string;
|
||||
inputs: DiagramInput[];
|
||||
out: DiagramOut;
|
||||
/** 금액이 어느 값인지 — 「기준」 · 「반영」(없으면 안 보임) */
|
||||
basis?: string;
|
||||
/** 자재목록 카드 — 누름 없음 */
|
||||
material?: boolean;
|
||||
}
|
||||
|
||||
export interface DiagramData {
|
||||
@@ -73,53 +82,86 @@ export interface DiagramData {
|
||||
export interface DiagramHandlers {
|
||||
/** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */
|
||||
openLogic(key: string): void;
|
||||
/** 넣을 값 알약 누름 — 미리 보기 그 입력 칸으로 */
|
||||
/** 넣을 값 알약 누름 — 그 로직 입력 모달 */
|
||||
focusInput(key: string, name: string): void;
|
||||
}
|
||||
|
||||
/* ── 값 만들기 (미리 보기가 부름 · DOM 없음) ──────────────────────────────── */
|
||||
/* ── 값 만들기 (일위대가 상세가 부름 · DOM 없음 · 39-3) ───────────────────── */
|
||||
|
||||
interface PreviewLineLike {
|
||||
ok: boolean;
|
||||
까닭?: string;
|
||||
갈래?: string;
|
||||
결과?: number;
|
||||
노무비?: number;
|
||||
재료비?: number;
|
||||
경비?: number;
|
||||
계?: number;
|
||||
}
|
||||
type Money = { 노무비: number; 재료비: number; 경비: number; 계: number };
|
||||
|
||||
export interface DiagramSource {
|
||||
export interface LaborDiagramSource {
|
||||
/** 담은 차례대로 */
|
||||
rows: { key: string; name: string; unit: string; inputs: { name: string; unit: string }[] }[];
|
||||
/** 로직 키 → 입력 이름 → 넣은 값 */
|
||||
values: Record<string, Record<string, string>>;
|
||||
/** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 null */
|
||||
lines: PreviewLineLike[] | null;
|
||||
rows: { 로직: string; 입력값: Record<string, string | number> }[];
|
||||
/** 로직 입력 목록(`/logic-lib/inputs`) */
|
||||
logics: {
|
||||
로직: string;
|
||||
이름: string;
|
||||
결과단위?: string;
|
||||
입력: { 이름: string; 단위?: string; 자동값?: unknown }[];
|
||||
}[];
|
||||
/** calc 답 · 아직이면 null */
|
||||
calc: {
|
||||
줄: {
|
||||
로직: string;
|
||||
기준: Money | null;
|
||||
반영?: Money | null;
|
||||
결과?: unknown;
|
||||
까닭?: string | null;
|
||||
}[];
|
||||
자재: { 기준단가: number | null; 반영?: number | null }[];
|
||||
} | null;
|
||||
/** 담은 자재 수 — 0 이면 자재 카드 없음 */
|
||||
mats: number;
|
||||
}
|
||||
|
||||
const outOf = (l: PreviewLineLike | undefined): DiagramOut => {
|
||||
if (!l) return null;
|
||||
if (!l.ok) return { 멈춤: l.까닭 ?? "" };
|
||||
if (l.갈래 === "결과") return { 값: l.결과 ?? 0 };
|
||||
return { 노무비: l.노무비 ?? 0, 재료비: l.재료비 ?? 0, 경비: l.경비 ?? 0, 계: l.계 ?? 0 };
|
||||
};
|
||||
const autoOf = (v: unknown): string =>
|
||||
typeof v === "string" || typeof v === "number" ? String(v) : "";
|
||||
|
||||
export function buildDiagramData(src: DiagramSource): DiagramData {
|
||||
return {
|
||||
logics: src.rows.map((r, i) => ({
|
||||
key: r.key,
|
||||
name: r.name,
|
||||
unit: r.unit,
|
||||
inputs: r.inputs.map((x) => ({
|
||||
name: x.name,
|
||||
unit: x.unit,
|
||||
value: src.values[r.key]?.[x.name] ?? "",
|
||||
})),
|
||||
out: outOf(src.lines?.[i]),
|
||||
})),
|
||||
};
|
||||
export function buildLaborDiagram(src: LaborDiagramSource): DiagramData {
|
||||
const logics: DiagramLogic[] = src.rows.map((r) => {
|
||||
const def = src.logics.find((l) => l.로직 === r.로직);
|
||||
const line = src.calc?.줄.find((l) => l.로직 === r.로직);
|
||||
let out: DiagramOut = null;
|
||||
let basis: string | undefined;
|
||||
if (line?.까닭) out = { 멈춤: line.까닭 };
|
||||
else if (line && !line.기준 && typeof line.결과 === "number") out = { 값: line.결과 };
|
||||
else if (line && (line.반영 || line.기준)) {
|
||||
out = { ...((line.반영 ?? line.기준) as Money) };
|
||||
basis = line.반영 ? "반영" : "기준";
|
||||
}
|
||||
return {
|
||||
key: r.로직,
|
||||
name: def?.이름 ?? r.로직,
|
||||
unit: def?.결과단위 ?? "",
|
||||
inputs: (def?.입력 ?? []).map((x) => {
|
||||
const mine = r.입력값[x.이름];
|
||||
const has = mine !== undefined && mine !== "";
|
||||
return {
|
||||
name: x.이름,
|
||||
unit: x.단위 ?? "",
|
||||
value: has ? String(mine) : autoOf(x.자동값),
|
||||
...(has || !autoOf(x.자동값) ? {} : { auto: true }),
|
||||
};
|
||||
}),
|
||||
out,
|
||||
...(basis ? { basis } : {}),
|
||||
};
|
||||
});
|
||||
if (src.mats > 0) {
|
||||
const mats = src.calc?.자재 ?? null;
|
||||
const sum = mats?.reduce((t, m) => t + (m.반영 ?? m.기준단가 ?? 0), 0) ?? 0;
|
||||
logics.push({
|
||||
key: "",
|
||||
name: `${td("Mats")} ${src.mats}`,
|
||||
unit: "",
|
||||
inputs: [],
|
||||
out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null,
|
||||
...(mats ? { basis: mats.every((m) => typeof m.반영 === "number") ? "반영" : "기준" } : {}),
|
||||
material: true,
|
||||
});
|
||||
}
|
||||
return { logics };
|
||||
}
|
||||
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
@@ -134,7 +176,7 @@ const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o;
|
||||
const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0);
|
||||
|
||||
function summary(l: DiagramLogic): string {
|
||||
if (isMoney(l.out)) return `${td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`;
|
||||
if (isMoney(l.out)) return `${l.basis ?? td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`;
|
||||
if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim();
|
||||
if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`;
|
||||
return td("Wait");
|
||||
@@ -360,8 +402,12 @@ export function renderComboDiagram(
|
||||
? l.inputs.map((x) => {
|
||||
const empty = x.value.trim() === "";
|
||||
const pill = el("button", {
|
||||
className: `m02-labordg__pill${empty ? " is-empty" : ""}`,
|
||||
attrs: { type: "button", "data-name": x.name, title: td("Goto") },
|
||||
className: `m02-labordg__pill${empty ? " is-empty" : ""}${x.auto ? " is-auto" : ""}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-name": x.name,
|
||||
title: x.auto ? `${td("Auto")} · ${td("Goto")}` : td("Goto"),
|
||||
},
|
||||
children: [
|
||||
el("span", { className: "m02-labordg__pill-name", text: x.name }),
|
||||
el("span", {
|
||||
@@ -376,8 +422,12 @@ export function renderComboDiagram(
|
||||
: [],
|
||||
});
|
||||
const card = el("button", {
|
||||
className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}`,
|
||||
attrs: { type: "button", style: `grid-column:3;grid-row:${row}`, title: td("Open") },
|
||||
className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}${l.material ? " is-material" : ""}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
style: `grid-column:3;grid-row:${row}`,
|
||||
...(l.material ? {} : { title: td("Open") }),
|
||||
},
|
||||
children: [
|
||||
el("span", { className: "m02-labordg__card-key", text: l.key }),
|
||||
el("strong", { className: "m02-labordg__card-name", text: l.name }),
|
||||
@@ -385,7 +435,7 @@ export function renderComboDiagram(
|
||||
el("span", { className: "m02-labordg__card-out", text: summary(l) }),
|
||||
],
|
||||
});
|
||||
card.addEventListener("click", () => on.openLogic(l.key));
|
||||
if (!l.material) card.addEventListener("click", () => on.openLogic(l.key));
|
||||
const mine = bins.map((b, c) => {
|
||||
const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b);
|
||||
sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Edit.ts
|
||||
* 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표
|
||||
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b).
|
||||
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 ·
|
||||
* 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감.
|
||||
* 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -12,10 +13,7 @@ import {
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
|
||||
import {
|
||||
deleteCombo,
|
||||
editCombo,
|
||||
@@ -26,9 +24,7 @@ import {
|
||||
type ComboSummary,
|
||||
type LaborScope,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { diagramTitle, renderComboDiagram } from "./M02_MasterTemplete_Labor_Diagram";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { buildPreview } from "./M02_MasterTemplete_Labor_Preview";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { matchText } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
@@ -215,31 +211,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
});
|
||||
return f.root;
|
||||
};
|
||||
const preview = buildPreview(() => combo);
|
||||
// 다이어그램 — 미리 보기와 값 한 벌(넣은 값 · 결과) · 보기만 · 로직 카드 누르면 단가산출 로직 화면
|
||||
const dgHost = el("div");
|
||||
const dgBox = el("details", {
|
||||
className: "m01lab__box m01-logic__section m02-labor__dg",
|
||||
attrs: { open: "" },
|
||||
children: [el("summary", { text: diagramTitle() }), dgHost],
|
||||
});
|
||||
const drawDg = (): void => {
|
||||
if (dgBox.open)
|
||||
renderComboDiagram(dgHost, preview.data(), {
|
||||
focusInput: preview.focusInput,
|
||||
openLogic: async (logic) => {
|
||||
if (dirty() && !(await showConfirmDialog(tc("LeaveAsk")))) return;
|
||||
setPendingLogicKey(logic);
|
||||
navigateTo(ROUTES.M01_MASTER_DATA);
|
||||
},
|
||||
});
|
||||
};
|
||||
let dgTimer = 0;
|
||||
preview.onUpdate(() => {
|
||||
window.clearTimeout(dgTimer);
|
||||
dgTimer = window.setTimeout(drawDg, 120);
|
||||
});
|
||||
dgBox.addEventListener("toggle", drawDg);
|
||||
const table = el("div", { className: "m01-master__grid-wrap" });
|
||||
const drawTable = (): void => {
|
||||
const move = (i: number, d: number): void => {
|
||||
@@ -306,7 +277,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
}),
|
||||
...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]),
|
||||
);
|
||||
void preview.redraw();
|
||||
};
|
||||
|
||||
const add = createButton({
|
||||
@@ -409,8 +379,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
|
||||
table,
|
||||
],
|
||||
}),
|
||||
dgBox,
|
||||
preview.root,
|
||||
);
|
||||
drawTable();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Input.ts
|
||||
* 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량
|
||||
* (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`).
|
||||
* [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborQty } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { createValueControl } from "./M02_Labor_Values";
|
||||
import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model";
|
||||
import "./M02_Labor_Values.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
const fill = (text: string, value: string): string => text.replace("{value}", value);
|
||||
|
||||
export interface QtySeat {
|
||||
/** 지금 수량 */
|
||||
qty: LaborQty;
|
||||
/** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */
|
||||
linked: string | null;
|
||||
/** 손 입력 단위 표기(분모 · 자재 단위) */
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface QtyPart {
|
||||
root: HTMLElement;
|
||||
/** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */
|
||||
read: () => LaborQty | undefined;
|
||||
}
|
||||
|
||||
/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */
|
||||
function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart {
|
||||
const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`;
|
||||
const radio = (value: "cell" | "hand"): HTMLInputElement => {
|
||||
const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement;
|
||||
input.disabled = readOnly;
|
||||
return input;
|
||||
};
|
||||
const cell = radio("cell");
|
||||
const hand = radio("hand");
|
||||
const number = el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: { type: "number", step: "any", "data-labor-qty": "hand" },
|
||||
}) as HTMLInputElement;
|
||||
number.readOnly = readOnly;
|
||||
if (seat.qty) number.value = String(seat.qty.손);
|
||||
(seat.qty ? hand : cell).checked = true;
|
||||
number.disabled = !hand.checked;
|
||||
for (const r of [cell, hand])
|
||||
r.addEventListener("change", () => {
|
||||
number.disabled = !hand.checked;
|
||||
if (hand.checked) number.focus();
|
||||
});
|
||||
const go = goArea
|
||||
? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea })
|
||||
: null;
|
||||
const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement =>
|
||||
el("label", {
|
||||
className: "m02-labin__qty-line",
|
||||
children: [input, el("span", { text: label }), ...rest],
|
||||
});
|
||||
const root = el("section", {
|
||||
className: "m02-labin__qty",
|
||||
children: [
|
||||
el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }),
|
||||
line(
|
||||
cell,
|
||||
tc("In_QtyCell"),
|
||||
el("small", {
|
||||
className: "m02-labin__muted",
|
||||
text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"),
|
||||
}),
|
||||
...(go ? [go] : []),
|
||||
),
|
||||
line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })),
|
||||
],
|
||||
});
|
||||
return {
|
||||
root,
|
||||
read: () => {
|
||||
if (!hand.checked) return null;
|
||||
const v = Number(number.value);
|
||||
return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement {
|
||||
return el("div", {
|
||||
className: "m02-labin__foot",
|
||||
children: readOnly
|
||||
? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })]
|
||||
: [
|
||||
createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }),
|
||||
createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export interface LaborInputResult {
|
||||
입력값: LaborValues;
|
||||
메모: string | null;
|
||||
수량: LaborQty;
|
||||
}
|
||||
|
||||
export interface LaborInputOptions {
|
||||
/** 「GF000219 메쌓기」 */
|
||||
title: string;
|
||||
/** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */
|
||||
logic: ItemLogic | null;
|
||||
values: LaborValues;
|
||||
memo: string | null;
|
||||
seat: QtySeat;
|
||||
readOnly: boolean;
|
||||
/** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */
|
||||
goArea: (() => void) | null;
|
||||
done: (next: LaborInputResult) => void;
|
||||
}
|
||||
|
||||
/** 담은 로직 줄 입력 모달 */
|
||||
export function openLaborInput(opt: LaborInputOptions): void {
|
||||
const inputs: ItemInput[] = opt.logic?.입력 ?? [];
|
||||
const { kept, lost } = keepKnown(inputs, opt.values);
|
||||
const draft: LaborValues = { ...kept };
|
||||
const bad = new Set<string>();
|
||||
openModal({
|
||||
title: fill(tc("In_Title"), opt.title),
|
||||
closeLabel: tc("In_Close"),
|
||||
dialogClass: "m02-modal m02-labin",
|
||||
mount: (body, close) => {
|
||||
const rows = inputs.map((input) => {
|
||||
const field = createValueControl(input, draft[input.이름] ?? null, (value, isBad) => {
|
||||
if (isBad) bad.add(input.이름);
|
||||
else bad.delete(input.이름);
|
||||
if (value === null) delete draft[input.이름];
|
||||
// 고르기 · 범위 없는 칸도 수로 읽히면 수로 둠(M01 넣을 값과 같게)
|
||||
else
|
||||
draft[input.이름] =
|
||||
typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))
|
||||
? Number(value)
|
||||
: value;
|
||||
});
|
||||
field.control.disabled = opt.readOnly;
|
||||
field.control.setAttribute("data-labor-input", input.이름);
|
||||
const hint = valueHint(input);
|
||||
return el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-inputval__name",
|
||||
text: input.단위 ? `${input.이름} (${input.단위})` : input.이름,
|
||||
attrs: { title: [input.설명, hint].filter(Boolean).join(" · ") },
|
||||
}),
|
||||
el("div", { className: "m02-inputval__cell", children: [field.control, field.error] }),
|
||||
],
|
||||
});
|
||||
});
|
||||
const memo = el("input", {
|
||||
className: "ui-input m02-inputval__control",
|
||||
attrs: { type: "text", maxlength: "200", "data-labor-memo": "" },
|
||||
}) as HTMLInputElement;
|
||||
memo.value = opt.memo ?? "";
|
||||
memo.readOnly = opt.readOnly;
|
||||
const qty = qtyPart(
|
||||
opt.seat,
|
||||
opt.readOnly,
|
||||
opt.goArea &&
|
||||
(() => {
|
||||
close();
|
||||
opt.goArea?.();
|
||||
}),
|
||||
);
|
||||
const ok = (): void => {
|
||||
if (bad.size) return showToast(tc("In_Bad"), "error");
|
||||
const 수량 = qty.read();
|
||||
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
|
||||
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 });
|
||||
close();
|
||||
};
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "m02-inputval",
|
||||
children: [
|
||||
...(lost.length
|
||||
? [
|
||||
el("p", {
|
||||
className: "m02-labin__lost",
|
||||
text: fill(tc("Labor_Lost"), lost.join(", ")),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(rows.length
|
||||
? rows
|
||||
: [el("p", { className: "m02-labin__muted", text: tc("In_None") })]),
|
||||
el("div", {
|
||||
className: "m02-inputval__row",
|
||||
children: [
|
||||
el("span", { className: "m02-inputval__name", text: tc("In_Memo") }),
|
||||
memo,
|
||||
],
|
||||
}),
|
||||
qty.root,
|
||||
],
|
||||
}),
|
||||
footer(opt.readOnly, ok, close),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 자재 줄 수량 모달 */
|
||||
export function openQtyModal(
|
||||
title: string,
|
||||
seat: QtySeat,
|
||||
readOnly: boolean,
|
||||
goArea: (() => void) | null,
|
||||
done: (qty: LaborQty) => void,
|
||||
): void {
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: tc("In_Close"),
|
||||
dialogClass: "m02-modal m02-labin",
|
||||
mount: (body, close) => {
|
||||
const qty = qtyPart(
|
||||
seat,
|
||||
readOnly,
|
||||
goArea &&
|
||||
(() => {
|
||||
close();
|
||||
goArea();
|
||||
}),
|
||||
);
|
||||
const ok = (): void => {
|
||||
const 수량 = qty.read();
|
||||
if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error");
|
||||
done(수량);
|
||||
close();
|
||||
};
|
||||
body.append(qty.root, footer(readOnly, ok, close));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Preview.ts
|
||||
* 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음
|
||||
* 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import {
|
||||
fetchLibAuto as fetchAuto,
|
||||
fetchLibLogic as fetchLogic,
|
||||
previewCombo,
|
||||
type Combo,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram";
|
||||
import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
|
||||
노무비: "Prev_Labor",
|
||||
재료비: "Prev_Material",
|
||||
경비: "Prev_Expense",
|
||||
};
|
||||
|
||||
const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v);
|
||||
/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */
|
||||
const fmt = formatMoney;
|
||||
|
||||
/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */
|
||||
const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? "");
|
||||
|
||||
/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */
|
||||
function inputCell(
|
||||
input: LogicInput,
|
||||
key: string,
|
||||
values: Record<string, Record<string, string>>,
|
||||
onEdit: () => void,
|
||||
onInput: () => void,
|
||||
): HTMLElement {
|
||||
const mine = (values[key] ??= {});
|
||||
const value = mine[input.이름] ?? initial(input);
|
||||
mine[input.이름] = value;
|
||||
const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) }));
|
||||
const field = choices
|
||||
? createSelectField({ options: choices, value, compact: true, label: input.이름 })
|
||||
: createInputField({ type: "text", label: input.이름, compact: true });
|
||||
const control = "select" in field ? field.select : field.input;
|
||||
control.value = value;
|
||||
control.setAttribute("data-input", `${key}|${input.이름}`);
|
||||
const edit = (): void => {
|
||||
mine[input.이름] = control.value;
|
||||
onInput();
|
||||
};
|
||||
control.addEventListener("input", edit);
|
||||
control.addEventListener("change", () => {
|
||||
edit();
|
||||
onEdit(); // 바꾸면 다시 미리 보기 계산
|
||||
});
|
||||
return field.root;
|
||||
}
|
||||
|
||||
export interface PreviewHandle {
|
||||
root: HTMLElement;
|
||||
/** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */
|
||||
redraw: () => Promise<void>;
|
||||
/** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */
|
||||
data: () => DiagramData;
|
||||
/** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */
|
||||
onUpdate: (fn: () => void) => () => void;
|
||||
/** 그 입력 칸으로 가서 깜빡임 */
|
||||
focusInput: (key: string, name: string) => void;
|
||||
}
|
||||
|
||||
export function buildPreview(combo: () => Combo): PreviewHandle {
|
||||
const values: Record<string, Record<string, string>> = {};
|
||||
/** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */
|
||||
const inputs = new Map<string, LogicInput[]>();
|
||||
/** 로직마다 자동값은 한 번만 받음 */
|
||||
const asked = new Set<string>();
|
||||
/** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */
|
||||
const meta = new Map<string, { name: string; unit: string }>();
|
||||
let lines: Parameters<typeof buildDiagramData>[0]["lines"] = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = (): void => listeners.forEach((f) => f());
|
||||
const form = el("div", { className: "m01-logic__stack" });
|
||||
const out = el("div", {
|
||||
className: "m01-master__grid-wrap",
|
||||
attrs: { "data-combo": "preview" },
|
||||
});
|
||||
const rowOf = (cells: string[], note = "", bold = false): HTMLElement =>
|
||||
el("tr", {
|
||||
className: bold ? "m01lab__subtotal" : "",
|
||||
children: cells.map((t, i) => {
|
||||
const text = i === 0 && note ? `${t} — ${note}` : t;
|
||||
return el("td", { text, attrs: { title: text } });
|
||||
}),
|
||||
});
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" }));
|
||||
lines = null;
|
||||
notify();
|
||||
const send: Record<string, Record<string, unknown>> = {};
|
||||
for (const [key, defs] of inputs) {
|
||||
send[key] = {};
|
||||
for (const i of defs) {
|
||||
const raw = values[key]?.[i.이름] ?? initial(i);
|
||||
if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음)
|
||||
send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw);
|
||||
}
|
||||
}
|
||||
let got;
|
||||
try {
|
||||
got = await previewCombo(combo(), send);
|
||||
lines = got.줄;
|
||||
notify();
|
||||
} catch (error) {
|
||||
out.replaceChildren(
|
||||
el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const trs = got.줄.map((l) =>
|
||||
rowOf(
|
||||
[
|
||||
`${l.로직} ${l.이름}`,
|
||||
fmt(l.노무비 ?? 0),
|
||||
fmt(l.재료비 ?? 0),
|
||||
fmt(l.경비 ?? 0),
|
||||
fmt(l.계 ?? 0),
|
||||
],
|
||||
l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`,
|
||||
),
|
||||
);
|
||||
trs.push(
|
||||
rowOf(
|
||||
[tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)],
|
||||
"",
|
||||
true,
|
||||
),
|
||||
);
|
||||
const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")];
|
||||
out.replaceChildren(
|
||||
el("table", {
|
||||
className: "m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: trs }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const button = el("button", {
|
||||
className: "ui-btn ui-btn--filled",
|
||||
text: tc("Prev_Run"),
|
||||
attrs: { type: "button", "data-combo": "run" },
|
||||
});
|
||||
button.addEventListener("click", () => void run());
|
||||
|
||||
const redraw = async (): Promise<void> => {
|
||||
const blocks: HTMLElement[] = [];
|
||||
inputs.clear();
|
||||
for (const row of combo().담은로직) {
|
||||
try {
|
||||
const [one, auto] = await Promise.all([
|
||||
fetchLogic(row.로직),
|
||||
asked.has(row.로직) ? Promise.resolve({}) : fetchAuto(row.로직),
|
||||
]);
|
||||
asked.add(row.로직);
|
||||
const mine = (values[row.로직] ??= {});
|
||||
for (const i of one.logic.입력) {
|
||||
const got = (auto as Record<string, unknown>)[i.이름];
|
||||
if (got === undefined || got === null || (mine[i.이름] ?? "").trim() !== "") continue;
|
||||
mine[i.이름] = String(got);
|
||||
}
|
||||
inputs.set(row.로직, one.logic.입력);
|
||||
meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" });
|
||||
blocks.push(
|
||||
el("div", {
|
||||
className: "m01lab__info",
|
||||
children: [
|
||||
el("strong", {
|
||||
className: "m01lab__cell--wide",
|
||||
text: `${row.로직} ${one.logic.이름}`,
|
||||
}),
|
||||
...one.logic.입력.map((i) =>
|
||||
inputCell(i, row.로직, values, () => void run(), notify),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */
|
||||
}
|
||||
}
|
||||
form.replaceChildren(...blocks);
|
||||
notify();
|
||||
if (blocks.length)
|
||||
await run(); // 열자마자 한 번 미리 보기 계산
|
||||
else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") }));
|
||||
};
|
||||
|
||||
const root = el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [el("h3", { text: tc("Prev_Title") })],
|
||||
}),
|
||||
el("h4", { text: tc("Prev_Inputs") }),
|
||||
form,
|
||||
button,
|
||||
out,
|
||||
],
|
||||
});
|
||||
const data = (): DiagramData =>
|
||||
buildDiagramData({
|
||||
rows: combo().담은로직.map((r) => ({
|
||||
key: r.로직,
|
||||
name: meta.get(r.로직)?.name ?? r.로직,
|
||||
unit: meta.get(r.로직)?.unit ?? "",
|
||||
inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })),
|
||||
})),
|
||||
values,
|
||||
lines,
|
||||
});
|
||||
const focusInput = (key: string, name: string): void => {
|
||||
const box = root.querySelector<HTMLElement>(`[data-input="${CSS.escape(`${key}|${name}`)}"]`);
|
||||
if (!box) return;
|
||||
box.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
box.focus({ preventScroll: true });
|
||||
box.classList.add("m02-labor__flash");
|
||||
window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600);
|
||||
};
|
||||
const onUpdate = (fn: () => void): (() => void) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
};
|
||||
return { root, redraw, data, onUpdate, focusInput };
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Rows.ts
|
||||
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
|
||||
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
|
||||
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」).
|
||||
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type {
|
||||
LaborCalc,
|
||||
LaborCalcLine,
|
||||
LaborCalcMat,
|
||||
LaborCost,
|
||||
LaborMat,
|
||||
LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
import { valueLine } from "./M02_Labor_Values_Model";
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
|
||||
노무비: "Prev_Labor",
|
||||
재료비: "Prev_Material",
|
||||
경비: "Prev_Expense",
|
||||
계: "Prev_Sum",
|
||||
};
|
||||
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */
|
||||
export function qtyText(
|
||||
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
|
||||
unit: string,
|
||||
cells: ReadonlyMap<string, string> = new Map(),
|
||||
): string {
|
||||
if (!line || line.수량 === null || line.수량 === undefined) return "";
|
||||
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
|
||||
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
|
||||
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
|
||||
return `${tc("Labor_Qty")} ${cell} = ${v}`;
|
||||
}
|
||||
|
||||
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */
|
||||
function moneyCell(
|
||||
base: number | null | undefined,
|
||||
applied: number | null | undefined,
|
||||
wait: boolean,
|
||||
): HTMLElement {
|
||||
const show = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
|
||||
return el("td", {
|
||||
className: "m02-labrow__money",
|
||||
children: [
|
||||
el("small", {
|
||||
className: "m02-labrow__base",
|
||||
text: show(base),
|
||||
attrs: { title: tc("Labor_Base") },
|
||||
}),
|
||||
el("span", {
|
||||
className: "m02-labrow__applied",
|
||||
text: show(applied),
|
||||
attrs: { title: tc("Labor_Applied") },
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-labrow",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
...(foot.length ? [el("tfoot", { children: foot })] : []),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */
|
||||
export interface RowHandlers {
|
||||
open: (i: number) => void;
|
||||
move?: (i: number, d: number) => void;
|
||||
remove?: (i: number) => void;
|
||||
}
|
||||
|
||||
export interface LogicTableData {
|
||||
rows: LaborRow[];
|
||||
logics: Map<string, ItemLogic>;
|
||||
/** calc 답 — null = 계산 중 · 못 셈은 `fail` */
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
/** 알약 키 → 이은 면적산출 칸(「B6」) */
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** 담은 로직 표 — 보기 갈래는 `on` 없이 */
|
||||
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const { move, remove } = on ?? {};
|
||||
const tools = !!(move && remove);
|
||||
const lineOf = (key: string): LaborCalcLine | undefined =>
|
||||
data.calc?.줄.find((l) => l.로직 === key);
|
||||
const trs = data.rows.map((row, i) => {
|
||||
const logic = data.logics.get(row.로직);
|
||||
const line = lineOf(row.로직);
|
||||
const unit = logic?.결과단위 ?? "";
|
||||
const values = valueLine(logic?.입력 ?? [], row.입력값);
|
||||
const qty = qtyText(line, line?.분모 ?? "", data.cells);
|
||||
const note = [values.text, qty].filter(Boolean).join(" · ");
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
const small = (label: string, run: () => void): HTMLElement =>
|
||||
createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation(); // 줄 누름(입력 모달)과 따로
|
||||
run();
|
||||
},
|
||||
});
|
||||
const tr = el("tr", {
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-logic": row.로직 },
|
||||
children: [
|
||||
el("td", { text: String(i + 1) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("span", { className: "m02-labrow__key", text: row.로직 }),
|
||||
el("strong", { text: logic?.이름 ?? row.로직 }),
|
||||
...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []),
|
||||
...(values.empty
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-labrow__empty",
|
||||
text: tc("Labor_Empty").replace("{n}", String(values.empty)),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []),
|
||||
...(why
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-labrow__empty",
|
||||
text: `${tc("Labor_Calc_Fail")}: ${why}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
el("td", { text: unit }),
|
||||
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)),
|
||||
...(move && remove
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
small(tc("Up"), () => move(i, -1)),
|
||||
small(tc("Down"), () => move(i, 1)),
|
||||
small(tc("Remove"), () => remove(i)),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
return tr;
|
||||
});
|
||||
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
|
||||
part?.[c] ?? null;
|
||||
const foot = data.rows.length
|
||||
? [
|
||||
el("tr", {
|
||||
className: "m02-labrow__sum",
|
||||
children: [
|
||||
el("td", { text: "" }),
|
||||
el("td", { text: tc("Prev_Sum") }),
|
||||
el("td", { text: "" }),
|
||||
...COSTS.map((c) =>
|
||||
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait),
|
||||
),
|
||||
...(tools ? [el("td", { text: "" })] : []),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const heads = [
|
||||
tc("Col_Order"),
|
||||
tc("Col_Name"),
|
||||
tc("Col_Unit"),
|
||||
...COSTS.map((c) => tc(HEAD[c])),
|
||||
...(tools ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.rows.length
|
||||
? table(heads, trs, foot)
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
}
|
||||
|
||||
export interface MatTableData {
|
||||
mats: LaborMat[];
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** 자재목록 표 — 보기 갈래는 `on` 없이 */
|
||||
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const remove = on?.remove;
|
||||
const show = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
|
||||
const trs = data.mats.map((mat, i) => {
|
||||
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
|
||||
const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량);
|
||||
const tr = el("tr", {
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
children: [
|
||||
el("td", { text: mat.코드 }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: mat.이름 ?? "" }),
|
||||
...(line?.까닭
|
||||
? [el("small", { className: "m02-labrow__empty", text: line.까닭 })]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
el("td", { text: mat.규격 ?? "" }),
|
||||
el("td", { text: mat.단위 ?? "" }),
|
||||
el("td", {
|
||||
className: "m02-labrow__num",
|
||||
text: qty,
|
||||
attrs: { title: qtyText(line, mat.단위 ?? "", data.cells) },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }),
|
||||
el("td", { className: "m02-labrow__num", text: show(line?.반영) }),
|
||||
...(remove
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
remove(i);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
return tr;
|
||||
});
|
||||
const heads = [
|
||||
tc("Labor_Code"),
|
||||
tc("Col_Name"),
|
||||
tc("Labor_Spec"),
|
||||
tc("Labor_Unit"),
|
||||
tc("Labor_Qty"),
|
||||
tc("Labor_BasePrice"),
|
||||
tc("Labor_AppliedSum"),
|
||||
...(remove ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.mats.length
|
||||
? table(heads, trs, [])
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
|
||||
}
|
||||
@@ -65,16 +65,48 @@ const TEXT = {
|
||||
Pick_None: ["찾은 로직 없음", "No logics found"],
|
||||
Pick_Close: ["닫기", "Close"],
|
||||
Pick_Blocked: ["막힘", "Blocked"],
|
||||
Prev_Title: ["미리 보기 (보기만 · 저장 안 함)", "Preview (view only)"],
|
||||
Prev_Inputs: ["넣을 값", "Inputs"],
|
||||
Prev_Run: ["미리 보기 계산", "Run preview"],
|
||||
Prev_Labor: ["노무비", "Labor"],
|
||||
Prev_Material: ["재료비", "Material"],
|
||||
Prev_Expense: ["경비", "Expense"],
|
||||
Prev_Sum: ["합계", "Total"],
|
||||
Prev_Row: ["로직", "Logic"],
|
||||
Prev_Fail: ["못 셈", "Failed"],
|
||||
Prev_Wait: ["미리 보기 계산을 누르면 값이 나옴", "Press the button to see values"],
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나
|
||||
Labor_Title: ["일위대가", "Unit price"],
|
||||
Labor_Unit: ["단위", "Unit"],
|
||||
Labor_Origin: ["본뜬 곳", "Copied from"],
|
||||
Labor_Mats: ["자재목록", "Materials"],
|
||||
Labor_NoMat: ["담은 자재 없음", "No materials yet"],
|
||||
Labor_AddMat: ["자재 추가", "Add material"],
|
||||
Labor_Qty: ["수량", "Quantity"],
|
||||
Labor_Base: ["기준", "Base"],
|
||||
Labor_Applied: ["반영", "Applied"],
|
||||
Labor_BasePrice: ["기준 단가", "Base price"],
|
||||
Labor_AppliedSum: ["반영 금액", "Applied amount"],
|
||||
Labor_Calc_Wait: ["계산 중…", "Calculating…"],
|
||||
Labor_Calc_Fail: ["계산 못 함", "Calculation failed"],
|
||||
Labor_Empty: ["빈 값 {n}", "{n} empty"],
|
||||
Labor_Lost: [
|
||||
"로직 입력이 바뀌어 저장 때 빠지는 값 — {value}",
|
||||
"Inputs no longer in the logic (dropped on save) — {value}",
|
||||
],
|
||||
Labor_Code: ["코드", "Code"],
|
||||
Labor_Spec: ["규격", "Spec"],
|
||||
Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"],
|
||||
Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"],
|
||||
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
|
||||
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
|
||||
In_Memo: ["메모", "Memo"],
|
||||
In_Qty: ["수량", "Quantity"],
|
||||
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
|
||||
In_QtyHand: ["손 입력", "Manual"],
|
||||
In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"],
|
||||
In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"],
|
||||
In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"],
|
||||
In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"],
|
||||
In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"],
|
||||
In_Ok: ["확인", "OK"],
|
||||
In_Cancel: ["취소", "Cancel"],
|
||||
In_Close: ["닫기", "Close"],
|
||||
In_Loading: ["입력 목록 받는 중…", "Loading inputs…"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type ComboTextKey = keyof typeof TEXT;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_Tree.ts
|
||||
* 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림.
|
||||
* 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api";
|
||||
|
||||
/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */
|
||||
export const pathOf = (c: ComboSummary): string[] =>
|
||||
c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : [];
|
||||
|
||||
/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */
|
||||
export const toView = (list: ComboList, note?: TreeNote): TreeView => ({
|
||||
판: list.판,
|
||||
폴더: list.폴더,
|
||||
items: list.줄.map((c) => ({
|
||||
key: c.키,
|
||||
열: c.키,
|
||||
경로: [...pathOf(c), c.이름],
|
||||
이름: c.이름,
|
||||
단위: c.단위,
|
||||
})),
|
||||
...(note ? { 알림: note } : {}),
|
||||
});
|
||||
|
||||
/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */
|
||||
export function noteOf(edit: TreeEdit, gone: string[]): TreeNote {
|
||||
const at = edit.경로 ?? [];
|
||||
const last = at[at.length - 1] ?? "";
|
||||
if (edit.키 && edit.op !== "delete") {
|
||||
const where = edit.op === "move" ? (edit.새경로 ?? []) : at;
|
||||
return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone };
|
||||
}
|
||||
const path =
|
||||
edit.op === "add-folder"
|
||||
? [...at, edit.이름 ?? ""]
|
||||
: edit.op === "rename"
|
||||
? [...at.slice(0, -1), edit.이름 ?? ""]
|
||||
: edit.op === "move"
|
||||
? [...(edit.새경로 ?? []), last]
|
||||
: undefined;
|
||||
return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_View.ts
|
||||
* 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영).
|
||||
* 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`).
|
||||
* · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만)
|
||||
* · `setQty({알약 키: 칸 값})` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 바로 곱함
|
||||
* (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」)
|
||||
* · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
areaLinks,
|
||||
calcLabor,
|
||||
fetchLogicInputs,
|
||||
normalizeLabor,
|
||||
type LaborCalc,
|
||||
type LaborDoc,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
export interface LaborViewRow {
|
||||
kind: "logic" | "material";
|
||||
/** 로직 키 · 자재 코드 */
|
||||
key: string;
|
||||
/** 면적산출 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — calc 전이면 null */
|
||||
pill: string | null;
|
||||
name: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface LaborViewHandle {
|
||||
root: HTMLElement;
|
||||
/** 그 열 일위대가를 읽어 그림 — `수량` = 저장 안 한 면적 칸 값(없으면 저장된 면적산출) */
|
||||
show: (layer: Layer, column: string | null, 수량?: Record<string, number>) => Promise<void>;
|
||||
/** 면적 칸 값이 바뀜(알약 값 전부) — 받은 기준에 바로 곱함(서버 안 부름) */
|
||||
setQty: (수량: Record<string, number>) => void;
|
||||
/** 지금 문서(없으면 null) */
|
||||
doc: () => LaborDoc | null;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle {
|
||||
const root = el("div", { className: "m02-labview" });
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let doc: LaborDoc | null = null;
|
||||
let calc: LaborCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
let cells = new Map<string, string>();
|
||||
let seq = 0;
|
||||
|
||||
const paint = (): void => {
|
||||
if (!doc) return;
|
||||
const d = doc;
|
||||
const pick = (row: LaborViewRow): (() => void) | undefined =>
|
||||
onRow ? () => onRow(row) : undefined;
|
||||
const logicOpen = (i: number): void => {
|
||||
const row = d.담은로직[i];
|
||||
const line = calc?.줄.find((l) => l.로직 === row.로직);
|
||||
pick({
|
||||
kind: "logic",
|
||||
key: row.로직,
|
||||
pill: line?.알약 ?? null,
|
||||
name: logics.get(row.로직)?.이름 ?? row.로직,
|
||||
unit: line?.분모 ?? "",
|
||||
})?.();
|
||||
};
|
||||
const matOpen = (i: number): void => {
|
||||
const mat = d.자재[i];
|
||||
const line = calc?.자재.find((m) => m.코드 === mat.코드);
|
||||
pick({
|
||||
kind: "material",
|
||||
key: mat.코드,
|
||||
pill: line?.알약 ?? null,
|
||||
name: mat.이름 ?? mat.코드,
|
||||
unit: mat.단위 ?? "",
|
||||
})?.();
|
||||
};
|
||||
root.replaceChildren(
|
||||
el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }),
|
||||
logicTable(
|
||||
{ rows: d.담은로직, logics, calc, fail, cells },
|
||||
onRow ? { open: logicOpen } : undefined,
|
||||
),
|
||||
el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }),
|
||||
matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined),
|
||||
);
|
||||
};
|
||||
|
||||
const show = async (
|
||||
layer: Layer,
|
||||
column: string | null,
|
||||
수량?: Record<string, number>,
|
||||
): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
doc = null;
|
||||
calc = null;
|
||||
fail = null;
|
||||
if (!column) return void root.replaceChildren();
|
||||
root.replaceChildren(el("p", { className: "m02-labrow__muted", text: tc("Loading") }));
|
||||
try {
|
||||
const got = await readTemplate(layer, "labor", column, null);
|
||||
if (mine !== seq) return;
|
||||
doc = normalizeLabor(got.문서, column);
|
||||
const want = doc.담은로직.map((r) => r.로직).filter((k) => !logics.has(k));
|
||||
const [inputs, area] = await Promise.all([
|
||||
fetchLogicInputs(want),
|
||||
readTemplate(layer, "area", column, null).then(
|
||||
(a) => a.문서,
|
||||
() => null,
|
||||
),
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
for (const l of inputs.로직) logics.set(l.로직, l);
|
||||
cells = new Map([...areaLinks(area)].map(([k, v]) => [k, v.칸]));
|
||||
paint(); // 금액 「…」
|
||||
calc = await calcLabor(layer, column, null, 수량);
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
if (!doc) {
|
||||
root.replaceChildren(
|
||||
el("p", {
|
||||
className: "m02-labrow__empty",
|
||||
text: error instanceof Error ? error.message : tc("Load_Failed"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
|
||||
}
|
||||
if (mine === seq) paint();
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
show,
|
||||
setQty: (수량) => {
|
||||
if (!doc || !calc) return;
|
||||
calc = settle(calc, doc, 수량);
|
||||
paint();
|
||||
},
|
||||
doc: () => doc,
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -79,7 +79,8 @@ export interface LayerAccess {
|
||||
needCompany: boolean;
|
||||
}
|
||||
|
||||
/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */
|
||||
/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`) = 그 회사를 맡았으면 고침(39-6) ·
|
||||
* 읽을 수 있나는 서버가 가름 */
|
||||
export function accessFor(
|
||||
layer: ManageLayer,
|
||||
owner: string | null,
|
||||
@@ -87,10 +88,86 @@ export function accessFor(
|
||||
): LayerAccess {
|
||||
if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false };
|
||||
const own = ownOwner(layer, user);
|
||||
if (owner && owner !== own) return { canWrite: false, needCompany: false };
|
||||
if (owner && owner !== own) {
|
||||
return { canWrite: manages(owner.split("/")[0], user), needCompany: false };
|
||||
}
|
||||
if (!own) return { canWrite: false, needCompany: true };
|
||||
const companyAdmin = user?.role === "ADMIN" || user?.role === "SYSTEM_ADMIN" || !!user?.is_master;
|
||||
return { canWrite: layer === "personal" || companyAdmin, needCompany: false };
|
||||
return {
|
||||
canWrite: layer === "personal" || manages(ownOwner("company", user), user),
|
||||
needCompany: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */
|
||||
export function manages(company: string | null, user: SessionUser | null): boolean {
|
||||
if (!company || !user) return false;
|
||||
if (user.role === "SYSTEM_ADMIN") return true;
|
||||
const companyAdmin = user.role === "ADMIN" || !!user.is_master;
|
||||
return companyAdmin && company === ownOwner("company", user);
|
||||
}
|
||||
|
||||
/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */
|
||||
export interface Source {
|
||||
id: "master" | "company" | "personal" | "people" | "practice";
|
||||
layer: ManageLayer;
|
||||
/** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */
|
||||
owner: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭.
|
||||
* 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */
|
||||
export function readableSources(
|
||||
user: SessionUser | null,
|
||||
company: string | null,
|
||||
opt: { practice?: boolean } = {},
|
||||
): { tabs: Source[]; first: Source["id"] } {
|
||||
const isSystem = user?.role === "SYSTEM_ADMIN";
|
||||
const own = ownOwner("personal", user);
|
||||
const tabs: Source[] = [
|
||||
{ id: "master", layer: "system", owner: null, label: L("M02_PickerSystem") },
|
||||
...(company
|
||||
? [
|
||||
{
|
||||
id: "company" as const,
|
||||
layer: "company" as const,
|
||||
owner: company === ownOwner("company", user) ? null : company,
|
||||
label: L("M02_PickerCompany"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(own
|
||||
? [
|
||||
{
|
||||
id: "personal" as const,
|
||||
layer: "personal" as const,
|
||||
owner: null,
|
||||
label: L("M02_PickerPersonal"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(manages(company, user)
|
||||
? [
|
||||
{
|
||||
id: "people" as const,
|
||||
layer: "personal" as const,
|
||||
owner: null,
|
||||
label: L("M02_SourcePeople"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(opt.practice && isSystem
|
||||
? [
|
||||
{
|
||||
id: "practice" as const,
|
||||
layer: "system" as const,
|
||||
owner: null,
|
||||
label: L("M02_SourcePractice"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return { tabs, first: isSystem || !own ? "master" : "personal" };
|
||||
}
|
||||
|
||||
/** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */
|
||||
|
||||
@@ -5,15 +5,7 @@
|
||||
* (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import {
|
||||
@@ -22,6 +14,7 @@ import {
|
||||
type MaterialCombo,
|
||||
type MaterialItem,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo";
|
||||
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
|
||||
|
||||
@@ -51,12 +44,6 @@ const TEXT = {
|
||||
askAlone:
|
||||
"이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀",
|
||||
failed: "자재 조합을 못 고침 — {value}",
|
||||
pickTitle: "자재 추가",
|
||||
pickClose: "닫기",
|
||||
search: "자재 이름 · 규격 · 코드",
|
||||
searching: "찾는 중…",
|
||||
noMatch: "맞는 자재 없음",
|
||||
searchFailed: "자재 목록을 못 받음",
|
||||
};
|
||||
|
||||
const fill = (text: string, values: Record<string, string | number>): string =>
|
||||
@@ -122,57 +109,6 @@ export interface MaterialPanelOptions {
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
/** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(좌측 자재 조합 상세도 씀) */
|
||||
export function pickMaterial(done: (item: MaterialItem) => void): void {
|
||||
openModal({
|
||||
title: TEXT.pickTitle,
|
||||
closeLabel: TEXT.pickClose,
|
||||
dialogClass: "m02-modal m02-pick",
|
||||
mount: (body, close) => {
|
||||
const search = createInputField({ placeholder: TEXT.search, type: "search" });
|
||||
const list = el("div", { className: "m02-pick__list" });
|
||||
let seq = 0;
|
||||
const paint = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching }));
|
||||
try {
|
||||
const hits = await searchMaterials(search.input.value.trim());
|
||||
if (mine !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(hits.length
|
||||
? hits.map((m) => {
|
||||
const row = el("button", {
|
||||
className: "m02-pick__item",
|
||||
attrs: { type: "button", "data-material": m.코드 },
|
||||
children: [
|
||||
el("span", { className: "m02-pick__key", text: m.코드 }),
|
||||
`${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
done(m);
|
||||
close();
|
||||
});
|
||||
return row;
|
||||
})
|
||||
: [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq) showToast(TEXT.searchFailed, "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
search.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void paint(), 250);
|
||||
});
|
||||
body.append(search.root, list);
|
||||
void paint();
|
||||
search.input.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */
|
||||
export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
|
||||
let key = opts.state.key;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
StaleError,
|
||||
type MaterialCombo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { pickMaterial } from "./M02_MasterTemplete_Materials";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { tm } from "./M02_MasterTemplete_Matset_Text";
|
||||
|
||||
export interface MatsetSectionOptions {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Practice.ts
|
||||
* 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) —
|
||||
* 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) ·
|
||||
* [통째 지우기](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인).
|
||||
* 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
fetchCombo,
|
||||
fetchComboList,
|
||||
type ComboList,
|
||||
type ComboOne,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
|
||||
export interface PracticeSectionOptions {
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 화면을 띄움 */
|
||||
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface PracticeSection {
|
||||
title: string;
|
||||
body: HTMLElement;
|
||||
/** 다른 화면을 열었음 — 고름 표시만 지움 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
/** 서버 까닭 글 — `{errors}` · 판 다름 · 글 */
|
||||
function reasonOf(detail: unknown, status: number): string {
|
||||
if (typeof detail === "string") return detail;
|
||||
const d = detail as { errors?: string[]; stale?: unknown } | null;
|
||||
if (d?.errors?.length) return d.errors.join(" · ");
|
||||
return d?.stale ? L("M02_PracticeStale") : `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** 통째 지우기 — 줄을 모두 비움(판 = 읽은 표 판) */
|
||||
async function dropPractice(판: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/m02/practice?판=${encodeURIComponent(판)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) return;
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
|
||||
throw new Error(reasonOf(data.detail, res.status));
|
||||
}
|
||||
|
||||
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-practice__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const cells = (values: string[]): HTMLElement[] =>
|
||||
values.map((v) => el("td", { text: v, attrs: { title: v } }));
|
||||
|
||||
export function buildPracticeSection(opt: PracticeSectionOptions): PracticeSection {
|
||||
const row = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: L("M02_PracticeRow"),
|
||||
attrs: { type: "button", "data-practice-row": "" },
|
||||
});
|
||||
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
|
||||
const view = el("div", { className: "m02-practice" });
|
||||
let opened = false;
|
||||
let nav = 0; // 늦게 온 응답 버림
|
||||
const mark = (on: boolean): void => {
|
||||
opened = on;
|
||||
row.classList.toggle("is-active", on);
|
||||
};
|
||||
row.addEventListener("click", () => void open());
|
||||
|
||||
async function open(): Promise<void> {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
opt.show(L("M02_PracticeTitle"), view);
|
||||
opt.onOpen();
|
||||
mark(true);
|
||||
await drawList();
|
||||
}
|
||||
|
||||
async function drawList(): Promise<void> {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view);
|
||||
let list: ComboList;
|
||||
try {
|
||||
list = await fetchComboList();
|
||||
} catch (error) {
|
||||
stop();
|
||||
if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawList()));
|
||||
return;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
if (my !== nav) return;
|
||||
const drop = createButton({ label: L("M02_PracticeDrop"), variant: "danger", size: "sm" });
|
||||
drop.disabled = !list.줄.length;
|
||||
drop.addEventListener("click", () => void dropAll(list, drop));
|
||||
const rows = list.줄.map((c) => {
|
||||
const tr = el("tr", {
|
||||
className: "m02-practice__row",
|
||||
attrs: { "data-practice-key": c.키 },
|
||||
children: cells([c.키, c.이름, c.구분 ?? "", c.단위 ?? "", String(c.count)]),
|
||||
});
|
||||
tr.addEventListener("click", () => void drawOne(c.키));
|
||||
return tr;
|
||||
});
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-practice__bar",
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-practice__note",
|
||||
text: L("M02_PracticeCount").replace("{n}", String(list.줄.length)),
|
||||
}),
|
||||
drop,
|
||||
],
|
||||
}),
|
||||
rows.length
|
||||
? table(
|
||||
[
|
||||
L("M02_PracticeKey"),
|
||||
L("M02_Name"),
|
||||
L("M02_PracticeGroup"),
|
||||
L("M02_PracticeUnit"),
|
||||
L("M02_PracticeLogics"),
|
||||
],
|
||||
rows,
|
||||
)
|
||||
: el("p", { className: "m02-practice__note", text: L("M02_PracticeEmpty") }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 상세 — 읽기만(이름 · 키 · 구분 · 단위 · 비고 + 담은 로직 표) */
|
||||
async function drawOne(key: string): Promise<void> {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view);
|
||||
let one: ComboOne;
|
||||
try {
|
||||
one = await fetchCombo(key);
|
||||
} catch (error) {
|
||||
stop();
|
||||
if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawOne(key)));
|
||||
return;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
if (my !== nav) return;
|
||||
const back = createButton({ label: L("M02_PracticeBack"), variant: "ghost", size: "sm" });
|
||||
back.addEventListener("click", () => void drawList());
|
||||
const c = one.combo;
|
||||
const info = [
|
||||
[L("M02_PracticeKey"), c.키],
|
||||
[L("M02_PracticeGroup"), c.구분 ?? ""],
|
||||
[L("M02_PracticeUnit"), c.단위 ?? ""],
|
||||
].map(([k, v]) => el("span", { className: "m02-practice__note", text: `${k} ${v}` }));
|
||||
const lines = one.줄.map((x) =>
|
||||
el("tr", {
|
||||
children: cells([
|
||||
String(x.차례),
|
||||
x.로직,
|
||||
x.ok ? (x.이름 ?? "") : (x.까닭 ?? ""),
|
||||
x.결과단위 ?? "",
|
||||
x.메모 ?? "",
|
||||
]),
|
||||
}),
|
||||
);
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-practice__bar",
|
||||
children: [back, el("strong", { text: c.이름 }), ...info],
|
||||
}),
|
||||
...(c.비고 ? [el("p", { className: "m02-practice__memo", text: c.비고 })] : []),
|
||||
lines.length
|
||||
? table(
|
||||
[
|
||||
L("M02_PracticeOrder"),
|
||||
L("M02_PracticeKey"),
|
||||
L("M02_Name"),
|
||||
L("M02_PracticeUnit"),
|
||||
L("M02_PracticeMemo"),
|
||||
],
|
||||
lines,
|
||||
)
|
||||
: el("p", { className: "m02-practice__note", text: L("M02_PracticeNoLogic") }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 통째 지우기 — 물음 두 번 · 서버가 쓰는 곳 · 판을 다시 봄 */
|
||||
async function dropAll(list: ComboList, button: HTMLButtonElement): Promise<void> {
|
||||
const n = String(list.줄.length);
|
||||
if (!(await showConfirmDialog(L("M02_PracticeDropAsk").replace("{n}", n), L("M02_Delete"))))
|
||||
return;
|
||||
if (!(await showConfirmDialog(L("M02_PracticeDropAsk2"), L("M02_PracticeDrop")))) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await dropPractice(list.판);
|
||||
showToast(L("M02_PracticeDropped").replace("{n}", n), "success");
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
await drawList();
|
||||
}
|
||||
|
||||
return { title: L("M02_PracticeTitle"), body, clear: () => mark(false) };
|
||||
}
|
||||
@@ -5,25 +5,36 @@
|
||||
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######)
|
||||
personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######)
|
||||
저장소 = `M02_MasterTemplete_Combo_Store`.
|
||||
담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
|
||||
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
|
||||
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
|
||||
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"])
|
||||
|
||||
def _door(request: Request, session: Session = Depends(verify_session)) -> None:
|
||||
"""일위대가 조합 = 실무문서(39-6) — 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만."""
|
||||
if "/logic-lib/" not in request.url.path and not access.is_system_admin(session):
|
||||
raise HTTPException(status_code=403, detail="실무문서(일위대가 조합)는 시스템 관리자만 봄.")
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)]
|
||||
)
|
||||
|
||||
|
||||
class ComboBody(BaseModel):
|
||||
@@ -61,13 +72,6 @@ class PasteBody(BaseModel):
|
||||
판: str = ""
|
||||
|
||||
|
||||
class PreviewBody(BaseModel):
|
||||
"""저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다."""
|
||||
|
||||
combo: dict[str, Any] | str
|
||||
inputs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@router.get("/combos/all")
|
||||
def get_all(session: Session = Depends(verify_session)) -> list[dict]:
|
||||
"""고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층."""
|
||||
@@ -157,6 +161,15 @@ def delete_combo(
|
||||
return _call(_root(session, layer, owner, write=True), combos.delete, key, 판)
|
||||
|
||||
|
||||
@router.delete("/practice")
|
||||
def delete_practice(판: str = "", session: Session = Depends(verify_session)) -> dict:
|
||||
"""실무문서 통째 지우기(PLAN 39-2) — 시스템 관리자만 · 마스터 조합 표 줄을 모두 비움 ·
|
||||
구조물이 쓰는 조합이 있으면 409."""
|
||||
if not access.is_system_admin(session):
|
||||
raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자만")
|
||||
return _call(_root(session, "system", None, write=True), combos.drop_practice, 판)
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/combos/tree")
|
||||
def post_tree(
|
||||
layer: Layer,
|
||||
@@ -192,17 +205,6 @@ def post_paste(
|
||||
return _call(folder, combo_copy.paste, source, body.model_dump())
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/combos/preview")
|
||||
def post_preview(
|
||||
layer: Layer,
|
||||
body: PreviewBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
):
|
||||
"""보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한."""
|
||||
return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs)
|
||||
|
||||
|
||||
# ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ──
|
||||
def _read(fn, *args):
|
||||
try:
|
||||
@@ -230,3 +232,9 @@ def get_logic(key: str) -> dict:
|
||||
def get_logic_auto(key: str) -> dict:
|
||||
"""견본 입력 — 미리 보기용 자동값."""
|
||||
return _read(m01.auto, key)
|
||||
|
||||
|
||||
@router.get("/logic-lib/inputs", dependencies=[Depends(verify_session)])
|
||||
def get_logic_inputs(keys: str = "") -> dict:
|
||||
"""일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`)."""
|
||||
return _read(labor_inputs.logic_inputs, keys.split(","))
|
||||
|
||||
@@ -10,13 +10,17 @@ PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import anyio.from_thread
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_audit import record_audit
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete import M02_Item_Paste as paste_store
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
|
||||
@@ -32,6 +36,7 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import (
|
||||
UseComboBody,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"])
|
||||
|
||||
Layer = Literal["system", "company", "personal"]
|
||||
@@ -74,14 +79,60 @@ class CopyItemBody(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
async def _member(owner: str) -> bool:
|
||||
"""층 주인이 있나 — 회사 `{C}` = 그 회사 · 개인 `{C}/{U}` = U 가 C 소속."""
|
||||
company, _, user = owner.partition("/")
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
if user:
|
||||
await cursor.execute(
|
||||
"SELECT 1 FROM users WHERE id = %s AND company_id = %s AND deleted_at IS NULL",
|
||||
(int(user), int(company)),
|
||||
)
|
||||
else:
|
||||
await cursor.execute(
|
||||
"SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (int(company),)
|
||||
)
|
||||
return await cursor.fetchone() is not None
|
||||
|
||||
|
||||
async def _note(session: Session, got: access.Grant) -> None:
|
||||
"""남의 층 쓰기 한 줄(39-6 · 시스템 로그) — 기록이 못 돼도 쓰기는 막지 않음."""
|
||||
try:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=int(session["user_id"]),
|
||||
action="M02_OTHER_LAYER_WRITE",
|
||||
resource_type=f"m02_{got.layer}",
|
||||
resource_ref=got.owner,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
logger.exception("M02 남의 층 쓰기 기록 실패: %s %s", got.layer, got.owner)
|
||||
|
||||
|
||||
async def others_write(session: Session, got: access.Grant, folder: Path) -> None:
|
||||
"""남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 쓸 때마다 시스템 로그."""
|
||||
if not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file():
|
||||
if not await _member(str(got.owner)):
|
||||
raise HTTPException(status_code=403, detail="그 회사 · 그 회사 사람이 아님")
|
||||
items_store.ensure(folder)
|
||||
await _note(session, got)
|
||||
|
||||
|
||||
def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path:
|
||||
"""층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦."""
|
||||
"""층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦 ·
|
||||
남의 층 쓰기(맡은 회사 · 39-6)는 `others_write`."""
|
||||
got = access.require(session, layer, owner, write=write)
|
||||
if layer == "system":
|
||||
return store.FOLDER
|
||||
folder = access.folder(got)
|
||||
folder = access.folder(got, create=write)
|
||||
if got.mine:
|
||||
items_store.ensure(folder)
|
||||
elif write:
|
||||
anyio.from_thread.run(others_write, session, got, folder)
|
||||
elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file():
|
||||
raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음")
|
||||
return folder
|
||||
|
||||
@@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
@@ -164,8 +165,10 @@ def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int:
|
||||
|
||||
|
||||
async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None:
|
||||
"""남의 개인 양식 = 그 회사를 맡은 사람만(39-6 · 층 길과 한 규칙) · 그 회사 사람이어야 함."""
|
||||
if user_id == session.get("user_id"):
|
||||
return
|
||||
access.grant(session, "personal", f"{company_id}/{user_id}")
|
||||
if await _user_company(user_id) != company_id:
|
||||
raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.")
|
||||
|
||||
@@ -192,7 +195,12 @@ async def _layer_dir(
|
||||
who = session["user_id"] if user_id is None else user_id
|
||||
owner = str(company_id) if layer == "company" else f"{company_id}/{who}"
|
||||
got = access.require(session, layer, owner, write=write)
|
||||
return _ensured(access.folder(got)) if got.mine else access.folder(got)
|
||||
folder = access.folder(got, create=write)
|
||||
if got.mine:
|
||||
return _ensured(folder)
|
||||
if write: # 남의 층 쓰기(맡은 회사 · 39-6) — 구조물 길과 한 벌
|
||||
await items_router.others_write(session, got, folder)
|
||||
return folder
|
||||
|
||||
|
||||
def _ensured(folder: Path) -> Path:
|
||||
@@ -214,9 +222,9 @@ async def list_owners(
|
||||
company: int | None = Query(None),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]` · 층 자리가 있는 것만(본인 뺌).
|
||||
회사 = SYSTEM_ADMIN 만 회사 목록 · 개인 = 그 회사 사람(SYSTEM_ADMIN 은 어느 회사나 ·
|
||||
회사 관리자는 자기 회사만)."""
|
||||
"""남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]`.
|
||||
회사 = SYSTEM_ADMIN 만 · 층 자리가 있는 것만(자기 회사는 늘) ·
|
||||
개인 = 그 회사를 맡은 사람(39-6)만 · 회사 사람 전부(나 맨 위 · 집계표 없으면 `비어있음`)."""
|
||||
if layer == "company":
|
||||
if not access.is_system_admin(session):
|
||||
raise HTTPException(status_code=403, detail="회사 목록은 시스템 관리자만 봄.")
|
||||
@@ -225,12 +233,16 @@ async def list_owners(
|
||||
target = session.get("company_id") if company is None else company
|
||||
if target is None:
|
||||
raise HTTPException(status_code=409, detail=access.NO_COMPANY)
|
||||
mine = str(target) == str(session.get("company_id"))
|
||||
if not (access.is_system_admin(session) or (mine and access.is_company_admin(session))):
|
||||
if not access.manages(session, target):
|
||||
raise HTTPException(status_code=403, detail="같은 회사 사람 목록은 회사 관리자만 봄.")
|
||||
me = session.get("user_id")
|
||||
users = await _company_users(int(target))
|
||||
rows = [(f"{target}/{u['user_id']}", u["name"]) for u in users if u["user_id"] != me]
|
||||
users.sort(key=lambda u: u["user_id"] != me) # 나 맨 위
|
||||
return [
|
||||
{"owner": owner, "name": u["name"], "비어있음": _empty(owner)}
|
||||
for u in users
|
||||
for owner in [f"{target}/{u['user_id']}"]
|
||||
]
|
||||
found = []
|
||||
for owner, name in rows:
|
||||
got = access.Grant(layer, owner, False, owner == access.own_owner(session, layer))
|
||||
@@ -242,6 +254,13 @@ async def list_owners(
|
||||
return found
|
||||
|
||||
|
||||
def _empty(owner: str) -> bool:
|
||||
"""개인 층에 집계표가 없음 — 사람 나무 「비어 있음」(폴더를 안 만듦)."""
|
||||
company, user = owner.split("/")
|
||||
table = layers.personal_dir(company, user) / "table" / f"{store.STRUCTURE_TABLE}.json"
|
||||
return not table.is_file()
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/templates")
|
||||
async def list_layer(
|
||||
layer: Layer,
|
||||
@@ -519,7 +538,11 @@ async def list_sources(
|
||||
)
|
||||
return rows
|
||||
|
||||
users = await _company_users(company_id)
|
||||
users = [ # 남의 개인 = 그 회사를 맡은 사람만(39-6)
|
||||
u
|
||||
for u in await _company_users(company_id)
|
||||
if u["user_id"] == session.get("user_id") or access.manages(session, company_id)
|
||||
]
|
||||
projects = await _company_projects(company_id)
|
||||
return {
|
||||
"system": await asyncio.to_thread(layers.list_templates, layers.system_dir()),
|
||||
|
||||
@@ -247,3 +247,8 @@
|
||||
.m02-struct__sheet-grid .ss-links__list {
|
||||
max-height: 8rem;
|
||||
}
|
||||
|
||||
/* 면적산출 카드 [알약] 끔(기본) — 아래 알약 줄만 숨김 · 연결 · 칸 세모는 그대로 */
|
||||
.m02-struct__sheet.is-pills-off .ss-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type TreeRef,
|
||||
type TreeView,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo";
|
||||
import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
|
||||
import {
|
||||
@@ -81,18 +82,12 @@ export interface TreePanelOptions {
|
||||
dest: string[],
|
||||
) => Promise<TreeView>;
|
||||
};
|
||||
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
|
||||
clipKind?: string;
|
||||
/** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */
|
||||
pickCombo?: () => Promise<{ key: string; label: string } | null>;
|
||||
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
|
||||
copyTo?: { layer: Layer; owner: string | null; label: string }[];
|
||||
/** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */
|
||||
copyTo?: CopyTarget[];
|
||||
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
||||
canEdit: boolean;
|
||||
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
|
||||
maxDepth?: number;
|
||||
/** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */
|
||||
item?: { add: string; name: string; onAdd?: (path: string[]) => void };
|
||||
/** 항목을 누름 — 표에서 그 열을 고름 */
|
||||
onPick: (item: TreeItem) => void;
|
||||
/** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */
|
||||
@@ -113,6 +108,14 @@ export interface TreePanelHandle {
|
||||
select: (colId: string | null) => void;
|
||||
/** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */
|
||||
newItem: () => void;
|
||||
/** 고른 폴더(하나) — 없으면 맨 위(`[]`) · 새 항목 · 복사 생성이 들어갈 자리 */
|
||||
folder: () => string[];
|
||||
/** 복사 생성(39-5) — 다른 곳(`from`) 항목 키 하나를 이 층 `dest` 에 새 키로 붙이고 그 열을 고름 */
|
||||
copyIn: (
|
||||
from: { layer: Layer; owner: string | null },
|
||||
key: string,
|
||||
dest: string[],
|
||||
) => Promise<void>;
|
||||
/** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */
|
||||
editFromTable: (body: TreeEdit, colId: string | null) => Promise<void>;
|
||||
}
|
||||
@@ -123,7 +126,7 @@ const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint
|
||||
export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const owner = opt.owner ?? null;
|
||||
const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree };
|
||||
const clipKind = opt.clipKind ?? "구조물";
|
||||
const clipKind = "구조물"; // 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임
|
||||
let view: TreeView | null = null;
|
||||
let text = "";
|
||||
let activeCol: string | null = null;
|
||||
@@ -213,13 +216,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
return [
|
||||
...(target.kind === "folder"
|
||||
? [
|
||||
...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth
|
||||
? []
|
||||
: [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]),
|
||||
{
|
||||
label: opt.item?.add ?? L("M02_TreeAddItem"),
|
||||
run: () => void addItem(target.path),
|
||||
},
|
||||
{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) },
|
||||
{ label: L("M02_TreeAddItem"), run: () => void addItem(target.path) },
|
||||
]
|
||||
: []),
|
||||
...(isTop
|
||||
@@ -428,8 +426,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
|
||||
const gone = note?.지운항목 ?? [];
|
||||
const made = note?.열;
|
||||
const col =
|
||||
typeof made === "string" ? made : keepCol && !gone.includes(keepCol) ? keepCol : null;
|
||||
// 붙여넣기로 새 열이 하나면(복사 생성) 그 열
|
||||
const one = typeof made === "string" ? made : made?.length === 1 ? made[0] : null;
|
||||
const col = one ?? (keepCol && !gone.includes(keepCol) ? keepCol : null);
|
||||
activeCol = col;
|
||||
const parent = note?.경로 ? note.경로.slice(0, -1) : [];
|
||||
parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1))));
|
||||
@@ -466,7 +465,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
|
||||
async function addItem(parent: string[]): Promise<void> {
|
||||
if (!(await ready())) return;
|
||||
if (opt.item?.onAdd) return opt.item.onAdd(parent);
|
||||
// 종류(맨 위 폴더 · 고른 폴더면 그 안) + 이름 + 일위대가 · 단위 안 받음(PLAN 31-2)
|
||||
const kinds = view ? buildFolders(view).folders.map((f) => f.path[0]) : [];
|
||||
const got = await askNewItem({
|
||||
@@ -490,10 +488,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const got = await askText(L("M02_TreeRename"), [
|
||||
{
|
||||
key: "name",
|
||||
label:
|
||||
target.kind === "item"
|
||||
? (opt.item?.name ?? L("M02_TreeItemName"))
|
||||
: L("M02_TreeFolderName"),
|
||||
label: target.kind === "item" ? L("M02_TreeItemName") : L("M02_TreeFolderName"),
|
||||
value: nameOf(target),
|
||||
},
|
||||
]);
|
||||
@@ -548,18 +543,20 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success");
|
||||
}
|
||||
|
||||
/** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */
|
||||
async function copyTo(
|
||||
list: Target[],
|
||||
to: { layer: Layer; owner: string | null; label: string },
|
||||
): Promise<void> {
|
||||
/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */
|
||||
async function copyTo(list: Target[], to: CopyTarget): Promise<void> {
|
||||
if (!api.paste || !list.length) return;
|
||||
const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) };
|
||||
try {
|
||||
await api.paste(to.layer, to.owner, clip, []);
|
||||
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, "");
|
||||
const dest = await askCopyFolder(api.fetch, to, list.length);
|
||||
if (!dest) return;
|
||||
await api.paste(to.layer, to.owner, clip, dest);
|
||||
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, "");
|
||||
showToast(
|
||||
L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where),
|
||||
L("M02_TreeCopiedTo")
|
||||
.replace("{n}", String(list.length))
|
||||
.replace("{layer}", where)
|
||||
.replace("{folder}", dest.join(" / ") || L("M02_TreeTop")),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -658,13 +655,36 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
});
|
||||
|
||||
/** 상세 제목 줄 「새 항목」(30-5) — 나무 「항목 추가」와 같은 창 · 고른 폴더(하나)가 있으면 그 안 · 아니면 맨 위 */
|
||||
const newItem = (): void => {
|
||||
const folder = (): string[] => {
|
||||
const at = anchor && picked.has(anchor) ? targets.get(anchor) : undefined;
|
||||
void addItem(at?.kind === "folder" ? at.path : []);
|
||||
return at?.kind === "folder" ? at.path : [];
|
||||
};
|
||||
const newItem = (): void => void addItem(folder());
|
||||
|
||||
const copyIn = async (
|
||||
from: { layer: Layer; owner: string | null },
|
||||
key: string,
|
||||
dest: string[],
|
||||
): Promise<void> => {
|
||||
const run = api.paste;
|
||||
if (!run || !opt.canEdit || !(await ready())) return;
|
||||
await apply(() =>
|
||||
run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest),
|
||||
);
|
||||
};
|
||||
|
||||
const editFromTable = async (body: TreeEdit, colId: string | null): Promise<void> => {
|
||||
if (opt.canEdit && (await ready())) await edit(body, colId);
|
||||
};
|
||||
return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable };
|
||||
return {
|
||||
root,
|
||||
search: search.root,
|
||||
add: tools,
|
||||
refresh,
|
||||
select,
|
||||
newItem,
|
||||
folder,
|
||||
copyIn,
|
||||
editFromTable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_TreePanel_CopyTo.ts
|
||||
* 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null.
|
||||
* 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
|
||||
export interface CopyTarget {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
/** 메뉴 글 — `{n}` = 개수 */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export async function askCopyFolder(
|
||||
fetchTree: (layer: Layer, owner: string | null) => Promise<TreeView>,
|
||||
to: CopyTarget,
|
||||
n: number,
|
||||
): Promise<string[] | null> {
|
||||
const view = await fetchTree(to.layer, to.owner).catch(() => null);
|
||||
const folders = view ? listFolders(buildFolders(view)) : [];
|
||||
return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []);
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
* 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임.
|
||||
* 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝)
|
||||
* 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사)
|
||||
* 시스템 관리자가 남의 회사를 보면 그 회사 관리자처럼(PLAN 29-6) — 「개인」 = 그 회사 사람 나무
|
||||
* (`createPeopleTree` · 사람 줄 → 그 사람 라이브러리 · 읽기) · 자기 개인 층은 자기 회사를 고를 때만
|
||||
* 볼 회사를 맡으면(`manages` · 39-6 — 시스템 관리자 · 그 회사 관리자) 「개인」 = 그 회사 사람 나무
|
||||
* (`createPeopleTree` · 나 맨 위 · 사람 줄 → 그 사람 라이브러리 · 고침) · 일반 = 내 개인 그대로
|
||||
* 누르면 `go`(부르는 쪽이 해시로 다시 엶).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
accessFor,
|
||||
buildNeedCompany,
|
||||
layerOfRoot,
|
||||
manages,
|
||||
ownOwner,
|
||||
type Root,
|
||||
} from "./M02_MasterTemplete_Layer";
|
||||
@@ -40,9 +41,9 @@ export interface LayerBarOptions {
|
||||
export const companyOwner = (company: string | null, user: SessionUser | null): string | null =>
|
||||
company && company !== ownOwner("company", user) ? company : null;
|
||||
|
||||
/** 시스템 관리자가 남의 회사를 봄 — 「개인」 단추 = 그 회사 사람 나무(읽기) */
|
||||
/** 볼 회사를 맡음(39-6) — 「개인」 단추 = 그 회사 사람 나무 */
|
||||
export const seesOthers = (company: string | null, user: SessionUser | null): boolean =>
|
||||
user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user);
|
||||
manages(company, user);
|
||||
|
||||
export function createLayerBar(opt: LayerBarOptions): HTMLElement {
|
||||
const { user, active } = opt;
|
||||
@@ -57,7 +58,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement {
|
||||
}
|
||||
|
||||
function button(root: Root, owner: string | null, people = false): HTMLButtonElement {
|
||||
const readOnly = people || !accessFor(layerOfRoot(root), owner, user).canWrite;
|
||||
const readOnly = !people && !accessFor(layerOfRoot(root), owner, user).canWrite;
|
||||
const name = L(
|
||||
root === "master"
|
||||
? "M02_PickerSystem"
|
||||
@@ -113,7 +114,8 @@ export function createPeopleTree(opt: PeopleTreeOptions): HTMLElement {
|
||||
box.replaceChildren(
|
||||
...rows.flatMap((one) => {
|
||||
const current = one.owner === opt.active;
|
||||
const node = { id: one.owner, label: one.name, count: null, folder: true };
|
||||
const label = one.비어있음 ? `${one.name} · ${L("M02_PeopleEmpty")}` : one.name;
|
||||
const node = { id: one.owner, label, count: null, folder: true };
|
||||
const row = createTreeRow(node, current ? "▾" : "▸", 0, () => {
|
||||
if (!current) return opt.go(one.owner);
|
||||
wrap.hidden = !wrap.hidden;
|
||||
|
||||
@@ -68,6 +68,7 @@ const NUMBER_FIELD: Partial<Record<Kind, string>> = {
|
||||
structure: "도번",
|
||||
area: "면적번",
|
||||
material: "재료번",
|
||||
labor: "일위대가번",
|
||||
};
|
||||
// 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음
|
||||
// 표는 진입 파일만 — 폴더 통째(`*.ts`)면 분수 모듈이 따로 늦게 받는 입구가 되어 번들러 도우미를
|
||||
@@ -152,7 +153,8 @@ export function createMain(
|
||||
...(extras.top ? [extras.top] : []),
|
||||
el("div", {
|
||||
className: "m02-main__head",
|
||||
children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])],
|
||||
// M01 머리 꼴(39-5) — ← 는 제목 앞 · 제목 18px 왼쪽 · 단추는 오른쪽(slot 이 늘어나 밂)
|
||||
children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])],
|
||||
}),
|
||||
...(extras.search ? [extras.search] : []),
|
||||
notice,
|
||||
@@ -450,6 +452,20 @@ export function createMain(
|
||||
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
|
||||
return;
|
||||
}
|
||||
if (at.kind === "labor") {
|
||||
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로
|
||||
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
|
||||
editor = mountLaborDetail(host, doc, {
|
||||
layer: at.layer,
|
||||
column: at.name,
|
||||
readOnly,
|
||||
onChanged: (d) => (dirty = d && !readOnly),
|
||||
goArea: async () => {
|
||||
if (await confirmLeave()) void open({ ...at, kind: "area" });
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (at.kind === "area" || at.kind === "material") {
|
||||
// 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
|
||||
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
|
||||
|
||||
@@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { navigateTo, type MountedPage } from "../A00_Common/router";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
accessFor,
|
||||
buildReadOnlyBar,
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
type OpenTarget,
|
||||
type Root,
|
||||
} from "./M02_MasterTemplete_Layer";
|
||||
import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor";
|
||||
import {
|
||||
headRenameEdit,
|
||||
pickCombo,
|
||||
@@ -42,9 +41,22 @@ import {
|
||||
seesOthers,
|
||||
} from "./M02_MasterTemplete_TreePanel_Roots";
|
||||
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import { buildSide } from "./M02_MasterTemplete_UI_Side";
|
||||
import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side";
|
||||
import { buildAllItems } from "./M02_MasterTemplete_ItemList";
|
||||
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
|
||||
import "./M02_MasterTemplete_UI_Style.css";
|
||||
|
||||
const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안";
|
||||
const NEW_ASK =
|
||||
"{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름";
|
||||
|
||||
/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */
|
||||
const ALL_TITLE = {
|
||||
structure: "M02_KindStructure",
|
||||
labor: "M02_KindLabor",
|
||||
area: "M02_KindArea",
|
||||
} as const satisfies Record<AllKind, string>;
|
||||
|
||||
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
const entry = entryFromHash();
|
||||
@@ -69,12 +81,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
|
||||
const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null;
|
||||
// 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래
|
||||
// 「〇〇로 복사」(30-2) — 이 사람이 쓸 수 있는 자기 층(지금 보는 층은 뺌) 맨 위로 바로
|
||||
// 「〇〇로 복사…」(30-2 · 39-6) — 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 보는 층은 뺌) ·
|
||||
// 대상 폴더는 고르는 창
|
||||
const here = owner === ownOwner(layer, user) ? null : owner;
|
||||
const copyTo = (["master", "company", "personal"] as Root[]).flatMap((one) => {
|
||||
const to = layerOfRoot(one);
|
||||
if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];
|
||||
const toOwner = one === "company" ? companyOwner(company, user) : null;
|
||||
if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return [];
|
||||
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
|
||||
return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }];
|
||||
return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }];
|
||||
});
|
||||
const tree = createTreePanel({
|
||||
layer,
|
||||
@@ -86,14 +101,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
settle: () => main.settle(),
|
||||
onChanged: (colId) => main.openTable(layer, colId, true),
|
||||
});
|
||||
// 일위대가 화면이 떠 있나 — 층을 바꿔도 일위대가로 다시 엶 ·
|
||||
// 해시 `open` 도 맞춤(새로 고침 · 카드 진입과 같게) · 옵션 · 도면은 옛 규칙(구조물 조합으로)
|
||||
let view: OpenTarget = entry.open;
|
||||
const mark = (open: OpenTarget): void => {
|
||||
view = open;
|
||||
const query = new URLSearchParams(entryQuery({ ...entry, open }));
|
||||
history.replaceState(history.state, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
|
||||
};
|
||||
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
|
||||
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
|
||||
if (!(await main.confirmLeave())) return;
|
||||
@@ -102,9 +109,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
|
||||
);
|
||||
};
|
||||
// 층 단추 셋(PLAN 29-2) — 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 한 벌씩
|
||||
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌(하위 도면 · 일위대가 · 면적산출도 이 층)
|
||||
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 구조물 = 집계표 · 일위대가 = 조합 목록)
|
||||
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
|
||||
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
@@ -127,16 +134,12 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
const main = createMain(
|
||||
isAdmin,
|
||||
() => void Promise.all([side.refresh(), tree.refresh()]),
|
||||
() => {
|
||||
side.paint();
|
||||
// 일위대가 화면에서 양식으로 옮김(일위대가 · 옵션 화면은 current 없음)
|
||||
if (main.current() && view === "일위대가") mark("구조물조합");
|
||||
},
|
||||
() => side.paint(),
|
||||
{
|
||||
top: readOnlyBar?.root,
|
||||
search: tree.search,
|
||||
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 나무 「항목 추가」와 같은 창
|
||||
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => tree.newItem()) } : {}),
|
||||
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
|
||||
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {}),
|
||||
onColumn: (column) => {
|
||||
readOnlyBar?.setColumn(column);
|
||||
tree.select(column);
|
||||
@@ -150,6 +153,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
/** 새 구조물 조합(39-5 · 답 2) — 새로 만들기 = 나무 「항목 추가」 창 · 복사 생성 = 탭 + 목록 →
|
||||
* 지금 보는 층 · 나무에서 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 새 키 · 새 번호 */
|
||||
const openNew = (): void => {
|
||||
const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop");
|
||||
main.showView(
|
||||
L("M02_TreeNewItem"),
|
||||
buildTwoWay({
|
||||
note: NEW_NOTE.replace("{value}", where()),
|
||||
onNew: () => tree.newItem(),
|
||||
picker: { user, company, kind: "item" },
|
||||
ask: (picked) =>
|
||||
NEW_ASK.replace("{name}", pickedName(picked))
|
||||
.replace("{from}", picked.from.label)
|
||||
.replace("{to}", where()),
|
||||
onCopy: async (picked) => {
|
||||
if ("row" in picked) await tree.copyIn(picked.from, picked.row.key, tree.folder());
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
|
||||
const home: Selection = {
|
||||
layer,
|
||||
@@ -168,19 +191,20 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||
peopleTree("구조물조합", tree.root),
|
||||
],
|
||||
labor: ({ onOpen }) =>
|
||||
buildLaborSection({
|
||||
show: main.showView,
|
||||
confirmLeave: main.confirmLeave,
|
||||
onOpen: () => {
|
||||
onOpen();
|
||||
mark("일위대가");
|
||||
},
|
||||
bar: (add, back) => layerBar("일위대가", add, back),
|
||||
wrap: (panel) => peopleTree("일위대가", panel),
|
||||
make: (reveal) =>
|
||||
buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal, copyTo }),
|
||||
}),
|
||||
// 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`)
|
||||
onAll: (kind) =>
|
||||
void main.showView(
|
||||
`${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`,
|
||||
buildAllItems(kind, layer, owner, (row) => {
|
||||
void main.open({
|
||||
layer,
|
||||
projectId: null,
|
||||
kind: kind as Kind,
|
||||
name: row.열,
|
||||
label: row.이름,
|
||||
});
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||
@@ -200,8 +224,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
root.innerHTML = "";
|
||||
root.append(layout);
|
||||
void main.open(home);
|
||||
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 세 층 ·
|
||||
// 옵션 · 일위대가 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
|
||||
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 구조물 조합 하위(세 층) ·
|
||||
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
|
||||
if (entry.open !== "구조물조합") side.reveal(entry.open);
|
||||
void tree.refresh();
|
||||
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 ·
|
||||
* 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`.
|
||||
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 ·
|
||||
* 면적산출 · 재료산출 · 자재 조합 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 서버 길은 계약 `6_계약.md`.
|
||||
* 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
|
||||
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
|
||||
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
|
||||
@@ -39,21 +40,30 @@ import {
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import type { LaborSection } from "./M02_MasterTemplete_Labor";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
{ kind: "table", label: () => L("M02_KindTable") },
|
||||
{ kind: "drawing", label: () => L("M02_KindDrawing") },
|
||||
];
|
||||
/** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */
|
||||
type ColumnKind = "structure" | "area" | "material";
|
||||
const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [
|
||||
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure") },
|
||||
{ kind: "area", key: "면적번", label: () => L("M02_KindArea") },
|
||||
{ kind: "material", key: "재료번", label: () => L("M02_KindMaterial") },
|
||||
/** 구조물 열마다 한 줄씩인 하위 목록 — 종류 · 번호 칸(null = 열 줄 아직 없음) · 제목 ·
|
||||
* `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가 → 면적산출 */
|
||||
type ColumnKind = "structure" | "labor" | "area" | "material";
|
||||
const COLUMN_KINDS: {
|
||||
kind: ColumnKind;
|
||||
key: keyof StructureNumbers | null;
|
||||
label: () => string;
|
||||
all: boolean;
|
||||
}[] = [
|
||||
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true },
|
||||
{ kind: "labor", key: null, label: () => L("M02_KindLabor"), all: true },
|
||||
{ kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true },
|
||||
{ kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false },
|
||||
];
|
||||
/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */
|
||||
export type AllKind = "structure" | "labor" | "area";
|
||||
/** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */
|
||||
const STUB_DRAWING = {
|
||||
format: 6,
|
||||
@@ -74,10 +84,10 @@ export interface SideOptions {
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
|
||||
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */
|
||||
labor?: (hooks: { onOpen: () => void }) => LaborSection;
|
||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||
tree?: HTMLElement[];
|
||||
/** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */
|
||||
onAll?: (kind: AllKind) => void;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -85,7 +95,7 @@ export interface SideHandle {
|
||||
refresh: () => Promise<void>;
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
paint: () => void;
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */
|
||||
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */
|
||||
reveal: (target: "옵션" | "도면" | "일위대가") => void;
|
||||
}
|
||||
|
||||
@@ -105,6 +115,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
let items: TemplateInfo[] = [];
|
||||
let columns: StructureColumn[] = [];
|
||||
let numbers: Record<string, StructureNumbers> = {};
|
||||
/** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */
|
||||
let allPicked: AllKind | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement =>
|
||||
@@ -136,7 +148,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
close: () => opt.onOpen(null),
|
||||
onOpen: () => (options.clear(), labor?.clear(), paint()),
|
||||
onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint()),
|
||||
});
|
||||
const matsetSection = section(matset.title, matset.body, ...matset.actions);
|
||||
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
|
||||
@@ -148,9 +160,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
isAdmin: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (matset.clear(), labor?.clear(), paint()),
|
||||
onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint()),
|
||||
});
|
||||
const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) });
|
||||
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
|
||||
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
|
||||
sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
|
||||
@@ -158,12 +169,14 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
});
|
||||
return sec;
|
||||
};
|
||||
// 구조물 집계 양식 — 층 단추 · 나무 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) ·
|
||||
const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i]));
|
||||
// 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) ·
|
||||
// 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6)
|
||||
const summary = section(
|
||||
L("M02_KindTable"),
|
||||
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
|
||||
...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])),
|
||||
el("hr", { className: "m02-side__rule" }),
|
||||
...columnSections,
|
||||
matsetSection,
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
@@ -179,17 +192,24 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
}
|
||||
: null;
|
||||
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만
|
||||
const practice =
|
||||
layer === "system" && opt.isAdmin
|
||||
? buildPracticeSection({
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (matset.clear(), options.clear(), (allPicked = null), paint()),
|
||||
})
|
||||
: null;
|
||||
const listHost = el("div", {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())] : []),
|
||||
summary,
|
||||
...(extras ? [extras.옵션, extras.도면] : []),
|
||||
...(practice ? [section(practice.title, practice.body)] : []),
|
||||
],
|
||||
});
|
||||
attachCollapsible(listHost);
|
||||
// 들어오면 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채(PLAN 31-5 · 구조물 조합은 위에서 펼침)
|
||||
labor?.body.closest("section")?.classList.remove("is-collapsed");
|
||||
|
||||
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
|
||||
|
||||
@@ -229,9 +249,25 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/** 구조물 도면 · 면적산출 · 재료산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */
|
||||
function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void {
|
||||
/** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */
|
||||
function allRow(kind: AllKind): HTMLElement {
|
||||
const b = el("button", {
|
||||
className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`,
|
||||
text: L("M02_ItemsAll"),
|
||||
attrs: { type: "button", "data-all": kind },
|
||||
});
|
||||
b.addEventListener("click", () => void selectAll(kind));
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */
|
||||
function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void {
|
||||
const { key } = k;
|
||||
const head = k.all ? [allRow(k.kind as AllKind)] : [];
|
||||
if (key === null) return body.replaceChildren(...head);
|
||||
const kind = k.kind as Kind; // 번호 칸이 있는 하위만 — 일위대가 열 줄은 번호(SU)가 선 뒤
|
||||
body.replaceChildren(
|
||||
...head,
|
||||
...(columns.length
|
||||
? columns.map((col) => {
|
||||
const num = numbers[col.id]?.[key] || L("M02_StructureNone");
|
||||
@@ -246,8 +282,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
);
|
||||
}
|
||||
const paintColumns = (): void =>
|
||||
COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key));
|
||||
const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k));
|
||||
|
||||
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
|
||||
function paintButtons(): void {
|
||||
@@ -258,12 +293,23 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음
|
||||
* (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */
|
||||
function paint(): void {
|
||||
if (opt.getOpen()) allPicked = null;
|
||||
paintList();
|
||||
paintColumns();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) matset.clear();
|
||||
if (opt.getOpen()) options.clear();
|
||||
if (opt.getOpen()) labor?.clear();
|
||||
if (opt.getOpen()) practice?.clear();
|
||||
}
|
||||
|
||||
async function selectAll(kind: AllKind): Promise<void> {
|
||||
if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;
|
||||
matset.clear();
|
||||
options.clear();
|
||||
practice?.clear();
|
||||
allPicked = kind;
|
||||
opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림
|
||||
paint();
|
||||
}
|
||||
|
||||
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
|
||||
@@ -272,7 +318,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
|
||||
}
|
||||
|
||||
async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise<void> {
|
||||
async function selectColumn(col: StructureColumn, kind: Kind): Promise<void> {
|
||||
if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return;
|
||||
// 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」)
|
||||
opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) });
|
||||
@@ -388,9 +434,12 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
const reveal = (target: "옵션" | "도면" | "일위대가"): void => {
|
||||
// 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」
|
||||
if (target === "일위대가") {
|
||||
labor?.body.closest("section")?.classList.remove("is-collapsed");
|
||||
return void labor?.open();
|
||||
columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove(
|
||||
"is-collapsed",
|
||||
);
|
||||
return void selectAll("labor");
|
||||
}
|
||||
extras?.[target].classList.remove("is-collapsed");
|
||||
if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();
|
||||
|
||||
@@ -15,6 +15,53 @@
|
||||
margin-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */
|
||||
.m02-side__rule {
|
||||
margin: var(--spacing-8) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */
|
||||
.m02-practice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-practice__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-practice__bar > button:last-child:not(:first-child) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m02-practice__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-practice__memo {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.m02-practice__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-practice__row:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-side__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -42,7 +89,7 @@
|
||||
overflow-wrap: anywhere; /* 번호 + 이름 — 잘리지 않게 줄바꿈 */
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
font-size: 18px; /* M01 머리 제목과 같음(39-5) */
|
||||
}
|
||||
|
||||
.m02-main__head .ui-btn {
|
||||
@@ -222,14 +269,6 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 나무는 좌측 패널(PLAN 29-2) — 일위대가 화면 = 찾기 칸 위 · 조합 목록 · 상세 아래 */
|
||||
.m02-labor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-main__head + .m02-tree__search {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* =============================================================================
|
||||
* M02_Material_Pick.ts
|
||||
* M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀).
|
||||
* 모양(`m02-pick*`)은 `M02_MasterTemplete_Structure.css`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
|
||||
/** 고른 자재 한 줄 — M01 자재(키 `MT`) */
|
||||
export interface MaterialItem {
|
||||
코드: string;
|
||||
이름: string | null;
|
||||
규격: string | null;
|
||||
단위: string | null;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
pickTitle: "자재 추가",
|
||||
pickClose: "닫기",
|
||||
search: "자재 이름 · 규격 · 코드",
|
||||
searching: "찾는 중…",
|
||||
noMatch: "맞는 자재 없음",
|
||||
searchFailed: "자재 목록을 못 받음",
|
||||
};
|
||||
|
||||
/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 */
|
||||
export function pickMaterial(done: (item: MaterialItem) => void): void {
|
||||
openModal({
|
||||
title: TEXT.pickTitle,
|
||||
closeLabel: TEXT.pickClose,
|
||||
dialogClass: "m02-modal m02-pick",
|
||||
mount: (body, close) => {
|
||||
const search = createInputField({ placeholder: TEXT.search, type: "search" });
|
||||
const list = el("div", { className: "m02-pick__list" });
|
||||
let seq = 0;
|
||||
const paint = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching }));
|
||||
try {
|
||||
const hits = await searchMaterials(search.input.value.trim());
|
||||
if (mine !== seq) return;
|
||||
list.replaceChildren(
|
||||
...(hits.length
|
||||
? hits.map((m) => {
|
||||
const row = el("button", {
|
||||
className: "m02-pick__item",
|
||||
attrs: { type: "button", "data-material": m.코드 },
|
||||
children: [
|
||||
el("span", { className: "m02-pick__key", text: m.코드 }),
|
||||
`${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "),
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
done(m);
|
||||
close();
|
||||
});
|
||||
return row;
|
||||
})
|
||||
: [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq) showToast(TEXT.searchFailed, "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
search.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void paint(), 250);
|
||||
});
|
||||
body.append(search.root, list);
|
||||
void paint();
|
||||
search.input.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user