/* ============================================================================= * M02_MasterTemplete_Library_Same.ts * 서고 [중복 찾기](733-3 · 요구서_572 3장 4) — 좌측 일위대가 · 단가산출 나무 메뉴에서 엶. * 창 = 같은 내용 묶음(이 층 + 위 층 · 서버 `GET {kind}-same`) · 묶음마다 꼬리표(내용 같음 · 수량만 다름) · * 문서 줄(층 · 키 · 이름 · 구분 · 쓰는 곳 수) · [남김] 고르기 · [합치기](물음 → 서버 `merge` · 이 층 나머지 문서를 * 지움 · 쓰는 곳 참조를 남김으로) · 결과(합침 · 권한 밖 남김) · 합친 것 목록 [되돌림](`undo`) · * 나무 지우기 기록(735-1 · 남김 없음 = 「지움」 · 고친 쓰는 곳 수)도 같은 목록 · 같은 [되돌림]. * 권한 = 그 층 쓰기(서버가 봄) · 판 다르면 409 → 다시 찾음. * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { askConfirm, openModal } from "@ui/ui_template_modal"; import { createTable, type Cell } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { call, StaleError, type Layer, type LibraryKind } from "./M02_MasterTemplete_Api_Fetch"; import "./M02_MasterTemplete_Library_Same.css"; const TEXT = { Title: ["중복 찾기", "Find duplicates"], Close: ["닫기", "Close"], Intro: [ "내용이 같은 문서 묶음 — [남김]을 고르고 [합치기]하면 이 층의 나머지 문서를 지우고 쓰는 곳을 남김으로 옮김 · [되돌림]으로 되살림", "Groups with the same content — pick [Keep] and [Merge]: other docs on this layer are removed and their users point to the kept one · [Undo] restores", ], None: ["같은 내용 문서 없음", "No duplicates"], Loading: ["찾는 중…", "Searching…"], Same: ["내용 같음", "Same content"], QtyOnly: ["수량만 다름", "Quantity differs"], Layer: ["층", "Layer"], Key: ["키", "Key"], Name: ["이름", "Name"], Class: ["구분", "Category"], Used: ["쓰는 곳", "Used by"], Keep: ["남김", "Keep"], Kept: ["남김 ✓", "Kept ✓"], Merge: ["합치기", "Merge"], Upper: ["위 층", "Upper"], system: ["마스터", "Master"], company: ["회사", "Company"], personal: ["개인", "Personal"], project: ["프로젝트", "Project"], Ask: [ "「{keep}」 하나로 합침 — 이 층 문서 {n} 개를 지우고 쓰는 곳 참조를 옮김 · 지운 문서는 [되돌림]으로 되살림", "Merge into “{keep}” — remove {n} doc(s) on this layer and move their users · [Undo] restores", ], AskQty: [ "⚠ 실무 수량(엑셀 규격)이 서로 다름 — 지운 문서의 실무 수량 기록은 되돌림 때까지 안 보임", "⚠ Practice quantities differ — removed docs' quantity record is hidden until undo", ], Done: ["합침 {n} · 옮긴 쓰는 곳 {m}", "Merged {n} · moved users {m}"], Left: [ "남김 — 「{key}」 쓰는 곳 {n}(권한 밖 · 못 옮김): {list}", "Kept “{key}” — {n} user(s) not moved: {list}", ], Gone: ["합친 것 · 지운 것(되돌림)", "Merged · deleted (undo)"], GoneNone: ["합친 것 · 지운 것 없음", "Nothing merged or deleted"], Deleted: ["지움", "Deleted"], When: ["때", "When"], Into: ["남김", "Kept as"], Undo: ["되돌림", "Undo"], AskUndo: [ "「{key}」 되돌림 — 문서를 되살리고 옮긴 쓰는 곳 {n} 을 다시 이 문서로", "Undo “{key}” — restore it and point its {n} user(s) back", ], AskUndoDeleted: [ "「{key}」 되돌림 — 지운 문서를 되살리고 쓰는 곳 {n} 에서 지운 줄을 다시 넣음(그새 고친 곳은 건너뜀)", "Undo “{key}” — restore deleted doc(s) and put the lines back in {n} user(s) (skips ones edited since)", ], Undone: ["되돌림 — 쓰는 곳 {n}", "Undone — users {n}"], Stale: ["그새 바뀜 — 다시 찾음", "Changed meanwhile — searching again"], } as const; type TextKey = keyof typeof TEXT; const tx = (key: TextKey, vars: Record = {}): string => Object.entries(vars).reduce( (s, [k, v]) => s.replace(`{${k}}`, String(v)), String(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]), ); /** 나무 메뉴 글 */ export const sameLabel = (): string => tx("Title"); export interface SameRef { 층: Layer; 주인: string | null; 키: string; } export interface SameDoc extends SameRef { 이름: string; 구분: string; 상세구분: string; 쓰는곳수: number; 위층: boolean; } export interface SameGroup { 지문: string; 꼬리표: "내용 같음" | "수량만 다름"; 문서: SameDoc[]; } export interface GoneRow { 키: string; 이름: string; /** null = 나무 지우기 기록(735-1) */ 남김: SameRef | null; 때: string; 옮긴곳수: number; } export interface SameView { 판: string; 묶음: SameGroup[]; 합친것: GoneRow[]; } export interface MergeResult extends SameView { 합침: { 키: string; 옮긴곳수: number }[]; 남김: { 키: string; 쓰는곳: string[] }[]; } const path = (kind: LibraryKind, layer: Layer, owner: string | null, tail = ""): string => `/layers/${layer}/${kind}-same${tail}${owner && layer !== "system" ? `?owner=${encodeURIComponent(owner)}` : ""}`; const post = (url: string, body: unknown): Promise => call(url, { method: "POST", body: JSON.stringify(body) }); const why = (error: unknown): string => (error instanceof Error ? error.message : ""); /** 묶음 하나에서 남김을 고를 때 지울 것 — 이 층(위 층 아님) 문서 가운데 남김 아닌 것 */ export const goneOf = (group: SameGroup, keep: SameRef): string[] => group.문서 .filter((d) => !d.위층 && !(d.층 === keep.층 && d.주인 === keep.주인 && d.키 === keep.키)) .map((d) => d.키); export interface SameOptions { kind: LibraryKind; layer: Layer; owner: string | null; /** 합치기 · 되돌림 뒤 — 좌측 나무 다시 그림 */ changed: () => void; } export function openLibrarySame(opt: SameOptions): void { const host = el("div", { className: "m02-same", attrs: { "data-library-same": opt.kind } }); const note = el("div", { className: "m02-same__note" }); let view: SameView | null = null; const load = async (): Promise => { host.replaceChildren(createEmpty({ text: tx("Loading"), small: true })); try { view = await call(path(opt.kind, opt.layer, opt.owner)); } catch (error) { host.replaceChildren(createText(why(error), "error")); return; } draw(); }; const run = async (job: () => Promise, done: (got: SameView) => void) => { try { const got = await job(); view = got; done(got); opt.changed(); draw(); } catch (error) { // 판 다름(409 · 서버 몸 = {stale, 판}) = 까닭 글 없음 → 「그새 바뀜」만 const text = why(error); const stale = error instanceof StaleError; showToast(stale && text.startsWith("HTTP") ? tx("Stale") : text, "error"); if (stale) void load(); } }; const merge = async (group: SameGroup, keep: SameDoc): Promise => { const gone = goneOf(group, keep); const message = [ tx("Ask", { keep: `${keep.키} ${keep.이름}`, n: gone.length }), ...(group.꼬리표 === "수량만 다름" ? [tx("AskQty")] : []), ].join("\n"); if (!view || !gone.length) return; if (!(await askConfirm({ message, okLabel: tx("Merge"), title: tx("Title") }))) return; const 판 = view.판; await run( () => post(path(opt.kind, opt.layer, opt.owner, "/merge"), { 남김: { 층: keep.층, 주인: keep.주인, 키: keep.키 }, 지움: gone, 판, }), (got) => { const res = got as MergeResult; const moved = res.합침.reduce((n, r) => n + r.옮긴곳수, 0); showToast(tx("Done", { n: res.합침.length, m: moved }), "success"); note.replaceChildren( ...res.남김.map((k) => createText( tx("Left", { key: k.키, n: k.쓰는곳.length, list: k.쓰는곳.join(" · ") }), "warning", ), ), ); }, ); }; const undo = async (row: GoneRow): Promise => { const ask = row.남김 ? "AskUndo" : "AskUndoDeleted"; const message = tx(ask, { key: `${row.키} ${row.이름}`, n: row.옮긴곳수 }); if (!(await askConfirm({ message, okLabel: tx("Undo"), title: tx("Title") }))) return; await run( () => post(path(opt.kind, opt.layer, opt.owner, "/undo"), { 키: row.키 }), (got) => { const back = (got as SameView & { 되돌린곳수?: number }).되돌린곳수 ?? 0; showToast(tx("Undone", { n: back }), "success"); note.replaceChildren(); }, ); }; const groupBlock = (group: SameGroup): HTMLElement => { let keep = group.문서[0]; const columns = [ { label: tx("Keep"), width: "88px" }, { label: tx("Layer"), width: "88px" }, { label: tx("Key"), width: "112px" }, { label: tx("Name") }, { label: tx("Class") }, { label: tx("Used"), width: "72px", align: "end" as const }, ]; const tableHost = el("div"); const paint = (): void => { const rows: Cell[][] = group.문서.map((d) => [ createButton({ label: d === keep ? tx("Kept") : tx("Keep"), variant: d === keep ? "filled" : "ghost", size: "sm", onClick: () => { keep = d; paint(); }, }), d.위층 ? `${tx(d.층)} · ${tx("Upper")}` : tx(d.층), d.키, d.이름, [d.구분, d.상세구분].filter(Boolean).join(" / "), String(d.쓰는곳수), ]); tableHost.replaceChildren(createTable(columns, rows, { density: "data" })); mergeButton.disabled = !goneOf(group, keep).length; }; const mergeButton = createButton({ label: tx("Merge"), onClick: () => void merge(group, keep), }); const tag = group.꼬리표 === "수량만 다름" ? tx("QtyOnly") : tx("Same"); const head = el("div", { className: "m02-same__head", children: [ createBadge(tag, group.꼬리표 === "수량만 다름" ? "warning" : "info"), el("span", { className: "m02-same__name", text: `${group.문서[0].이름} · ${group.문서.length}`, }), mergeButton, ], }); paint(); return el("section", { className: "m02-same__group", attrs: { "data-same-group": group.지문 }, children: [head, tableHost], }); }; const goneBlock = (rows: GoneRow[]): HTMLElement => { const columns = [ { label: tx("Key"), width: "112px" }, { label: tx("Name") }, { label: tx("Into"), width: "140px" }, { label: tx("When"), width: "160px" }, { label: "", width: "88px" }, ]; const cells: Cell[][] = rows.map((r) => [ r.키, r.이름, r.남김 ? `${tx(r.남김.층)} ${r.남김.키}` : tx("Deleted"), r.때.replace("T", " "), createButton({ label: tx("Undo"), variant: "ghost", size: "sm", onClick: () => void undo(r), }), ]); return el("section", { className: "m02-same__group", attrs: { "data-same-gone": "" }, children: [ el("h4", { text: tx("Gone") }), createTable(columns, cells, { density: "data", empty: tx("GoneNone") }), ], }); }; const draw = (): void => { if (!view) return; const groups = view.묶음.length ? view.묶음.map(groupBlock) : [createEmpty({ text: tx("None"), small: true })]; host.replaceChildren(createText(tx("Intro"), "muted"), note, ...groups, goneBlock(view.합친것)); }; openModal({ title: tx("Title"), closeLabel: tx("Close"), width: "960px", mount: (body) => body.append(host), }); void load(); }