diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index e5ff8fa15..c04b45aaf 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -49,16 +49,49 @@ .lstk-edit { display: flex; flex-direction: column; - gap: var(--spacing-8); + gap: var(--spacing-16); } +/* 항목(804-5) — 머리 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 = 한 단 더 들여씀 */ .lstk-item { display: flex; flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8); - border: 1px solid var(--color-border); + gap: var(--spacing-8); +} + +.lstk-item.is-sub { + margin-inline-start: var(--spacing-24); +} + +.lstk-item + .lstk-item.is-sub { + margin-block-start: calc(-1 * var(--spacing-8)); +} + +.lstk-ihead { + align-items: center; + padding: var(--spacing-4) var(--spacing-8); + border-inline-start: 4px solid var(--color-accent); border-radius: var(--radius-sm); + background: var(--color-surface-sunken); +} + +.lstk-item.is-sub > .lstk-ihead { + border-inline-start-color: var(--color-border-strong); +} + +.lstk-no { + min-width: 1.5em; + font-weight: var(--font-weight-bold); + color: var(--color-text); +} + +.lstk-lines { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin-inline-start: var(--spacing-12); + padding-inline-start: var(--spacing-12); + border-inline-start: 2px solid var(--color-border-strong); } .lstk-ihead, @@ -84,9 +117,15 @@ flex-direction: column; gap: var(--spacing-4); padding: var(--spacing-8); - padding-inline-end: 120px; + padding-inline: var(--spacing-24) var(--spacing-32); + border: 1px solid var(--color-border); border-radius: var(--radius-sm); - background: var(--color-surface-sunken); + background: var(--color-surface-raised); +} + +/* 글 · 근거 칸 = 한 줄 높이(세로 묶음에서 늘지 않게) */ +.lstk-line > .lstk-f-l { + flex: none; } .lstk-ltools { @@ -96,6 +135,45 @@ gap: 0; } +/* 끌기(804-5) — 손잡이 · 끄는 것 반투명 · 놓을 자리 = 위 · 아래 선 · 빈 항목 = 점선 */ +.lstk-grip { + touch-action: none; +} + +.lstk-line > .lstk-grip { + position: absolute; + inset-block-start: var(--spacing-12); + inset-inline-start: var(--spacing-8); +} + +.lstk-edit[data-dragging] { + cursor: grabbing; + user-select: none; +} + +.is-dragging { + opacity: 0.45; +} + +.lstk-line[data-drop="before"], +.lstk-item[data-drop="before"] { + box-shadow: 0 -3px 0 0 var(--color-accent); +} + +.lstk-line[data-drop="after"], +.lstk-item[data-drop="after"] { + box-shadow: 0 3px 0 0 var(--color-accent); +} + +.lstk-item[data-drop="in"] .lstk-empty { + outline: 2px dashed var(--color-accent); + outline-offset: 2px; +} + +.lstk-res { + max-width: 100%; +} + .lstk-kind { font-size: var(--text-caption); font-weight: var(--font-weight-medium); @@ -126,16 +204,11 @@ .lstk-val, .lstk-unit, .lstk-tag, -.lstk-hint, -.lstk-link { +.lstk-hint { font-size: var(--text-body-sm); color: var(--color-text-secondary); } -.lstk-link { - color: var(--color-text-body); -} - .lstk-tag { padding: 0 var(--spacing-4); border: 1px solid var(--color-border); diff --git a/A00_Common/line_stack_drag.ts b/A00_Common/line_stack_drag.ts new file mode 100644 index 000000000..9f32f3ae8 --- /dev/null +++ b/A00_Common/line_stack_drag.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * line_stack_drag.ts + * 줄 쌓기 왼쪽 끌어 옮기기(PLAN 804-5 · 요구 682-4) — 손잡이(⋮⋮)를 누르고 끌면 놓을 자리에 선 표시 · 놓으면 `drop` + * 줄 = 항목 안 · 항목 사이 어디든(빈 항목 = 그 안) → `k.p`(항목 k 의 p 자리) · 항목 = 항목 사이 → `p` + * 포인터 끌기(마우스 · 터치 같음) · Esc = 그만 · 자리 표시 = 클래스(`data-drop`)만 — 끄는 동안 배치 안 바뀜 + * ========================================================================== */ + +export interface DragOpts { + /** 끄는 것 — 줄 카드 · 항목 상자 */ + what: "line" | "item"; + /** 끌기 영역(`.lstk-edit`) */ + root: HTMLElement; + /** 끄는 요소(반투명) */ + self: HTMLElement; + /** 놓음 — line `k.p` · item `p` */ + drop: (to: string) => void; +} + +const mid = (r: DOMRect): number => r.top + r.height / 2; + +/** 포인터 y 의 놓을 자리 + 표시할 요소 · 꼴 */ +function spotOf(o: DragOpts, y: number): { to: string; at: HTMLElement; side: string } | null { + const boxes = [...o.root.querySelectorAll(":scope > .lstk-item")]; + if (!boxes.length) return null; + if (o.what === "item") { + const k = boxes.findIndex((b) => y < mid(b.getBoundingClientRect())); + return k < 0 + ? { to: String(boxes.length), at: boxes[boxes.length - 1], side: "after" } + : { to: String(k), at: boxes[k], side: "before" }; + } + // 줄 — 포인터가 든 항목(위 · 아래 밖 = 처음 · 끝 항목) + let k = boxes.findIndex((b) => y < b.getBoundingClientRect().bottom); + if (k < 0) k = boxes.length - 1; + const cards = [...boxes[k].querySelectorAll(":scope .lstk-lines > .lstk-line")]; + if (!cards.length) return { to: `${k}.0`, at: boxes[k], side: "in" }; + const p = cards.findIndex((c) => y < mid(c.getBoundingClientRect())); + return p < 0 + ? { to: `${k}.${cards.length}`, at: cards[cards.length - 1], side: "after" } + : { to: `${k}.${p}`, at: cards[p], side: "before" }; +} + +/** 손잡이에 끌기를 붙임 */ +export function attachDrag(grip: HTMLElement, o: DragOpts): void { + grip.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + grip.setPointerCapture(e.pointerId); + let spot: ReturnType = null; + const clear = (): void => { + for (const x of o.root.querySelectorAll("[data-drop]")) x.removeAttribute("data-drop"); + }; + o.self.classList.add("is-dragging"); + o.root.dataset.dragging = o.what; + const move = (ev: PointerEvent): void => { + clear(); + spot = spotOf(o, ev.clientY); + if (spot) spot.at.dataset.drop = spot.side; + }; + const end = (ok: boolean): void => { + grip.removeEventListener("pointermove", move); + grip.removeEventListener("pointerup", up); + grip.removeEventListener("pointercancel", cancel); + window.removeEventListener("keydown", esc); + clear(); + o.self.classList.remove("is-dragging"); + delete o.root.dataset.dragging; + if (ok && spot) o.drop(spot.to); + }; + const up = (): void => end(true); + const cancel = (): void => end(false); + const esc = (ev: KeyboardEvent): void => { + if (ev.key === "Escape") end(false); + }; + grip.addEventListener("pointermove", move); + grip.addEventListener("pointerup", up); + grip.addEventListener("pointercancel", cancel); + window.addEventListener("keydown", esc); + }); +} diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index ac9904e5e..0f7c60b3b 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -1,31 +1,39 @@ /* ============================================================================= * line_stack_edit.ts - * 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(제목 · 가나다 · [+ 줄] · 지움) 안 줄 카드(위 · 아래 · 지움) + * 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(띠 · 번호 · 제목 · 가나다 · [+ 줄] · 접기 · X) 안 줄 카드(⋮⋮ · X) + * 차림(804-5 · 요구 682) = 항목 머리 띠 · 줄 = 들여쓰기 + 왼쪽 세로 선 · 순서 = 손잡이 끌기(`line_stack_drag.ts`) · + * X = 확인 없이 지우고 알림 눌러 되돌리기 한 번 · 자원 이름 = 단추(누르면 찾아 넣기 창 → 바꿈) * 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식) * · 공식 묶음(`line_stack_rows.ts`) · 글 줄 * 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때) * 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 = - * 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = [찾아 바꾸기] + * 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추 * ========================================================================== */ -import { createButton } from "@ui/ui_template_button"; +import { createButton, createIconButton } from "@ui/ui_template_button"; import { createCheckbox } from "@ui/ui_template_checkbox"; -import { el } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc"; import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; import type { DocTable } from "../common_util/common_util_formula_doc_table"; import { dropSyms, + docOf, freshId, kindOf, + moveItem, + moveLine, newVar, ownedOf, + stackOf, type MoneyKind, type StackDoc, type StackItem, } from "../common_util/common_util_line_stack"; +import { placeNames } from "../common_util/common_util_line_stack_copy"; import { addPicked, repick } from "../common_util/common_util_line_stack_pick"; +import { attachDrag } from "./line_stack_drag"; import { openStackPick, type StackTake } from "./line_stack_pick"; import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows"; import { lt, type LsKey } from "./line_stack_text"; @@ -65,8 +73,8 @@ const field = ( f.input.addEventListener("change", () => set(f.input.value)); return f.root; }; -const btn = (label: string, onClick: () => void, variant: "ghost" | "danger-ghost" = "ghost") => - createButton({ label, variant, size: 24, onClick }); +const btn = (label: string, onClick: () => void) => + createButton({ label, variant: "ghost", size: 24, onClick }); const KIND_KEY: Record = { 인력: "K_인력", @@ -124,18 +132,42 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { : mat ? String(mat.검색어 ?? mat.이름 ?? mat.상세구분 ?? "") : ""; - price.push( - el("span", { className: "lstk-link", text: label || "—" }), - btn(lt("Repick"), () => - openStackPick(kind, (p) => h.change((doc) => repick(doc, i, own, kind, p))), - ), - ); + const size = (line as { 규격?: string }).규격; + const pick = createButton({ + label: (size && !label.includes(size) ? `${label} ${size}` : label) || "—", + variant: "ghost", + tone: "link", + size: 24, + iconStart: "search", + onClick: () => openStackPick(kind, (p) => h.change((doc) => repick(doc, i, own, kind, p))), + }); + pick.classList.add("lstk-res"); + pick.title = lt("Repick"); + pick.dataset.repick = String(i); + price.push(pick); } else price.push(field(lt("Price"), line.단가, (t) => put((l) => (l.단가 = t.trim())), "l")); const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭; + // 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5) + const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null; + const read = (t: unknown): string => + String(t ?? "").replace(/번지\.([^.\s()]+)\./g, (all, p: string) => + places?.[p] ? `${places[p]} ` : all, + ); return [ el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }), head, - el("div", { className: "lstk-row", children: [qty, el("span", { text: "×" }), ...price] }), + el("div", { + className: "lstk-row", + children: [qty, el("span", { className: "lstk-unit", text: "×" }), ...price], + }), + ...(places + ? [ + el("div", { + className: "lstk-hint", + text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`, + }), + ] + : []), ...(why ? [el("div", { className: "lstk-miss", text: why })] : []), ]; } @@ -175,15 +207,6 @@ function addLine(h: EditHooks, at: number, kind: string): void { }); } -/** 줄 옮김 — 항목 끝을 넘으면 옆 항목으로 */ -function move(items: StackItem[], i: number, j: number, by: -1 | 1): void { - const [line] = items[i].줄.splice(j, 1); - const to = j + by; - if (to < 0 && i > 0) items[i - 1].줄.push(line); - else if (to > items[i].줄.length && i < items.length - 1) items[i + 1].줄.unshift(line); - else items[i].줄.splice(Math.max(0, Math.min(to, items[i].줄.length)), 0, line); -} - /** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */ function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void { const line = items[i].줄[j]; @@ -221,10 +244,67 @@ export function buildEdit(h: EditHooks): HTMLElement { }); return pick.root; }; + // X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만) + const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => { + const snap = JSON.stringify(h.doc); + let after = ""; + h.change((d, items) => { + fn(d, items); + after = JSON.stringify(docOf(d, items)); + }); + showToast(`${what || lt("K_줄")} ${lt("Dropped")}`, "info", 6000, () => + h.change((d, items) => { + if (JSON.stringify(docOf(d, items)) !== after) + return void showToast(lt("Undo_Late"), "warning"); + const back = JSON.parse(snap) as StackDoc; + for (const k of Object.keys(d)) delete (d as Record)[k]; + Object.assign(d, back); + items.splice(0, items.length, ...stackOf(back)); + }), + ); + }; + const x = (title: string, onClick: () => void): HTMLElement => { + const b = createIconButton({ icon: "x", title, size: 24, onClick }); + b.classList.add("lstk-x"); + return b; + }; + const grip = (): HTMLElement => + el("span", { className: "ui-list-row__grip lstk-grip", attrs: { title: lt("Drag") } }); + const root = el("div", { className: "lstk-edit" }); + /** 항목 접힘 표시(화면 상태)를 항목 차례 바뀜에 맞춤 */ + const refold = (fn: (flags: boolean[]) => void): void => { + const flags = h.items.map((_, k) => h.open.has(`fold:${k}`)); + fn(flags); + h.items.forEach((_, k) => h.open.delete(`fold:${k}`)); + flags.forEach((f, k) => f && h.open.add(`fold:${k}`)); + }; + // 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 가나다 = 가. 나.) + let n = 0; + let sub = 0; + const nos = h.items.map((it) => { + if (!it.제목.trim()) return ""; + const no = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"}.` : `${++n}.`; + if (!it.가나다) sub = 0; + return no; + }); const items = h.items.map((it, i) => { + const folded = h.open.has(`fold:${i}`); + const itemGrip = grip(); const head = el("div", { className: "lstk-ihead", children: [ + itemGrip, + createIconButton({ + icon: folded ? "chevronRight" : "chevronDown", + title: folded ? lt("Unfold_Item") : lt("Fold_Item"), + size: 24, + onClick: () => { + if (folded) h.open.delete(`fold:${i}`); + else h.open.add(`fold:${i}`); + h.change(() => undefined); + }, + }), + el("b", { className: "lstk-no", text: nos[i] }), field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"), createCheckbox(lt("Gana"), { checked: !!it.가나다, @@ -232,25 +312,29 @@ export function buildEdit(h: EditHooks): HTMLElement { onChange: (c) => h.change((_, items) => (c ? (items[i].가나다 = true) : delete items[i].가나다)), }).root, + ...(folded + ? [el("span", { className: "lstk-hint", text: `${lt("K_줄")} ${it.줄.length}` })] + : []), add(i), - btn( - lt("Del_Item"), - () => - h.change((d, items) => { - for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j); - items.splice(i, 1); - }), - "danger-ghost", + x(lt("Del_Item"), () => + drop(it.제목.trim() || lt("Item"), (d, items) => { + refold((f) => f.splice(i, 1)); + for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j); + items.splice(i, 1); + }), ), ], }); const lines = it.줄.map((line, j) => { + const lineGrip = grip(); const tools = el("div", { className: "lstk-ltools", children: [ - btn("↑", () => h.change((_, items) => move(items, i, j, -1))), - btn("↓", () => h.change((_, items) => move(items, i, j, 1))), - btn(lt("Del"), () => h.change((d, items) => dropLine(d, items, i, j)), "danger-ghost"), + x(lt("Del"), () => + drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) => + dropLine(d, items, i, j), + ), + ), ], }); const body = @@ -266,29 +350,56 @@ export function buildEdit(h: EditHooks): HTMLElement { ), ] : buildBlock(h, i, j, line); - const card = el("div", { className: `lstk-line k-${line.꼴}`, children: [tools, ...body] }); + const card = el("div", { + className: `lstk-line k-${line.꼴}`, + children: [lineGrip, tools, ...body], + }); card.dataset.loc = `${i}.${j}`; card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`)); + attachDrag(lineGrip, { + what: "line", + root, + self: card, + drop: (to) => { + const [k, p] = to.split(".").map(Number); + h.change((_, items) => moveLine(items, i, j, k, p)); + }, + }); return card; }); const box = el("div", { - className: "lstk-item", + className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`, attrs: { "data-loc": String(i) }, children: [ head, - ...(lines.length - ? lines - : [el("div", { className: "lstk-empty", text: lt("Empty_Item") })]), + ...(folded + ? [] + : [ + el("div", { + className: "lstk-lines", + children: lines.length + ? lines + : [el("div", { className: "lstk-empty", text: lt("Empty_Item") })], + }), + ]), ], }); head.addEventListener("pointerdown", () => h.pair(String(i))); + attachDrag(itemGrip, { + what: "item", + root, + self: box, + drop: (to) => { + const p = Number(to); + refold((f) => moveItem(f, i, p)); + h.change((_, items) => moveItem(items, i, p)); + }, + }); return box; }); - return el("div", { - className: "lstk-edit", - children: [ - ...items, - btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))), - ], - }); + root.append( + ...items, + btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))), + ); + return root; } diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index 6bb9b8428..2897a6de8 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -29,6 +29,7 @@ import { subInputs, type StackDoc, } from "../common_util/common_util_line_stack"; +import { placeNames } from "../common_util/common_util_line_stack_copy"; import { stackLevels } from "../common_util/common_util_line_stack_pick"; import { katexNode, symTex, tex, valTex } from "./line_stack_tex"; import { lt } from "./line_stack_text"; @@ -61,12 +62,20 @@ const won = (n: number): string => eqText(Math.round(n)); const one = (n: number): string => n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); const MAX_DEPTH = 3; +const LONG_SUM = 4; // 합계 덧셈 항이 이보다 많으면 항목 소계끼리(경쟁사 「노무비 : 2,523.3 + 919.5 = 3,442」) /** 식 글 → 조판(못 풀면 글) */ -function exprNode(text: unknown, side: "sym" | "val", vals: Values, inline = false, cost?: string) { +function exprNode( + text: unknown, + side: "sym" | "val", + vals: Values, + inline = false, + cost?: string, + place?: Record, +) { const n = nodeOf(text); return n - ? katexNode(tex(n, { side, vals, inline, cost })) + ? katexNode(tex(n, { side, vals, inline, cost, place })) : el("span", { text: String(text ?? "") }); } const hasVar = (text: unknown): boolean => { @@ -93,6 +102,7 @@ function rows( void out.push({ ...r, depth, lv, loc, ...(depth ? { cols: undefined } : {}) }); const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만 const steps = lineMap(doc); + const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5) const unit = (id: string): string => vars.get(id)?.단위 ?? ""; const subOf = (id: string): FormulaDoc | null => { const k = vars.get(id)?.연결; @@ -206,9 +216,18 @@ function rows( 단가?: unknown; }; const scope: Values = { ...vals, ...moneyScope(a.줄, i) }; + const at: Record = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 804-5) money.slice(0, i).forEach((m, k) => { - if (m.기호) scope[m.기호] = (a.줄?.[k] as { 금액?: unknown } | undefined)?.금액; // 앞 금액 줄 기호 = 그 줄 금액 + const got = a.줄?.[k] as { 금액?: unknown; 비목?: Values } | undefined; + if (m.기호) scope[m.기호] = got?.금액; // 앞 금액 줄 기호 = 그 줄 금액 + if (m.번지) + for (const c of COSTS) { + const box = (at[String(m.번지)] ??= { 노무비: 0, 재료비: 0, 경비: 0 }); + if (isNum(got?.비목?.[c])) + box[c] = Math.round(((box[c] as number) + (got!.비목![c] as number)) * 1e6) / 1e6; + } }); + if (Object.keys(at).length) scope.번지 = at; const name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" "); const qty = line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null; @@ -244,7 +263,11 @@ function rows( const amt = r.금액 ?? 0; const price = hasVar(line.단가) && kindOf(doc, i) === "율" - ? [exprNode(line.단가, "sym", scope, true), " = ", exprNode(line.단가, "val", scope, true)] + ? [ + exprNode(line.단가, "sym", scope, true, undefined, place), + " = ", + exprNode(line.단가, "val", scope, true), + ] : [exprNode(line.단가, "val", scope, true)]; const c = String(line.비목); push({ @@ -256,6 +279,7 @@ function rows( const items = stackOf(doc); const terms: Record = { 노무비: [], 재료비: [], 경비: [] }; + const perItem: Record = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(804-5) let n = 0; let sub = 0; const levels = stackLevels(items); @@ -291,7 +315,10 @@ function rows( const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10; push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" }); } - for (const c of COSTS) terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c])); + for (const c of COSTS) { + terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c])); + if (sum[c]) perItem[c].push(sum[c]); + } } lv = base + 1; loc = undefined; @@ -310,7 +337,7 @@ function rows( push({ h: [`${++n}. ${lt("Total")}`], cls: "item" }); lv = base + 2; for (const c of COSTS) { - const t = terms[c]; + const t = terms[c].length > LONG_SUM ? perItem[c] : terms[c]; const v = a[c]; const body = !isNum(v) ? "—" diff --git a/A00_Common/line_stack_tex.ts b/A00_Common/line_stack_tex.ts index 806e057b3..e3fe96c68 100644 --- a/A00_Common/line_stack_tex.ts +++ b/A00_Common/line_stack_tex.ts @@ -19,6 +19,8 @@ export interface TexCtx { inline?: boolean; /** 묶음 값의 비목(기계 · 단가산출 줄 노 · 재 · 경) */ cost?: string; + /** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */ + place?: Record; } const HIDE = new Set(["반올림", "올림", "버림"]); @@ -69,6 +71,14 @@ export const valTex = (v: unknown): string => { return isNum(v) ? t.replace(/,/g, "{,}") : txt(t); }; +/** 칸 값 — `번지.복사1.노무비` 처럼 겹친 칸까지 */ +const look = (n: Node, vals: Values): unknown => { + if (n[0] === "var") return vals[n[1]]; + if (n[0] !== "field") return undefined; + const v = look(n[1], vals); + return v && typeof v === "object" ? (v as Values)[n[2]] : undefined; +}; + const paren = (t: string): string => `\\left(${t}\\right)`; const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`; @@ -88,11 +98,13 @@ export function tex(n: Node, c: TexCtx): string { : valTex(v); } case "field": { - if (c.side === "val" && n[1][0] === "var") { - const v = c.vals[n[1][1]]; - const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined; + const [, of, key] = n; + if (c.side === "val") { + const got = look(n, c.vals); if (isNum(got)) return valTex(got); } + if (of[0] === "field" && of[1][0] === "var" && of[1][1] === "번지" && c.place?.[of[2]]) + return txt(`${c.place[of[2]]} ${key}`); return `${n[1][0] === "var" ? symTex(n[1][1]) : go(n[1])}.${txt(n[2])}`; } case "neg": diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index e791e236d..91172d2b9 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -15,8 +15,13 @@ const TEXT = { Add_Line: ["+ 줄", "+ Line"], Del_Item: ["항목 지움", "Delete item"], Del: ["지움", "Delete"], - Up: ["위로", "Up"], - Down: ["아래로", "Down"], + Item: ["항목", "Item"], + K_줄: ["줄", "Line"], + Drag: ["끌어 옮기기", "Drag to move"], + Fold_Item: ["항목 접기", "Collapse item"], + Unfold_Item: ["항목 펼치기", "Expand item"], + Dropped: ["지움 — 눌러 되돌리기", "deleted — click to undo"], + Undo_Late: ["그 뒤 고친 것이 있어 못 되돌림", "Changed since — cannot undo"], Title: ["항목 이름(비우면 번호 없음)", "Item title"], Gana: ["가나다", "Sub"], K_인력: ["인력", "Labor"], diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts index 107d19070..f2bf87b14 100644 --- a/common_util/common_util_line_stack.ts +++ b/common_util/common_util_line_stack.ts @@ -318,6 +318,19 @@ export function dropSyms(doc: FormulaDoc, ids: string[]): void { doc.식 = (doc.식 ?? []).filter((l) => !gone.has(String(l.기호 ?? ""))); } +/** 끌어 옮김(804-5) — 줄 `[i, j]` → 항목 k 의 p 자리(빼기 전 차례 · 같은 항목 아래로면 하나 당김) */ +export function moveLine(items: StackItem[], i: number, j: number, k: number, p: number): void { + const [line] = items[i].줄.splice(j, 1); + if (!line || !items[k]) return void (line && items[i].줄.splice(j, 0, line)); + items[k].줄.splice(Math.min(i === k && p > j ? p - 1 : p, items[k].줄.length), 0, line); +} + +/** 끌어 옮김(804-5) — 항목 i → p 자리(빼기 전 차례) */ +export function moveItem(items: T[], i: number, p: number): void { + const [it] = items.splice(i, 1); + if (it !== undefined) items.splice(Math.min(p > i ? p - 1 : p, items.length), 0, it); +} + /** 공식 줄 값 · 식 고침 — 수 = 숫자 연결 · 식 = 식 줄 */ export function setRow(doc: FormulaDoc, id: string, text: string): void { const t = text.trim().replace(/,/g, ""); diff --git a/common_util/common_util_line_stack_copy.ts b/common_util/common_util_line_stack_copy.ts index c883d2bb2..f8e5aa5ca 100644 --- a/common_util/common_util_line_stack_copy.ts +++ b/common_util/common_util_line_stack_copy.ts @@ -195,3 +195,29 @@ export function addTaken(doc: StackDoc, items: StackItem[], t: Taken): void { for (const r of rates) pushMoney({ ...r, 단가: rescope(r.단가, places) as string }); } } + +/** 번지 읽는 이름(804-5) — 산출근거 「번지.복사N.노무비」 대신 · 그 번지 줄만 든 항목 제목 · 없으면 줄 이름(둘까지 · 「외 n」) */ +export function placeNames(doc: StackDoc, items: StackItem[]): Record { + const money = doc.금액 ?? []; + const out: Record = {}; + for (const p of new Set(money.map((l) => String(l.번지 ?? "")).filter(Boolean))) { + const mine = (l: StackLine): boolean => l.꼴 === "금액" && money[l.i]?.번지 === p; + const holds = items.filter((it) => it.줄.some(mine)); + const only = holds.every((it) => + it.줄.every((l) => l.꼴 !== "금액" || mine(l) || !money[l.i]?.번지), + ); + let title = only ? (holds.find((it) => it.제목.trim())?.제목.trim() ?? "") : ""; + // 여러 항목에 걸친 조각 = 첫 항목 「조각 이름 · 항목 제목」의 조각 이름(addTaken 꼴) + // ponytail: 항목 제목에 「 · 」가 있으면 덜 자름 — 조각 이름을 문서에 따로 적게 되면 그것으로 + if (title && holds.length > 1 && title.lastIndexOf(" · ") > 0) + title = title.slice(0, title.lastIndexOf(" · ")); + const lines = money.filter((l) => l.번지 === p).map((l) => String(l.이름 ?? "").trim()); + out[p] = + title || + (lines.length > 2 + ? `${lines[0]} 외 ${lines.length - 1}` + : lines.filter(Boolean).join(" · ")) || + p; + } + return out; +} diff --git a/resources/tester/helper_804_2_line_stack.cjs b/resources/tester/helper_804_2_line_stack.cjs index 91b8c7d28..f58f610f7 100644 --- a/resources/tester/helper_804_2_line_stack.cjs +++ b/resources/tester/helper_804_2_line_stack.cjs @@ -190,6 +190,29 @@ function takeIn({ 문서, 받음 }) { }; } +/* 804-5 끌어 옮김 · 번지 읽는 이름 — 옮김 = [[from, to] …](줄 "i.j" → "k.p" · 항목 "i" → "p") */ +function moveAll({ 문서, 옮김 }) { + let doc = JSON.parse(JSON.stringify(문서)); + for (const [a, b] of 옮김) { + const items = ls.stackOf(doc); + if (a.includes(".")) + ls.moveLine(items, ...a.split(".").map(Number), ...b.split(".").map(Number)); + else ls.moveItem(items, Number(a), Number(b)); + doc = ls.docOf(doc, items); + } + const items = ls.stackOf(doc); + return { + 문서: doc, + 항목: items.map((it) => ({ + 제목: it.제목, + 줄: it.줄.map((l) => + l.꼴 === "금액" ? doc.금액[l.i].이름 : l.꼴 === "글" ? l.글 : l.기호.join(","), + ), + })), + }; +} +const placeOf = ({ 문서 }) => cp.placeNames(문서, ls.stackOf(문서)); + const input = JSON.parse(fs.readFileSync(0, "utf8")); const job = { 왕복: roundTrip, @@ -199,5 +222,7 @@ const job = { 넣기: putPicked, 바꿈: repickOne, 복사: takeIn, + 옮김: moveAll, + 번지: placeOf, }[input.할일]; process.stdout.write(JSON.stringify(input.문서들.map(job))); diff --git a/resources/tester/test_804_4_line_stack_take.py b/resources/tester/test_804_4_line_stack_take.py index af2dc1d92..df830adca 100644 --- a/resources/tester/test_804_4_line_stack_take.py +++ b/resources/tester/test_804_4_line_stack_take.py @@ -200,3 +200,33 @@ def test_권한_저장_단가갱신(world, monkeypatch): got["후"]["계"] > again["셈"]["계"] + 1000 * 0.3 ) # 보통인부 줄들(복사한 0.3 인 포함) 따라감 assert got["문서"]["금액"] == again["문서"]["금액"] + + +def test_끌어옮김_번지이름(world): + """804-5 — 줄 끌기(항목 안 · 사이) · 항목 끌기 = 되돌려 끌면 같은 문서 · 율 줄 번지 = 읽는 이름.""" + gc = dc.folder()[DIG] # 항목 둘(인력 · 굴착기) + one = _ts("옮김", [{"문서": gc, "옮김": []}])[0] + first = one["항목"][0]["줄"][0] + gone = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "1.0"]]}])[0] + assert ( + gone["항목"][1]["줄"][0] == first + and len(gone["항목"][0]["줄"]) == len(one["항목"][0]["줄"]) - 1 + ) + back = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "1.0"], ["1.0", "0.0"]]}])[0] + assert back["문서"] == one["문서"] + # 같은 항목 안 아래로(빼기 전 차례) — 첫 줄을 끝 자리 = 둘째로 + inner = _ts("옮김", [{"문서": gc, "옮김": [["0.0", "0.2"]]}])[0] + assert inner["항목"][0]["줄"] == one["항목"][0]["줄"][::-1] + n = len(one["항목"]) + rot = _ts("옮김", [{"문서": gc, "옮김": [["0", str(n)]]}])[0] + assert rot["항목"][-1] == one["항목"][0] and rot["항목"][0] == one["항목"][1] + assert ( + _money(rot["문서"])["계"] == _money(gc)["계"] + ) # 항목만 옮김 = 금액 같음(율 줄은 그 항목 안) + # 율 줄 번지 — 일위대가 복사 = 번지마다 읽는 이름(「복사N」 아님) + me, sk, sc = _combo(world) + g = _into(dc.md.blank("SK-000009", "빈"), _take(me, _rid(sc))) + names = _ts("번지", [{"문서": g["문서"]}])[0] + places = {m["번지"] for m in g["문서"]["금액"] if m.get("번지")} + assert places and set(names) == places + assert all(v and not v.startswith("복사") for v in names.values()), names diff --git a/ui_template/ui_template_toast.ts b/ui_template/ui_template_toast.ts index 4d73ea50a..b432ab998 100644 --- a/ui_template/ui_template_toast.ts +++ b/ui_template/ui_template_toast.ts @@ -90,8 +90,11 @@ export function showToast( ): void { const key = `${kind}::${message}`; const now = Date.now(); - if (lastToast && lastToast.key === key && now < lastToast.until) return; - lastToast = { key, until: now + durationMs }; + // 누름 동작 알림(되돌리기 등)은 조작마다 따로 — 같은 문구여도 버리지 않음(804-5) + if (!onClick) { + if (lastToast && lastToast.key === key && now < lastToast.until) return; + lastToast = { key, until: now + durationMs }; + } const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message }); if (onClick) { toast.style.cursor = "pointer";