feat(view): attribute-copy dialog (시안 ⑥, plan C8)

- 속성 복사… opens the four-step dialog: source (current inputs), items,
  targets (same-type filter on by default), and a preview with changed values
  and skipped items per target. Apply changes only the visible, ticked targets.
- Items that the model does not have yet (기둥, 받침, 배근, 기초 형상) are shown
  as 예정 and disabled. Coping length and pile length are off by default.
- Option: regenerate the targets right after applying (default on); otherwise
  they show as 재생성 필요 through the normal change tracking.
- Not verified in a browser.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-22 10:01:18 +09:00
1 parent 4accf1f3be
commit f75431d898
3 files changed
+184

No files matched your search

+122
View File
@@ -7,6 +7,7 @@ import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
import { buildTemplateWorkbook } from './core/template';
import { EDITABLE_FIELDS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
import type { Issue, PierInput, PierType } from './core/types';
@@ -266,6 +267,7 @@ function renderHeader(): void {
regen.textContent = total ? `선택 교각 다시 생성 · 변경 ${total}` : '선택 교각 형상 생성';
$('bulk-count').textContent = `선택 ${checked}기`;
$<HTMLButtonElement>('bulk-regen').disabled = checked === 0;
$<HTMLButtonElement>('bulk-copy').disabled = !activePier();
}
// --- results panel (시안 ① 입력값 · 계산 결과) ----------------------------------------
@@ -369,6 +371,114 @@ function renderAll(): void {
scheduleBackup();
}
// --- 속성 복사 (시안 ⑥, plan C8) ---------------------------------------------------------
/** Names of the pier targets ticked in the copy dialog. */
let copySelection = new Set<string>();
function copyTargets(): Pier[] {
const src = activePier();
const sameType = $<HTMLInputElement>('cp-same-type').checked;
return state.piers.filter((p) => p !== src && (!sameType || p.input.type === src?.input.type));
}
/** Targets that are both visible in the dialog and ticked. Hidden targets are never changed. */
function selectedCopyTargets(): Pier[] {
return copyTargets().filter((t) => copySelection.has(t.input.name));
}
function chosenCopyItems(): CopyItem[] {
return Array.from(document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]'))
.filter((i) => i.checked)
.map((i) => i.dataset.item as CopyItem);
}
function renderCopyTargets(): void {
const list = $<HTMLUListElement>('cp-targets');
list.replaceChildren();
for (const t of copyTargets()) {
const li = el('li');
const check = el('input');
check.type = 'checkbox';
check.checked = copySelection.has(t.input.name);
check.setAttribute('aria-label', `${t.input.name} 대상`);
check.addEventListener('change', () => {
if (check.checked) copySelection.add(t.input.name);
else copySelection.delete(t.input.name);
renderCopyPreview();
});
li.append(check, el('span', `${t.input.name} · ${t.input.type} · ${t.input.foundationType}`));
list.appendChild(li);
}
}
function renderCopyPreview(): void {
const src = activePier();
const items = chosenCopyItems();
const body = $<HTMLTableElement>('cp-preview').tBodies[0];
body.replaceChildren();
let applied = 0;
let skippedTotal = 0;
const selected = selectedCopyTargets();
for (const t of selected) {
if (!src) break;
const plan = planCopy(src.draft, t.draft, items);
const labels = plan.changes.map((c) => `${EDITABLE_FIELDS.find((f) => f.key === c.key)!.label} ${c.from} → ${c.to}`);
const skipped = plan.skipped.map((k) => k.reason);
skippedTotal += skipped.length;
if (plan.changes.length) applied++;
const row = el('tr');
row.append(
el('td', t.input.name),
el('td', t.input.type),
el('td', String(plan.changes.length)),
el('td', String(skipped.length)),
el('td', [...labels, ...skipped].join(' · ') || '바뀌는 값 없음'),
);
body.appendChild(row);
}
const n = selected.length;
$('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`;
const apply = $<HTMLButtonElement>('cp-apply');
apply.textContent = `대상 ${applied}기에 적용`;
apply.disabled = applied === 0;
}
function openCopyDialog(): void {
const src = activePier();
if (!src) return;
$('cp-source').textContent = `${src.input.name} · ${src.input.type} · ${src.input.foundationType}`;
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
i.checked = (DEFAULT_COPY_ITEMS as string[]).includes(i.dataset.item ?? '');
}
copySelection = new Set(state.piers.filter((p) => p.checked && p !== src).map((p) => p.input.name));
renderCopyTargets();
renderCopyPreview();
$<HTMLDialogElement>('copy-dialog').showModal();
}
function applyCopyDialog(): void {
const src = activePier();
if (!src) return;
const items = chosenCopyItems();
const regen = $<HTMLInputElement>('cp-regen').checked;
let applied = 0;
let changes = 0;
const targets = new Set(selectedCopyTargets().map((t) => t.input.name));
state.piers = state.piers.map((t) => {
if (t === src || !targets.has(t.input.name)) return t;
const plan = planCopy(src.draft, t.draft, items);
if (plan.changes.length === 0) return t;
applied++;
changes += plan.changes.length;
const next = { ...t, draft: applyCopy(t.draft, plan) };
return regen ? regenerateEntry(next) : next;
});
$<HTMLDialogElement>('copy-dialog').close();
setupScene();
renderAll();
setStatus(`${applied}기에 속성을 복사했습니다. 변경 ${changes}건${regen ? ' (형상 다시 생성)' : ' · 재생성 필요'}.`);
}
// --- input cards and change tracking (plan B1 · C2) -------------------------------------
function pendingTotal(): number {
return state.piers.filter((p) => p.checked).reduce((n, p) => n + pendingKeys(p).length, 0);
@@ -781,6 +891,18 @@ $<HTMLButtonElement>('dlg-cancel').addEventListener('click', () => $<HTMLDialogE
$<HTMLButtonElement>('dlg-apply').addEventListener('click', applyImport);
$<HTMLButtonElement>('regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-regen').addEventListener('click', regenerateChecked);
$<HTMLButtonElement>('bulk-copy').addEventListener('click', openCopyDialog);
$<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
renderCopyTargets();
renderCopyPreview();
});
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-dialog input[data-item]')) {
i.addEventListener('change', renderCopyPreview);
}
$<HTMLInputElement>('cp-regen').addEventListener('change', renderCopyPreview);
$<HTMLButtonElement>('cp-apply').addEventListener('click', applyCopyDialog);
$<HTMLButtonElement>('cp-cancel').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
$<HTMLButtonElement>('cp-close').addEventListener('click', () => $<HTMLDialogElement>('copy-dialog').close());
$<HTMLButtonElement>('sv-save').addEventListener('click', confirmSaveDialog);
$<HTMLButtonElement>('sv-cancel').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
$<HTMLButtonElement>('sv-close').addEventListener('click', () => $<HTMLDialogElement>('save-dialog').close());
+7
View File
@@ -644,4 +644,11 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.views .btn[aria-pressed='true'] { background: #1f5fa8; color: #ffffff; font-weight: 700; }
.viewer-head { gap: 8px; }
.edit-card h3 { font-size: 12px; color: #55606e; }
/* 속성 복사 (시안 ⑥) */
.cp-items li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 6px 0; border-bottom: 1px solid #e6eaef; }
.cp-group { min-width: 110px; font-size: 12px; font-weight: 700; color: #55606e; }
.cp-targets { list-style: none; padding: 0; margin: 6px 0; max-height: 240px; overflow-y: auto; }
.cp-targets li { display: flex; align-items: center; gap: 8px; min-height: 36px; }
.chk.disabled { opacity: 0.5; }
#cp-preview td, #cp-preview th { font-size: 12px; }