feat(copy): 속성 복사는 고른 대상 교각에만 적용
- 대상 교각을 원본 아래(2번)로 올리고, 기본 0기로 시작
- 같은 형식 모두 선택 / 선택 해제 버튼 추가, 모델 없는 교각은 고를 수 없음
- 적용 버튼에 대상 이름을 표시 ("P1, P5 2기에만 적용"), 미리보기는 고른 교각만 표시
- 후보·선택 규칙을 core/copy-targets.ts로 분리하고 테스트 6건 추가
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8b383423c
commit
ccc1f494bc
5 files changed
+124
-26
No files matched your search
+14
-9
@@ -80,7 +80,17 @@
|
||||
<p class="muted small">현재 입력값 기준 (미반영 변경 포함)</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>2 · 복사할 항목</h3>
|
||||
<h3>2 · 대상 교각</h3>
|
||||
<div class="cp-tools">
|
||||
<label class="chk"><input id="cp-same-type" type="checkbox" checked />원본과 같은 형식만</label>
|
||||
<button id="cp-select-same" class="btn-o small" type="button">같은 형식 모두 선택</button>
|
||||
<button id="cp-clear" class="btn-o small" type="button">선택 해제</button>
|
||||
</div>
|
||||
<div id="cp-targets" class="cp-chips"></div>
|
||||
<p class="muted small">고른 교각에만 적용합니다. 고르지 않은 교각은 바뀌지 않습니다. 모델이 없는 교각은 고를 수 없습니다.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>3 · 복사할 항목</h3>
|
||||
<ul class="cp-items">
|
||||
<li><span class="cp-group">코핑</span>
|
||||
<label class="chk"><input type="checkbox" data-item="coping-width" />폭 B</label>
|
||||
@@ -100,27 +110,22 @@
|
||||
</ul>
|
||||
<p class="muted small">복사하지 않는 값: 좌표 · IP · 받침 EL · 코핑 상면 EL · 기초 하단 EL · 지반고 · 기둥 높이 · 말뚝 선단 EL · 계단 구간</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>3 · 대상 교각</h3>
|
||||
<label class="chk"><input id="cp-same-type" type="checkbox" checked />원본과 같은 형식만 보기</label>
|
||||
<ul id="cp-targets" class="cp-targets"></ul>
|
||||
</section>
|
||||
</div>
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>4 · 적용 미리보기</h3>
|
||||
<h3>4 · 적용 미리보기 (고른 교각만)</h3>
|
||||
<table id="cp-preview" class="grid-table">
|
||||
<thead><tr><th>대상</th><th>형식</th><th>바뀌는 값</th><th>건너뜀</th><th>내용</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<label class="chk"><input id="cp-regen" type="checkbox" checked />적용 후 대상 교각 형상까지 바로 다시 생성</label>
|
||||
<label class="chk"><input id="cp-regen" type="checkbox" checked />적용 후 고른 교각만 바로 다시 생성</label>
|
||||
<p class="muted small">적용하면 대상 교각마다 "변경 n건"이 표시되고, 입력 수정 카드에서 항목별로 되돌릴 수 있습니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="copy-foot">
|
||||
<span id="cp-summary" class="muted"></span>
|
||||
<button id="cp-apply" class="btn-p" type="button" disabled>대상 0기에 적용</button>
|
||||
<button id="cp-apply" class="btn-p" type="button" disabled>대상을 고르세요</button>
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** One pier as a target of 속성 복사. */
|
||||
export interface CopyCandidate {
|
||||
name: string;
|
||||
type: string;
|
||||
/** A pier without a generated model cannot be regenerated, so it is listed but not selectable. */
|
||||
hasModel: boolean;
|
||||
}
|
||||
|
||||
export interface TargetCandidate {
|
||||
name: string;
|
||||
type: string;
|
||||
selectable: boolean;
|
||||
}
|
||||
|
||||
/** The piers offered as targets: every other pier, or only those of the source type. */
|
||||
export function copyCandidates(piers: CopyCandidate[], sourceName: string, sameTypeOnly: boolean): TargetCandidate[] {
|
||||
const source = piers.find((p) => p.name === sourceName);
|
||||
return piers
|
||||
.filter((p) => p.name !== sourceName && (!sameTypeOnly || p.type === source?.type))
|
||||
.map((p) => ({ name: p.name, type: p.type, selectable: p.hasModel }));
|
||||
}
|
||||
|
||||
/** The targets that get the change: ticked by the user, selectable, and visible under the current filter. */
|
||||
export function selectedTargets(piers: CopyCandidate[], sourceName: string, sameTypeOnly: boolean, ticked: Set<string>): string[] {
|
||||
return copyCandidates(piers, sourceName, sameTypeOnly)
|
||||
.filter((c) => c.selectable && ticked.has(c.name))
|
||||
.map((c) => c.name);
|
||||
}
|
||||
+35
-17
@@ -9,6 +9,7 @@ import { buildTemplateWorkbook } from './core/template';
|
||||
import { EDITABLE_FIELDS, FIELD_LABELS, overrideKeys, pendingKeys, revertDraft, type EditableKey, type PierValues } from './core/changes';
|
||||
import { adjustRow, layoutText, resolveRowCounts, rowLayout, totalPiles } from './core/piles';
|
||||
import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/copy';
|
||||
import { copyCandidates, selectedTargets, type CopyCandidate } from './core/copy-targets';
|
||||
import { pileGeometry } from './core/geometry';
|
||||
import { rebarSummary, type RebarRow } from './core/rebar/summary';
|
||||
import { shearSegments } from './core/rebar/coping';
|
||||
@@ -631,15 +632,16 @@ function renderAll(): void {
|
||||
/** Names of the pier targets ticked in the copy panel. */
|
||||
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));
|
||||
function copyCandidateList(): CopyCandidate[] {
|
||||
return state.piers.map((p) => ({ name: p.input.name, type: p.input.type, hasModel: p.model !== null }));
|
||||
}
|
||||
|
||||
/** Targets that are both visible in the dialog and ticked. Hidden targets are never changed. */
|
||||
/** The piers that get the change: only those ticked in the panel (기본 0기). */
|
||||
function selectedCopyTargets(): Pier[] {
|
||||
return copyTargets().filter((t) => copySelection.has(t.input.name));
|
||||
const src = activePier();
|
||||
if (!src) return [];
|
||||
const names = new Set(selectedTargets(copyCandidateList(), src.input.name, $<HTMLInputElement>('cp-same-type').checked, copySelection));
|
||||
return state.piers.filter((p) => names.has(p.input.name));
|
||||
}
|
||||
|
||||
function chosenCopyItems(): CopyItem[] {
|
||||
@@ -649,21 +651,25 @@ function chosenCopyItems(): CopyItem[] {
|
||||
}
|
||||
|
||||
function renderCopyTargets(): void {
|
||||
const list = $<HTMLUListElement>('cp-targets');
|
||||
list.replaceChildren();
|
||||
for (const t of copyTargets()) {
|
||||
const li = el('li');
|
||||
const box = $('cp-targets');
|
||||
box.replaceChildren();
|
||||
const src = activePier();
|
||||
const sameType = $<HTMLInputElement>('cp-same-type').checked;
|
||||
for (const c of copyCandidates(copyCandidateList(), src?.input.name ?? '', sameType)) {
|
||||
const label = el('label', undefined, 'chk');
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.checked = copySelection.has(t.input.name);
|
||||
check.setAttribute('aria-label', `${t.input.name} 대상`);
|
||||
check.disabled = !c.selectable;
|
||||
check.checked = c.selectable && copySelection.has(c.name);
|
||||
check.setAttribute('aria-label', `${c.name} 대상`);
|
||||
check.addEventListener('change', () => {
|
||||
if (check.checked) copySelection.add(t.input.name);
|
||||
else copySelection.delete(t.input.name);
|
||||
if (check.checked) copySelection.add(c.name);
|
||||
else copySelection.delete(c.name);
|
||||
renderCopyPreview();
|
||||
});
|
||||
li.append(check, el('span', `${t.input.name} · ${t.input.type} · ${t.input.foundationType}`));
|
||||
list.appendChild(li);
|
||||
const text = c.selectable ? `${c.name} · ${c.type}` : `${c.name} · ${c.type} (모델 없음, 제외)`;
|
||||
label.append(check, document.createTextNode(text));
|
||||
box.appendChild(label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,7 +701,7 @@ function renderCopyPreview(): void {
|
||||
const n = selected.length;
|
||||
$('cp-summary').textContent = `${src?.input.name ?? ''} → ${n}기 · 항목 ${items.length}개 · 건너뜀 ${skippedTotal}건`;
|
||||
const apply = $<HTMLButtonElement>('cp-apply');
|
||||
apply.textContent = `대상 ${applied}기에 적용`;
|
||||
apply.textContent = selected.length === 0 ? '대상을 고르세요' : `${selected.map((t) => t.input.name).join(', ')} ${selected.length}기에만 적용`;
|
||||
apply.disabled = applied === 0;
|
||||
}
|
||||
|
||||
@@ -3061,6 +3067,18 @@ $<HTMLInputElement>('cp-same-type').addEventListener('change', () => {
|
||||
renderCopyTargets();
|
||||
renderCopyPreview();
|
||||
});
|
||||
$<HTMLButtonElement>('cp-select-same').addEventListener('click', () => {
|
||||
const src = activePier();
|
||||
const sameType = $<HTMLInputElement>('cp-same-type').checked;
|
||||
copySelection = new Set(copyCandidates(copyCandidateList(), src?.input.name ?? '', sameType).filter((c) => c.selectable).map((c) => c.name));
|
||||
renderCopyTargets();
|
||||
renderCopyPreview();
|
||||
});
|
||||
$<HTMLButtonElement>('cp-clear').addEventListener('click', () => {
|
||||
copySelection = new Set();
|
||||
renderCopyTargets();
|
||||
renderCopyPreview();
|
||||
});
|
||||
for (const i of document.querySelectorAll<HTMLInputElement>('#copy-panel input[data-item]')) {
|
||||
i.addEventListener('change', renderCopyPreview);
|
||||
}
|
||||
|
||||
@@ -828,3 +828,9 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.copy-panel .dlg-col + .dlg-col { margin-top: 10px; }
|
||||
.copy-panel .grid-table { font-size: 12px; display: block; overflow-x: auto; }
|
||||
.copy-panel .copy-foot { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-top: 10px; }
|
||||
.copy-panel .cp-tools { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px; }
|
||||
.copy-panel .cp-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 4px; }
|
||||
.copy-panel .cp-chips .chk { border: 1px solid #d5dde6; border-radius: 6px; padding: 4px 8px; background: #fff; }
|
||||
.copy-panel .cp-chips .chk:has(input:checked) { border-color: #1f5fa8; background: #e3edf8; }
|
||||
.copy-panel .cp-chips .chk:has(input:disabled) { color: #8a94a3; background: #f4f6f9; }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { copyCandidates, selectedTargets, type CopyCandidate } from '../src/core/copy-targets';
|
||||
|
||||
const piers: CopyCandidate[] = [
|
||||
{ name: 'P1', type: 'T형', hasModel: true },
|
||||
{ name: 'P2', type: 'T형', hasModel: true },
|
||||
{ name: 'P3', type: 'T형', hasModel: true },
|
||||
{ name: 'P4', type: 'Y형', hasModel: true },
|
||||
{ name: 'P5', type: 'T형', hasModel: false },
|
||||
];
|
||||
|
||||
describe('copyCandidates (대상 후보)', () => {
|
||||
it('excludes the source pier', () => {
|
||||
expect(copyCandidates(piers, 'P3', false).map((c) => c.name)).not.toContain('P3');
|
||||
});
|
||||
|
||||
it('keeps only the source type when 같은 형식만 is on', () => {
|
||||
expect(copyCandidates(piers, 'P3', true).map((c) => c.name)).toEqual(['P1', 'P2', 'P5']);
|
||||
});
|
||||
|
||||
it('marks a pier without a model as not selectable', () => {
|
||||
const p5 = copyCandidates(piers, 'P3', false).find((c) => c.name === 'P5');
|
||||
expect(p5?.selectable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectedTargets (선택한 대상만)', () => {
|
||||
it('is empty when nothing is ticked', () => {
|
||||
expect(selectedTargets(piers, 'P3', false, new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only ticked, selectable piers', () => {
|
||||
const picked = new Set(['P1', 'P4', 'P5']);
|
||||
expect(selectedTargets(piers, 'P3', false, picked)).toEqual(['P1', 'P4']);
|
||||
});
|
||||
|
||||
it('ignores a tick on a pier hidden by the same-type filter', () => {
|
||||
const picked = new Set(['P4']);
|
||||
expect(selectedTargets(piers, 'P3', true, picked)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user