- M01 왼쪽 「별칭 장부」 → 「별칭 모아 보기」 — 별칭이 적힌 줄 전부(인력 · 기계 · 재료 · 유가 · 단가산출 기초) · 수기/승인 표시 · 갈래 거름 · 찾기 · 줄 누르면 그 요소 화면 그 줄 · 별칭 칸 고침 → [저장](그 줄 별칭만) - 위 「승인 대기 n」 — [승인] = 그 요소 별칭에 콤마로 더함(속 콤마 = 큰따옴표 조각) · [반려] = 지움 - 서버 M01_MasterData_Alias 새로 — GET /api/m01/alias · POST save · approve · reject(시스템 관리자) · POST suggest(로그인만 · storage/_shared/별칭제안.json) · 풀이 = master_alias - 옛 /api/m01/alias-ledger(모인 후보 · 마스터 장부 · 반려 기억) 걷음 - M01 저장 — 값이 같은 칸은 파일 글 그대로(화면 왕복 9.0 → 9 막음 · 그 줄 별칭만 바뀜) - 대시보드 카드 이름 「별칭 모아 보기」 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
9.1 KiB
TypeScript
273 lines
9.1 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_Alias.ts
|
|
* M01 별칭 모아 보기(PLAN 815-2 · 요구 764 · 765) — 본 칸 · 시스템 관리자만.
|
|
* · 위 = 「승인 대기 n」 — 일반 사용자 제안(가져온 이름 · 규격 → 우리 요소) · [승인] = 그 요소 별칭에
|
|
* 콤마로 더함 · [반려] = 지움(둘 다 확인 창 뒤 바로 서버)
|
|
* · 아래 = 별칭이 적힌 줄 전부(인력 · 기계 · 재료 · 유가 · 단가산출 기초 — 수기로 넣은 것도 바로) ·
|
|
* 갈래 거름 · 찾기 · 별칭 칸 눌러 고침(콤마로 여러 개) → [저장](그 줄 별칭만 · `/api/m01/alias/save`) ·
|
|
* 줄을 누르면 그 요소 화면 그 줄 · 단가산출 기초는 로직 탭
|
|
* · 서버 = `M01_MasterData_Alias.py` · 조각 나누기 = 서버(`master_alias.names`)
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { createBadge } from "@ui/ui_template_badge";
|
|
import { askConfirm, createButton, el, showToast } from "@ui/ui_template_elements";
|
|
import { createInputField } from "@ui/ui_template_input";
|
|
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
|
import { createPanelHead } from "@ui/ui_template_panel_head";
|
|
import { createSegment } from "@ui/ui_template_segment";
|
|
import { createTable, type Column } from "@ui/ui_template_table";
|
|
import { startEdit } from "./M01_MasterData_UI_Cells";
|
|
|
|
export const ALIAS_TITLE = "별칭 모아 보기";
|
|
const KINDS = ["인력", "기계", "재료", "유가", "단가산출 기초"];
|
|
|
|
export interface AliasRow {
|
|
갈래: string;
|
|
파일: string;
|
|
키: string;
|
|
이름: string;
|
|
규격: string;
|
|
별칭: string;
|
|
조각: { 글: string; 승인: boolean }[];
|
|
들어옴: "수기" | "승인";
|
|
}
|
|
interface Waiting {
|
|
id: string;
|
|
갈래: string;
|
|
키: string;
|
|
이름: string;
|
|
규격: string;
|
|
단위: string;
|
|
별칭: string;
|
|
누가?: { 이름?: string } | null;
|
|
때?: string;
|
|
}
|
|
interface Board {
|
|
줄: AliasRow[];
|
|
대기: Waiting[];
|
|
}
|
|
|
|
async function call<T>(path: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(`${API_BASE_URL}/m01/alias${path}`, {
|
|
method: body === undefined ? "GET" : "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
const got = (await res.json()) as T & { detail?: unknown };
|
|
if (!res.ok) throw new Error(String(got.detail ?? `HTTP ${res.status}`));
|
|
return got;
|
|
}
|
|
|
|
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
|
|
const WAIT_COLS: Column[] = [
|
|
{ label: "갈래", width: "96px" },
|
|
{ label: "가져온 이름 · 규격" },
|
|
{ label: "단위", width: "64px" },
|
|
{ label: "우리 요소" },
|
|
{ label: "누가 · 때", width: "180px" },
|
|
{ label: "", width: "136px" },
|
|
];
|
|
const ROW_COLS: Column[] = [
|
|
{ label: "갈래", width: "96px" },
|
|
{ label: "키", width: "96px" },
|
|
{ label: "이름 · 규격" },
|
|
{ label: "별칭" },
|
|
{ label: "들어옴", width: "72px" },
|
|
];
|
|
|
|
/** `open` = 줄을 눌렀을 때(왼쪽 판이 그 요소 화면 · 로직 탭으로) · `setDirty` = 저장 안 한 고침 표시 */
|
|
export function buildAliasBoard(opts: {
|
|
open: (row: AliasRow) => void;
|
|
setDirty?: (dirty: boolean) => void;
|
|
}): HTMLElement {
|
|
const root = el("div", { className: "m01-alias", attrs: { "data-alias-board": "" } });
|
|
const waitHost = el("div");
|
|
const listHost = el("div");
|
|
const edits = new Map<string, string>();
|
|
let board: Board = { 줄: [], 대기: [] };
|
|
let kind = "";
|
|
let q = "";
|
|
|
|
const save = createButton({ label: "저장", variant: "filled", disabled: true });
|
|
const undo = createButton({ label: "되돌림", variant: "ghost", disabled: true });
|
|
const mark = (): void => {
|
|
save.disabled = undo.disabled = edits.size === 0;
|
|
opts.setDirty?.(edits.size > 0);
|
|
};
|
|
|
|
async function decide(w: Waiting, ok: boolean): Promise<void> {
|
|
const yes = await askConfirm({
|
|
title: ok ? "별칭 승인" : "제안 반려",
|
|
message: ok
|
|
? `「${w.별칭}」 → ${w.키}\n그 요소 별칭에 더함(다음 불러오기부터 이어짐).`
|
|
: `「${w.별칭}」 → ${w.키}\n반려하면 제안이 지워짐.`,
|
|
okLabel: ok ? "승인" : "반려",
|
|
danger: !ok,
|
|
});
|
|
if (!yes) return;
|
|
try {
|
|
board = await call<Board>(ok ? "/approve" : "/reject", { id: w.id });
|
|
showToast(ok ? "별칭에 더함" : "반려함", "success");
|
|
paint();
|
|
} catch (error) {
|
|
showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error");
|
|
}
|
|
}
|
|
|
|
const target = (key: string): string => {
|
|
const one = board.줄.find((r) => r.키 === key);
|
|
return one ? `${key} · ${one.이름} ${one.규격}`.trim() : key;
|
|
};
|
|
|
|
function paintWaiting(): void {
|
|
const rows = board.대기.map((w) => [
|
|
w.갈래,
|
|
`${w.이름} ${w.규격}`.trim(),
|
|
w.단위,
|
|
target(w.키),
|
|
`${w.누가?.이름 ?? ""} · ${(w.때 ?? "").slice(0, 16).replace("T", " ")}`,
|
|
el("div", {
|
|
className: "ui-toolbar",
|
|
children: [
|
|
createButton({
|
|
label: "승인",
|
|
variant: "filled",
|
|
size: "sm",
|
|
onClick: () => void decide(w, true),
|
|
}),
|
|
createButton({
|
|
label: "반려",
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => void decide(w, false),
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
waitHost.replaceChildren(
|
|
createPanelHead({
|
|
title: `승인 대기 ${board.대기.length}`,
|
|
sub: "M02 에서 못 이은 줄을 사용자가 우리 요소로 고른 제안 · 승인 = 그 요소 별칭에 더함",
|
|
}).root,
|
|
createTable(WAIT_COLS, rows, { density: "data", empty: "승인 대기 없음" }),
|
|
);
|
|
}
|
|
|
|
function aliasCell(r: AliasRow): HTMLElement {
|
|
const box = el("div", { className: "ui-toolbar", attrs: { "data-alias-cell": r.키 } });
|
|
const edited = edits.get(r.키);
|
|
if (edited !== undefined) box.append(createBadge(edited || "(지움)", "warning"));
|
|
else for (const p of r.조각) box.append(createBadge(p.글, p.승인 ? "success" : "neutral"));
|
|
box.title = "눌러 고침 — 콤마로 여러 개";
|
|
box.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
startEdit(box, edited ?? r.별칭, (text) => {
|
|
if (text.trim() === r.별칭.trim()) edits.delete(r.키);
|
|
else edits.set(r.키, text.trim());
|
|
mark();
|
|
paintList();
|
|
});
|
|
});
|
|
return box;
|
|
}
|
|
|
|
function paintList(): void {
|
|
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
const shown = board.줄.filter(
|
|
(r) =>
|
|
(!kind || r.갈래 === kind) &&
|
|
words.every((w) => `${r.키} ${r.이름} ${r.규격} ${r.별칭}`.toLowerCase().includes(w)),
|
|
);
|
|
const table = createTable(
|
|
ROW_COLS,
|
|
shown.map((r) => [
|
|
r.갈래,
|
|
r.키,
|
|
`${r.이름} ${r.규격}`.trim(),
|
|
aliasCell(r),
|
|
createBadge(r.들어옴, r.들어옴 === "승인" ? "success" : "neutral"),
|
|
]),
|
|
{ density: "data", empty: "별칭이 적힌 줄 없음" },
|
|
);
|
|
table.querySelectorAll("tbody tr").forEach((tr, i) => {
|
|
const r = shown[i];
|
|
if (!r) return;
|
|
tr.setAttribute("data-key", r.키);
|
|
tr.addEventListener("click", () => opts.open(r));
|
|
});
|
|
listHost.replaceChildren(table);
|
|
}
|
|
|
|
function paint(): void {
|
|
paintWaiting();
|
|
paintList();
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
const stop = showLoading(listHost);
|
|
try {
|
|
board = await call<Board>("");
|
|
paint();
|
|
} catch (error) {
|
|
listHost.replaceChildren(loadingError(why(error), () => void load()));
|
|
} finally {
|
|
stop();
|
|
}
|
|
}
|
|
|
|
save.addEventListener("click", async () => {
|
|
save.disabled = true;
|
|
try {
|
|
for (const [key, text] of [...edits]) {
|
|
board = await call<Board>("/save", { 키: key, 별칭: text });
|
|
edits.delete(key);
|
|
}
|
|
showToast("별칭 저장함", "success");
|
|
} catch (error) {
|
|
showToast(`저장 못 함 — ${why(error)}`, "error");
|
|
} finally {
|
|
mark();
|
|
paint();
|
|
}
|
|
});
|
|
undo.addEventListener("click", () => {
|
|
edits.clear();
|
|
mark();
|
|
paintList();
|
|
});
|
|
|
|
const kinds = createSegment({
|
|
options: [{ value: "", label: "전체" }, ...KINDS.map((k) => ({ value: k, label: k }))],
|
|
value: kind,
|
|
onChange: (v) => {
|
|
kind = v;
|
|
paintList();
|
|
},
|
|
ariaLabel: "갈래",
|
|
});
|
|
const find = createInputField({
|
|
type: "search",
|
|
placeholder: "키 · 이름 · 별칭 찾기",
|
|
compact: true,
|
|
onInput: (v) => {
|
|
q = v.trim();
|
|
paintList();
|
|
},
|
|
});
|
|
const listHead = createPanelHead({
|
|
title: "별칭이 적힌 줄",
|
|
sub: "수기로 넣은 것 · 승인한 것 모두 · 줄을 누르면 그 요소 화면 · 별칭 칸을 눌러 고친 뒤 [저장]",
|
|
});
|
|
root.append(
|
|
waitHost,
|
|
listHead.root,
|
|
el("div", { className: "ui-toolbar", children: [kinds.root, find.root, undo, save] }),
|
|
listHost,
|
|
);
|
|
void load();
|
|
return root;
|
|
}
|