feat(733-3): 같은 내용 물음 화면 — 단가산출로 저장 · 공유 승인 · B09 내 자료집

- 부품 모달에 갈래 묻기 askChoice(취소 + 단추 여럿 · 고른 값 · 취소 = null) · 부품 모음 견본 한 칸
- 새 M02_MasterTemplete_Library_SameAsk.ts — 409 같은것 → 「그것을 씀 / 그래도 새로」 → 몸 씀 · 새로 로 다시 부름
- from-line(단가산출로도 저장 · 단가산출로 저장) · 공유 승인 · B09 내 자료집에 물음 · 취소 = 저장 안 함
- 새로 만들기(나무 · 목록 [생성])는 빈 문서라 서버가 묻지 않음 — 화면 손대지 않음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:57:07 +09:00
co-authored by Claude Opus 5.5
parent 320db86802
commit 2604a5073a
9 changed files with 135 additions and 17 deletions
@@ -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<void> {
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);
@@ -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");
@@ -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<string> {
): Promise<string | null> {
const line: Record<string, unknown> = { ...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;
}
@@ -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({
@@ -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<SameExtra | null> {
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<T>(job: (extra: SameExtra) => Promise<T>): Promise<T | null> {
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;
}
}
@@ -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<T = SkAnswer>(
});
}
/** [단가산출로 저장] — 그 층 새 SK · 답 = 새 키 + 그 SK 참조 줄 */
/** [단가산출로 저장] — 그 층 새 SK · 답 = 새 키 + 그 SK 참조 줄 · 같은 내용이 있으면 물음(733-3 ·
* 그것을 씀 = 그 키 · 그래도 새로) · 취소 = null */
export const sangeunFromLine = (
scope: SkScope,
line: PatchRow & Record<string, unknown>,
이름: string,
단위: string,
): Promise<{ 이름: string; 줄: Record<string, unknown> }> =>
call(`/layers/${scope.layer}/sangeun/from-line${q(scope)}`, {
method: "POST",
body: JSON.stringify({ 줄: line, 이름, 단위 }),
});
): Promise<{ 이름: string; 줄: Record<string, unknown> } | null> =>
withSameAsk((extra) =>
call(`/layers/${scope.layer}/sangeun/from-line${q(scope)}`, {
method: "POST",
body: JSON.stringify({ 줄: line, 이름, 단위, ...extra }),
}),
);
/** 뺀 줄(cid · 이름) 띠 — 고칠 수 있으면 [되살림] */
export function removedStrip(
@@ -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) {
+29 -4
View File
@@ -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<string | null> {
);
}
/** 갈래 묻기 — 고른 단추 `value` · 취소(단추 · Esc) = null */
export function askChoice(opts: AskOptions & { choices: AskChoice[] }): Promise<string | null> {
return ask(opts, undefined, opts.choices).then((r) => (r.ok ? r.text : null));
}
/** 위험 확인(옛 부름 꼴) — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */
export function showConfirmDialog(
message: string,
+1 -1
View File
@@ -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",