Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Library_Same.ts
T
eomsangdonandClaude Opus 5.5 6cf57dc289 feat(733-3): 서고 중복 찾기 틀 — 서버 길(목록 · 합치기 · 되돌림) · 화면 창 · 나무 메뉴 (등록 전)
- 새 M02_Library_Same_Router.py — GET {kind}-same · POST merge · POST undo (main.py 등록 · 시험은 다음 걸음)
- 새 M02_MasterTemplete_Library_Same.ts · .css — 묶음 표 · 남김 고르기 · 합치기 물음 · 합친 것 되돌림
- 서고 나무 뿌리 메뉴 [중복 찾기]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:39:47 +09:00

309 lines
11 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Library_Same.ts
* 서고 [중복 찾기](733-3 · 요구서_572 3장 4) — 좌측 일위대가 · 단가산출 나무 메뉴에서 엶.
* 창 = 같은 내용 묶음(이 층 + 위 층 · 서버 `GET {kind}-same`) · 묶음마다 꼬리표(내용 같음 · 수량만 다름) ·
* 문서 줄(층 · 키 · 이름 · 구분 · 쓰는 곳 수) · [남김] 고르기 · [합치기](물음 → 서버 `merge` · 이 층 나머지 문서를
* 지움 · 쓰는 곳 참조를 남김으로) · 결과(합침 · 권한 밖 남김) · 합친 것 목록 [되돌림](`undo`).
* 권한 = 그 층 쓰기(서버가 봄) · 판 다르면 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 (undo)"],
GoneNone: ["합친 것 없음", "Nothing merged"],
When: ["때", "When"],
Into: ["남김", "Kept as"],
Undo: ["되돌림", "Undo"],
AskUndo: [
"「{key}」 되돌림 — 문서를 되살리고 옮긴 쓰는 곳 {n} 을 다시 이 문서로",
"Undo “{key}” — restore it and point its {n} user(s) back",
],
Undone: ["되돌림 — 쓰는 곳 {n}", "Undone — users {n}"],
Stale: ["그새 바뀜 — 다시 찾음", "Changed meanwhile — searching again"],
} as const;
type TextKey = keyof typeof TEXT;
const tx = (key: TextKey, vars: Record<string, string | number> = {}): 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;
남김: SameRef;
때: 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 = <T>(url: string, body: unknown): Promise<T> =>
call<T>(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<void> => {
host.replaceChildren(createEmpty({ text: tx("Loading"), small: true }));
try {
view = await call<SameView>(path(opt.kind, opt.layer, opt.owner));
} catch (error) {
host.replaceChildren(createText(why(error), "error"));
return;
}
draw();
};
const run = async (job: () => Promise<SameView>, done: (got: SameView) => void) => {
try {
const got = await job();
view = got;
done(got);
opt.changed();
draw();
} catch (error) {
showToast(
error instanceof StaleError ? `${tx("Stale")} — ${why(error)}` : why(error),
"error",
);
if (error instanceof StaleError) void load();
}
};
const merge = async (group: SameGroup, keep: SameDoc): Promise<void> => {
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<MergeResult>(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<void> => {
const message = tx("AskUndo", { key: `${row.키} ${row.이름}`, n: row.옮긴곳수 });
if (!(await askConfirm({ message, okLabel: tx("Undo"), title: tx("Title") }))) return;
await run(
() => post<SameView>(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"),
size: "sm",
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", { 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.이름,
`${tx(r.남김.층)} ${r.남김.키}`,
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();
}