diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts index af5d1b2a0..044d267ba 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts @@ -22,6 +22,11 @@ import type { Row } from "./B09_Estimation_Calc_Base"; import { COPY } from "./B09_Estimation_Calc_Labor"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; import { editCell } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { + askSame, + sameHits, + type SameExtra, +} from "../M02_MasterTemplete/M02_MasterTemplete_Library_SameAsk"; /** 어디 쓰였나 — 같은 품목으로 찾는 줄 */ const GOODS = ["labor", "material", "machine"]; @@ -100,10 +105,18 @@ async function savePersonal(ctx: TabContext, block: Row): Promise { okLabel: "저장", }); let overwrite: string | null = null; + let extra: SameExtra = {}; // 같은 내용 물음 답(733-3 · 씀 = 그 키 · 새로 = 새 문서) while (name !== null && name.trim()) { - const body = { cover: block.cover, name: name.trim(), overwrite, user: ctx.user }; + const body = { cover: block.cover, name: name.trim(), overwrite, user: ctx.user, ...extra }; const { ok, status, got } = await postPersonal(body); const same = got.detail?.같은이름; + const hits = status === 409 && !extra.새로 ? sameHits(got.detail) : null; + if (hits) { + const pick = await askSame(hits); + if (!pick) return; + extra = pick; + continue; + } if (ok) { const skip: Row = got.안담음 || {}; const left = (skip.품 || 0) + (skip.단가 || 0); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts index 2696ff5d8..ff6ea9c92 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowModal.ts @@ -175,7 +175,7 @@ export function openRowModal(o: RowModalOptions): void { if (also.input.checked) { try { const name = await saveAsSk(row, got?.patch ?? row.패치 ?? null, o.title, o.unit); - showToast(t("Saved").replace("{k}", name), "success"); + if (name) showToast(t("Saved").replace("{k}", name), "success"); // null = 같은 내용 물음 취소 document.dispatchEvent(new Event(HOPYO_BUILT)); // 좌측 단가산출 나무 다시 읽음(725-4) } catch (error) { return showToast(error instanceof Error ? error.message : String(error), "error"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts index 219d92261..ed0081ecb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts @@ -85,17 +85,18 @@ export function mountRowSk( }; } -/** [단가산출로도 저장] — 그 줄(고친 패치 포함)을 개인 층 새 SK 로 · 새 이름 돌려줌 */ +/** [단가산출로도 저장] — 그 줄(고친 패치 포함)을 개인 층 새 SK 로 · 새 이름 돌려줌 · + * 같은 내용 물음(733-3)에서 그것을 씀 = 그 키 · 취소 = null */ export async function saveAsSk( row: LaborRow, patch: LinePatch | null, name: string, unit: string, -): Promise { +): Promise { const line: Record = { ...row }; delete line.패치; delete line.수량; if (patch) line.패치 = patch; const got = await sangeunFromLine({ layer: "personal", owner: null }, line, name, unit || "식"); - return got.이름; + return got?.이름 ?? null; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts index a5145e4d3..7b3f2da21 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts @@ -96,6 +96,7 @@ export function openRowSangeun(o: RowSangeunOptions): void { if (!name) return; try { const key = await saveAsSk(o.row, now(), name, o.unit); + if (!key) return; // 같은 내용 물음 취소(733-3) showToast(t("Saved").replace("{k}", key), "success"); if (!o.replace) return; o.replace({ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_SameAsk.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_SameAsk.ts new file mode 100644 index 000000000..6fd9afa7f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_SameAsk.ts @@ -0,0 +1,69 @@ +/* ============================================================================= + * M02_MasterTemplete_Library_SameAsk.ts + * 서고 같은 내용 물음(733-3 · 서버 꼴 = 733-2) — 사람 길(단가산출로 저장 · 공유 승인 · B09 내 자료집 …)이 + * 같은 층 같은 내용이면 서버 409 `detail = {같은것: [{키, 이름, 층, 주인}], 까닭}` → + * 물음 「같은 것 있음 — 그것을 씀 / 그래도 새로」 → 같은 부름을 몸 덧칸 `{씀: 키}` · `{새로: true}` 로 다시 · + * 취소 = null. 물음 꼴 = 부품 `askChoice`. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { askChoice } from "@ui/ui_template_modal"; +import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; + +const TEXT = { + Title: ["같은 내용 있음", "Same content exists"], + Message: [ + "이 층에 내용이 같은 문서가 이미 있음 — {list}", + "A document with the same content already exists on this layer — {list}", + ], + More: [" 외 {n}", " and {n} more"], + Use: ["그것을 씀", "Use it"], + New: ["그래도 새로", "Create anyway"], +} as const; +const tx = (key: keyof typeof TEXT): string => + TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; + +export interface SameHit { + 키: string; + 이름: string; + 층: string; + 주인: string | null; +} +/** 다시 보낼 몸 덧칸 — 그것을 씀 = 그 키 · 그래도 새로 = 새 문서 */ +export interface SameExtra { + 씀?: string; + 새로?: boolean; +} + +/** 서버 409 몸에서 같은 내용 문서 목록 — 없으면 null(다른 409 = 판 · 같은 이름 …) */ +export const sameHits = (detail: unknown): SameHit[] | null => { + const list = (detail as { 같은것?: unknown } | null)?.같은것; + return Array.isArray(list) && list.length ? (list as SameHit[]) : null; +}; + +/** 물음 — 그것을 씀(첫 문서 = 먼저 만든 것) · 그래도 새로 · 취소 = null */ +export async function askSame(hits: SameHit[]): Promise { + const shown = hits.slice(0, 3).map((h) => `「${h.키} ${h.이름}」`); + const more = hits.length > 3 ? tx("More").replace("{n}", String(hits.length - 3)) : ""; + const got = await askChoice({ + title: tx("Title"), + message: tx("Message").replace("{list}", shown.join(" · ") + more), + choices: [ + { label: tx("New"), value: "new" }, + { label: tx("Use"), value: "use" }, + ], + }); + return got === "use" ? { 씀: hits[0].키 } : got === "new" ? { 새로: true } : null; +} + +/** `job(덧칸)` — 409 같은것이면 물은 뒤 덧칸을 넣어 한 번 더 · 취소 = null · 그 밖 실패 = 그대로 던짐 */ +export async function withSameAsk(job: (extra: SameExtra) => Promise): Promise { + try { + return await job({}); + } catch (error) { + const hits = error instanceof StaleError ? sameHits(error.detail) : null; + if (!hits) throw error; + const extra = await askSame(hits); + return extra ? job(extra) : null; + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts index 013d7171e..7ce45416f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts @@ -12,6 +12,7 @@ import { el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { withSameAsk } from "./M02_MasterTemplete_Library_SameAsk"; import type { PatchRow, SkAnswer } from "./logic/M02_Sangeun_Cover_Model"; import "./M02_MasterTemplete_Sangeun.css"; @@ -35,17 +36,20 @@ export function calcSangeun( }); } -/** [단가산출로 저장] — 그 층 새 SK · 답 = 새 키 + 그 SK 참조 줄 */ +/** [단가산출로 저장] — 그 층 새 SK · 답 = 새 키 + 그 SK 참조 줄 · 같은 내용이 있으면 물음(733-3 · + * 그것을 씀 = 그 키 · 그래도 새로) · 취소 = null */ export const sangeunFromLine = ( scope: SkScope, line: PatchRow & Record, 이름: string, 단위: string, -): Promise<{ 이름: string; 줄: Record }> => - call(`/layers/${scope.layer}/sangeun/from-line${q(scope)}`, { - method: "POST", - body: JSON.stringify({ 줄: line, 이름, 단위 }), - }); +): Promise<{ 이름: string; 줄: Record } | null> => + withSameAsk((extra) => + call(`/layers/${scope.layer}/sangeun/from-line${q(scope)}`, { + method: "POST", + body: JSON.stringify({ 줄: line, 이름, 단위, ...extra }), + }), + ); /** 뺀 줄(cid · 이름) 띠 — 고칠 수 있으면 [되살림] */ export function removedStrip( diff --git a/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts b/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts index 133e4b0b7..f8152ec0e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts @@ -18,6 +18,7 @@ import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { call, fetchPrecomboTree, fetchTree, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, ownOwner } from "./M02_MasterTemplete_Layer"; import { askCopyFolder } from "./M02_MasterTemplete_TreePanel_CopyTo"; +import { withSameAsk } from "./M02_MasterTemplete_Library_SameAsk"; import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; @@ -170,7 +171,11 @@ export function createShare(opt: ShareOptions): ShareHandle { ); if (!dest) return; try { - await post(`/share-requests/${row.id}/approve${q}`, { 경로: dest }); + // 같은 내용이 회사 층에 있으면 물음(733-3 · 그것을 씀 = 새 사본 없음) · 취소 = 승인 안 함 + const got = await withSameAsk((extra) => + post(`/share-requests/${row.id}/approve${q}`, { 경로: dest, ...extra }), + ); + if (got === null) return; showToast(`「${row.이름}」 회사 층에 올림 — ${dest.join(" / ") || "맨 위"}`, "success"); opt.changed(); } catch (error) { diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts index 83d18e36c..5f98f87f4 100644 --- a/ui_template/ui_template_modal.ts +++ b/ui_template/ui_template_modal.ts @@ -4,6 +4,7 @@ * 꼴 = 모달(`openModal` · 제목줄 · 닫기 · 몸은 `mount`) · 넓음(`wide` · 96vw × 94vh) · 폭 단(`width`: 24rem · 40rem · 960px · 기본 720) * · 넓은 중간 창(`wide` + `width: "1100px"` · 1100 × 680 · 몸 넘침만 스크롤) * · 확인창(`askConfirm`) · 위험 확인(`danger` · 확인 단추 빨강 = `showConfirmDialog`) · 글 묻기(`askText` · window.prompt 대신) + * · 갈래 묻기(`askChoice` · 취소 + 단추 여럿 · 고른 값 · 취소 = null — 733-3 「그것을 씀 / 그래도 새로」) * 크기 = 모달 여백 16 · 둥글기 12 · 확인창 폭 300 ~ 420 · 단추 = 단추 부품 기본 * 상태 = 기본만 · Esc = 닫기(한글 조합 중 · 몸이 막은 Esc 는 안 닫음) · 바깥 누르기 = 누름 · 뗌 둘 다 배경일 때만 * 쓰는 법 = `openModal({ title: "구조물", closeLabel: "닫기", mount: (body) => body.append(form) })` @@ -106,6 +107,12 @@ export interface AskOptions { danger?: boolean; } +/** 갈래 묻기 단추 하나 — 누르면 `value` · 맨 뒤 단추가 주 단추(filled) */ +export interface AskChoice { + label: string; + value: string; +} + export interface AskField { value?: string; placeholder?: string; @@ -113,7 +120,7 @@ export interface AskField { /** 확인창 판 — 가림막(.ui-confirm) 없이 판만(부품 모음 견본도 씀) · `field` = 글 묻기 칸 */ export function confirmPanel( - opts: AskOptions & { field?: AskField }, + opts: AskOptions & { field?: AskField; choices?: AskChoice[] }, done: (ok: boolean, text: string) => void = () => {}, ): { panel: HTMLElement; first: HTMLElement } { const panel = el("div", { className: "ui-confirm__panel", attrs: { role: "dialog" } }); @@ -144,11 +151,24 @@ export function confirmPanel( variant: opts.danger ? "danger" : "filled", onClick: () => done(true, input?.value ?? ""), }); - panel.append(el("div", { className: "ui-confirm__actions", children: [cancel, ok] })); + const picks = opts.choices?.map((c, i, all) => + createButton({ + label: c.label, + variant: i === all.length - 1 ? "filled" : "ghost", + onClick: () => done(true, c.value), + }), + ); + panel.append( + el("div", { className: "ui-confirm__actions", children: [cancel, ...(picks ?? [ok])] }), + ); return { panel, first: input ?? cancel }; } -function ask(opts: AskOptions, field?: AskField): Promise<{ ok: boolean; text: string }> { +function ask( + opts: AskOptions, + field?: AskField, + choices?: AskChoice[], +): Promise<{ ok: boolean; text: string }> { return new Promise((resolve) => { const overlay = el("div", { className: "ui-confirm" }); const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 @@ -157,7 +177,7 @@ function ask(opts: AskOptions, field?: AskField): Promise<{ ok: boolean; text: s if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); resolve({ ok, text }); }; - const { panel, first } = confirmPanel({ ...opts, field }, done); + const { panel, first } = confirmPanel({ ...opts, field, choices }, done); overlay.addEventListener("keydown", (ev) => { if (ev.key !== "Escape" || ev.isComposing) return; ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 @@ -181,6 +201,11 @@ export function askText(opts: AskOptions & AskField): Promise { ); } +/** 갈래 묻기 — 고른 단추 `value` · 취소(단추 · Esc) = null */ +export function askChoice(opts: AskOptions & { choices: AskChoice[] }): Promise { + return ask(opts, undefined, opts.choices).then((r) => (r.ok ? r.text : null)); +} + /** 위험 확인(옛 부름 꼴) — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */ export function showConfirmDialog( message: string, diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 0ca6d8c27..8c77a6813 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -354,7 +354,7 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => { if (s !== "default") return null; const m = buildModal({ title: "구조물 고르기", closeLabel: "닫기", width: "40rem" }, noop); m.body.append("몸 — 표 · 양식이 들어감"); m.dialog.style.margin = "0"; const long = buildModal({ title: "배수관 · 횡배수관 · 측구 · 집수정 · 옹벽 구조물을 한꺼번에 고르기", closeLabel: "닫기", width: "24rem" }, noop); long.body.append("[닫기] 는 한 줄 · 제목은 말줄임"); long.dialog.style.margin = "0"; const mid = buildModal({ title: "운반 단가 — 넓은 중간 창", closeLabel: "닫기", wide: true, width: "1100px" }, noop); mid.body.append("몸 — 두 칸이 따로 스크롤"); mid.dialog.style.cssText = "margin:0;max-width:100%;height:160px"; - return col(m.dialog, long.dialog, mid.dialog, confirmPanel({ message: "노선을 저장할까요?" }).panel, confirmPanel({ title: "삭제", message: "임도 2 노선을 지울까요? 되돌릴 수 없음", okLabel: "삭제", danger: true }).panel, confirmPanel({ message: "블록 이름", field: { placeholder: "예: 배수관" } }).panel); } }, + return col(m.dialog, long.dialog, mid.dialog, confirmPanel({ message: "노선을 저장할까요?" }).panel, confirmPanel({ title: "삭제", message: "임도 2 노선을 지울까요? 되돌릴 수 없음", okLabel: "삭제", danger: true }).panel, confirmPanel({ message: "블록 이름", field: { placeholder: "예: 배수관" } }).panel, confirmPanel({ title: "같은 내용 있음", message: "「SK-000001 유로폼(보통)」 과 내용이 같음", choices: [{ label: "그래도 새로", value: "new" }, { label: "그것을 씀", value: "use" }] }).panel); } }, { key: "toast", name: "알림", group: "compound", kinds: "토스트 4꼴(성공 · 오류 · 정보 · 경고) · 로딩 가림막 · 진행 가림막", file: "ui_template_toast.ts", demo: (s) => (s === "default" ? col(toastNode("저장됨", "success"), toastNode("저장 못 함 — 다시 시도", "error"), toastNode("노선을 불러옴", "info"), toastNode("측점 간격이 넓음", "warning")) : null) }, { key: "banner", name: "띠 알림", group: "compound", kinds: "왼쪽 띠 · 정보 · 경고 · 위험 · 성공(✓) · 펼침 · 안 단추 · 닫기", file: "ui_template_banner.ts",