@simplysm/angular 14.0.48 → 14.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +234 -226
  2. package/dist/controls/select/sd-select.js +3 -3
  3. package/dist/index.d.ts +0 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +0 -2
  6. package/dist/layout/dock/sd-dock-container.js +1 -1
  7. package/dist/styles.css +9 -0
  8. package/docs/bootstrap/provide-sd-angular.md +37 -0
  9. package/docs/bootstrap/sd-angular-config-provider.md +16 -0
  10. package/docs/directives/sd-command-directive.md +30 -0
  11. package/docs/directives/sd-events.md +25 -0
  12. package/docs/directives/sd-intersection-directive.md +36 -0
  13. package/docs/directives/sd-invalid.md +24 -0
  14. package/docs/directives/sd-resize-directive.md +42 -0
  15. package/docs/directives/sd-ripple.md +23 -0
  16. package/docs/directives/sd-router-link.md +38 -0
  17. package/docs/directives/sd-show-effect.md +18 -0
  18. package/docs/directives/sd-typed-template.md +69 -0
  19. package/docs/features/sd-address-search-modal.md +50 -0
  20. package/docs/features/sd-permission-table.md +20 -0
  21. package/docs/features/sd-shared-data-components.md +158 -0
  22. package/docs/features/sd-tiptap-editor.md +26 -0
  23. package/docs/{pipes.md → pipes/format-pipe.md} +14 -5
  24. package/docs/plugins/sd-global-error-handler.md +23 -0
  25. package/docs/{plugins.md → plugins/sd-option-event-plugin.md} +9 -12
  26. package/docs/provider-types/sd-menu.md +65 -0
  27. package/docs/provider-types/sd-modal-content-def.md +148 -0
  28. package/docs/provider-types/sd-toast-content-def.md +73 -0
  29. package/docs/provider-types/shared-data-base.md +59 -0
  30. package/docs/providers/sd-activated-modal-provider.md +34 -0
  31. package/docs/providers/sd-app-structure-provider.md +81 -0
  32. package/docs/providers/sd-busy-provider.md +18 -0
  33. package/docs/providers/sd-file-dialog-provider.md +40 -0
  34. package/docs/providers/sd-local-storage-provider.md +20 -0
  35. package/docs/providers/sd-modal-provider.md +67 -0
  36. package/docs/providers/sd-navigate-window-provider.md +18 -0
  37. package/docs/providers/sd-print-provider.md +25 -0
  38. package/docs/providers/sd-service-client-factory-provider.md +43 -0
  39. package/docs/providers/sd-shared-data-provider.md +64 -0
  40. package/docs/providers/sd-system-config-provider.md +46 -0
  41. package/docs/providers/sd-system-log-provider.md +18 -0
  42. package/docs/providers/sd-theme-provider.md +38 -0
  43. package/docs/providers/sd-toast-provider.md +65 -0
  44. package/docs/recipes/_common-rules.md +244 -0
  45. package/docs/recipes/crud-detail/extension-a-edit-save.md +230 -0
  46. package/docs/recipes/crud-detail/extension-b-delete-restore.md +142 -0
  47. package/docs/recipes/crud-detail/extension-c-modal-view.md +214 -0
  48. package/docs/recipes/crud-detail/extension-d-control-view.md +103 -0
  49. package/docs/recipes/crud-detail/extension-e-auxiliary.md +87 -0
  50. package/docs/recipes/crud-detail/extension-f-complex-detail.md +234 -0
  51. package/docs/recipes/crud-detail.md +215 -722
  52. package/docs/recipes/crud-list/extension-a-inline-edit.md +410 -0
  53. package/docs/recipes/crud-list/extension-b-selection.md +226 -0
  54. package/docs/recipes/crud-list/extension-c-inline-delete.md +87 -0
  55. package/docs/recipes/crud-list/extension-d-select-modal.md +207 -0
  56. package/docs/recipes/crud-list/extension-e-readonly-modal.md +165 -0
  57. package/docs/recipes/crud-list/extension-f-modal-edit.md +177 -0
  58. package/docs/recipes/crud-list/extension-g-excel.md +157 -0
  59. package/docs/recipes/crud-list.md +293 -626
  60. package/docs/recipes/data-select-button.md +194 -101
  61. package/docs/recipes/page-modal-container.md +168 -86
  62. package/docs/styling/classes.md +149 -0
  63. package/docs/styling/mixins.md +100 -0
  64. package/docs/styling/themes.md +35 -0
  65. package/docs/styling/variables.md +147 -0
  66. package/docs/{type-utilities.md → type-utilities/directive-input-signals.md} +17 -35
  67. package/docs/ui-data/sd-list.md +37 -0
  68. package/docs/ui-data/sd-sheet.md +227 -0
  69. package/docs/ui-form/sd-additional-button.md +26 -0
  70. package/docs/ui-form/sd-anchor.md +31 -0
  71. package/docs/ui-form/sd-button.md +105 -0
  72. package/docs/ui-form/sd-checkbox-group.md +39 -0
  73. package/docs/ui-form/sd-checkbox.md +81 -0
  74. package/docs/ui-form/sd-date-range-picker.md +27 -0
  75. package/docs/ui-form/sd-form.md +89 -0
  76. package/docs/ui-form/sd-modal-select-button.md +54 -0
  77. package/docs/ui-form/sd-numpad.md +26 -0
  78. package/docs/ui-form/sd-range.md +26 -0
  79. package/docs/ui-form/sd-select.md +68 -0
  80. package/docs/ui-form/sd-shared-data-select.md +52 -0
  81. package/docs/ui-form/sd-state-preset.md +37 -0
  82. package/docs/ui-form/sd-switch.md +27 -0
  83. package/docs/ui-form/sd-textarea.md +33 -0
  84. package/docs/ui-form/sd-textfield.md +145 -0
  85. package/docs/ui-layout/sd-dock-container.md +64 -0
  86. package/docs/ui-layout/sd-dock.md +37 -0
  87. package/docs/ui-layout/sd-gap.md +26 -0
  88. package/docs/{ui-layout.md → ui-layout/sd-kanban-board.md} +41 -85
  89. package/docs/ui-layout/sd-kanban-lane.md +34 -0
  90. package/docs/ui-layout/sd-kanban.md +29 -0
  91. package/docs/ui-navigation/sd-collapse.md +35 -0
  92. package/docs/ui-navigation/sd-pagination.md +26 -0
  93. package/docs/ui-navigation/sd-sidebar-container.md +49 -0
  94. package/docs/ui-navigation/sd-sidebar-menu.md +22 -0
  95. package/docs/ui-navigation/sd-sidebar-user.md +43 -0
  96. package/docs/ui-navigation/sd-tab.md +51 -0
  97. package/docs/ui-navigation/sd-topbar-container.md +97 -0
  98. package/docs/ui-navigation/sd-topbar-menu.md +23 -0
  99. package/docs/ui-navigation/sd-topbar-user.md +38 -0
  100. package/docs/ui-navigation/sd-topbar.md +30 -0
  101. package/docs/ui-overlay/sd-busy-container.md +69 -0
  102. package/docs/ui-overlay/sd-confirm-modal.md +30 -0
  103. package/docs/ui-overlay/sd-dropdown.md +40 -0
  104. package/docs/ui-overlay/sd-modal.md +34 -0
  105. package/docs/ui-overlay/sd-prompt-modal.md +30 -0
  106. package/docs/ui-overlay/sd-toast.md +35 -0
  107. package/docs/ui-visual/sd-barcode.md +36 -0
  108. package/docs/ui-visual/sd-calendar.md +34 -0
  109. package/docs/ui-visual/sd-echarts.md +32 -0
  110. package/docs/ui-visual/sd-label.md +24 -0
  111. package/docs/ui-visual/sd-note.md +23 -0
  112. package/docs/ui-visual/sd-progress.md +23 -0
  113. package/docs/utils/inject-routing-signals.md +161 -0
  114. package/docs/utils/inject-sd-system-config-resource.md +35 -0
  115. package/docs/utils/mark.md +43 -0
  116. package/docs/utils/selection-managers.md +96 -0
  117. package/docs/utils/set-safe-style.md +19 -0
  118. package/docs/utils/setup-functions.md +93 -0
  119. package/package.json +7 -7
  120. package/scss/commons/_styles.scss +12 -0
  121. package/src/controls/select/sd-select.ts +3 -3
  122. package/src/core/modal/sd-modal.provider.ts +1 -1
  123. package/src/core/modal/sd-modal.ts +1 -1
  124. package/src/core/routing/menu-utils.ts +1 -1
  125. package/src/core/shared-data/sd-shared-data.provider.ts +7 -7
  126. package/src/data/shared-data/sd-shared-data-select.ts +2 -2
  127. package/src/index.ts +0 -3
  128. package/src/layout/dock/sd-dock-container.ts +1 -1
  129. package/dist/data/getOrmDataEditToastErrorMessage.d.ts +0 -2
  130. package/dist/data/getOrmDataEditToastErrorMessage.d.ts.map +0 -1
  131. package/dist/data/getOrmDataEditToastErrorMessage.js +0 -8
  132. package/docs/bootstrap.md +0 -38
  133. package/docs/directives.md +0 -236
  134. package/docs/features.md +0 -169
  135. package/docs/provider-types.md +0 -283
  136. package/docs/providers.md +0 -379
  137. package/docs/styling.md +0 -222
  138. package/docs/ui-data.md +0 -333
  139. package/docs/ui-form.md +0 -502
  140. package/docs/ui-navigation.md +0 -273
  141. package/docs/ui-overlay.md +0 -157
  142. package/docs/ui-visual.md +0 -127
  143. package/docs/utils.md +0 -244
  144. package/src/data/getOrmDataEditToastErrorMessage.ts +0 -10
@@ -0,0 +1,87 @@
1
+ ← [CRUD 리스트 레시피 진입점](../crud-list.md)
2
+
3
+ # 확장 C: inline 삭제/복구 열
4
+
5
+ > **선행:** [확장 A: inline 편집/저장](./extension-a-inline-edit.md) + [확장 B: 선택 기능 + 선택 삭제/복구](./extension-b-selection.md) (`isDeleted` 플래그 / `selectedItems` / `getItemCellStyleFn` 취소선은 확장 B가 도입)
6
+
7
+ 확장 B의 상단 "선택 삭제 / 선택 복구" 바(다건 일괄 토글) 위에, 시트 맨 앞 고정 컬럼에 **row별 inline 삭제/복구 아이콘**을 추가한다. 같은 `isDeleted` 플래그를 row별 빠른 토글 경로로 조작하는 보완 관계 — 다건 일괄 토글과 별도 경로가 아니라 **동일 플래그에 대한 다른 진입 UI**다.
8
+
9
+ > **적용 조건:** DB Table에 `isDeleted` 컬럼이 있는 경우에만 사용한다. 컬럼이 없는 테이블은 물리 삭제(row DELETE)로 처리하며 이 확장을 사용하지 않는다 → [공통 규칙: 삭제 방식](../_common-rules.md#삭제-방식은-db-스키마에-따라-결정한다).
10
+
11
+ **이 확장이 도입하는 요소:**
12
+
13
+ - **imports:** `tablerEraser` / `tablerRestore` (확장 B가 이미 imports에 포함한 경우 추가 불필요)
14
+ - **메서드:** `onToggleDeleteItemButtonClick(item)` — `item.isDeleted` 토글 후 `mark(this.items)`
15
+ - **템플릿:** 시트 맨 앞에 `[fixed]="true" [key]="'_isDeleted'"` 고정 컬럼 추가 + `#headerTpl`(아이콘 헤더) + `[cell]`(`<sd-anchor>` 토글)
16
+
17
+ > 상세: [`<sd-sheet-column> #headerTpl`](../../ui-data/sd-sheet.md#sdsheetcolumn) · [`<sd-anchor>`](../../ui-form/sd-anchor.md)
18
+
19
+ > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A+B) 위에 번호 순서대로 삽입할 지점을 나타낸다. 그대로 컴파일되지 않는다.
20
+
21
+ ```typescript
22
+ // 1) 메서드 추가
23
+ protected onToggleDeleteItemButtonClick(item: ICustomer): void {
24
+ item.isDeleted = !item.isDeleted;
25
+ mark(this.items); // OnPush 재렌더 + diffs computed 알림
26
+ }
27
+
28
+ // 2) template — <sd-sheet> 가장 앞에 _isDeleted 고정 컬럼 삽입.
29
+ // canEdit() && viewType() === "page" 조건부 (modal/control 뷰나 권한 없음이면 열 자체 숨김)
30
+ `
31
+ <sd-sheet ...>
32
+ @if (canEdit() && viewType() === "page") {
33
+ <sd-sheet-column [fixed]="true" [key]="'_isDeleted'">
34
+ <ng-template #headerTpl>
35
+ <div class="p-xs-sm tx-center">
36
+ <ng-icon [svg]="tablerEraser" />
37
+ </div>
38
+ </ng-template>
39
+ <ng-template [cell]="items()" let-item="item">
40
+ <div class="p-xs-sm tx-center">
41
+ <sd-anchor [theme]="'danger'" (click)="onToggleDeleteItemButtonClick(item)">
42
+ <ng-icon [svg]="item.isDeleted ? tablerRestore : tablerEraser" />
43
+ {{ item.isDeleted ? "복구" : "삭제" }}
44
+ </sd-anchor>
45
+ </div>
46
+ </ng-template>
47
+ </sd-sheet-column>
48
+ }
49
+ <!-- 나머지 컬럼(id / name / phone / categoryId / ...)은 확장 A/B와 동일 -->
50
+ </sd-sheet>
51
+ `
52
+ ```
53
+
54
+ **포인트:**
55
+
56
+ - **확장 B와의 보완 관계.** 본 확장의 셀 아이콘 클릭과 확장 B의 상단 바 "선택 삭제 / 선택 복구"가 **같은 `isDeleted` 플래그**를 조작한다. 확장 B가 시트에 바인딩한 `[getItemCellStyleFn]` 취소선 스타일이 본 확장의 row별 토글 결과에도 그대로 반영된다. 선택 체크 없이 한 행만 빠르게 토글할 때는 이 확장, 여러 행을 한 번에 토글할 때는 확장 B를 사용한다.
57
+ - **row 삭제는 `isDeleted` 플래그 토글로 표현.** 확장 A / B와 동일 원리. DB 반영은 확장 A의 저장 버튼(또는 Ctrl+S) 클릭 시 일괄 처리(soft-delete) — 상단 바(확장 B)가 confirm 후 즉시 DB 반영하는 것과 달리, 본 확장의 row별 토글은 저장 버튼을 눌러야 반영된다.
58
+ - **컬럼 key는 `"_isDeleted"`** (언더스코어 prefix). `_` prefix를 붙여 DB 컬럼 key(`isDeleted` 등)와 분리한다 — 서버 정렬 키·시트 컬럼 지속성 설정 저장소와 충돌하지 않는 임의 키이기 때문. 상세는 아래 [🚫 흔한 실수](#컬럼-key를-isdeleted로-둔다) 참조.
59
+ - **`canEdit() && viewType() === "page"` 조건부.** modal/control 뷰나 권한 없음이면 열 자체 숨김. 확장 A의 `canEdit = computed(() => perms().includes("edit") && viewType() === "page")`와 `viewType() === "page"` 이중 조건으로 의도를 선명하게 드러낸다. 본 확장의 셀은 `<sd-anchor>`만 사용하므로 [공통 규칙: 시트 셀 `[inset]/[size]`](../_common-rules.md#시트-셀-내부-컨트롤에-insettrue-sizesm을-명시한다) 대상이 아니다.
60
+ - **`mark(this.items)` 호출 이유.** 필드 mutation(`item.isDeleted = !item.isDeleted`)은 signal의 참조를 바꾸지 않으므로, OnPush 재렌더와 `diffs` computed 통지를 위해 명시적 호출이 필요하다. 저장 감지 용도가 아님에 주의 — [공통 규칙: `mark(sig)`](../_common-rules.md#marksig를-저장-감지-수단으로-사용하지-않는다) 참조.
61
+
62
+ **🚫 흔한 실수**
63
+
64
+ > 공통 규칙(`mark` 저장 감지 오해, 시트 셀 `[inset]/[size]`, 삭제 방식 등)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 본 섹션은 **inline 삭제/복구 확장 고유 실수**만 다룬다.
65
+
66
+ ### 컬럼 key를 `"isDeleted"`로 둔다
67
+
68
+ ```html
69
+ <!-- ❌ 일반 DB 컬럼 key와 동일한 네이밍 — 서버 정렬 orderBy 키·시트 컬럼 지속성 설정과 충돌 -->
70
+ <sd-sheet-column [fixed]="true" [key]="'isDeleted'">
71
+ <ng-template #headerTpl>...</ng-template>
72
+ <ng-template [cell]="items()" let-item="item">...</ng-template>
73
+ </sd-sheet-column>
74
+
75
+ <!-- ✅ 언더스코어 prefix로 분리 — DB 컬럼 key와 충돌하지 않는 임의 키 -->
76
+ <sd-sheet-column [fixed]="true" [key]="'_isDeleted'">
77
+ <ng-template #headerTpl>...</ng-template>
78
+ <ng-template [cell]="items()" let-item="item">...</ng-template>
79
+ </sd-sheet-column>
80
+ ```
81
+
82
+ **근거**: `<sd-sheet-column>`의 `[key]`는 (a) 서버 정렬 `orderBy(key, ...)`의 키, (b) 시트 컬럼 순서·폭 등 지속성 설정(`[key]="'customer-list-sheet'"`에 종속된 저장소)의 컬럼 식별자로 쓰인다. 토글 아이콘 컬럼에 `"isDeleted"`를 그대로 부여하면 실제 DB 컬럼 `isDeleted`와 충돌하여, 사용자가 해당 컬럼 헤더를 드래그해 정렬하거나 폭을 조정할 때 의도하지 않은 저장 키가 덮어써진다. `_` prefix는 레시피 전반의 관례(예: 확장 A의 `_itemsSnapshot`)와 일치하며, 시트 내부 전용 컬럼임을 시각적으로도 구분한다.
83
+
84
+ ## Cross-reference
85
+
86
+ - 진입점: [crud-list.md](../crud-list.md)
87
+ - 선행: [확장 A: inline 편집/저장](./extension-a-inline-edit.md) + [확장 B: 선택 기능 + 선택 삭제/복구](./extension-b-selection.md)
@@ -0,0 +1,207 @@
1
+ ← [CRUD 리스트 레시피 진입점](../crud-list.md)
2
+
3
+ # 확장 D: 선택 모달 전환
4
+
5
+ > **선행:** [확장 A: inline 편집/저장](./extension-a-inline-edit.md) + [확장 B: 선택 기능](./extension-b-selection.md)
6
+
7
+ 같은 리스트 화면이 **다른 화면에서 항목을 골라주는 "선택 모달"로도 재사용**되도록 한다. 라우트로 진입하면 page 뷰(CRUD 리스트), `SdModalProvider.showAsync()`로 열리면 modal 뷰(selectMode에 따라 single/multi)로 자동 전환되며, 선택 결과를 `close.emit`으로 돌려준다.
8
+
9
+ ## When to use / When NOT to use
10
+
11
+ - ✅ 같은 리스트를 라우트 페이지와 "선택 모달" 두 용도로 겸용
12
+ - ✅ 항목을 골라 호출 측에 `SelectModalOutputResult<T>`로 돌려주는 selector 화면
13
+ - ✅ multi 선택에서 페이지 이동 후에도 선택을 누적 유지해야 하는 경우
14
+ - ❌ 부모 레코드의 자식 목록·이력을 **읽기 전용으로** 표시(닫기는 SdModal 기본 "X") → [확장 E](./extension-e-readonly-modal.md)
15
+ - ❌ 리스트 **자체는 page** 이고 행 클릭 시 **편집 모달**만 띄우는 경우 → [확장 F](./extension-f-modal-edit.md). 확장 A(inline 편집)와 상호 배타
16
+ - ❌ 리스트가 아닌 단일 값 셀렉트 버튼 → [`data-select-button.md`](../data-select-button.md)
17
+
18
+ ## 전제조건
19
+
20
+ - 선행: 확장 A(inline 편집/저장) + 확장 B(선택 기능). 본 확장의 코드는 A의 `canEdit` / `_checkIgnoreChanges` 와 B의 `selectedItems` / `isDeleted` / `getItemCellStyleFn` / `selectMode="multi"` 를 그대로 재사용한다
21
+ - 횡단 규칙: [`_common-rules.md`](../_common-rules.md) — 특히 [`injectViewTypeSignal()` 호출 시점](../_common-rules.md#injectviewtypesignal은-생성자-또는-필드-이니셜라이저에서만-호출한다), [signal 필드 초기값에서 다른 signal 읽기 금지](../_common-rules.md#signal-필드-초기값에서-다른-signal을-읽지-않는다)
22
+ - 호출 측: `SdModalProvider.showAsync(CustomerList, { inputs: { selectMode: "multi", selectedItemKeys: [...] } })` 형태로 연다
23
+
24
+ ## 이 확장이 도입하는 요소
25
+
26
+ | 영역 | 추가 |
27
+ |------|------|
28
+ | imports | `input`, `output` (`@angular/core`), `type SdSelectModal`, `type SelectModalOutputResult` (`@simplysm/angular`) |
29
+ | 계약 | `implements SdSelectModal<ICustomer>` + `selectMode` input + `selectedItemKeys` input + `close` output |
30
+ | 생성자 effect | modal 뷰일 때 `selectedItemKeys` → `selectedItems` 복원 (items 로드 후) |
31
+ | `setupCanDeactivate` | modal 뷰에서 변경사항 체크 스킵 |
32
+ | 메서드 | `onModalConfirmClick`, `onModalCancelClick` |
33
+ | 파생 | `canEdit = computed(() => perms().includes("edit") && viewType() === "page")` — modal에서는 inline 편집 자동 비활성화 |
34
+ | 템플릿 | `<sd-sheet>`에 `[selectMode]` / `[cumulativeSelection]` 조건부 바인딩 + modal 전용 하단 dock (확인/선택 해제) |
35
+
36
+ ## 코드 (확장 A + B 위에 얹는 diff)
37
+
38
+ > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A+B) 위에 번호 순서대로 삽입·교체할 지점을 나타낸다. 그대로 컴파일되지 않는다.
39
+
40
+ ```typescript
41
+ // 1) imports 추가
42
+ import { input, output } from "@angular/core";
43
+ import { type SdSelectModal, type SelectModalOutputResult } from "@simplysm/angular";
44
+
45
+ // 2) 클래스에 SdSelectModal<ICustomer> 계약 구현
46
+ export class CustomerList implements SdSelectModal<ICustomer> {
47
+ // ... 기존 확장 A + B 멤버 ...
48
+
49
+ //== SdSelectModal<ICustomer> 계약 ==
50
+ selectMode = input<"single" | "multi" | undefined>();
51
+ selectedItemKeys = input<(number | undefined)[]>([]);
52
+ close = output<SelectModalOutputResult<ICustomer> | undefined>();
53
+
54
+ //== 파생 재정의 — modal 뷰에서는 inline 편집 자동 비활성화 ==
55
+ // canEdit을 viewType=="page" 조건으로 묶어, modal 뷰에서는 편집 셀·저장·등록 버튼이 자동으로 숨겨진다
56
+ canEdit = computed(() => this.perms().includes("edit") && this.viewType() === "page");
57
+
58
+ constructor() {
59
+ // ... 기존 초기 effect (필터/페이지/정렬 재조회) ...
60
+
61
+ // modal 뷰: selectedItemKeys → selectedItems 복원 (items 로드 후)
62
+ effect(() => {
63
+ if (this.viewType() !== "modal") return;
64
+
65
+ const keys = this.selectedItemKeys();
66
+ if (keys.length === 0) return;
67
+
68
+ const currItems = this.items();
69
+ if (currItems.length === 0) return;
70
+
71
+ untracked(() => {
72
+ const sel = currItems.filter((it) => keys.includes(this.trackByFn(it)));
73
+ if (sel.length > 0) this.selectedItems.set(sel);
74
+ });
75
+ });
76
+
77
+ // modal 뷰는 라우트 이탈 개념이 없으므로 변경사항 체크 스킵
78
+ setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
79
+ }
80
+
81
+ // 3) 메서드 추가
82
+ onModalConfirmClick(): void {
83
+ const sel = this.selectedItems();
84
+ this.close.emit({
85
+ // id=undefined 신규 행 제거 — index fallback 금지(아래 Anti-patterns 참조)
86
+ selectedItemKeys: sel.map((it) => this.trackByFn(it)).filterExists(),
87
+ selectedItems: sel,
88
+ });
89
+ }
90
+
91
+ onModalCancelClick(): void {
92
+ this.selectedItems.set([]);
93
+
94
+ // single 모드에서만 즉시 close (multi는 "확인" 버튼 필요 — 아래 Anti-patterns 참조)
95
+ if (this.selectMode() === "single") {
96
+ this.close.emit({ selectedItemKeys: [], selectedItems: [] });
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ```html
103
+ <!-- 4) template — <sd-sheet>에 selectMode·cumulativeSelection 추가, 시트 뒤에 modal 하단 dock 배치 -->
104
+ <sd-sheet
105
+ ...(기존)
106
+ [selectMode]="selectMode() ?? 'multi'"
107
+ [(selectedItems)]="selectedItems"
108
+ [cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
109
+ >
110
+ <!-- 컬럼들 동일 -->
111
+ </sd-sheet>
112
+
113
+ <!-- modal 하단 확인 바 -->
114
+ @if (viewType() === "modal") {
115
+ <sd-dock
116
+ [position]="'bottom'"
117
+ class="p-sm-default flex-row main-align-end gap-sm bdt bdt-theme-gray-lightest"
118
+ >
119
+ <sd-button [size]="'sm'" [theme]="'danger'"
120
+ (click)="onModalCancelClick()"
121
+ [disabled]="selectedItems().length < 1">
122
+ 선택 해제
123
+ </sd-button>
124
+ @if (selectMode() === "multi") {
125
+ <sd-button [size]="'sm'" [theme]="'primary'" (click)="onModalConfirmClick()">
126
+ 확인({{ selectedItems().length }})
127
+ </sd-button>
128
+ }
129
+ </sd-dock>
130
+ }
131
+ ```
132
+
133
+ ## 포인트
134
+
135
+ - **`cumulativeSelection` 의도 — multi + modal에서만 활성화.** `<sd-sheet>`의 기본값은 `false`(`packages/angular/src/data/sheet/sd-sheet.ts:583`)이며, 켜지면 페이지를 넘어 선택을 **누적**한다. page 뷰의 "선택 삭제/복구"는 현재 페이지 행만 다루므로 누적하지 않는다. single 모드는 누적 개념이 없다.
136
+ - **`canEdit`에 `viewType() === "page"` 조건 추가.** modal 뷰에서 자동으로 inline 편집 셀이 읽기 전용이 되고, 상단 "저장" 버튼과 inline 도구 dock(등록/선택 삭제·복구)이 숨겨진다. 선택 모달은 편집 목적이 아니다.
137
+ - **`setupCanDeactivate`는 modal 뷰에서 무조건 true.** modal은 라우트 이탈 개념이 없으므로 변경사항 확인은 page 뷰에서만 수행한다.
138
+ - **복원 effect는 items 로드 후에만 동작.** `items()`와 `selectedItemKeys()` 두 signal에 의존하고, set 호출이 자기 자신을 재실행하지 않도록 `untracked()`로 감싼다.
139
+
140
+ ## 🚫 Anti-patterns
141
+
142
+ ### modal = 선택 모달로 반사 단정
143
+
144
+ 진입점 [crud-list.md 의 "modal = 선택 모달로 반사적 부착"](../crud-list.md#modal--선택-모달로-반사적-부착)을 먼저 확인한다. 조회 전용 modal은 [확장 E](./extension-e-readonly-modal.md)로 분기하며 `SdSelectModal<T>` 계약을 부착하지 않는다.
145
+
146
+ ### multi 모드에서 "선택 해제"가 close까지 호출
147
+
148
+ ```typescript
149
+ // ❌ multi에서도 close.emit — "선택 해제" 한 번으로 모달이 닫혀 다시 선택하려면 재오픈 필요
150
+ onModalCancelClick(): void {
151
+ this.selectedItems.set([]);
152
+ this.close.emit({ selectedItemKeys: [], selectedItems: [] });
153
+ }
154
+
155
+ // ✅ single에서만 즉시 close. multi는 set([])만 수행 후 "확인" 버튼으로 최종 emit
156
+ onModalCancelClick(): void {
157
+ this.selectedItems.set([]);
158
+ if (this.selectMode() === "single") {
159
+ this.close.emit({ selectedItemKeys: [], selectedItems: [] });
160
+ }
161
+ }
162
+ ```
163
+
164
+ **근거**: multi에서 "선택 해제" = "취소 + 닫기"가 되면 사용자가 다시 선택을 시작하려고 모달을 재오픈해야 한다. multi는 여러 행을 점진적으로 누적하는 UX이므로 닫는 트리거는 "확인" 버튼에만 둔다.
165
+
166
+ ### `selectedItemKeys` 반환에 index fallback
167
+
168
+ ```typescript
169
+ // ❌ id=undefined 신규 행이 있을 때 index(0, 1, 2…)가 가짜 key로 들어가 호출 측이 잘못된 selection을 돌려받음
170
+ this.close.emit({
171
+ selectedItemKeys: sel.map((it, i) => this.trackByFn(it) ?? i),
172
+ selectedItems: sel,
173
+ });
174
+
175
+ // ✅ undefined를 제거하여 확정된 key만 전달
176
+ this.close.emit({
177
+ selectedItemKeys: sel.map((it) => this.trackByFn(it)).filterExists(),
178
+ selectedItems: sel,
179
+ });
180
+ ```
181
+
182
+ **근거**: `SelectModalOutputResult<T>.selectedItemKeys`는 호출 측이 DB 식별자로 사용한다(`packages/angular/src/core/select-modal-output-result.ts:4`). 신규 행 index를 섞으면 `[0, 1, 12345]` 같은 값이 호출 측의 "이미 저장된 id" 집합과 충돌한다. 신규 행은 모달에서 선택 대상이 아니며(DB에 없음), `filterExists`로 제거하는 것이 의미도 맞다.
183
+
184
+ ### `<sd-dock>`의 `[position]="'bottom'"` 생략
185
+
186
+ ```html
187
+ <!-- ❌ position 생략 — 기본값이 top이 아니더라도 명시하지 않으면 레이아웃 의도가 깨질 수 있음 -->
188
+ @if (viewType() === "modal") {
189
+ <sd-dock class="p-sm-default flex-row ..."> ... </sd-dock>
190
+ }
191
+
192
+ <!-- ✅ 하단 바는 반드시 [position]="'bottom'" 명시 -->
193
+ @if (viewType() === "modal") {
194
+ <sd-dock [position]="'bottom'" class="..."> ... </sd-dock>
195
+ }
196
+ ```
197
+
198
+ **근거**: `<sd-dock>`은 부모 `<sd-dock-container>` 내부에서 position에 따라 top/right/bottom/left 중 한 곳에 배치된다. 확인 바는 시트 **아래**에 놓여야 하는 뷰별 고유 조각이므로 position을 누락하면 시트 위나 필터 옆으로 쌓이며 버튼이 본문 스크롤 안에 묻힌다.
199
+
200
+ ## 관련 Entry
201
+
202
+ - 진입점: [crud-list.md](../crud-list.md)
203
+ - 선행: [확장 A: inline 편집/저장](./extension-a-inline-edit.md) — `canEdit` / `_checkIgnoreChanges` 제공
204
+ - 선행: [확장 B: 선택 기능](./extension-b-selection.md) — `selectedItems` / multi 선택 / `getItemCellStyleFn` 제공
205
+ - 대안: [확장 E: 조회 전용 modal](./extension-e-readonly-modal.md) — 부모 레코드의 자식 목록·이력을 input으로 받아 읽기 전용 표시. `SdSelectModal<T>` 계약을 부착하지 않는다
206
+ - 대안: [확장 F: 모달 편집 모드](./extension-f-modal-edit.md) — page 상에서 행 클릭 시 편집 모달. inline 편집(확장 A)과 상호 배타
207
+ - 계약 타입: `SdSelectModal<T>` (`packages/angular/src/controls/button/sd-modal-select-button.ts:30`), `SelectModalOutputResult<T>` (`packages/angular/src/core/select-modal-output-result.ts:4`)
@@ -0,0 +1,165 @@
1
+ ← [CRUD 리스트 레시피 진입점](../crud-list.md)
2
+
3
+ # 확장 E: 조회 전용 modal
4
+
5
+ > **선행:** 없음 (최소 뼈대 §3에 직접 얹음 — 확장 A/B/D 미사용)
6
+
7
+ 호출자(상세 화면 등)가 부모 레코드 식별자를 input으로 전달하면, 이 modal은 해당 부모의 자식 목록·이력만 필터링해 읽기 전용으로 표시한다. 닫기는 SdModal 기본 "X" 버튼이며, 선택·확정·저장·이탈 방지는 전부 불필요하다.
8
+
9
+ - ✅ 부모 레코드의 자식 목록·이력만 필터링해 읽기 전용으로 표시할 때
10
+ - ❌ 항목을 골라 호출자에게 돌려줘야 할 때 → [확장 D: 선택 모달](./extension-d-select-modal.md)
11
+ - ❌ 부모 레코드에 대한 inline 편집·삭제가 필요할 때 → [확장 A](./extension-a-inline-edit.md)(+B) 또는 [확장 F: 모달 편집](./extension-f-modal-edit.md)
12
+
13
+ 호출 예:
14
+
15
+ ```typescript
16
+ await this._sdModal.showAsync({
17
+ title: "고객 주문 이력",
18
+ type: CustomerOrderHistoryModal,
19
+ inputs: { customerId: 123 },
20
+ });
21
+ ```
22
+
23
+ **이 확장이 도입하는 요소:**
24
+
25
+ - **imports:** `input`(`@angular/core`), `expr`(`@simplysm/orm-common`) 추가
26
+ - **input:** 부모 식별자(예: `customerId = input.required<number>()`)
27
+ - **초기 effect 의존성:** 부모 식별자 input 추가
28
+ - **`_search` 변경:** where절에 부모 식별자 하드 필터(`expr.eq(...)`), 기존 `filter.searchText` 조건과 AND
29
+ - **부착하지 않는 요소:** `implements SdSelectModal<T>` 계약, `selectedItems` / 하단 확인 바 / `cumulativeSelection` / `canEdit` / `diffs` / `setupCanDeactivate` / `<sd-form #formCtrl>` 래퍼
30
+
31
+ ```typescript
32
+ // 1) imports 추가 — @angular/core의 {input}
33
+ import { input } from "@angular/core";
34
+
35
+ // 2) 클래스 선언 — implements 없음. SdSelectModal<T> 계약 3종 부착 안 함
36
+ export class CustomerOrderHistoryModal {
37
+ // ...DI, perms, 상태는 최소 뼈대와 동일
38
+
39
+ // 3) 부모 식별자 input (필수) — 맥락에 맞는 이름(customerId / orderId / companyId 등)
40
+ customerId = input.required<number>();
41
+
42
+ // 4) 초기 effect 의존성에 input 추가
43
+ constructor() {
44
+ effect(() => {
45
+ if (!this.perms().includes("use")) {
46
+ this.initialized.set(true);
47
+ return;
48
+ }
49
+
50
+ this.lastFilter();
51
+ this.page();
52
+ this.sortingDefs();
53
+ this.customerId(); // ← input 의존성 추가
54
+
55
+ void untracked(async () => {
56
+ this.busyCount.update((v) => v + 1);
57
+ await this._sdToast.try(async () => { await this._refresh(); });
58
+ this.busyCount.update((v) => v - 1);
59
+ this.initialized.set(true);
60
+ });
61
+ });
62
+ }
63
+
64
+ // 5) _search — where절에 부모 식별자 하드 필터. filter.searchText 등 기존 조건과 AND
65
+ private async _search(usePagination: boolean): Promise<{ items: ICustomerOrder[]; pageLength: number }> {
66
+ const filter = this.lastFilter();
67
+ const sortingDefs = this.sortingDefs();
68
+ const page = this.page();
69
+ const customerId = this.customerId(); // ← input 값
70
+
71
+ return this._appOrm.connectAsync(async (db) => {
72
+ let qr1 = db.customerOrder()
73
+ .where((item) => [expr.eq(item.customerId, customerId)]);
74
+
75
+ if (!str.isNullOrEmpty(filter.searchText)) {
76
+ qr1 = qr1.search((item) => [item.name], filter.searchText);
77
+ }
78
+ // 나머지는 최소 뼈대 _search와 동일 (paging/sorting/select/limit)
79
+ // ...
80
+ });
81
+ }
82
+ }
83
+
84
+ // 6) template — modal 전용 레이아웃. <sd-sheet>에 selectMode / selectedItems / cumulativeSelection 미사용.
85
+ // 시트 셀은 순수 표시({{ item.name }}). 하단 "선택 해제 / 확인" 바, 상단 inline 도구 dock 부재.
86
+ `
87
+ <sd-topbar-container>
88
+ <sd-dock-container>
89
+ <sd-dock class="p-default">
90
+ <sd-form (formSubmit)="onFilterSubmit()">
91
+ <!-- 필터 (최소 뼈대와 동일) -->
92
+ </sd-form>
93
+ </sd-dock>
94
+
95
+ <sd-sheet
96
+ [key]="'customer-order-history-sheet'"
97
+ [items]="items()"
98
+ [(currentPage)]="page"
99
+ [totalPageCount]="pageLength()"
100
+ [(sorts)]="sortingDefs"
101
+ [trackByFn]="trackByFn"
102
+ >
103
+ <sd-sheet-column [key]="'name'" [header]="'이름'">
104
+ <ng-template [cell]="items()" let-item="item">
105
+ <div class="p-xs-sm">{{ item.name }}</div>
106
+ </ng-template>
107
+ </sd-sheet-column>
108
+ <!-- 필요한 조회 컬럼만 -->
109
+ </sd-sheet>
110
+ </sd-dock-container>
111
+ </sd-topbar-container>
112
+ `
113
+ ```
114
+
115
+ **포인트:**
116
+
117
+ - **부모 식별자는 호출자가 `inputs`으로 전달한다.** 값이 반드시 주어져야 하면 `input.required<T>()`, 없으면 전체 조회로 fallback하는 설계면 `input<T | undefined>()`. 어느 쪽이든 `_search`의 `where` 절과 초기 effect 의존성에 반드시 포함시킨다.
118
+ - **닫기 = SdModal 기본 "X".** `close` output이 없으므로 `SdModalProvider.showAsync`의 리턴값도 사용하지 않는다. 호출 측은 `await showAsync(...)` 결과를 버리거나 `void`로 처리한다.
119
+ - **시트는 읽기 전용.** `[cell]` 템플릿에 `<sd-textfield>` 대신 `{{ item.name }}` 같은 순수 표시만 쓰고, `let-edit="edit"` / `[readonly]="!edit"` / `(valueChange)="mark(items)"`는 제거한다. `canEdit` / `diffs` / `_itemsSnapshot` / `setupCanDeactivate`도 함께 제거한다.
120
+ - **`viewType()` 분기는 쓰지 않아도 된다.** 조회 전용 modal은 보통 modal 전용으로 라우트 없이 등록된다. page/modal 양쪽을 모두 지원해야 할 때만 `viewType()`으로 topbar 영역을 분기하고, 시트·필터는 공통으로 둔다.
121
+ - **input 반영 패턴은 공통 규칙을 따른다.** → [공통 규칙: input 변경을 effect 내부에서 filter·lastFilter·page에 반영한다](../_common-rules.md#input-변경을-effect-내부에서-filterlastfilterpage에-반영한다) · [공통 규칙: input 의존 데이터 로딩에 `void this._initAsync()`를 사용하지 않는다](../_common-rules.md#input-의존-데이터-로딩에-void-this_initasync를-사용하지-않는다)
122
+
123
+ ## 🚫 흔한 실수
124
+
125
+ ### signal 필드 초기값에서 부모 식별자 input 읽기
126
+
127
+ ```typescript
128
+ // ❌ 필드 이니셜라이저는 클래스 생성 시점에 실행된다 — input 기본값만 반환
129
+ filter = signal<IFilter>({
130
+ customerId: this.customerId(), // 항상 undefined (required) / 초기값
131
+ });
132
+
133
+ // ✅ 기본값은 고정, 부모 식별자는 _search 호출부에서 input을 직접 읽는다
134
+ filter = signal<IFilter>({});
135
+ // _search 내부:
136
+ const customerId = this.customerId();
137
+ qr1 = qr1.where((item) => [expr.eq(item.customerId, customerId)]);
138
+ ```
139
+
140
+ **근거**: 필드 이니셜라이저는 부모로부터 input 값이 전달되기 전에 실행되므로 항상 기본값만 반환한다. → [공통 규칙: signal 필드 초기값에서 다른 signal을 읽지 않는다](../_common-rules.md#signal-필드-초기값에서-다른-signal을-읽지-않는다)
141
+
142
+ ### modal = 선택 계약으로 반사 부착
143
+
144
+ ```typescript
145
+ // ❌ 조회 전용인데 SdSelectModal 계약 일체를 그대로 이식
146
+ export class CustomerOrderHistoryModal implements SdSelectModal<ICustomerOrder> {
147
+ selectMode = input<"single" | "multi">();
148
+ selectedItemKeys = input<any[]>([]);
149
+ close = output<SelectModalOutputResult<ICustomerOrder>>();
150
+ // ... cumulativeSelection, 하단 "선택 해제 / 확인" 바까지 전부
151
+ }
152
+
153
+ // ✅ 계약 없이 부모 식별자 input만 받는다
154
+ export class CustomerOrderHistoryModal {
155
+ customerId = input.required<number>();
156
+ // ...
157
+ }
158
+ ```
159
+
160
+ **근거**: 조회 전용 modal은 선택 결과를 돌려주지 않는다. 계약을 부착하면 호출되지 않아 죽은 코드가 된다. 항목 선택이 필요하면 [확장 D: 선택 모달](./extension-d-select-modal.md).
161
+
162
+ ## Cross-reference
163
+
164
+ - 진입점: [crud-list.md](../crud-list.md)
165
+ - 관련: [확장 D: 선택 모달 전환](./extension-d-select-modal.md) (계약이 다른 modal 변형) · [확장 F: 모달 편집 모드](./extension-f-modal-edit.md) (부모 레코드 편집 modal)
@@ -0,0 +1,177 @@
1
+ ← [CRUD 리스트 레시피 진입점](../crud-list.md)
2
+
3
+ # 확장 F: 모달 편집 모드
4
+
5
+ > **선행:** 없음 (최소 뼈대 §3에 직접 얹음 — [확장 A: inline 편집/저장](./extension-a-inline-edit.md)와 **상호 배타**)
6
+
7
+ 시트 셀 직접 수정(inline 편집) + 일괄 저장 대신, **행 클릭 시 편집 모달을 띄워 한 행씩 편집**하는 모드. 확장 A(inline 편집)와 **상호 배타**이므로 확장 A가 덧씌우는 파이프라인(`diffs` / `_itemsSnapshot` / `onSubmit` / `setupCanDeactivate` / `hostDirectives.sdSaveCommand` / `<sd-form #formCtrl>` 래퍼)을 이 확장에서는 부착하지 않는다. 대신 [`SdModalProvider.showAsync`](../../providers/sd-modal-provider.md)로 편집 모달을 호출하고, 모달 close 후 `_refresh()`로 리스트를 재조회한다.
8
+
9
+ **이 확장이 도입하는 요소:**
10
+
11
+ - **imports:** `SdAnchor`, `SdModalProvider`, `tablerEdit`
12
+ - **DI:** `SdModalProvider`
13
+ - **클래스 필드:** `tablerEdit` 아이콘 템플릿 참조
14
+ - **메서드:** `onCreateItemButtonClick`, `onEditItemButtonClick`, `_editItem`
15
+ - **템플릿:** 이름 컬럼 셀을 `<sd-anchor>` + 편집 아이콘으로 교체한다. inline 편집용 `<sd-textfield let-edit="edit">`는 사용하지 않는다. 등록 버튼은 `_editItem()` 직접 호출로 전환한다
16
+ - **제거 대상(확장 A가 이미 적용되어 있는 경우):** `hostDirectives.sdSaveCommand` / `host (sdSaveCommand)` / `onSaveButtonClick` / `onSubmit` / `diffs` / `_itemsSnapshot` / `_checkIgnoreChanges` / `_upsertItem` / `getIsItemChanged` / `onRemoveNewItemButtonClick` / `setupCanDeactivate` / `<sd-form #formCtrl (formSubmit)="onSubmit()">` 래퍼
17
+
18
+ > 상세: [`SdModalProvider.showAsync` 편집 모달 호출](../../providers/sd-modal-provider.md#편집-모달-호출) · [`<sd-anchor>`](../../ui-form/sd-anchor.md)
19
+
20
+ > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 최소 뼈대 위에 번호 순서대로 삽입할 지점을 나타낸다. 그대로 컴파일되지 않는다.
21
+
22
+ ```typescript
23
+ // 1) imports 추가
24
+ import { SdAnchor, SdModalProvider } from "@simplysm/angular";
25
+ import { tablerEdit } from "@ng-icons/tabler-icons";
26
+ // 앱별 편집 모달 컴포넌트 (crud-detail.md 레시피로 작성):
27
+ import { CustomerEditModal } from "./CustomerEditModal";
28
+
29
+ // 2) DI 추가
30
+ private readonly _sdModal = inject(SdModalProvider);
31
+
32
+ // 3) 클래스 필드 추가 — 편집 버튼 아이콘
33
+ protected readonly tablerEdit = tablerEdit;
34
+
35
+ // 4) template — 이름 컬럼 셀을 <sd-anchor> + 편집 아이콘으로 교체한다.
36
+ // inline 편집용 <sd-textfield> / let-edit 바인딩은 쓰지 않는다.
37
+ `
38
+ <sd-sheet-column [key]="'name'" [header]="'이름'">
39
+ <ng-template [cell]="items()" let-item="item">
40
+ <sd-anchor (click)="onEditItemButtonClick(item, $event)" class="flex-row">
41
+ <div class="p-xs-sm">
42
+ <ng-icon [svg]="tablerEdit" />
43
+ </div>
44
+ <div class="flex-fill p-xs-sm">{{ item.name }}</div>
45
+ </sd-anchor>
46
+ </ng-template>
47
+ </sd-sheet-column>
48
+ `
49
+
50
+ // 5) inline 편집용 도구 dock의 "등록" 버튼은 _editItem() 호출로 바꾼다.
51
+ // "선택 삭제/복구"를 남기려면 bulk API로 전환한다(아래 포인트 참조).
52
+ // template:
53
+ `<sd-button ... (click)="onCreateItemButtonClick()">등록</sd-button>`
54
+
55
+ // 6) 메서드 교체
56
+ protected async onCreateItemButtonClick(): Promise<void> {
57
+ await this._editItem();
58
+ }
59
+
60
+ protected async onEditItemButtonClick(item: ICustomer, event: MouseEvent): Promise<void> {
61
+ event.preventDefault();
62
+ event.stopPropagation();
63
+ await this._editItem(item);
64
+ }
65
+
66
+ private async _editItem(item?: ICustomer): Promise<void> {
67
+ const r = await this._sdModal.showAsync({
68
+ title: item == null ? "고객 등록" : "고객 수정",
69
+ type: CustomerEditModal,
70
+ inputs: { itemId: item?.id },
71
+ });
72
+ if (r != null) await this._refresh();
73
+ }
74
+
75
+ // 7) 제거 대상 (확장 A가 이미 적용되어 있는 경우 — 위 "제거 대상" 불릿 참조):
76
+ // - @Component hostDirectives의 sdSaveCommand / host의 (sdSaveCommand)
77
+ // - onSaveButtonClick / onSubmit / onAddItemButtonClick
78
+ // - diffs computed / _itemsSnapshot / _checkIgnoreChanges / _upsertItem
79
+ // - getIsItemChanged / onRemoveNewItemButtonClick
80
+ // - setupCanDeactivate(...) 호출 (이탈 방지는 편집 모달 쪽 책임)
81
+ // - <sd-form #formCtrl (formSubmit)="onSubmit()"> 래퍼 → <sd-sheet>를 main 영역에 직접 배치
82
+ ```
83
+
84
+ **전환 후 남는 핵심 요소 체크리스트 (확장 A에서 확장 F로 이관 시 검증용):**
85
+
86
+ - [x] `hostDirectives`: `sdRefreshCommand`만 유지 (`sdSaveCommand` 제거)
87
+ - [x] host: `(sdRefreshCommand)="onRefreshButtonClick()"`만 유지
88
+ - [x] DI: `_sdModal`, `_appOrm`, `_sdToast` (+ 선택적으로 확장 B 병용 시 `_appAuth`, `_appSharedData`)
89
+ - [x] 상태: `items` / `page` / `pageLength` / `sortingDefs` / `filter` / `lastFilter` / `perms` / `viewType` / `viewTitle` / `busyCount` / `initialized`
90
+ - [x] 메서드: `onFilterSubmit` / `onRefreshButtonClick` / `onCreateItemButtonClick` / `onEditItemButtonClick` / `_editItem` / `_refresh` / `_search` / `trackByFn`
91
+ - [x] 템플릿: `<sd-form (formSubmit)="onFilterSubmit()">` 필터 dock + 이름 컬럼 `<sd-anchor>` + 기타 읽기 전용 셀
92
+ - [x] `onRefreshButtonClick` 선두의 `if (!this._checkIgnoreChanges()) return;` **제거** (확장 A 잔재)
93
+
94
+ **포인트:**
95
+
96
+ - **모달 편집 모드에는 inline diff 개념이 없다.** 개별 item 변경은 `CustomerEditModal`(상세 폼) 내부에서 즉시 upsert하고 결과를 `close.emit(true)` 같은 신호로 반환한다. 리스트는 모달 close 후 `_refresh()`로 재조회한다.
97
+ - **`CustomerEditModal`은 [`crud-detail.md`](../crud-detail.md) 레시피로 별도 작성한다.** modal 뷰 분기를 그대로 활용한다.
98
+ - **시트 `[cell]` 템플릿에 `let-edit="edit"` / `[readonly]="!edit"` 바인딩은 불필요** — inline 편집이 아니며 읽기 전용 표시만 한다.
99
+ - **"선택 삭제/복구"를 남길 경우 (확장 B와 병용):** `onToggleDeleteItemsButtonClick(del)` 내부를 diff 방식 대신 **bulk API 호출 + `_refresh()`** 로 구현한다. 확장 F는 `diffs` 파이프라인이 없어 확장 B 원본의 diff 경로가 동작하지 않기 때문이다.
100
+ ```typescript
101
+ protected async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
102
+ if (this.busyCount() > 0) return;
103
+ const ids = this.selectedItems().map((it) => this.trackByFn(it)).filterExists();
104
+ if (ids.length === 0) return;
105
+
106
+ this.busyCount.update((v) => v + 1);
107
+ await this._sdToast.try(async () => {
108
+ await this._appOrm.connectAsync((db) =>
109
+ db.customer().where((c) => [expr.in(c.id, ids)])
110
+ .update(() => ({ isDeleted: del })));
111
+ await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, ids);
112
+ this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
113
+ await this._refresh();
114
+ });
115
+ this.busyCount.update((v) => v - 1);
116
+ }
117
+ ```
118
+ - **`itemId: item?.id`**: `item`이 undefined이면 "등록", id가 있으면 "수정"으로 위임한다. `CustomerEditModal` 내부에서 id 유무로 분기한다.
119
+
120
+ **🚫 흔한 실수**
121
+
122
+ > 공통 규칙(`mark` 오용, `setupCanDeactivate` 호출 위치, 시트 셀 `[inset]`/`[size]` 등)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 이 섹션은 **모달 편집 모드 고유 실수**만 다룬다.
123
+
124
+ ### 확장 A의 inline 편집 파이프라인과 동시 적용
125
+
126
+ ```typescript
127
+ // ❌ 확장 A의 일괄 저장 경로와 확장 F의 모달 편집 경로를 모두 부착
128
+ @Component({
129
+ hostDirectives: [
130
+ { directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
131
+ ],
132
+ host: {
133
+ "(sdSaveCommand)": "onSaveButtonClick()", // 확장 A 경로 잔존
134
+ },
135
+ template: `
136
+ <sd-form #formCtrl (formSubmit)="onSubmit()"> <!-- 확장 A 래퍼 잔존 -->
137
+ <sd-sheet ...>
138
+ <sd-sheet-column [key]="'name'">
139
+ <ng-template [cell]="items()" let-item="item">
140
+ <sd-anchor (click)="onEditItemButtonClick(item, $event)"> <!-- 확장 F -->
141
+ <ng-icon [svg]="tablerEdit" /> {{ item.name }}
142
+ </sd-anchor>
143
+ </ng-template>
144
+ </sd-sheet-column>
145
+ </sd-sheet>
146
+ </sd-form>
147
+ `,
148
+ })
149
+
150
+ // ✅ 두 경로 중 하나만 선택한다. 확장 F를 선택하면 확장 A가 덧씌웠던 파이프라인은 전부 제거한다.
151
+ @Component({
152
+ hostDirectives: [
153
+ { directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
154
+ ],
155
+ // (sdSaveCommand) host 바인딩 없음
156
+ template: `
157
+ <!-- <sd-form #formCtrl (formSubmit)="onSubmit()"> 래퍼 없음 -->
158
+ <sd-sheet ...>
159
+ <sd-sheet-column [key]="'name'">
160
+ <ng-template [cell]="items()" let-item="item">
161
+ <sd-anchor (click)="onEditItemButtonClick(item, $event)">
162
+ <ng-icon [svg]="tablerEdit" /> {{ item.name }}
163
+ </sd-anchor>
164
+ </ng-template>
165
+ </sd-sheet-column>
166
+ </sd-sheet>
167
+ `,
168
+ })
169
+ ```
170
+
171
+ **근거**: Ctrl+S가 어느 폼을 submit할지 모호해지고, `setupCanDeactivate`가 편집 모달 open 중에 이중 발동하며, 동일 행 편집 경로(inline vs 모달)가 둘 다 활성화되어 UX가 깨진다. "편집 모드"는 하나만 선택한다.
172
+
173
+ ## Cross-reference
174
+
175
+ - 진입점: [crud-list.md](../crud-list.md)
176
+ - 관련: [확장 A: inline 편집/저장](./extension-a-inline-edit.md) (이 확장과 상호 배타)
177
+ - 공통 규칙: [_common-rules.md](../_common-rules.md)