feat(804-5): 줄 쌓기 왼쪽 손질 — 자원 이름 단추 · 항목 띠 · 끌어 옮기기 · X 지움 되돌리기 · 율 번지 읽는 이름
- 인력 · 기계 · 재료 · 단가산출 줄 자원 이름 = 단추(이름 · 규격) → 찾아 넣기 창 → 바꿈 · [찾아 바꾸기] 걷음 - 항목 머리 = 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 한 단 더 · 접기 - 순서 = 손잡이 끌기(줄 = 항목 안 · 사이 · 항목 끌기 · 자리 선 표시) · [↑][↓] 걷음 - 지움 = X 아이콘 · 확인 없이 지우고 알림 눌러 되돌리기(그 뒤 고침 있으면 못 함) - 산출근거 율 줄 「번지.복사N.노무비」 → 그 조각 이름 · 값 식 = 번지 값 · 합계 덧셈 항 넷 넘으면 항목 소계끼리 - 알림 부품: 누름 동작 알림은 같은 문구 버림 안 함(두 번째 지움 되돌리기 안 뜨던 흠) - 글 · 근거 카드 높이 늘던 흠 고침 · 시험 test_804_4 끌어옮김 · 번지 이름 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+85
-12
@@ -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);
|
||||
|
||||
@@ -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<HTMLElement>(":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<HTMLElement>(":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<typeof spotOf> = 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);
|
||||
});
|
||||
}
|
||||
+156
-45
@@ -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<MoneyKind, LsKey> = {
|
||||
인력: "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<string, unknown>)[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;
|
||||
}
|
||||
|
||||
@@ -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<string, string>,
|
||||
) {
|
||||
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<string, Values> = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 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<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
|
||||
const perItem: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(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)
|
||||
? "—"
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface TexCtx {
|
||||
inline?: boolean;
|
||||
/** 묶음 값의 비목(기계 · 단가산출 줄 노 · 재 · 경) */
|
||||
cost?: string;
|
||||
/** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */
|
||||
place?: Record<string, string>;
|
||||
}
|
||||
|
||||
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":
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<T>(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, "");
|
||||
|
||||
@@ -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<string, string> {
|
||||
const money = doc.금액 ?? [];
|
||||
const out: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user