Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -178,7 +178,7 @@ export function buildSide(
|
||||
children: [
|
||||
el("span", {
|
||||
children: [
|
||||
el("span", { className: "m01-tree__caret", text: caret }),
|
||||
el("span", { className: "ui-tree__caret", text: caret }),
|
||||
el("span", { text: node.label }),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -17,24 +17,6 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-tree__kids {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m01-tree__branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.m01-tree__caret {
|
||||
display: inline-block;
|
||||
width: var(--spacing-12);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-side__muted,
|
||||
.m01-master__muted {
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Tree.ts
|
||||
* 왼쪽 패널 트리 — 부모(접기/펴기) · 자식(들여쓰기) · 줄 수 표시
|
||||
* 왼쪽 패널 트리 — 공용 `ui_template_tree` 를 그대로 다시 내보냄(부르는 쪽 import 불변)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 줄 수 — null 이면 안 보임 */
|
||||
count: number | null;
|
||||
/** 눌렀을 때 — 없으면 접기/펴기만 */
|
||||
run?: () => void;
|
||||
children?: TreeNode[];
|
||||
/** 처음부터 펴짐 */
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
|
||||
export type MakeRow = (
|
||||
node: TreeNode,
|
||||
caret: string,
|
||||
depth: number,
|
||||
onClick: () => void,
|
||||
) => HTMLElement;
|
||||
|
||||
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
|
||||
return nodes.map((node) => {
|
||||
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
|
||||
const kids = el("div", {
|
||||
className: "m01-tree__kids",
|
||||
children: renderTree(node.children, make, depth + 1),
|
||||
});
|
||||
kids.hidden = !node.open;
|
||||
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
|
||||
kids.hidden = !kids.hidden;
|
||||
const caret = row.querySelector(".m01-tree__caret");
|
||||
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
|
||||
node.run?.();
|
||||
});
|
||||
return el("div", { className: "m01-tree__branch", children: [row, kids] });
|
||||
});
|
||||
}
|
||||
export { renderTree, type MakeRow, type TreeNode } from "@ui/ui_template_tree";
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 항목 고르기 자료 쪽 검증 — 나무 · 찾기 · 돌려줄 summary_item · 폴더 줄 · 가짜 응답(21-1 모양).
|
||||
* 실행: node --experimental-strip-types resources/tester/ui/item-picker-data.test.ts
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildTree,
|
||||
filterRows,
|
||||
folderLines,
|
||||
isCurrent,
|
||||
pathIds,
|
||||
rowId,
|
||||
toSummary,
|
||||
type TreeLabels,
|
||||
} from "../../../ui_template/ui_template_item_picker_data.ts";
|
||||
import { fakeCopy, fakeItems } from "../../../ui_template/ui_template_item_picker_fake.ts";
|
||||
|
||||
const labels: TreeLabels = {
|
||||
layers: { system: "마스터", company: "회사", personal: "개인" },
|
||||
unclassified: "분류 없음",
|
||||
};
|
||||
const rows = fakeItems();
|
||||
|
||||
// 나무 — 그룹은 늘 마스터 · 회사 · 개인 차례 · 그룹 → 종류 → 분류1 → 잎
|
||||
const tree = buildTree(rows, labels);
|
||||
assert.deepEqual(
|
||||
tree.map((g) => [g.label, g.count]),
|
||||
[
|
||||
["마스터", 5],
|
||||
["회사", 2],
|
||||
["개인", 1],
|
||||
],
|
||||
);
|
||||
const system = tree[0];
|
||||
assert.deepEqual(
|
||||
system.children?.map((k) => [k.label, k.count]),
|
||||
[
|
||||
["돌기슭막이", 3],
|
||||
["콘크리트포장", 1],
|
||||
["관보호공", 1],
|
||||
],
|
||||
);
|
||||
const cat = system.children?.[0].children?.[0];
|
||||
assert.equal(cat?.label, "메쌓기");
|
||||
assert.equal(cat?.count, 2);
|
||||
const leaf = cat?.children?.[0];
|
||||
assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유");
|
||||
assert.equal(leaf?.tip, "SS000001"); // 키는 툴팁
|
||||
assert.equal(leaf?.count, null);
|
||||
assert.ok(leaf?.row);
|
||||
|
||||
// 분류가 빈 항목은 「분류 없음」 한 칸에 모임
|
||||
const company = tree[1];
|
||||
assert.equal(company.children?.[1].children?.[0].label, "분류 없음");
|
||||
|
||||
// 빈 그룹도 한 줄은 남음
|
||||
const onlySystem = buildTree(
|
||||
rows.filter((r) => r.layer === "system"),
|
||||
labels,
|
||||
);
|
||||
assert.deepEqual(
|
||||
onlySystem.map((g) => [g.id, g.count, g.children?.length]),
|
||||
[
|
||||
["system", 5, 3],
|
||||
["company", 0, 0],
|
||||
["personal", 0, 0],
|
||||
],
|
||||
);
|
||||
|
||||
// 잎 id 는 층 · 주인 · 키 — 같은 키라도 층이 다르면 다른 줄
|
||||
const twin = { ...rows[0], layer: "company" as const, owner: "샘플회사" };
|
||||
assert.notEqual(rowId(rows[0]), rowId(twin));
|
||||
|
||||
// 찾기 — 이름 · 키 · 연결 이름 · 여러 낱말은 모두 들어 있어야 함 · 빈 글은 전부
|
||||
assert.equal(filterRows(rows, "").length, rows.length);
|
||||
assert.equal(filterRows(rows, " ").length, rows.length);
|
||||
assert.deepEqual(
|
||||
filterRows(rows, "옹벽").map((r) => r.key),
|
||||
["SS000102"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterRows(rows, "ss000004").map((r) => r.key),
|
||||
["SS000004"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterRows(rows, "돌기슭막이 개인").map((r) => r.key),
|
||||
["SS000201"],
|
||||
);
|
||||
assert.equal(filterRows(rows, "없는이름").length, 0);
|
||||
|
||||
// 돌려줄 summary_item — 마스터는 owner null · 회사 · 개인은 주인
|
||||
assert.deepEqual(toSummary(rows[0]), {
|
||||
layer: "system",
|
||||
owner: null,
|
||||
key: "SS000001",
|
||||
name: "돌기슭막이 메쌓기 1.2m 기초유",
|
||||
});
|
||||
const mine = rows.find((r) => r.layer === "personal");
|
||||
assert.ok(mine);
|
||||
assert.deepEqual(toSummary(mine), {
|
||||
layer: "personal",
|
||||
owner: "샘플회사/사용자",
|
||||
key: "SS000201",
|
||||
name: "돌기슭막이 메쌓기 1.2m(개인)",
|
||||
});
|
||||
// layer 없는 줄(옛 서버)은 마스터로 봄
|
||||
assert.equal(toSummary({ key: "SS000009", 열: "임시" }).layer, "system");
|
||||
|
||||
// 지금 지정된 항목인지
|
||||
assert.ok(isCurrent(toSummary(mine), mine));
|
||||
assert.ok(!isCurrent(toSummary(mine), rows[0]));
|
||||
assert.ok(!isCurrent(null, rows[0]));
|
||||
|
||||
// 처음 펼칠 자리
|
||||
assert.deepEqual(pathIds(rows[0], labels), [
|
||||
"system",
|
||||
"system|돌기슭막이",
|
||||
"system|돌기슭막이|메쌓기",
|
||||
]);
|
||||
|
||||
// 폴더 모양 — 도면 · 면적산출 · 재료산출은 항목 이름 · 번호는 툴팁 · 값이 없으면 빈 글
|
||||
const lines = folderLines(rows[0]);
|
||||
assert.deepEqual(
|
||||
lines.map((l) => l.kind),
|
||||
["drawing", "area", "material", "combo", "unitPrice"],
|
||||
);
|
||||
assert.equal(lines[0].text, "돌기슭막이 메쌓기 1.2m 기초유");
|
||||
assert.equal(lines[0].tip, "SD000001");
|
||||
assert.equal(folderLines({ key: "SS1", 열: "빈 항목" })[3].text, "");
|
||||
|
||||
// 가짜 복사 — 새 개인 줄이 생기고 키가 겹치지 않음
|
||||
const before = fakeItems();
|
||||
const copied = fakeCopy(before[0]);
|
||||
const after = fakeItems();
|
||||
assert.equal(after.length, before.length + 1);
|
||||
assert.equal(copied.layer, "personal");
|
||||
assert.equal(new Set(after.map(rowId)).size, after.length);
|
||||
|
||||
console.log("항목 고르기 자료 시험 통과");
|
||||
@@ -0,0 +1,101 @@
|
||||
/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */
|
||||
.ui-item-picker {
|
||||
width: min(900px, 94vw);
|
||||
}
|
||||
|
||||
.ui-item-picker .ui-modal__body {
|
||||
height: min(60vh, 520px);
|
||||
}
|
||||
|
||||
.ui-item-picker__cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr);
|
||||
gap: var(--spacing-12);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-item-picker__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-item-picker__name {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-heading);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-item-picker__where,
|
||||
.ui-item-picker__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-item-picker__hint {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-item-picker__folder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin: 0;
|
||||
padding: 0 0 0 var(--spacing-12);
|
||||
border-left: 2px solid var(--color-border);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ui-item-picker__folder li {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-item-picker__folder-label {
|
||||
flex: 0 0 5.5em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-item-picker__none {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-item-picker__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker.ts
|
||||
* 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름.
|
||||
* 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0).
|
||||
*
|
||||
* const result = await openItemPicker({ current, canCopy });
|
||||
* result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel"
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_item_picker.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, createInputField, el, showToast } from "./ui_template_elements";
|
||||
import { fakeCopy, fakeItems } from "./ui_template_item_picker_fake";
|
||||
import {
|
||||
buildTree,
|
||||
filterRows,
|
||||
folderLines,
|
||||
isCurrent,
|
||||
layerOf,
|
||||
ownerOf,
|
||||
pathIds,
|
||||
rowId,
|
||||
toSummary,
|
||||
type FolderKind,
|
||||
type PickNode,
|
||||
type PickerRow,
|
||||
type SummaryItem,
|
||||
type TreeLabels,
|
||||
} from "./ui_template_item_picker_data";
|
||||
import { t } from "./ui_template_locale";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree";
|
||||
|
||||
export type { SummaryItem } from "./ui_template_item_picker_data";
|
||||
|
||||
export interface ItemPickerOptions {
|
||||
/** 지금 지정된 항목 — 없으면 null */
|
||||
current: SummaryItem | null;
|
||||
/** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */
|
||||
canCopy: boolean;
|
||||
}
|
||||
|
||||
export type ItemPickerResult =
|
||||
{ action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" };
|
||||
|
||||
const MANAGE_HASH = "#/m02-master-template?layer=personal";
|
||||
|
||||
const FOLDER_LABEL: Record<FolderKind, () => string> = {
|
||||
drawing: () => t("M02_PickerDrawing"),
|
||||
area: () => t("M02_PickerArea"),
|
||||
material: () => t("M02_PickerMaterial"),
|
||||
combo: () => t("M02_PickerCombo"),
|
||||
unitPrice: () => t("M02_PickerUnitPrice"),
|
||||
};
|
||||
|
||||
const labels = (): TreeLabels => ({
|
||||
layers: {
|
||||
system: t("M02_PickerSystem"),
|
||||
company: t("M02_PickerCompany"),
|
||||
personal: t("M02_PickerPersonal"),
|
||||
},
|
||||
unclassified: t("M02_PickerNoClass"),
|
||||
});
|
||||
|
||||
/** 서버가 막은 까닭 그대로 */
|
||||
function reasonOf(body: unknown, status: number): string {
|
||||
const detail = (body as { detail?: unknown } | null)?.detail;
|
||||
return typeof detail === "string" && detail ? detail : `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** 실서버 응답 — 아직 없으면(404 · 405) 21-1 모양 가짜 응답. ponytail: 21-2 서버가 오면 가짜를 지움 */
|
||||
async function loadRows(): Promise<PickerRow[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" });
|
||||
if (response.status === 404 || response.status === 405) return fakeItems();
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
PickerRow[] | { items?: PickerRow[]; detail?: unknown } | null;
|
||||
if (!response.ok) throw new Error(reasonOf(body, response.status));
|
||||
return Array.isArray(body) ? body : (body?.items ?? []);
|
||||
}
|
||||
|
||||
/** 개인용으로 복사 — 새로 생긴 줄의 키를 돌려줌(서버가 안 주면 null) */
|
||||
async function copyToPersonal(row: PickerRow): Promise<string | null> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02/layers/personal/items/copy`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from_layer: layerOf(row), from_owner: ownerOf(row), key: row.key }),
|
||||
});
|
||||
if (response.status === 404 || response.status === 405) return fakeCopy(row).key;
|
||||
const body = (await response.json().catch(() => null)) as { key?: string } | null;
|
||||
if (!response.ok) throw new Error(reasonOf(body, response.status));
|
||||
return body?.key ?? null;
|
||||
}
|
||||
|
||||
const message = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResult> {
|
||||
return new Promise((resolve) => {
|
||||
let rows: PickerRow[] = [];
|
||||
let chosen: PickerRow | null = null;
|
||||
/** 접었다 펼친 것을 다시 그려도 남기려고 펼친 줄 id 를 기억 */
|
||||
const opened = new Set<string>();
|
||||
const leafButtons = new Map<string, HTMLElement>();
|
||||
|
||||
const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true });
|
||||
const treeHost = el("div", { className: "ui-item-picker__tree" });
|
||||
const detail = el("div", { className: "ui-item-picker__detail" });
|
||||
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
|
||||
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
|
||||
const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" });
|
||||
const manageButton = createButton({ label: t("M02_PickerManage"), variant: "ghost" });
|
||||
let close = (): void => {};
|
||||
|
||||
const finish = (result: ItemPickerResult): void => {
|
||||
resolve(result);
|
||||
close();
|
||||
};
|
||||
|
||||
const hint = (text: string): HTMLElement =>
|
||||
el("p", { className: "ui-item-picker__hint", text });
|
||||
|
||||
const paintDetail = (): void => {
|
||||
if (!chosen) {
|
||||
detail.replaceChildren(hint(t("M02_PickerHint")));
|
||||
} else {
|
||||
const where = [labels().layers[layerOf(chosen)], chosen.종류, chosen.분류1]
|
||||
.filter(Boolean)
|
||||
.join(" › ");
|
||||
const small = (text: string): HTMLElement =>
|
||||
el("p", { className: "ui-item-picker__where", text });
|
||||
detail.replaceChildren(
|
||||
el("h4", {
|
||||
className: "ui-item-picker__name",
|
||||
text: chosen.열,
|
||||
attrs: { title: chosen.key },
|
||||
}),
|
||||
small(where),
|
||||
...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []),
|
||||
el("ul", {
|
||||
className: "ui-item-picker__folder",
|
||||
children: folderLines(chosen).map((line) =>
|
||||
el("li", {
|
||||
attrs: line.tip ? { title: line.tip } : {},
|
||||
children: [
|
||||
el("span", {
|
||||
className: "ui-item-picker__folder-label",
|
||||
text: FOLDER_LABEL[line.kind](),
|
||||
}),
|
||||
el("span", {
|
||||
className: line.text ? "" : "ui-item-picker__none",
|
||||
text: line.text || t("M02_PickerNone"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const layer = chosen ? layerOf(chosen) : null;
|
||||
applyButton.disabled = !chosen;
|
||||
clearButton.disabled = !opts.current;
|
||||
copyButton.hidden = !(chosen && layer !== "personal" && opts.canCopy);
|
||||
manageButton.hidden = !(chosen && layer === "personal");
|
||||
};
|
||||
|
||||
const choose = (row: PickerRow): void => {
|
||||
chosen = row;
|
||||
for (const [id, button] of leafButtons)
|
||||
button.classList.toggle("is-active", id === rowId(row));
|
||||
paintDetail();
|
||||
};
|
||||
|
||||
const toTree = (node: PickNode, searching: boolean): TreeNode => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
count: node.count,
|
||||
tip: node.tip,
|
||||
open: searching || opened.has(node.id),
|
||||
run: node.row ? () => choose(node.row as PickerRow) : undefined,
|
||||
children: node.children?.map((child) => toTree(child, searching)),
|
||||
});
|
||||
|
||||
const paintTree = (): void => {
|
||||
leafButtons.clear();
|
||||
const text = search.input.value;
|
||||
const searching = text.trim() !== "";
|
||||
const groups = buildTree(filterRows(rows, text), labels())
|
||||
// 찾는 중에는 결과가 없는 그룹을 감춤
|
||||
.filter((group) => !searching || (group.count ?? 0) > 0)
|
||||
.map((group) => toTree(group, searching));
|
||||
if (!groups.length) {
|
||||
treeHost.replaceChildren(hint(t("M02_PickerNoMatch")));
|
||||
return;
|
||||
}
|
||||
treeHost.replaceChildren(
|
||||
...renderTree(groups, (node, caret, depth, onClick) => {
|
||||
const button = createTreeRow(node, caret, depth, () => {
|
||||
onClick();
|
||||
if (!node.children?.length) return;
|
||||
if (button.querySelector(".ui-tree__caret")?.textContent === "▾") opened.add(node.id);
|
||||
else opened.delete(node.id);
|
||||
});
|
||||
if (node.run) {
|
||||
leafButtons.set(node.id, button);
|
||||
if (chosen && node.id === rowId(chosen)) button.classList.add("is-active");
|
||||
}
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const load = async (pickKey: string | null): Promise<void> => {
|
||||
treeHost.replaceChildren(hint(t("M02_PickerLoading")));
|
||||
try {
|
||||
rows = await loadRows();
|
||||
} catch (error) {
|
||||
treeHost.replaceChildren(
|
||||
hint(t("M02_PickerLoadFailed").replace("{value}", message(error))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const start = pickKey
|
||||
? rows.find((row) => layerOf(row) === "personal" && row.key === pickKey)
|
||||
: rows.find((row) => isCurrent(opts.current, row));
|
||||
if (start) {
|
||||
for (const id of pathIds(start, labels())) opened.add(id);
|
||||
chosen = start;
|
||||
} else {
|
||||
opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침
|
||||
}
|
||||
paintTree();
|
||||
paintDetail();
|
||||
treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
search.input.addEventListener("input", paintTree);
|
||||
applyButton.addEventListener(
|
||||
"click",
|
||||
() => chosen && finish({ action: "apply", item: toSummary(chosen) }),
|
||||
);
|
||||
clearButton.addEventListener("click", () => finish({ action: "clear" }));
|
||||
manageButton.addEventListener("click", () => {
|
||||
finish({ action: "cancel" });
|
||||
location.hash = MANAGE_HASH;
|
||||
});
|
||||
copyButton.addEventListener("click", async () => {
|
||||
if (!chosen) return;
|
||||
copyButton.disabled = true;
|
||||
try {
|
||||
const key = await copyToPersonal(chosen);
|
||||
showToast(t("M02_PickerCopied"), "success");
|
||||
await load(key);
|
||||
} catch (error) {
|
||||
showToast(t("M02_PickerCopyFailed").replace("{value}", message(error)), "error");
|
||||
} finally {
|
||||
copyButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
close = openModal({
|
||||
title: t("M02_PickerTitle"),
|
||||
closeLabel: t("M02_Close"),
|
||||
dialogClass: "ui-item-picker",
|
||||
// 닫기 단추 · Esc · 바깥 누르기 = 취소
|
||||
beforeClose: () => {
|
||||
resolve({ action: "cancel" });
|
||||
return true;
|
||||
},
|
||||
mount: (body) => {
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "ui-item-picker__cols",
|
||||
children: [
|
||||
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
|
||||
detail,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-item-picker__actions",
|
||||
children: [copyButton, manageButton, clearButton, applyButton],
|
||||
}),
|
||||
);
|
||||
paintDetail();
|
||||
void load(null);
|
||||
},
|
||||
}).close;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_data.ts
|
||||
* 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`.
|
||||
* 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`.
|
||||
* ========================================================================== */
|
||||
|
||||
export type PickerLayer = "system" | "company" | "personal";
|
||||
|
||||
/** 21-1 `summary_item` — 구조물 기록에 붙는 항목 참조 */
|
||||
export interface SummaryItem {
|
||||
layer: PickerLayer;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** `GET /api/m02/items` 한 줄(21-1) — `?all=1` 은 줄마다 `layer` · `owner` 가 붙음 */
|
||||
export interface PickerRow {
|
||||
key: string;
|
||||
열: string;
|
||||
종류?: string;
|
||||
분류1?: string;
|
||||
분류2?: string;
|
||||
분류3?: string;
|
||||
단위?: string;
|
||||
일위대가?: string;
|
||||
자재조합?: string;
|
||||
도번?: string;
|
||||
면적번?: string;
|
||||
재료번?: string;
|
||||
layer?: PickerLayer;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** 그룹 차례 = 마스터 · 회사 · 개인 */
|
||||
export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "personal"];
|
||||
|
||||
export interface PickNode {
|
||||
id: string;
|
||||
/** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */
|
||||
label: string;
|
||||
/** 마우스를 올렸을 때 — 잎은 키 */
|
||||
tip?: string;
|
||||
/** 아래 잎 수 — 잎은 null */
|
||||
count: number | null;
|
||||
children?: PickNode[];
|
||||
/** 잎이면 그 항목 */
|
||||
row?: PickerRow;
|
||||
}
|
||||
|
||||
export const layerOf = (row: PickerRow): PickerLayer => row.layer ?? "system";
|
||||
export const ownerOf = (row: PickerRow): string | null =>
|
||||
layerOf(row) === "system" ? null : (row.owner ?? null);
|
||||
|
||||
/** 같은 항목의 자리표 — 층 · 주인 · 키 */
|
||||
export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
|
||||
|
||||
export function toSummary(row: PickerRow): SummaryItem {
|
||||
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: row.열 };
|
||||
}
|
||||
|
||||
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {
|
||||
return (
|
||||
!!current &&
|
||||
current.layer === layerOf(row) &&
|
||||
(current.owner ?? null) === ownerOf(row) &&
|
||||
current.key === row.key
|
||||
);
|
||||
}
|
||||
|
||||
/** 찾기 — 이름 · 종류 · 분류 · 키 · 연결 이름에 글이 들어 있는 줄(빈 글이면 전부) */
|
||||
export function filterRows(rows: PickerRow[], text: string): PickerRow[] {
|
||||
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return rows;
|
||||
return rows.filter((row) => {
|
||||
const hay = [
|
||||
row.열,
|
||||
row.종류,
|
||||
row.분류1,
|
||||
row.분류2,
|
||||
row.분류3,
|
||||
row.key,
|
||||
row.일위대가,
|
||||
row.자재조합,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return words.every((word) => hay.includes(word));
|
||||
});
|
||||
}
|
||||
|
||||
export interface TreeLabels {
|
||||
layers: Record<PickerLayer, string>;
|
||||
/** 종류 · 분류1 이 비었을 때 */
|
||||
unclassified: string;
|
||||
}
|
||||
|
||||
/** 그룹 → 종류 → 분류1 → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */
|
||||
export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] {
|
||||
return PICKER_LAYERS.map((layer) => {
|
||||
const mine = rows.filter((row) => layerOf(row) === layer);
|
||||
const kinds = new Map<string, Map<string, PickerRow[]>>();
|
||||
for (const row of mine) {
|
||||
const kind = row.종류 || labels.unclassified;
|
||||
const cat = row.분류1 || labels.unclassified;
|
||||
const cats = kinds.get(kind) ?? new Map<string, PickerRow[]>();
|
||||
cats.set(cat, [...(cats.get(cat) ?? []), row]);
|
||||
kinds.set(kind, cats);
|
||||
}
|
||||
const kindNodes: PickNode[] = [...kinds].map(([kind, cats]) => {
|
||||
const catNodes: PickNode[] = [...cats].map(([cat, leaves]) => ({
|
||||
id: `${layer}|${kind}|${cat}`,
|
||||
label: cat,
|
||||
count: leaves.length,
|
||||
children: leaves.map((row) => ({
|
||||
id: rowId(row),
|
||||
label: row.열,
|
||||
tip: row.key,
|
||||
count: null,
|
||||
row,
|
||||
})),
|
||||
}));
|
||||
return {
|
||||
id: `${layer}|${kind}`,
|
||||
label: kind,
|
||||
count: catNodes.reduce((sum, node) => sum + (node.count ?? 0), 0),
|
||||
children: catNodes,
|
||||
};
|
||||
});
|
||||
return { id: layer, label: labels.layers[layer], count: mine.length, children: kindNodes };
|
||||
});
|
||||
}
|
||||
|
||||
/** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */
|
||||
export function pathIds(row: PickerRow, labels: TreeLabels): string[] {
|
||||
const layer = layerOf(row);
|
||||
const kind = row.종류 || labels.unclassified;
|
||||
const cat = row.분류1 || labels.unclassified;
|
||||
return [layer, `${layer}|${kind}`, `${layer}|${kind}|${cat}`];
|
||||
}
|
||||
|
||||
export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice";
|
||||
|
||||
export interface FolderLine {
|
||||
kind: FolderKind;
|
||||
/** 보이는 이름 */
|
||||
text: string;
|
||||
/** 마우스를 올렸을 때 — 키 · 번호 */
|
||||
tip: string;
|
||||
}
|
||||
|
||||
/** 오른쪽 폴더 모양 — 도면 · 면적산출 · 재료산출은 그 항목(열) 이름 · 자재조합 · 일위대가는 서버 값 */
|
||||
export function folderLines(row: PickerRow): FolderLine[] {
|
||||
return [
|
||||
{ kind: "drawing", text: row.열, tip: row.도번 ?? "" },
|
||||
{ kind: "area", text: row.열, tip: row.면적번 ?? "" },
|
||||
{ kind: "material", text: row.열, tip: row.재료번 ?? "" },
|
||||
{ kind: "combo", text: row.자재조합 ?? "", tip: row.자재조합 ?? "" },
|
||||
{ kind: "unitPrice", text: row.일위대가 ?? "", tip: row.일위대가 ?? "" },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_fake.ts
|
||||
* 항목 목록 서버(`GET /api/m02/items` · 복사 `POST /api/m02/layers/personal/items/copy`, 21-2)가
|
||||
* 아직 없을 때만 쓰는 가짜 응답 — 21-1 모양 그대로. 실서버가 오면 이 파일과 부르는 자리를 지움.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { PickerRow } from "./ui_template_item_picker_data";
|
||||
|
||||
const row = (
|
||||
key: string,
|
||||
열: string,
|
||||
종류: string,
|
||||
분류1: string,
|
||||
layer: PickerRow["layer"],
|
||||
extra: Partial<PickerRow> = {},
|
||||
): PickerRow => ({
|
||||
key,
|
||||
열,
|
||||
종류,
|
||||
분류1,
|
||||
분류2: "",
|
||||
분류3: "",
|
||||
단위: "m",
|
||||
일위대가: `${열} 일위대가`,
|
||||
자재조합: `${열} 자재`,
|
||||
도번: `SD${key.slice(2)}`,
|
||||
면적번: `SA${key.slice(2)}`,
|
||||
재료번: `SM${key.slice(2)}`,
|
||||
layer,
|
||||
owner: layer === "company" ? "샘플회사" : layer === "personal" ? "샘플회사/사용자" : null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
let personalSeq = 900;
|
||||
|
||||
const memory: PickerRow[] = [
|
||||
row("SS000001", "돌기슭막이 메쌓기 1.2m 기초유", "돌기슭막이", "메쌓기", "system"),
|
||||
row("SS000002", "돌기슭막이 메쌓기 1.5m 기초유", "돌기슭막이", "메쌓기", "system"),
|
||||
row("SS000003", "돌기슭막이 찰쌓기 1.2m", "돌기슭막이", "찰쌓기", "system"),
|
||||
row("SS000004", "콘크리트포장 T=20cm B=3m", "콘크리트포장", "일반", "system", { 단위: "㎡" }),
|
||||
row("SS000005", "관보호공 콘크리트 유입", "관보호공", "콘크리트", "system", { 단위: "개소" }),
|
||||
row("SS000101", "돌기슭막이 메쌓기 1.2m(회사)", "돌기슭막이", "메쌓기", "company"),
|
||||
row("SS000102", "옹벽 H=2m(회사)", "옹벽", "", "company"),
|
||||
row("SS000201", "돌기슭막이 메쌓기 1.2m(개인)", "돌기슭막이", "메쌓기", "personal"),
|
||||
];
|
||||
|
||||
export const fakeItems = (): PickerRow[] => memory.map((item) => ({ ...item }));
|
||||
|
||||
/** 복사 — 새 개인 항목을 맨 뒤에 더하고 그 줄을 돌려줌(서버는 새 SS 키를 돌려줄 것) */
|
||||
export function fakeCopy(from: PickerRow): PickerRow {
|
||||
personalSeq += 1;
|
||||
const copy: PickerRow = {
|
||||
...from,
|
||||
key: `SS000${personalSeq}`,
|
||||
layer: "personal",
|
||||
owner: "샘플회사/사용자",
|
||||
};
|
||||
memory.push(copy);
|
||||
return { ...copy };
|
||||
}
|
||||
@@ -56,4 +56,27 @@ export const ui_locales_m2 = {
|
||||
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
|
||||
M02_StructureNone: ["없음", "None"],
|
||||
M02_Back: ["돌아가기", "Back"],
|
||||
M02_PickerTitle: ["항목 고르기", "Pick an item"],
|
||||
M02_PickerSearch: ["항목 찾기", "Search items"],
|
||||
M02_PickerSystem: ["마스터", "Master"],
|
||||
M02_PickerCompany: ["회사", "Company"],
|
||||
M02_PickerPersonal: ["개인", "Personal"],
|
||||
M02_PickerNoClass: ["분류 없음", "Unclassified"],
|
||||
M02_PickerHint: ["왼쪽에서 항목을 고를 것", "Pick an item on the left"],
|
||||
M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"],
|
||||
M02_PickerLoading: ["읽는 중…", "Loading…"],
|
||||
M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"],
|
||||
M02_PickerDrawing: ["도면", "Drawing"],
|
||||
M02_PickerArea: ["면적산출", "Area takeoff"],
|
||||
M02_PickerMaterial: ["재료산출", "Material takeoff"],
|
||||
M02_PickerCombo: ["자재조합", "Material combo"],
|
||||
M02_PickerUnitPrice: ["일위대가", "Unit price"],
|
||||
M02_PickerUnit: ["단위", "Unit"],
|
||||
M02_PickerNone: ["없음", "None"],
|
||||
M02_PickerApply: ["적용", "Apply"],
|
||||
M02_PickerClear: ["해제", "Clear"],
|
||||
M02_PickerCopy: ["개인용으로 복사", "Copy to personal"],
|
||||
M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"],
|
||||
M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"],
|
||||
M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"],
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) */
|
||||
.ui-tree__kids {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ui-tree__branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ui-tree__caret {
|
||||
display: inline-block;
|
||||
width: var(--spacing-12);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-tree__count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/* =============================================================================
|
||||
* ui_template_tree.ts
|
||||
* 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀.
|
||||
* 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_tree.css";
|
||||
import { el } from "./ui_template_elements";
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 줄 수 — null 이면 안 보임 */
|
||||
count: number | null;
|
||||
/** 눌렀을 때 — 없으면 접기/펴기만 */
|
||||
run?: () => void;
|
||||
children?: TreeNode[];
|
||||
/** 처음부터 펴짐 */
|
||||
open?: boolean;
|
||||
/** 마우스를 올렸을 때 글(기본 줄) */
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
|
||||
export type MakeRow = (
|
||||
node: TreeNode,
|
||||
caret: string,
|
||||
depth: number,
|
||||
onClick: () => void,
|
||||
) => HTMLElement;
|
||||
|
||||
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
|
||||
return nodes.map((node) => {
|
||||
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
|
||||
const kids = el("div", {
|
||||
className: "ui-tree__kids",
|
||||
children: renderTree(node.children, make, depth + 1),
|
||||
});
|
||||
kids.hidden = !node.open;
|
||||
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
|
||||
kids.hidden = !kids.hidden;
|
||||
const caret = row.querySelector(".ui-tree__caret");
|
||||
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
|
||||
node.run?.();
|
||||
});
|
||||
return el("div", { className: "ui-tree__branch", children: [row, kids] });
|
||||
});
|
||||
}
|
||||
|
||||
/** 기본 줄 — 화살표 · 글 · 줄 수 · 깊이 들임. 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */
|
||||
export function createTreeRow(
|
||||
node: TreeNode,
|
||||
caret: string,
|
||||
depth: number,
|
||||
onClick: () => void,
|
||||
): HTMLButtonElement {
|
||||
const button = el("button", {
|
||||
className: "ui-tree__row ui-sidebar-row",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", {
|
||||
children: [
|
||||
el("span", { className: "ui-tree__caret", text: caret }),
|
||||
el("span", { text: node.label }),
|
||||
],
|
||||
}),
|
||||
...(node.count === null
|
||||
? []
|
||||
: [el("span", { className: "ui-tree__count", text: node.count.toLocaleString("ko-KR") })]),
|
||||
],
|
||||
});
|
||||
button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`;
|
||||
if (node.tip) button.title = node.tip;
|
||||
button.addEventListener("click", onClick);
|
||||
return button;
|
||||
}
|
||||
Reference in New Issue
Block a user