feat(815-2): M01 별칭 모아 보기 · 제안 → 승인 길

- 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>
This commit is contained in:
2026-11-11 15:02:00 +09:00
co-authored by Claude Opus 5.5
parent c2b9ce859e
commit 285332c44a
8 changed files with 538 additions and 326 deletions
+190 -155
View File
@@ -1,67 +1,55 @@
/* =============================================================================
* M01_MasterData_UI_Alias.ts
* M01 별칭 장부(PLAN 718-3 · 요구서 내역12 4장 23 · 4-1 확정 6) — 본 칸 · 시스템 관리자만.
* · 「모인 후보」 = 모든 회사 · 개인 층 별칭 장부(일위대가 맞춤) + 공사 내역 맞춤을 서버가 모음
* (`/api/m01/alias-ledger` · 718-2) · 이름 · 짝 · 회사 수 · 사람 수(누군지 안 보임) · 충돌 ·
* [승인] = 마스터 장부에 넣음(모든 사용자 제안에 쓰임) · [반려] = 다시 안 보임 · 둘 다 확인 창
* · 「마스터 장부」 = M02 별칭 장부 부품(`buildHopyoLedger` · 마스터 층) 그대로 + 마스터 공사 내역 맞춤 줄 [지움]
* · 자동 반영 없음 — 승인한 것만 모두에게 · 승인 · 반려 · 지움은 누를 때 바로 서버(확인 창 뒤)
* 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 { createTabs } from "@ui/ui_template_tabs";
import "@ui/ui_template_general_layout.css"; // 마스터 장부 부품(M02 Hopyo_Ledger) 표 꼴 — M01 엔 안 실려 있음
import type { LedgerPair } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
import {
buildHopyoLedger,
KIND_TEXT,
LEDGER_HINT,
pairText,
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger";
import { startEdit } from "./M01_MasterData_UI_Cells";
export const ALIAS_TITLE = "별칭 장부";
const KIND_NAME: Record<string, string> = { 맞춤: "일위대가", 내역맞춤: "공사 내역" };
export const ALIAS_TITLE = "별칭 모아 보기";
const KINDS = ["인력", "기계", "재료", "유가", "단가산출 기초"];
interface Origin {
품명: string;
export interface AliasRow {
갈래: string;
파일: string;
키: string;
이름: string;
규격: string;
별칭: string;
조각: { 글: string; 승인: boolean }[];
들어옴: "수기" | "승인";
}
interface Waiting {
id: string;
갈래: string;
키: string;
이름: string;
규격: string;
단위: string;
별칭: string;
누가?: { 이름?: string } | null;
때?: string;
}
export interface AliasCandidate {
종류: "맞춤" | "내역맞춤";
열쇠: string;
서명: string;
원래: Origin;
갈래?: string;
짝?: LedgerPair[];
확정?: string;
이름?: string;
회사: number;
사람: number;
때: string | null;
충돌: boolean;
마스터: "다름" | null;
}
interface MasterBill {
열쇠: string;
원래: Origin;
확정: string;
이름?: string;
}
export interface AliasBoard {
후보: AliasCandidate[];
모은곳: { 회사: number; 사람: number };
반려: number;
마스터내역?: MasterBill[];
interface Board {
줄: AliasRow[];
대기: Waiting[];
}
async function call<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE_URL}/m01/alias-ledger${path}`, {
const res = await fetch(`${API_BASE_URL}/m01/alias${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
@@ -72,72 +60,75 @@ async function call<T>(path: string, body?: unknown): Promise<T> {
return got;
}
/** 짝 글 — 일위대가 = 갈래 · 우리 로직 · 공사 내역 = 우리 공종 이름 */
export const candidateText = (c: AliasCandidate): string =>
c.종류 === "내역맞춤"
? `${c.이름 ?? ""} (${c.확정 ?? ""})`
: c.갈래 === "짝"
? (c.짝 ?? []).map(pairText).join(" + ")
: (KIND_TEXT[c.갈래 ?? ""] ?? c.갈래 ?? "");
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const pick = (c: AliasCandidate) => ({ 종류: c.종류, 열쇠: c.열쇠, 서명: c.서명 });
const CANDIDATE_COLS: Column[] = [
{ label: "종류", width: "84px" },
{ label: "엑셀 품명" },
{ label: "규격" },
const WAIT_COLS: Column[] = [
{ label: "갈래", width: "96px" },
{ label: "가져온 이름 · 규격" },
{ label: "단위", width: "64px" },
{ label: "짝" },
{ label: "회사", width: "56px", align: "end" },
{ label: "사람", width: "56px", align: "end" },
{ label: "충돌", width: "96px" },
{ label: "우리 요소" },
{ label: "누가 · 때", width: "180px" },
{ label: "", width: "136px" },
];
const BILL_COLS: Column[] = [
{ label: "엑셀 품명" },
{ label: "규격" },
{ label: "단위", width: "64px" },
{ label: "우리 공종" },
{ label: "", width: "72px" },
const ROW_COLS: Column[] = [
{ label: "갈래", width: "96px" },
{ label: "키", width: "96px" },
{ label: "이름 · 규격" },
{ label: "별칭" },
{ label: "들어옴", width: "72px" },
];
export function buildAliasLedger(): HTMLElement {
const root = el("div", { className: "m01-alias", attrs: { "data-alias-ledger": "" } });
const body = el("div", { className: "m01-alias__body" });
let view = "candidates";
/** `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 = "";
async function act(c: AliasCandidate, ok: boolean): Promise<void> {
const name = `${c.원래.품명} ${c.원래.규격}`.trim();
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 ? "마스터 장부에 승인" : "후보 반려",
title: ok ? "별칭 승인" : "제안 반려",
message: ok
? `「${name}」 → ${candidateText(c)}\n마스터 장부에 넣으면 모든 사용자 제안에 쓰임(각자 장부가 먼저).`
: `「${name}」 → ${candidateText(c)}\n반려하면 같은 짝은 후보에 다시 안 보임.`,
? `「${w.별칭}」 → ${w.키}\n그 요소 별칭에 더함(다음 불러오기부터 이어짐).`
: `「${w.별칭}」 → ${w.키}\n반려하면 제안이 지워짐.`,
okLabel: ok ? "승인" : "반려",
danger: !ok,
});
if (!yes) return;
try {
const got = await call<AliasBoard>(ok ? "/approve" : "/reject", pick(c));
showToast(ok ? "마스터 장부에 넣음" : "반려함", "success");
paintCandidates(got);
board = await call<Board>(ok ? "/approve" : "/reject", { id: w.id });
showToast(ok ? "별칭에 더함" : "반려함", "success");
paint();
} catch (error) {
showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error");
}
}
function candidateRow(c: AliasCandidate): (string | Node)[] {
const clash = c.마스터 === "다름" ? "마스터와 다름" : c.충돌 ? "다른 짝 있음" : "";
return [
KIND_NAME[c.종류] ?? c.종류,
c.원래.품명,
c.원래.규격,
c.원래.단위,
candidateText(c),
String(c.회사),
String(c.사람),
clash ? createBadge(clash, "warning") : "",
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: [
@@ -145,93 +136,137 @@ export function buildAliasLedger(): HTMLElement {
label: "승인",
variant: "filled",
size: "sm",
onClick: () => void act(c, true),
onClick: () => void decide(w, true),
}),
createButton({
label: "반려",
variant: "ghost",
size: "sm",
onClick: () => void act(c, false),
onClick: () => void decide(w, false),
}),
],
}),
];
}
function paintCandidates(board: AliasBoard): void {
const head = createPanelHead({
title: "모인 후보",
sub: `${LEDGER_HINT} · 회사 ${board.모은곳.회사} · 사람 ${board.모은곳.사람}의 장부에서 모음 · 반려 ${board.반려} · 승인한 것만 모두에게 · 누가 적었는지는 안 보임`,
});
body.replaceChildren(
head.root,
createTable(CANDIDATE_COLS, board.후보.map(candidateRow), {
density: "data",
empty: "모인 후보 없음",
}),
);
}
async function forget(b: MasterBill): Promise<void> {
const yes = await askConfirm({
title: "마스터 공사 내역 맞춤 지움",
message: `「${b.원래.품명} ${b.원래.규격}」 → ${b.이름 ?? b.확정}\n지우면 모든 사용자 제안에서 빠짐.`,
okLabel: "지움",
danger: true,
});
if (!yes) return;
try {
const got = await call<{ 마스터내역: MasterBill[] }>("/master-bill/forget", { 열쇠: b.열쇠 });
showToast("지움", "success");
paintMaster(got.마스터내역);
} catch (error) {
showToast(`지우지 못 함 — ${why(error)}`, "error");
}
}
function paintMaster(bill: MasterBill[]): void {
const rows = bill.map((b) => [
b.원래.품명,
b.원래.규격,
b.원래.단위,
`${b.이름 ?? ""} (${b.확정})`,
createButton({ label: "지움", variant: "danger", size: "sm", onClick: () => void forget(b) }),
]);
body.replaceChildren(
buildHopyoLedger({ scope: { layer: "system", owner: null }, canWrite: true }),
waitHost.replaceChildren(
createPanelHead({
title: "마스터 공사 내역 맞춤",
sub: "B09 내역 줄 ↔ 우리 공종(각자 기억이 먼저)",
title: `승인 대기 ${board.대기.length}`,
sub: "M02 에서 못 이은 줄을 사용자가 우리 요소로 고른 제안 · 승인 = 그 요소 별칭에 더함",
}).root,
createTable(BILL_COLS, rows, { density: "data", empty: "마스터 공사 내역 맞춤 없음" }),
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(body);
const stop = showLoading(listHost);
try {
const got = await call<AliasBoard>("");
if (view === "candidates") paintCandidates(got);
else paintMaster(got.마스터내역 ?? []);
board = await call<Board>("");
paint();
} catch (error) {
body.replaceChildren(loadingError(why(error), () => void load()));
listHost.replaceChildren(loadingError(why(error), () => void load()));
} finally {
stop();
}
}
const tabs = createTabs({
tabs: [
{ key: "candidates", label: "모인 후보" },
{ key: "master", label: "마스터 장부" },
],
value: view,
onChange: (key) => {
view = key;
void load();
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();
},
});
root.append(tabs.root, body); // 제목 = 본 칸 머리(ALIAS_TITLE · 왼쪽 판이 넘김)
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;
}