diff --git a/index.html b/index.html index 4f69c9f..7e86439 100644 --- a/index.html +++ b/index.html @@ -80,7 +80,17 @@

현재 입력값 기준 (미반영 변경 포함)

-

2 · 복사할 항목

+

2 · 대상 교각

+
+ + + +
+
+

고른 교각에만 적용합니다. 고르지 않은 교각은 바뀌지 않습니다. 모델이 없는 교각은 고를 수 없습니다.

+
+
+

3 · 복사할 항목

복사하지 않는 값: 좌표 · IP · 받침 EL · 코핑 상면 EL · 기초 하단 EL · 지반고 · 기둥 높이 · 말뚝 선단 EL · 계단 구간

-
-

3 · 대상 교각

- - -
-

4 · 적용 미리보기

+

4 · 적용 미리보기 (고른 교각만)

대상형식바뀌는 값건너뜀내용
- +

적용하면 대상 교각마다 "변경 n건"이 표시되고, 입력 수정 카드에서 항목별로 되돌릴 수 있습니다.

diff --git a/src/core/copy-targets.ts b/src/core/copy-targets.ts new file mode 100644 index 0000000..2933ebb --- /dev/null +++ b/src/core/copy-targets.ts @@ -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[] { + return copyCandidates(piers, sourceName, sameTypeOnly) + .filter((c) => c.selectable && ticked.has(c.name)) + .map((c) => c.name); +} diff --git a/src/main.ts b/src/main.ts index d952fc3..6f91beb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); -function copyTargets(): Pier[] { - const src = activePier(); - const sameType = $('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, $('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 = $('cp-targets'); - list.replaceChildren(); - for (const t of copyTargets()) { - const li = el('li'); + const box = $('cp-targets'); + box.replaceChildren(); + const src = activePier(); + const sameType = $('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 = $('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 @@ $('cp-same-type').addEventListener('change', () => { renderCopyTargets(); renderCopyPreview(); }); +$('cp-select-same').addEventListener('click', () => { + const src = activePier(); + const sameType = $('cp-same-type').checked; + copySelection = new Set(copyCandidates(copyCandidateList(), src?.input.name ?? '', sameType).filter((c) => c.selectable).map((c) => c.name)); + renderCopyTargets(); + renderCopyPreview(); +}); +$('cp-clear').addEventListener('click', () => { + copySelection = new Set(); + renderCopyTargets(); + renderCopyPreview(); +}); for (const i of document.querySelectorAll('#copy-panel input[data-item]')) { i.addEventListener('change', renderCopyPreview); } diff --git a/src/style.css b/src/style.css index a238e64..e67f42a 100644 --- a/src/style.css +++ b/src/style.css @@ -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; } + diff --git a/tests/copy-targets.test.ts b/tests/copy-targets.test.ts new file mode 100644 index 0000000..39a37de --- /dev/null +++ b/tests/copy-targets.test.ts @@ -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([]); + }); +});