Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts
T
eomsangdonandClaude Sonnet 5.5 e1b17a523c feat(732-1): M02 실무문서 칸 · 복사 탭 · 서버 길 걷음
- 왼쪽 「실무문서」 칸(Practice.ts) · 일위대가 가져오기 「실무문서」 탭 · 복사 원본 고르기 · 낱 CSS · locale 열쇠 걷음
- 서버 `from_layer=practice`(_copy_practice) · `DELETE /api/m02/practice`(drop_practice) · 조합 목록 · 키 하나 읽기 길 걷음 · 조합 길 시스템 관리자 문 걷음(담을 로직 읽기는 그대로)
- 옛 마스터 조합 파일 `일위대가조합.json`(UA 37) = 쓰는 곳 있음(구조물집계표 일위대가 칸 · labor 본뜬키 · 프로젝트 초기값 사본 · 불러오기 맞춤 씨앗) → 파일 · 읽기 길 유지 · 지우지 않음
- 낡은 시험: test_m02_practice 지움(화면 · 길 없음) · 조합 길 읽던 시험(combo · combo_inputs · combo_layers)은 저장소 함수로 · 실무문서 복사 시험(labor_copy · pair_copy) 줄 지움 · 탭 · 차례 단정(layer_screen · structure_options · structure_panel · 720 · precombo_scope · ui_loading) 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:17:24 +09:00

259 lines
9.2 KiB
TypeScript

/* =============================================================================
* 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 { fetchSessionUser, type SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import {
buildItemList,
ITEM_COLS,
loadItems,
missingPart,
type ItemRow,
type ListKind,
} from "./M02_MasterTemplete_ItemList";
import {
entryFromHash,
ownOwner,
readableSources,
viewCompany,
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: "복사 생성",
};
/** 고른 곳 */
export interface From {
layer: Layer;
owner: string | null;
label: string;
}
export type Picked = { from: From; row: ItemRow };
/** 고른 것 이름 — 확인 글에 씀 */
export const pickedName = (p: Picked): string => leafName(p.row);
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);
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 = createButton({ label: tab.label, variant: "ghost", onClick: () => void show(tab) });
b.classList.add("m02-layers__btn");
b.dataset.source = tab.id;
return b;
});
async function show(tab: Source): Promise<void> {
const mine = ++seq;
buttons.forEach((b) => {
const on = b.dataset.source === tab.id;
b.classList.toggle("is-active", on);
b.classList.toggle("ui-btn--filled", on);
b.classList.toggle("ui-btn--ghost", !on);
});
people.root.hidden = tab.id !== "people";
list.message();
try {
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 };
list.show(
rows.filter((row) => !opt.skip?.(from, row)),
ITEM_COLS[opt.kind],
(row) => opt.onPick({ from, row }),
(row) => missingPart(opt.kind, row), // 그 부분 문서 없는 줄 = 흐림 · 까닭(39-8 H3)
);
} 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;
/** 누른 단추 = 지금 화면 — 진한 단추가 고른 쪽 · 복사 생성 목록은 그쪽일 때만 보임 */
const choose = (which: HTMLElement): void => {
for (const b of [fresh, copy]) {
b.classList.toggle("ui-btn--filled", b === which);
b.classList.toggle("ui-btn--ghost", b !== which);
}
below.hidden = which !== copy;
};
fresh.addEventListener("click", () => {
choose(fresh);
opt.onNew();
});
copy.addEventListener("click", () => {
choose(copy);
if (below.firstChild) return;
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: "ui-text ui-text--muted m02-itempick__note", text: opt.note }),
el("div", { className: "m02-itempick__buttons", children: [fresh, copy] }),
below,
],
});
}
/** 창으로 고름 — 위 안내 한 줄(`note` · 있으면) · 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */
export function pickFrom(
opt: Omit<PickerOptions, "onPick"> & {
title: string;
note?: 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();
})(),
});
if (opt.note)
body.append(
el("p", { className: "ui-text ui-text--muted m02-itempick__note", text: opt.note }),
);
body.append(picker.root);
},
});
});
}
/** 지금 화면의 사람 · 볼 회사로 `pickFrom`(카드 · 집계표 줄처럼 페이지 값을 못 받는 곳) */
export async function pickHere(
opt: Omit<PickerOptions, "onPick" | "user" | "company"> & {
title: string;
note?: string;
ask?: (picked: Picked) => string;
},
): Promise<Picked | null> {
const user = await fetchSessionUser().catch(() => null);
return pickFrom({ ...opt, user, company: viewCompany(entryFromHash(), user) });
}