feat(M02): 가져올 곳 고르기 ItemPick — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록 · 칸 안 · 창 · 줄 누름 확인
39-5 H3 — 탭 목록 · 기본 탭 = readableSources(39-6 E5) · 다른 사람 = 사람 고르기 하나 · 실무문서 = 마스터 일위대가 조합(일위대가 고를 때). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/* 가져올 곳 고르기(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);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ItemPick.ts
|
||||
* 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`).
|
||||
* 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀.
|
||||
* 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈
|
||||
* (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로.
|
||||
* 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만).
|
||||
* ========================================================================== */
|
||||
|
||||
import { 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 { 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: "다른 사람이 없음",
|
||||
failed: "목록을 못 받음 — {value}",
|
||||
};
|
||||
|
||||
/** 고른 곳 — `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));
|
||||
|
||||
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") {
|
||||
owners ??= await fetchOwners("personal", opt.company);
|
||||
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: o.name })),
|
||||
owners[0].owner,
|
||||
);
|
||||
}
|
||||
owner = people.select.value;
|
||||
label = `${tab.label} · ${owners.find((o) => o.owner === owner)?.name ?? owner}`;
|
||||
}
|
||||
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,
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 창으로 고름 — 줄 누름 → `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);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user