Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5

This commit is contained in:
2026-10-04 17:45:21 +09:00
48 changed files with 2433 additions and 675 deletions
+2
View File
@@ -62,6 +62,8 @@ export const STATE_REGISTRY = {
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
/** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */
"m02-card-pills": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
+13
View File
@@ -24,6 +24,7 @@ import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys";
import type { ConditionOptions } from "./spreadsheet_condition";
import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
@@ -48,6 +49,7 @@ import type {
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
export type { LinkInfo } from "./spreadsheet_link_set";
export interface SpreadsheetOptions {
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
@@ -76,6 +78,9 @@ export interface SpreadsheetHandle {
recalc(): CalcValues;
/** 알약 키 → 연결 칸 값(빈 칸 0) */
getLinkedValues(): Record<string, CalcValue>;
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
* 주소가 틀리거나 읽기만이면 false */
setLink(key: string, cell: string | null, info?: LinkInfo): boolean;
destroy(): void;
}
@@ -288,6 +293,14 @@ export function createSpreadsheet(
}
return out;
},
setLink(key, cell, info) {
if (readOnly) return false;
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info);
if (command === false) return false;
if (command) dispatch(command);
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
return true;
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
@@ -0,0 +1,55 @@
/* =============================================================================
* spreadsheet_link_set.ts (주인 D)
* 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`).
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 ·
* 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
* ========================================================================== */
import { parseA1, toA1 } from "./spreadsheet_address";
import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types";
/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */
export interface LinkInfo {
이름?: string;
단위?: string;
자재?: LinkMaterial;
}
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */
export function linkCommand(
book: Workbook,
sheetId: string,
key: string,
cell: string | null,
info: LinkInfo = {},
): Command | null | false {
const at = cell === null ? null : parseA1(cell.trim().toUpperCase());
if (cell !== null && !at) return false;
const a1 = at ? toA1(at.r, at.c) : null;
const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key);
const home = book.시트.find((s) => s.id === sheetId);
if (a1 && !home) return false;
if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null;
if (one) {
const 이름 = info.이름 ?? old?.이름;
const 단위 = info.단위 ?? old?.단위;
const 자재 = info.자재 ?? old?.자재;
if (이름) one.이름 = 이름;
if (단위) one.단위 = 단위;
if (자재) one.자재 = 자재;
}
const commands: Command[] = [];
for (const s of book.시트) {
const was = s.연결 ?? [];
const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1));
const next = s === home && one ? [...rest, one] : rest;
if (next.length === was.length && next.every((l, i) => l === was[i])) continue;
const copy = structuredClone(s);
if (next.length) copy.연결 = next;
else delete copy.연결;
commands.push({ 종류: "시트통째", 시트: copy });
}
if (!commands.length) return null;
return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands };
}
+94
View File
@@ -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,
}
)
+99
View File
@@ -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);
}
+316
View File
@@ -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 };
}
+22 -17
View File
@@ -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
@@ -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();
},
});
}
@@ -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("일위대가"))
# ── 항목 연결 파일 ─────────────────────────────────────
+8 -291
View File
@@ -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);
},
});
});
}
+39 -1
View File
@@ -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:
+81 -4
View File
@@ -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 {
@@ -7,22 +7,33 @@
저장소 = `M02_MasterTemplete_Combo_Store`.
담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(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_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):
@@ -173,3 +184,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 {
@@ -83,8 +84,8 @@ export interface TreePanelOptions {
};
/** 항목 추가 창의 일위대가 고르기(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;
/** 항목을 누름 — 표에서 그 열을 고름 */
@@ -107,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>;
}
@@ -417,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))));
@@ -533,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) {
@@ -643,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;
@@ -152,7 +152,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,
@@ -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,
@@ -41,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();
@@ -68,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,
@@ -122,8 +138,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
{
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);
@@ -137,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,
@@ -155,6 +191,20 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
peopleTree("구조물조합", tree.root),
],
// 「전체 항목」(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" });
@@ -89,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 {
+77
View File
@@ -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();
},
});
}
+12 -7
View File
@@ -4,13 +4,18 @@ const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
// 상대 경로 TS(값 함수 = `M02_Labor_Values_Model.ts` 다시 내보냄)는 이어 트랜스파일
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const local = (request) =>
request.startsWith(".") ? loadTs(`${path.resolve(path.dirname(file), request)}.ts`) : require(request);
new Function("exports", "module", "require", js)(box.exports, box, local);
return box.exports;
}
const m = loadTs(path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"));
// 같은 이름(높이)의 입력이 두 로직에 있음
const list = {
+65
View File
@@ -0,0 +1,65 @@
/* M02 항목 목록(`M02_MasterTemplete_ItemList.ts`) 칸 · 찾기 · 쪽 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고
* JSON 으로 냄. 화면 부품(ui_template · M01 머리 · css)은 빈 것으로 갈음 · 나무 자료(`TreePanel_Model`)는 진짜. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const M02 = path.join(__dirname, "..", "..", "M02_MasterTemplete");
const load = (name, stubs) => {
const js = ts.transpileModule(fs.readFileSync(path.join(M02, name), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const need = (id) => (id in stubs ? stubs[id] : {});
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
};
const model = load("M02_MasterTemplete_TreePanel_Model.ts", {});
const m = load("M02_MasterTemplete_ItemList.ts", {
"./M02_MasterTemplete_TreePanel_Model": model,
});
const row = (열, 경로, extra = {}) => ({
key: `SS-${열}`,
열,
경로,
이름: 경로.join(" "),
단위: "m",
도번: `SD-${열}`,
면적번: `SA-${열}`,
수정일: { structure: "2026-10-01T09:00:00", area: "2026-10-03T10:00:00" },
labor: null,
...extra,
});
const rows = [
row("c1", ["기슭막이", "메쌓기", "1.2m"], {
labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 },
수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" },
}),
row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }),
row("c3", ["옹벽"], { 도번: "", 수정일: {} }),
];
const cells = (kind) => rows.map((r) => m.ITEM_COLS[kind].map((c) => c.get(r)));
const many = Array.from({ length: 65 }, (_, i) => row(`n${i}`, ["배수", `측구 ${i}`]));
process.stdout.write(
JSON.stringify({
heads: Object.fromEntries(
Object.entries(m.ITEM_COLS).map(([k, cols]) => [k, cols.map((c) => c.head)]),
),
cells: {
structure: cells("structure"),
area: cells("area"),
labor: cells("labor"),
item: cells("item"),
},
find: m.pageOf(rows, m.ITEM_COLS.structure, "기슭 찰", 1).shown.map((r) => r.열),
findLabor: m.pageOf(rows, m.ITEM_COLS.labor, "SU-000001", 1).shown.map((r) => r.열),
findNone: m.pageOf(rows, m.ITEM_COLS.structure, "없는말", 1),
page3: (({ page, total, shown }) => ({ page, total, n: shown.length }))(
m.pageOf(many, m.ITEM_COLS.structure, "", 3),
),
pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page,
pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page,
}),
);
@@ -0,0 +1,92 @@
/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림.
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands";
import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
let pass = 0;
const failures: string[] = [];
const eq = (name: string, got: unknown, want: unknown): void => {
if (JSON.stringify(got) === JSON.stringify(want)) pass += 1;
else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
};
const book = (): Workbook =>
({
종류: "통합문서",
판: 1,
열: "t",
서식: [{}],
시트: [
{
id: "s1",
이름: "면적산출",
칸: { B1: { 값: 12.5 } },
연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }],
},
{ id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] },
],
활성: "s1",
}) as unknown as Workbook;
const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null);
const run = (b: Workbook, c: Command | null | false) => {
if (!c) return null;
return applyCommand(b, c).undo;
};
// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음
let b = book();
const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" };
let undo = run(
b,
linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }),
);
eq("새 연결", b.시트[0].연결?.[1], {
키: "자재|MT000008",
칸: "D2",
이름: "치즐",
단위: "EA",
자재: mt,
});
// 되돌리기 = 처음 그대로
run(b, undo);
eq("되돌림", links(b), links(book()));
// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것
b = book();
run(b, linkCommand(b, "s1", "깔기|㎡", "B1"));
eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]);
// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나)
b = book();
run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }));
eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다
b = book();
undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1"));
eq("시트 넘어 옮김", links(b), [
[
{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" },
{ 키: "치기|㎥", 칸: "A1", 이름: "치기" },
],
null,
]);
run(b, undo);
eq("시트 넘어 되돌림", links(b), links(book()));
// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐
b = book();
run(b, linkCommand(b, "s1", "깔기|㎡", null));
eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]);
// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false
b = book();
eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null);
eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null);
eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false);
eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false);
eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false);
eq("문서 그대로", links(b), links(book()));
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
+7 -2
View File
@@ -137,11 +137,14 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀():
def test_선택_입력_카드는_sub2_파일_한_벌(out):
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일)."""
"""두 벌 금지 — 모달의 값 받기 카드는 `M02_Labor_Values.ts`(39-3 · 일위대가 입력 모달과 같은
값 칸) · 옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄."""
assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists()
inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8")
values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8")
assert "export function createInputsCard(" in inputs # M02 자리 고르기(link)
assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value)
assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value)
assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs
for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"):
text = (UI / name).read_text(encoding="utf-8")
assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name
@@ -183,6 +186,8 @@ def test_소스가_700줄을_넘지_않음():
M02 / "M02_MasterTemplete_ItemSource.ts",
M02 / "M02_MasterTemplete_Inputs.ts",
M02 / "M02_MasterTemplete_Inputs_Model.ts",
M02 / "M02_Labor_Values.ts",
M02 / "M02_Labor_Values_Model.ts",
):
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name
@@ -75,3 +75,17 @@ def test_저장_안_한_카드_고침_물음():
assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle)
assert main.index("await panel.saveCards();") < main.index("await panel.reload();")
assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main
def test_면적산출_카드_알약_줄_켬끔_기본_숨김():
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in cards # 없으면 숨김
assert 'writeStateRaw("m02-card-pills", String(shown));' in cards
assert 'sheets[0].host.classList.toggle("is-pills-off", !shown);' in cards
assert "pills.hidden = !sheetEdit;" in cards # 고칠 수 있는 카드만(보기만은 알약 줄 없음)
css = _text(M02 / "M02_MasterTemplete_Structure.css")
assert re.search(r"\.m02-struct__sheet\.is-pills-off \.ss-links \{\s*display: none;", css)
state = _text(ROOT / "A00_Common" / "b_page_state.ts")
assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state
# 상세(전체 편집기)는 켬끔 없음 — 늘 보임
assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts")
+2 -2
View File
@@ -44,8 +44,8 @@ def test_네_카드가_틀을_씀_수정_단추_한_모양():
assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost)
assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임
assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards
inputs = _text("M02_MasterTemplete_Inputs.ts")
assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드
inputs = _text("M02_MasterTemplete_Inputs.ts") + _text("M02_Labor_Values.ts")
assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로)
assert "shell.fail(" in inputs and "shell.loading()" in inputs
assert "m02-struct__card ui-collapsible" not in inputs # 손으로 만든 겉 틀 없음
drawing = _text("M02_MasterTemplete_DrawingCard.ts")
+20
View File
@@ -53,9 +53,29 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
app.include_router(items_router.router)
app.include_router(combos_router.router)
app.dependency_overrides[verify_session] = lambda: session
# 문(39-6 · 시스템 관리자만)은 `test_조합_길은_시스템_관리자만` 이 봄 — 여기는 그 안의 층 규칙
app.dependency_overrides[combos_router._door] = lambda: None
return {"client": TestClient(app), "session": session, "storage": storage}
def test_조합_길은_시스템_관리자만(world):
"""39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만."""
world["client"].app.dependency_overrides.pop(combos_router._door)
for role in ("USER", "ADMIN"):
client = _as(world, role)
assert client.get(URL.format("system")).status_code == 403
assert client.get("/api/m02/combos/all").status_code == 403
assert client.get("/api/m02/combos/key/UA000001").status_code == 403
assert _new(client, "personal", _combo("내 것"), 403) is not None
assert client.get("/api/m02/logic-lib/logics").status_code == 200
world["session"]["is_master"] = True
assert _as(world, "USER").get(URL.format("system")).status_code == 403 # 회사 마스터도
world["session"]["is_master"] = False
admin = _as(world, "SYSTEM_ADMIN")
assert admin.get(URL.format("system")).status_code == 200
assert admin.get(URL.format("company")).status_code == 200
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
world["session"].update(role=role, user_id=user)
return world["client"]
+94
View File
@@ -0,0 +1,94 @@
"""M02 항목 목록(PLAN 39-5) — 칸 · 찾기 · 쪽(TS · `helper_m02_item_list.cjs`) ·
서버 목록 칸(임시 storage).
① 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 빈 번호 「—」 · 합계 없으면 「—」 ·
고친 날 = 날짜만 · 찾기 = 보이는 칸 AND · 쪽 30 줄 · 넘친 쪽은 끝 쪽.
② 서버 `items` — `수정일`(도면 · 면적산출 · 일위대가 파일) · `labor`(번호 · 담은 로직 수 ·
합계 = 계산값.반영합.계) · 일위대가 파일이 없으면 null · 깨진 파일 · 수 아닌 합계도 목록은 뜸.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
from resources.tester import test_m02_tree as base
from resources.tester.test_m02_tree import Tree, _as
world = base.world # 같은 임시 storage 판
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def ts() -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_item_list.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_종류마다_칸(ts):
assert ts["heads"]["structure"] == ["도번", "이름", "폴더", "단위", "고친 날"]
assert ts["heads"]["area"] == ["면적번", "이름", "폴더", "단위", "고친 날"]
assert ts["heads"]["labor"] == [
"일위대가번",
"이름",
"폴더",
"단위",
"담은 로직 수",
"합계",
"고친 날",
]
assert "규격" not in sum(ts["heads"].values(), []) and "층" not in sum(ts["heads"].values(), [])
def test_칸_값_빈_번호_합계_날짜(ts):
s, labor = ts["cells"]["structure"], ts["cells"]["labor"]
assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"]
assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음)
assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림
assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음
assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날
def test_찾기_쪽(ts):
assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸)
assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음
assert ts["findNone"] == {"shown": [], "page": 1, "total": 0}
assert ts["page3"] == {"page": 3, "total": 65, "n": 5}
assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1
def test_서버_목록_칸_수정일_labor(world):
t = Tree(_as(world), "personal")
t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m")
t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m")
root = world["storage"] / "7" / "42" / "templates"
ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"])
assert set(ditch["수정일"]) >= {"structure", "area"}
assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO
assert ditch["labor"] is None # 일위대가 파일 없음
(root / "labor").mkdir(exist_ok=True)
doc = {
"일위대가번": "SU-000001",
"담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}],
"계산값": {"반영합": {"계": 12345.6}},
}
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
(root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8")
rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6}
assert "labor" in rows[ditch["열"]]["수정일"]
assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None}
doc["계산값"] = {"반영합": {"계": "글"}}
(root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8")
again = {r["열"]: r for r in t.client.get(t.url).json()["items"]}
assert again[ditch["열"]]["labor"]["합계"] is None
+101
View File
@@ -0,0 +1,101 @@
"""일위대가 넣을 값(39-3) — 입력 목록 길 `GET /api/m02/logic-lib/inputs` · 값 셈(TS 모델).
못박는 것
- 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」.
- 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`).
- 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 ·
모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만).
화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router
ROOT = Path(__file__).resolve().parents[2]
A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비
@pytest.fixture()
def client() -> TestClient:
app = FastAPI()
app.include_router(combos_router.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "USER"}
return TestClient(app)
def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(client: TestClient) -> None:
res = client.get("/api/m02/logic-lib/inputs", params={"keys": f"{B},{A},{B},GF999999,"})
assert res.status_code == 200, res.text
got = res.json()
assert [x["로직"] for x in got["로직"]] == [B, A]
assert got["빠진로직"] == ["GF999999"]
one = got["로직"][1]
assert one["이름"] and one["결과단위"]
assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"])
# 옛 선택 입력 목록과 같은 몸
assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"]
def test_빈_키는_빈_목록(client: TestClient) -> None:
got = client.get("/api/m02/logic-lib/inputs").json()
assert got == {"로직": [], "빠진로직": []}
SCRIPT = r"""
const fs = require("fs");
const path = require("path");
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
const file = path.join(process.argv[1], "M02_MasterTemplete", "M02_Labor_Values_Model.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
const height = { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 };
const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] };
const size = { 이름: "규격", 고르기: [25, 40] };
const area = { 이름: "지역" };
const inputs = [height, kind, size, area];
console.log(JSON.stringify({
stored: [
m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"),
m.storedOf(size, "40"), m.storedOf(area, "서울"),
],
outside: [
m.isOutside(height, "9"), m.isOutside(height, "0.5"), m.isOutside(height, ""),
m.isOutside(height, "abc"), m.isOutside(area, "9"),
],
known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }),
line: m.valueLine(inputs, { 높이: 2, 규격: 40 }),
lineEmpty: m.valueLine(inputs, {}),
}));
"""
def test_값_셈() -> None:
node = shutil.which("node")
if not node:
pytest.skip("node 없음")
out = subprocess.run(
[node, "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8"
)
assert out.returncode == 0, out.stderr
got = json.loads(out.stdout)
assert got["stored"] == [None, 2.5, "막돌", 40, "서울"]
assert got["outside"] == [True, False, False, True, False]
assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]}
assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음
assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님
+60 -11
View File
@@ -16,6 +16,7 @@ from fastapi import HTTPException
from config import config_system
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
@@ -55,16 +56,16 @@ def test_권한_표():
}
assert got == {
"일반": ["읽기", "읽기", "403", "수정", "403", "403"],
"회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"],
"마스터": ["읽기", "수정", "403", "수정", "읽기", "403"],
"시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"],
"회사관리": ["읽기", "수정", "403", "수정", "수정", "403"], # 맡은 회사(39-6)
"마스터": ["읽기", "수정", "403", "수정", "수정", "403"],
"시스템": ["수정", "수정", "수정", "수정", "수정", "수정"], # 어느 회사나 맡음
}
# 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409
assert access.grant(USER, "personal", "7/42").write is True
assert _answer(SYSADMIN, "personal", "9") == "400"
assert _answer({**USER, "company_id": None}, "company", None) == "409"
with pytest.raises(HTTPException) as caught:
access.require(ADMIN, "personal", "7/43", write=True)
access.require(USER, "company", None, write=True)
assert caught.value.status_code == 403
@@ -89,8 +90,19 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
monkeypatch.setattr(layers_router, "_company_users", company_users)
monkeypatch.setattr(layers_router, "_companies", companies)
notes: list[tuple[int, str, str | None]] = []
async def member(owner: str) -> bool:
company, _, user = owner.partition("/")
return users.get(int(user)) == int(company) if user else int(company) in (7, 8, 9)
async def note(session: dict[str, Any], got: Any) -> None:
notes.append((session["user_id"], got.layer, got.owner))
monkeypatch.setattr(items_router, "_member", member)
monkeypatch.setattr(items_router, "_note", note)
session: dict[str, Any] = dict(USER)
return {"client": _app(session), "session": session, "storage": storage}
return {"client": _app(session), "session": session, "storage": storage, "notes": notes}
def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]):
@@ -111,14 +123,44 @@ def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]):
assert client.get(tree).status_code == 200
assert client.get(table).status_code == 200 # 양식 길도 같은 규칙
body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]}
assert client.post(tree, json=body).status_code == 403 # 읽기만
assert client.post(tree, json=body).status_code == 200 # 맡은 회사 = 고침(39-6)
assert world["notes"] == [(42, "personal", "7/43")] # 남의 층 쓰기 = 시스템 로그 한 줄
assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200
session.update(USER)
assert client.get(tree).status_code == 403
assert client.get(table).status_code == 403
assert (
client.post(tree, json={**body, "이름": "몰래"}).status_code == 403
) # 일반 = 남 개인 막힘
session.update(SYSADMIN)
assert client.get(tree).status_code == 200
assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만
body = {"op": "add-folder", "경로": [], "이름": "AS", "판": client.get(tree).json()["판"]}
assert client.post(tree, json=body).status_code == 200 # 시스템 관리자 = 어느 회사나
def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any]):
"""39-6 — 폴더 없는 남의 층에 쓰면 주인이 있을 때만 뼈대 · 없는 사람 · 남의 회사 사람은 403 ·
읽기는 여전히 뼈대를 안 만듦 · 일위대가 키는 그 층 머리(UC · UP)."""
client, session, storage = world["client"], world["session"], world["storage"]
session.update(ADMIN)
add = {"op": "add-folder", "경로": [], "이름": "새", "판": ""}
assert client.get("/api/m02/layers/personal/tree?owner=7/44").status_code == 404
assert not (storage / "7/44").exists()
assert client.post("/api/m02/layers/personal/tree?owner=7/99", json=add).status_code == 403
assert not (storage / "7/99").exists() # 없는 사람 폴더를 안 만듦
assert client.post("/api/m02/layers/personal/tree?owner=9/90", json=add).status_code == 403
first = client.post("/api/m02/layers/personal/tree?owner=7/44", json=add)
assert first.status_code == 409 # 뼈대는 섰고 판이 달라 409 — 다시 읽고 고침
assert (storage / "7/44/templates/table/구조물집계표.json").is_file()
add["판"] = client.get("/api/m02/layers/personal/tree?owner=7/44").json()["판"]
assert client.post("/api/m02/layers/personal/tree?owner=7/44", json=add).status_code == 200
session.update(SYSADMIN)
assert client.post("/api/m02/layers/company/tree?owner=5", json=add).status_code == 403
assert not (storage / "5").exists() # 없는 회사
add["판"] = ""
assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409
assert (storage / "9/templates/table/구조물집계표.json").is_file()
assert [n[2] for n in world["notes"]] == ["7/44", "7/44", "9"]
def test_남의_층_목록(world: dict[str, Any]):
@@ -129,14 +171,21 @@ def test_남의_층_목록(world: dict[str, Any]):
people = "/api/m02/layers/personal/owners"
assert client.get(people).status_code == 403 # 일반 사용자
assert client.get("/api/m02/layers/company/owners").status_code == 403
(storage / "7/43/templates/table").mkdir(parents=True)
(storage / "7/43/templates/table/구조물집계표.json").write_text("{}", encoding="utf-8")
session.update(ADMIN)
assert client.get(people).json() == [
{"owner": "7/43", "name": "u43"}
] # 본인 · 44(자리 없음) 뺌
assert client.get(people).json() == [ # 39-6 — 회사 사람 전부 · 나 맨 위 · 빈 사람 표시
{"owner": "7/42", "name": "u42", "비어있음": True},
{"owner": "7/43", "name": "u43", "비어있음": False},
{"owner": "7/44", "name": "u44", "비어있음": True},
]
assert not (storage / "7/44").exists() # 목록이 폴더를 안 만듦
assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람
session.update(SYSADMIN)
assert client.get("/api/m02/layers/company/owners").json() == [
{"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도
{"owner": "9", "name": "c9"},
]
assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}]
assert client.get(f"{people}?company=9").json() == [
{"owner": "9/90", "name": "u90", "비어있음": True}
]
+70 -7
View File
@@ -26,11 +26,12 @@ def _text(path: Path) -> str:
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_층별_고칠_권한():
"""서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만."""
"""서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c · 39-6) — 남의 층(owner) = 맡은 회사면 고침."""
text = _text(LAYER)
own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M)
found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M)
assert own and found, "ownOwner · accessFor 를 못 찾음"
manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M)
assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음"
script = (
'type ManageLayer = "system" | "company" | "personal";\n'
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
@@ -38,6 +39,8 @@ def test_층별_고칠_권한():
"interface LayerAccess { canWrite: boolean; needCompany: boolean }\n"
+ own.group(0).replace("export ", "")
+ found.group(0).replace("export ", "")
+ "\n"
+ manages.group(0).replace("export ", "")
+ "\nconst cases = JSON.parse(process.argv[1]);"
+ "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));"
)
@@ -54,7 +57,8 @@ def test_층별_고칠_권한():
["personal", None, user], ["personal", None, admin], ["personal", None, orphan],
["personal", None, sysadmin],
["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin],
["personal", "9/90", sysadmin],
["personal", "9/90", sysadmin], ["personal", "3/5", user], ["personal", "9/90", admin],
["company", "9", admin],
] # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
@@ -69,7 +73,8 @@ def test_층별_고칠_권한():
ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만
ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만
ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내
ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만
ok, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침
ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403)
] # fmt: skip
@@ -87,6 +92,55 @@ def test_주소_인자를_라우트에서_뗌():
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_가져올_곳_탭():
"""39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 ·
일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터)."""
text = _text(LAYER)
parts = [
re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M),
re.search(r"^export function manages[\s\S]*?^}", text, re.M),
re.search(r"^export interface Source[\s\S]*?^}", text, re.M),
re.search(r"^export function readableSources[\s\S]*?^}", text, re.M),
]
assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음"
script = (
'type ManageLayer = "system" | "company" | "personal";\n'
"interface SessionUser { id: string; role?: string; company_id?: number | null;"
" is_master?: boolean }\n"
"const L = (key: string): string => key;\n"
+ "\n".join(p.group(0).replace("export ", "") for p in parts if p)
+ "\nconst cases = JSON.parse(process.argv[1]);"
+ "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {"
+ "\n const got = readableSources(u, c, { practice });"
+ "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];"
+ "\n})));"
)
sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1}
admin = {"id": "5", "role": "ADMIN", "company_id": 3}
master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True}
user = {"id": "7", "role": "USER", "company_id": 3}
orphan = {"id": "8", "role": "USER", "company_id": None}
cases = [
[sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False],
[user, "3", True], [orphan, None, False],
] # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(cases)],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄
["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님
["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음
["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자
["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음
["master", ["master"]], # 회사 없음
] # fmt: skip
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_볼_회사는_시스템_관리자만_고름():
"""29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 ·
@@ -132,7 +186,9 @@ def test_층_단추_셋_남의_회사_사람_나무():
assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰
assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무
assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기
assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots
assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6)
assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침
assert 'L("M02_PeopleEmpty")' in roots
assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page
assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page
assert 'peopleTree("구조물조합", tree.root)' in page
@@ -220,12 +276,19 @@ def test_읽기만_표는_단추_고치기_모두_막음():
def test_켜진_층_단추는_목록으로_복사_끌기():
"""30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) ·
30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만."""
30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만."""
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page
# 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창
assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page
assert "(to === layer && toOwner === here)) return [];" in page
copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts")
assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to
assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text(
M02 / "M02_MasterTemplete_TreePanel.ts"
)
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
+7 -1
View File
@@ -77,7 +77,13 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world):
rows = client.get("/api/m02/items").json()
ss = store.read_combos()["줄"]
assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001"
assert set(rows[0]) == {"key", "names", *items_router.items_store.ITEM_FIELDS}
assert set(rows[0]) == {
"key",
"names",
"수정일", # 목록 칸(39-5)
"labor",
*items_router.items_store.ITEM_FIELDS,
}
assert set(rows[0]["names"]) == {"일위대가", "자재조합", "도번", "면적번", "재료번"}
assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos()
# 시스템 층 쓰기는 SYSTEM_ADMIN 만
+29 -5
View File
@@ -13,6 +13,7 @@ from fastapi.testclient import TestClient
from common_util import common_util_storage
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import sample_table
@@ -135,15 +136,23 @@ def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict
assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company"
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None:
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(
world: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
client, session = world["client"], world["session"]
notes: list[str] = []
async def note(_session: dict[str, Any], got: Any) -> None:
notes.append(got.owner)
monkeypatch.setattr(items_router, "_note", note)
body = {"to": "personal", "종류": "table", "이름": "구조물집계표"}
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}"
assert client.get(mine).status_code == 200
session.update(user_id=43)
theirs = mine + "&user_id=42"
# 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만
# 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만(맡은 회사 · 39-6)
assert client.get(theirs).status_code == 403
session["role"] = "ADMIN"
assert client.get(theirs).status_code == 200
@@ -152,8 +161,9 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor
f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42",
json={"판": None, "문서": {"열": []}},
)
assert put.status_code == 403
assert not (world["storage"] / "7/42/templates/table/내표.json").exists()
assert put.status_code == 200 # 회사 관리자 = 같은 회사 개인 고침 · 시스템 로그
assert (world["storage"] / "7/42/templates/table/내표.json").is_file()
assert notes == ["7/42"]
session["role"] = "USER"
# 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀
put = client.put(
@@ -165,7 +175,12 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor
got = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
)
assert got.status_code == 200
assert got.status_code == 403 # 일반 = 남 개인 못 가져옴(39-6)
session["role"] = "ADMIN"
got = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
)
assert got.status_code == 200 # 회사 관리자 = 같은 회사 개인 가져옴
assert client.get(mine.replace(P1, P_OTHER)).status_code == 403
far = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90}
@@ -186,6 +201,15 @@ def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str
sources = client.get(f"/api/m02/projects/{P1}/sources").json()
assert [row["project_id"] for row in sources["project"]] == [P2]
assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"}
for user in (42, 43): # 개인 층 둘 — 일반은 내 것만 · 회사 관리자는 회사 사람 전부(39-6)
layers.write_template(layers.personal_dir(7, user), "table", "표", {"열": []})
assert [row["user_id"] for row in sources_of(client)] == [42]
world["session"]["role"] = "ADMIN"
assert [row["user_id"] for row in sources_of(client)] == [42, 43]
def sources_of(client: TestClient) -> list[dict[str, Any]]:
return client.get(f"/api/m02/projects/{P1}/sources").json()["personal"]
def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None:
@@ -0,0 +1,68 @@
"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4).
순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 —
새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소.
손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts"
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_setLink_순수_길():
with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir:
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
build = subprocess.run(
[
"node",
str(VITE),
"build",
"--configLoader",
"runner",
"--ssr",
entry_rel,
"--outDir",
outdir,
],
cwd=str(ROOT),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
run = subprocess.run(
["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
assert run.stdout.strip(), run.stderr
result = json.loads(run.stdout)
assert result["fail"] == 0, result["failures"]
assert result["pass"] == 13
def test_손잡이_setLink_몸():
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src
body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0]
assert "if (readOnly) return false;" in body
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body
assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님)
assert "loadLinks();" in body
+7 -4
View File
@@ -103,6 +103,9 @@ export const ui_locales_m2 = {
M02_RootShared: ["회사 공용", "Company shared"],
M02_RootReadOnly: ["읽기", "read-only"],
M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"],
M02_SourcePeople: ["다른 사람", "Others"],
M02_SourcePractice: ["실무문서", "Practice documents"],
M02_PeopleEmpty: ["비어 있음", "empty"],
M02_TreeSearch: [
"찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)",
"Find — name, key, folder, unit (all words)",
@@ -142,10 +145,10 @@ export const ui_locales_m2 = {
M02_NewItemPickCombo: ["고르기", "Pick"],
M02_TreePickCombo: ["일위대가 고르기", "Pick unit price"],
M02_TableFold: ["표 접기 · 펴기", "Fold or unfold the table"],
M02_TreeCopyToMaster: ["마스터로 복사 ({n}개)", "Copy to master ({n})"],
M02_TreeCopyToCompany: ["회사로 복사 ({n}개)", "Copy to company ({n})"],
M02_TreeCopyToPersonal: ["개인으로 복사 ({n}개)", "Copy to personal ({n})"],
M02_TreeCopiedTo: ["{n}개를 {layer} 맨 위에 복사함", "Copied {n} to the top of {layer}"],
M02_TreeCopyToMaster: ["마스터로 복사… ({n}개)", "Copy to master… ({n})"],
M02_TreeCopyToCompany: ["회사로 복사… ({n}개)", "Copy to company… ({n})"],
M02_TreeCopyToPersonal: ["개인으로 복사… ({n}개)", "Copy to personal… ({n})"],
M02_TreeCopiedTo: ["{n}개를 {layer} 「{folder}」에 복사함", "Copied {n} to {layer} “{folder}”"],
M02_TreeFolderName: ["폴더 이름", "Folder name"],
M02_TreeItemName: ["항목 이름", "Item name"],
M02_TreeUnit: ["단위", "Unit"],