@simplysm/angular 14.0.51 → 14.0.52

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 (134) hide show
  1. package/dist/data/sheet/sd-sheet.d.ts +9 -12
  2. package/dist/data/sheet/sd-sheet.d.ts.map +1 -1
  3. package/dist/data/sheet/sd-sheet.js +150 -168
  4. package/dist/data/sheet/types.d.ts +1 -0
  5. package/dist/data/sheet/types.d.ts.map +1 -1
  6. package/dist/data/sheet/useSheetCellStyling.d.ts +2 -2
  7. package/dist/data/sheet/useSheetCellStyling.d.ts.map +1 -1
  8. package/dist/data/sheet/useSheetCellStyling.js +20 -12
  9. package/dist/data/sheet/useSheetColumnFixing.d.ts +3 -8
  10. package/dist/data/sheet/useSheetColumnFixing.d.ts.map +1 -1
  11. package/dist/data/sheet/useSheetColumnFixing.js +19 -27
  12. package/dist/data/sheet/useSheetLayoutEngine.d.ts.map +1 -1
  13. package/dist/data/sheet/useSheetLayoutEngine.js +4 -1
  14. package/dist/layout/dock/sd-dock-container.d.ts.map +1 -1
  15. package/dist/layout/dock/sd-dock-container.js +3 -3
  16. package/package.json +7 -8
  17. package/src/data/sheet/sd-sheet.ts +39 -60
  18. package/src/data/sheet/types.ts +1 -0
  19. package/src/data/sheet/useSheetCellStyling.ts +19 -11
  20. package/src/data/sheet/useSheetColumnFixing.ts +21 -28
  21. package/src/data/sheet/useSheetLayoutEngine.ts +4 -1
  22. package/src/layout/dock/sd-dock-container.ts +1 -0
  23. package/README.md +0 -476
  24. package/docs/bootstrap/provide-sd-angular.md +0 -37
  25. package/docs/bootstrap/sd-angular-config-provider.md +0 -16
  26. package/docs/directives/sd-command-directive.md +0 -30
  27. package/docs/directives/sd-events.md +0 -25
  28. package/docs/directives/sd-intersection-directive.md +0 -36
  29. package/docs/directives/sd-invalid.md +0 -24
  30. package/docs/directives/sd-resize-directive.md +0 -42
  31. package/docs/directives/sd-ripple.md +0 -23
  32. package/docs/directives/sd-router-link.md +0 -38
  33. package/docs/directives/sd-show-effect.md +0 -18
  34. package/docs/directives/sd-typed-template.md +0 -69
  35. package/docs/features/sd-address-search-modal.md +0 -50
  36. package/docs/features/sd-permission-table.md +0 -20
  37. package/docs/features/sd-shared-data-components.md +0 -158
  38. package/docs/features/sd-tiptap-editor.md +0 -26
  39. package/docs/pipes/format-pipe.md +0 -41
  40. package/docs/plugins/sd-global-error-handler.md +0 -23
  41. package/docs/plugins/sd-option-event-plugin.md +0 -34
  42. package/docs/provider-types/sd-menu.md +0 -65
  43. package/docs/provider-types/sd-modal-content-def.md +0 -148
  44. package/docs/provider-types/sd-toast-content-def.md +0 -73
  45. package/docs/provider-types/shared-data-base.md +0 -59
  46. package/docs/providers/sd-activated-modal-provider.md +0 -34
  47. package/docs/providers/sd-app-structure-provider.md +0 -81
  48. package/docs/providers/sd-busy-provider.md +0 -18
  49. package/docs/providers/sd-file-dialog-provider.md +0 -40
  50. package/docs/providers/sd-local-storage-provider.md +0 -20
  51. package/docs/providers/sd-modal-provider.md +0 -67
  52. package/docs/providers/sd-navigate-window-provider.md +0 -18
  53. package/docs/providers/sd-print-provider.md +0 -25
  54. package/docs/providers/sd-service-client-factory-provider.md +0 -43
  55. package/docs/providers/sd-shared-data-provider.md +0 -64
  56. package/docs/providers/sd-system-config-provider.md +0 -46
  57. package/docs/providers/sd-system-log-provider.md +0 -18
  58. package/docs/providers/sd-theme-provider.md +0 -38
  59. package/docs/providers/sd-toast-provider.md +0 -65
  60. package/docs/recipes/_common-rules.md +0 -244
  61. package/docs/recipes/crud-detail/extension-a-edit-save.md +0 -230
  62. package/docs/recipes/crud-detail/extension-b-delete-restore.md +0 -142
  63. package/docs/recipes/crud-detail/extension-c-modal-view.md +0 -214
  64. package/docs/recipes/crud-detail/extension-d-control-view.md +0 -103
  65. package/docs/recipes/crud-detail/extension-e-auxiliary.md +0 -87
  66. package/docs/recipes/crud-detail/extension-f-complex-detail.md +0 -234
  67. package/docs/recipes/crud-detail.md +0 -353
  68. package/docs/recipes/crud-list/extension-a-inline-edit.md +0 -410
  69. package/docs/recipes/crud-list/extension-b-selection.md +0 -226
  70. package/docs/recipes/crud-list/extension-c-inline-delete.md +0 -87
  71. package/docs/recipes/crud-list/extension-d-select-modal.md +0 -207
  72. package/docs/recipes/crud-list/extension-e-readonly-modal.md +0 -165
  73. package/docs/recipes/crud-list/extension-f-modal-edit.md +0 -177
  74. package/docs/recipes/crud-list/extension-g-excel.md +0 -157
  75. package/docs/recipes/crud-list.md +0 -446
  76. package/docs/recipes/data-select-button.md +0 -412
  77. package/docs/recipes/page-modal-container.md +0 -260
  78. package/docs/styling/classes.md +0 -149
  79. package/docs/styling/mixins.md +0 -100
  80. package/docs/styling/themes.md +0 -35
  81. package/docs/styling/variables.md +0 -147
  82. package/docs/type-utilities/directive-input-signals.md +0 -232
  83. package/docs/ui-data/sd-list.md +0 -37
  84. package/docs/ui-data/sd-sheet.md +0 -227
  85. package/docs/ui-form/sd-additional-button.md +0 -26
  86. package/docs/ui-form/sd-anchor.md +0 -31
  87. package/docs/ui-form/sd-button.md +0 -105
  88. package/docs/ui-form/sd-checkbox-group.md +0 -39
  89. package/docs/ui-form/sd-checkbox.md +0 -81
  90. package/docs/ui-form/sd-date-range-picker.md +0 -27
  91. package/docs/ui-form/sd-form.md +0 -89
  92. package/docs/ui-form/sd-modal-select-button.md +0 -54
  93. package/docs/ui-form/sd-numpad.md +0 -26
  94. package/docs/ui-form/sd-range.md +0 -26
  95. package/docs/ui-form/sd-select.md +0 -68
  96. package/docs/ui-form/sd-shared-data-select.md +0 -52
  97. package/docs/ui-form/sd-state-preset.md +0 -37
  98. package/docs/ui-form/sd-switch.md +0 -27
  99. package/docs/ui-form/sd-textarea.md +0 -33
  100. package/docs/ui-form/sd-textfield.md +0 -145
  101. package/docs/ui-layout/sd-dock-container.md +0 -64
  102. package/docs/ui-layout/sd-dock.md +0 -37
  103. package/docs/ui-layout/sd-gap.md +0 -26
  104. package/docs/ui-layout/sd-kanban-board.md +0 -96
  105. package/docs/ui-layout/sd-kanban-lane.md +0 -34
  106. package/docs/ui-layout/sd-kanban.md +0 -29
  107. package/docs/ui-navigation/sd-collapse.md +0 -35
  108. package/docs/ui-navigation/sd-pagination.md +0 -26
  109. package/docs/ui-navigation/sd-sidebar-container.md +0 -49
  110. package/docs/ui-navigation/sd-sidebar-menu.md +0 -22
  111. package/docs/ui-navigation/sd-sidebar-user.md +0 -43
  112. package/docs/ui-navigation/sd-tab.md +0 -51
  113. package/docs/ui-navigation/sd-topbar-container.md +0 -97
  114. package/docs/ui-navigation/sd-topbar-menu.md +0 -23
  115. package/docs/ui-navigation/sd-topbar-user.md +0 -38
  116. package/docs/ui-navigation/sd-topbar.md +0 -30
  117. package/docs/ui-overlay/sd-busy-container.md +0 -69
  118. package/docs/ui-overlay/sd-confirm-modal.md +0 -30
  119. package/docs/ui-overlay/sd-dropdown.md +0 -40
  120. package/docs/ui-overlay/sd-modal.md +0 -34
  121. package/docs/ui-overlay/sd-prompt-modal.md +0 -30
  122. package/docs/ui-overlay/sd-toast.md +0 -35
  123. package/docs/ui-visual/sd-barcode.md +0 -36
  124. package/docs/ui-visual/sd-calendar.md +0 -34
  125. package/docs/ui-visual/sd-echarts.md +0 -32
  126. package/docs/ui-visual/sd-label.md +0 -24
  127. package/docs/ui-visual/sd-note.md +0 -23
  128. package/docs/ui-visual/sd-progress.md +0 -23
  129. package/docs/utils/inject-routing-signals.md +0 -161
  130. package/docs/utils/inject-sd-system-config-resource.md +0 -35
  131. package/docs/utils/mark.md +0 -43
  132. package/docs/utils/selection-managers.md +0 -96
  133. package/docs/utils/set-safe-style.md +0 -19
  134. package/docs/utils/setup-functions.md +0 -93
@@ -1,410 +0,0 @@
1
- ← [CRUD 리스트 레시피 진입점](../crud-list.md)
2
-
3
- # 확장 A: inline 편집/저장
4
-
5
- > **선행:** 없음 (최소 뼈대 §3에 직접 얹음)
6
-
7
- 시트 셀을 **직접 편집**하고 상단 저장 버튼(또는 Ctrl+S)으로 **일괄 저장**한다. 최소 뼈대(§3)의 읽기 전용 셀을 `<sd-textfield [inset]="true" [size]="'sm'" [readonly]="!edit">`로 교체하고, `oneWayDiffs` 기반 변경 감지 + `_upsertItem` 일괄 실행을 추가한다.
8
-
9
- > **적용 조건:** 시트 셀을 **직접 편집**할 때 이 확장을 사용한다. 행 클릭 → 편집 모달로 수정하는 방식은 [확장 F: 모달 편집 모드](./extension-f-modal-edit.md)를 대신 얹는다. 두 확장은 **상호 배타**이며 동시에 적용하지 않는다.
10
-
11
- **이 확장이 도입하는 요소:**
12
-
13
- - **imports:** `computed`, `viewChild`, `oneWayDiffs`(side-effect import), `obj`, `ArgumentError`, `expr`, `FormatPipe`, `SdSharedDataSelect`, `SdItemOfTemplate`, `SdAnchor`(신규 행 삭제 아이콘), `setupCanDeactivate`, `type DateTime`, `tablerCirclePlus` / `tablerDeviceFloppy` / `tablerX` 아이콘
14
- - **DI:** `AppAuthProvider`, `AppSharedDataProvider`
15
- - **타입 확장:** `ICustomer`에 `id?`(신규 행용) / `categoryId?` / `lastModifiedAt?` / `lastModifiedBy?` 필드 추가
16
- - **권한 키 확장:** `injectPermsSignal` 두 번째 인자 `["use"]` → `["use", "edit"]`
17
- - **상태:** `_itemsSnapshot: ICustomer[]` (직전 `_refresh()` 시점의 items 깊은 복제), `sharedCategories = useSharedSignal("카테고리")`
18
- - **파생:** `diffs = computed(() => items().oneWayDiffs(_itemsSnapshot, "id"))`, `canEdit = computed(() => perms().includes("edit") && viewType() === "page")` — `perms()` 외에 `viewType()` 조건도 함께 확인하므로 computed로 wrapping한다. 단일 `perms().includes("...")` 검사만 필요한 곳에서는 인라인이 기본 패턴이다
19
- - **hostDirectives·host:** outputs에 `sdSaveCommand` 추가, host에 `(sdSaveCommand)="onSaveButtonClick()"` 바인딩 추가
20
- - **viewChild:** `formCtrl = viewChild<SdForm>("formCtrl")` (Ctrl+S → form submit 경로 공유용)
21
- - **메서드:** `onSaveButtonClick`, `onSubmit`, `onAddItemButtonClick`, `onRemoveNewItemButtonClick`, `_upsertItem`, `_checkIgnoreChanges`, `getIsItemChanged`
22
- - **생성자:** `setupCanDeactivate(() => this._checkIgnoreChanges())` 추가
23
- - **템플릿:** topbar `@if (canEdit())` 블록으로 "저장" 버튼 추가, 필터 dock 아래 inline 도구 dock("등록" 버튼) 추가, 시트 셀을 inline 편집 컨트롤(`<sd-textfield>` / `<sd-shared-data-select>`)로 교체, 시트를 `<sd-form #formCtrl (formSubmit)="onSubmit()">`로 감쌈, id 컬럼 신규 행용 `<sd-anchor>` 제거 버튼 추가
24
- - **_refresh 변경:** 말미에 `_itemsSnapshot = obj.clone(r.items)` 한 줄 추가
25
- - **onRefreshButtonClick 변경:** 선두에 `if (!this._checkIgnoreChanges()) return;` 추가
26
-
27
- > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 최소 뼈대 위에 번호(1)·2)·3)…) 순서대로 삽입·교체할 지점을 나타낸다. 그대로 컴파일되지 않는다.
28
-
29
- ```typescript
30
- // 1) imports 교체 — @simplysm/angular에 {SdAnchor, setupCanDeactivate, SdSharedDataSelect, SdItemOfTemplate, FormatPipe, viewChild 등}을 추가.
31
- // @simplysm/core-common에 {ArgumentError, type DateTime, obj}를 추가. @simplysm/orm-common에서 {expr}를 가져온다.
32
- // oneWayDiffs는 @simplysm/core-common의 side-effect import로 Array.prototype을 확장하므로 `import "@simplysm/core-common";` 한 줄만으로 활성화된다.
33
- import { NgIcon } from "@ng-icons/core";
34
- import {
35
- tablerAlertTriangle, tablerCirclePlus, tablerDeviceFloppy,
36
- tablerRefresh, tablerSearch, tablerX,
37
- } from "@ng-icons/tabler-icons";
38
- import {
39
- ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked,
40
- viewChild, ViewEncapsulation,
41
- } from "@angular/core";
42
- import { ArgumentError, type DateTime, obj, str } from "@simplysm/core-common";
43
- import { expr } from "@simplysm/orm-common";
44
- import {
45
- FormatPipe, injectPermsSignal, injectViewTitleSignal, injectViewTypeSignal, mark,
46
- SdAnchor, SdBusyContainer, SdButton, SdCommandDirective, SdDock, SdDockContainer,
47
- SdForm, SdItemOfTemplate, SdSharedDataSelect, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
48
- SdTextfield, SdToastProvider, SdTopbar, SdTopbarContainer,
49
- setupCanDeactivate, type SortingDef,
50
- } from "@simplysm/angular";
51
- // 앱별 대체: ORM + 공유 데이터 + 인증 provider / DbContext.
52
- import { AppOrmProvider, AppSharedDataProvider, useSharedSignal } from "@adtek/client-common";
53
- import type { MainDbContext } from "@adtek/db-main";
54
- import { AppAuthProvider } from "../../../providers/AppAuthProvider";
55
-
56
- // 2) ICustomer 확장 — 편집 가능한 필드(선택적 신규 id 포함 / 카테고리 / 감사 필드)
57
- interface IFilter {
58
- searchText?: string;
59
- }
60
-
61
- interface ICustomer {
62
- id?: number; // 신규 행은 undefined (diff의 "create" 타입 판정 근거)
63
- name: string; // 필수 — upsert 시 non-null 단정이 불필요해진다
64
- phone?: string;
65
- categoryId?: number;
66
- lastModifiedAt?: DateTime; // 서버 감사 필드 — 신규 행엔 없음
67
- lastModifiedBy?: string;
68
- }
69
-
70
- // 3) @Component — imports 배열에 SdAnchor/SdSharedDataSelect/SdItemOfTemplate/FormatPipe 추가.
71
- // hostDirectives outputs에 "sdSaveCommand" 추가. host에 (sdSaveCommand) 바인딩 추가.
72
- @Component({
73
- // ...selector/cd/encapsulation/standalone 동일
74
- imports: [
75
- SdBusyContainer, SdTopbarContainer, SdTopbar,
76
- SdDockContainer, SdDock,
77
- SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
78
- SdButton, SdAnchor, SdTextfield,
79
- SdSharedDataSelect, SdItemOfTemplate,
80
- NgIcon, FormatPipe,
81
- ],
82
- hostDirectives: [
83
- { directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
84
- ],
85
- host: {
86
- "(sdRefreshCommand)": "onRefreshButtonClick()",
87
- "(sdSaveCommand)": "onSaveButtonClick()",
88
- },
89
- // template: 아래 7)에서 상세
90
- })
91
-
92
- // 4) DI 추가
93
- private readonly _appAuth = inject(AppAuthProvider);
94
- private readonly _appSharedData = inject(AppSharedDataProvider);
95
-
96
- // 5) 권한·공유 데이터·상태·파생 추가
97
- SHARED_DATA_KEY = "고객" as const;
98
- perms = injectPermsSignal(["sales.customer"], ["use", "edit"]);
99
- canEdit = computed(() => this.perms().includes("edit") && this.viewType() === "page");
100
-
101
- sharedCategories = useSharedSignal("카테고리");
102
-
103
- private _itemsSnapshot: ICustomer[] = [];
104
- diffs = computed(() => this.items().oneWayDiffs(this._itemsSnapshot, "id"));
105
-
106
- filter = signal<IFilter>({});
107
- lastFilter = signal<IFilter>({});
108
-
109
- // viewChild (Ctrl+S → form submit 경로 공유)
110
- formCtrl = viewChild<SdForm>("formCtrl");
111
-
112
- // 6) 생성자에 setupCanDeactivate 추가 + _refresh에 snapshot 갱신
113
- constructor() {
114
- // 기존 초기 effect는 동일...
115
- setupCanDeactivate(() => this._checkIgnoreChanges());
116
- }
117
-
118
- getIsItemChanged(item: ICustomer): boolean {
119
- if (item.id == null) return true;
120
- return this.diffs().some((diff) => diff.item.id === item.id);
121
- }
122
-
123
- // 7) template — topbar에 "저장" 버튼, 시트 셀을 inline 편집으로 교체,
124
- // 시트를 <sd-form #formCtrl (formSubmit)="onSubmit()">로 감싼다.
125
- template: `
126
- <sd-busy-container [busy]="busyCount() > 0">
127
- @if (initialized()) {
128
- @if (!perms().includes("use")) { <!-- 경고 동일 --> }
129
- @else {
130
- <sd-topbar-container>
131
- @if (viewType() === "page") {
132
- <sd-topbar>
133
- <h4>{{ viewTitle() }}</h4>
134
- <sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
135
- <ng-icon [svg]="tablerRefresh" /> 새로고침 <small>(CTRL+ALT+L)</small>
136
- </sd-button>
137
- @if (canEdit()) {
138
- <sd-button [theme]="'link-primary'" (click)="onSaveButtonClick()">
139
- <ng-icon [svg]="tablerDeviceFloppy" /> 저장 <small>(CTRL+S)</small>
140
- </sd-button>
141
- }
142
- </sd-topbar>
143
- }
144
-
145
- <sd-dock-container>
146
- <sd-dock class="p-default">
147
- <sd-form (formSubmit)="onFilterSubmit()">
148
- <div class="form-box-inline">
149
- <div class="form-box-item">
150
- <sd-button [type]="'submit'" [theme]="'info'"><ng-icon [svg]="tablerSearch" /> 조회</sd-button>
151
- </div>
152
- <div class="form-box-item">
153
- <label>검색어</label>
154
- <sd-textfield [type]="'text'" [placeholder]="'이름/전화번호'"
155
- [(value)]="filter().searchText" (valueChange)="mark(filter)" />
156
- </div>
157
- </div>
158
- </sd-form>
159
- </sd-dock>
160
-
161
- <!-- 도구 (page 뷰에서만) — 확장 B/G 등이 이 dock에 버튼을 추가한다 -->
162
- @if (canEdit() && viewType() === "page") {
163
- <sd-dock class="flex-row gap-sm p-xs-default">
164
- <sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddItemButtonClick()">
165
- <ng-icon [svg]="tablerCirclePlus" /> 등록
166
- </sd-button>
167
- </sd-dock>
168
- }
169
-
170
- <!-- 시트를 일괄 저장용 <sd-form>으로 감싼다. #formCtrl는 Ctrl+S 경로 공유 -->
171
- <sd-form #formCtrl (formSubmit)="onSubmit()" class="block fill p-default pt-0">
172
- <sd-sheet
173
- [key]="'customer-list-sheet'"
174
- [items]="items()"
175
- [(currentPage)]="page"
176
- [totalPageCount]="pageLength()"
177
- [(sorts)]="sortingDefs"
178
- [trackByFn]="trackByFn"
179
- >
180
- <!-- id 컬럼: 신규 행이면 <sd-anchor>로 제거 버튼 -->
181
- <sd-sheet-column [fixed]="true" [key]="'id'" [header]="'#'">
182
- <ng-template [cell]="items()" let-item="item">
183
- <div class="p-xs-sm"
184
- [class.tx-right]="item.id"
185
- [style.background]="getIsItemChanged(item) ? 'yellow' : ''">
186
- @if (item.id) {
187
- {{ item.id }}
188
- } @else if (canEdit()) {
189
- <sd-anchor (click)="onRemoveNewItemButtonClick(item)">
190
- <ng-icon [svg]="tablerX" />
191
- </sd-anchor>
192
- }
193
- </div>
194
- </ng-template>
195
- </sd-sheet-column>
196
-
197
- <!-- 이름/전화번호: inline 편집 가능 — [inset]/[size]/[readonly]="!edit" -->
198
- <sd-sheet-column [key]="'name'" [header]="'이름'">
199
- <ng-template [cell]="items()" let-item="item" let-edit="edit">
200
- <sd-textfield
201
- [type]="'text'" [inset]="true" [size]="'sm'" [required]="true"
202
- [disabled]="!canEdit()" [readonly]="!edit"
203
- [(value)]="item.name" (valueChange)="mark(items)" />
204
- </ng-template>
205
- </sd-sheet-column>
206
-
207
- <sd-sheet-column [key]="'phone'" [header]="'전화번호'">
208
- <ng-template [cell]="items()" let-item="item" let-edit="edit">
209
- <sd-textfield
210
- [type]="'text'" [inset]="true" [size]="'sm'"
211
- [disabled]="!canEdit()" [readonly]="!edit"
212
- [(value)]="item.phone" (valueChange)="mark(items)" />
213
- </ng-template>
214
- </sd-sheet-column>
215
-
216
- <!-- 카테고리: <sd-shared-data-select> + <ng-template [itemOf]> -->
217
- <sd-sheet-column [key]="'categoryId'" [header]="'카테고리'">
218
- <ng-template [cell]="items()" let-item="item">
219
- <sd-shared-data-select
220
- [inset]="true" [size]="'sm'" [disabled]="!canEdit()"
221
- [items]="sharedCategories.items()"
222
- [(value)]="item.categoryId" (valueChange)="mark(items)">
223
- <ng-template [itemOf]="sharedCategories.items()" let-cat>
224
- {{ cat.name }}
225
- </ng-template>
226
- </sd-shared-data-select>
227
- </ng-template>
228
- </sd-sheet-column>
229
-
230
- <!-- 수정일시/수정자: [hidden]="true" 기본 숨김 -->
231
- <sd-sheet-column [key]="'lastModifiedAt'" [header]="'수정일시'" [hidden]="true">
232
- <ng-template [cell]="items()" let-item="item">
233
- <div class="p-xs-sm tx-center">{{ item.lastModifiedAt | format: "yyyy-MM-dd HH:mm" }}</div>
234
- </ng-template>
235
- </sd-sheet-column>
236
- <sd-sheet-column [key]="'lastModifiedBy'" [header]="'수정자'" [hidden]="true">
237
- <ng-template [cell]="items()" let-item="item">
238
- <div class="p-xs-sm tx-center">{{ item.lastModifiedBy }}</div>
239
- </ng-template>
240
- </sd-sheet-column>
241
- </sd-sheet>
242
- </sd-form>
243
- </sd-dock-container>
244
- </sd-topbar-container>
245
- }
246
- }
247
- </sd-busy-container>
248
- `
249
-
250
- // 8) 메서드 추가
251
- onSaveButtonClick(): void {
252
- this.formCtrl()?.requestSubmit();
253
- }
254
-
255
- async onSubmit(): Promise<void> {
256
- if (this.busyCount() > 0) return;
257
-
258
- const diffs = this.diffs();
259
- if (diffs.length === 0) {
260
- this._sdToast.info("변경사항이 없습니다.");
261
- return;
262
- }
263
-
264
- this.busyCount.update((v) => v + 1);
265
- await this._sdToast.try(async () => {
266
- const changedIds: number[] = [];
267
- await this._appOrm.connectAsync(async (db) => {
268
- for (const diff of diffs) {
269
- changedIds.push(await this._upsertItem(
270
- db, diff.item, diff.type === "create" ? "등록" : "수정",
271
- ));
272
- }
273
- });
274
- await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, changedIds);
275
- this._sdToast.success("저장되었습니다.");
276
- await this._refresh();
277
- });
278
- this.busyCount.update((v) => v - 1);
279
- }
280
-
281
- onAddItemButtonClick(): void {
282
- this.items.update((list) => [{}, ...list]);
283
- }
284
-
285
- onRemoveNewItemButtonClick(item: ICustomer): void {
286
- this.items.update((list) => list.filter((it) => it !== item));
287
- }
288
-
289
- // 9) onRefreshButtonClick / _refresh에 변경 감지·스냅샷 갱신 추가
290
- onRefreshButtonClick(): void {
291
- if (this.busyCount() > 0) return;
292
- if (!this.perms().includes("use")) return;
293
- if (!this._checkIgnoreChanges()) return;
294
- mark(this.lastFilter);
295
- }
296
-
297
- private _checkIgnoreChanges(): boolean {
298
- return this.diffs().length === 0 || confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
299
- }
300
-
301
- private async _refresh(): Promise<void> {
302
- const r = await this._search(true);
303
- this.items.set(r.items);
304
- this.pageLength.set(r.pageLength);
305
- this._itemsSnapshot = obj.clone(r.items); // ← snapshot 갱신
306
- }
307
-
308
- // 10) _search의 where절·select에 카테고리/감사 필드 반영 + _upsertItem 추가
309
- private async _search(usePagination: boolean): Promise<{ items: ICustomer[]; pageLength: number }> {
310
- const filter = this.lastFilter();
311
- const sortingDefs = this.sortingDefs();
312
- const page = this.page();
313
-
314
- return this._appOrm.connectAsync(async (db) => {
315
- let qr1 = db.customer();
316
- if (!str.isNullOrEmpty(filter.searchText)) {
317
- qr1 = qr1.search((item) => [item.name, item.phone], filter.searchText);
318
- }
319
-
320
- const pageLength = usePagination ? Math.ceil((await qr1.count()) / 50) : 0;
321
-
322
- let qr2 = qr1.joinLastDataLog().select((item) => ({
323
- id: item.id, name: item.name, phone: item.phone, categoryId: item.categoryId,
324
- lastModifiedAt: item.lastDataLog?.dateTime,
325
- lastModifiedBy: item.lastDataLog?.userName,
326
- }));
327
-
328
- for (const s of sortingDefs) qr2 = qr2.orderBy(s.key, s.desc ? "DESC" : "ASC");
329
- if (!sortingDefs.some((s) => s.key === "name")) qr2 = qr2.orderBy((item) => item.name);
330
- if (usePagination) qr2 = qr2.limit(page * 50, 50);
331
-
332
- const items = await qr2.execute();
333
- return { items, pageLength };
334
- });
335
- }
336
-
337
- private async _upsertItem(
338
- db: MainDbContext, item: ICustomer, logType: string,
339
- ): Promise<number> {
340
- if (await db.customer().where((c) => [
341
- expr.eq(c.name, item.name),
342
- expr.not(expr.eq(c.id, item.id)),
343
- ]).exists()) {
344
- throw new ArgumentError("동일한 명칭이 이미 등록되어 있습니다.", { 명칭: item.name });
345
- }
346
-
347
- const upsertResult = await db.customer()
348
- .where((c) => [expr.eq(c.id, item.id)])
349
- .upsert(() => ({
350
- name: item.name, phone: item.phone, categoryId: item.categoryId,
351
- }), ["id"]);
352
- const upsertId = upsertResult[0].id;
353
-
354
- await db.customer().insertDataLogAsync({
355
- type: logType, itemId: upsertId, valueJson: undefined,
356
- userId: this._appAuth.authInfo()!.user.id,
357
- });
358
-
359
- return upsertId;
360
- }
361
- ```
362
-
363
- **포인트:**
364
-
365
- - **`diffs = computed(() => items.oneWayDiffs(_itemsSnapshot, "id"))`는 변경 감지 signal**. 템플릿(`getIsItemChanged`)·호출부(`onSubmit` / `_checkIgnoreChanges`) 모두에서 `this.diffs()`로 참조한다. `_itemsSnapshot`은 `obj.clone(r.items)`로 `_refresh()` 시점마다 깊은 복제로 갱신한다 (얕은 참조 대입은 cell mutation이 snapshot까지 오염시켜 비교가 실패한다).
366
- - **`oneWayDiffs`는 delete를 다루지 않는다.** 반환 `type`은 `"create" | "update" | "same"`만 있다(`packages/core-common/src/extensions/arr-ext.types.ts:206`). 상세 처리 경로는 아래 [🚫 흔한 실수](#items-배열에서-기존-row를-물리-제거한다) 참조.
367
- - **시트 셀 내부 컨트롤은 `[inset]="true" [size]="'sm'"` 명시 필수.** 본 확장에서 inline 편집으로 교체되는 `<sd-textfield>` / `<sd-shared-data-select>`에 모두 적용한다. 상세·예외 케이스는 [공통 규칙: 시트 셀 `[inset]/[size]`](../_common-rules.md#시트-셀-내부-컨트롤에-insettrue-sizesm을-명시한다) 참조.
368
- - **`let-edit="edit"` + `[readonly]="!edit"` + `[disabled]="!canEdit()"` 3종 바인딩**으로 (a) 전체 권한 / (b) 개별 행 편집 모드 분리 제어. 시트가 제공하는 편집 모드 토글을 사용하지 않는다면 `[readonly]="!canEdit()"`만 써도 된다.
369
- - **`canEdit = computed(() => perms().includes("edit") && viewType() === "page")`**: edit 권한이 있어도 modal(선택 모달·조회 전용 modal) 뷰에서는 항상 false. 확장 D/E 적용 시 inline 편집이 자동으로 꺼진다.
370
- - **`setupCanDeactivate(() => this._checkIgnoreChanges())`**: 라우트 이탈 시 `diffs().length > 0`이면 `confirm`으로 사용자 확인 후 true/false를 리턴한다. 내부에서 `inject()`를 사용하므로 호출 위치는 생성자·필드 이니셜라이저로 제한된다 — 동일 제약을 다루는 [공통 규칙: `inject()` 의존 함수 호출 시점](../_common-rules.md#injectviewtypesignal은-생성자-또는-필드-이니셜라이저에서만-호출한다) 참조.
371
- - **Ctrl+S 경로 통일:** `hostDirectives` → `(sdSaveCommand)="onSaveButtonClick()"` → `formCtrl()?.requestSubmit()` → `<sd-form (formSubmit)="onSubmit()">`. 상단 "저장" 버튼 클릭과 완전히 동일한 경로로 수렴한다.
372
- - **신규 행 삽입:** `this.items.update((list) => [{}, ...list])`. id=undefined이면 snapshot에 존재하지 않으므로 `diffs`에서 `type: "create"`로 잡힌다. `getIsItemChanged(item)`은 `item.id == null`일 때 true를 반환 — 노란 배경 하이라이트.
373
- - **`(valueChange)="mark(items)"`는 OnPush 통지 용도.** 저장 여부를 `mark` 호출로 판정하지 않는다 (변경 감지는 `diffs().length`가 담당). 상세는 [공통 규칙: `mark(sig)`를 '저장 감지' 수단으로 사용하지 않는다](../_common-rules.md#marksig를-저장-감지-수단으로-사용하지-않는다) 참조.
374
- - **`_upsertItem`의 중복 명칭 검사**: `expr.not(expr.eq(c.id, item.id))`로 자기 자신 제외. `isDeleted` 컬럼이 있는 테이블에서는 `expr.eq(c.isDeleted, false)` 조건을 추가하여 soft-delete된 동일 명칭은 허용할 수 있다.
375
- - **`insertDataLogAsync`로 감사 로그 기록**: `type` 필드에 "등록" / "수정" / "엑셀업로드"(확장 G) 등 맥락 구분 문자열. `_refresh` 이후 `joinLastDataLog()`로 `lastModifiedAt` / `lastModifiedBy` 표시.
376
-
377
- **🚫 흔한 실수**
378
-
379
- > 공통 규칙(`mark` 저장 감지 오해, `setupCanDeactivate` 호출 위치, 시트 셀 `[inset]/[size]` 등)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 본 섹션은 **inline 편집 확장 고유 실수**만 다룬다.
380
-
381
- ### `items` 배열에서 기존 row를 물리 제거한다
382
-
383
- ```typescript
384
- // ❌ 기존 row를 배열에서 제거 — oneWayDiffs는 delete를 반환하지 않으므로
385
- // 저장 시 서버는 이 row가 사라진 사실을 감지하지 못한다.
386
- onDeleteRow(item: ICustomer): void {
387
- this.items.update((list) => list.filter((it) => it !== item));
388
- }
389
-
390
- // ✅ 삭제 의사는 isDeleted 플래그로 표현 → diff의 type: "update"로 전송
391
- onDeleteRow(item: ICustomer): void {
392
- this.items.update((list) => list.map(
393
- (it) => it.id === item.id ? { ...it, isDeleted: true } : it,
394
- ));
395
- }
396
-
397
- // ✅ 예외: 신규 행(id == null)은 저장 포기 시 물리 제거해도 된다
398
- // — snapshot에 존재하지 않으므로 diff 자체가 발생하지 않는다.
399
- onRemoveNewItemButtonClick(item: ICustomer): void {
400
- this.items.update((list) => list.filter((it) => it !== item));
401
- }
402
- ```
403
-
404
- **근거**: `oneWayDiffs`의 반환 `type`은 `"create" | "update" | "same"` 3종뿐이며 delete를 다루지 않는다(`packages/core-common/src/extensions/arr-ext.types.ts:206`). 기존 row를 화면에서 제거하면 diff가 발생하지 않아 서버 저장 경로를 우회한다. soft-delete 경로는 [확장 B: 선택 기능 + 선택 삭제/복구](./extension-b-selection.md) / [확장 C: inline 삭제/복구](./extension-c-inline-delete.md)가 전담한다. `isDeleted` 컬럼이 없는 테이블은 이 확장을 사용하지 않는다 — [공통 규칙: 삭제 방식](../_common-rules.md#삭제-방식은-db-스키마에-따라-결정한다) 참조.
405
-
406
- ## Cross-reference
407
-
408
- - 진입점: [crud-list.md](../crud-list.md)
409
- - 다음 확장: [확장 B: 선택 기능 + 선택 삭제/복구](./extension-b-selection.md) (이 확장 위에 누적)
410
- - 관련 확장: [확장 F: 모달 편집 모드](./extension-f-modal-edit.md) (이 확장과 상호 배타)
@@ -1,226 +0,0 @@
1
- ← [CRUD 리스트 레시피 진입점](../crud-list.md)
2
-
3
- # 확장 B: 선택 기능 + 선택 삭제/복구
4
-
5
- > **선행:** [확장 A: inline 편집/저장](./extension-a-inline-edit.md)
6
-
7
- 시트에 체크박스 기반 선택 기능을 추가하고, 선택된 행에 대한 "선택 삭제 / 선택 복구" 버튼 바를 상단에 배치한다. 확장 A(inline 편집)가 도입한 `canEdit` / `SHARED_DATA_KEY` / `_appAuth` / `_appSharedData` 등을 그대로 재사용하며, 본 확장이 `isDeleted` 플래그 / `isIncludeDeleted` 필터 / `getItemCellStyleFn`(삭제 취소선)을 도입한다.
8
-
9
- > **적용 조건:** DB Table에 `isDeleted` 컬럼이 있는 경우에만 사용한다. 컬럼이 없는 테이블은 물리 삭제(row DELETE)로 처리하며 이 확장을 사용하지 않는다 → [공통 규칙: 삭제 방식](../_common-rules.md#삭제-방식은-db-스키마에-따라-결정한다).
10
-
11
- **이 확장이 도입하는 요소:**
12
-
13
- - **imports:** `SdCheckbox` (필터 "삭제항목 포함" 체크박스), `tablerEraser`, `tablerRestore`
14
- - **타입 확장:** `IFilter`에 `isIncludeDeleted: boolean` 추가, `ICustomer`에 `isDeleted: boolean` 추가
15
- - **상태:** `selectedItems = signal<ICustomer[]>([])`, `filter`/`lastFilter` 기본값에 `isIncludeDeleted: false` 추가
16
- - **파생:** `hasSelectedDeleted`, `hasSelectedNotDeleted`, `getItemCellStyleFn` (삭제 행 취소선)
17
- - **메서드:** `onToggleDeleteItemsButtonClick(del: boolean)` — confirm → DB `update` → `emitAsync` → 토스트 → `_refresh`
18
- - **템플릿:** 필터 dock에 "삭제항목 포함" 체크박스 추가, 확장 A 도구 dock("등록" 버튼 우측)에 "선택 삭제 / 선택 복구" 버튼 추가, `<sd-sheet>`에 `[selectMode]="'multi'"` + `[(selectedItems)]` + `[getItemCellStyleFn]` 바인딩 추가
19
- - **_search 변경:** `isIncludeDeleted` 필터에 따른 `isDeleted` where절 추가, select에 `isDeleted` 필드 추가
20
- - **_upsertItem 변경:** upsert 데이터에 `isDeleted: item.isDeleted` 추가, 중복 검사에 `expr.eq(c.isDeleted, false)` 조건 추가
21
- - **onAddItemButtonClick 변경:** 신규 행 리터럴에 `isDeleted: false` 포함
22
- - **_refresh 변경:** 말미에 "현재 페이지에 남아 있는 항목만 `selectedItems` 유지" 로직 추가 — 페이지 이동 시 선택 유지를 위해
23
-
24
- > 상세: [`<sd-sheet> selectMode/selectedItems`](../../ui-data/sd-sheet.md)
25
-
26
- > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A) 위에 번호 순서대로 삽입·교체할 지점을 나타낸다. 그대로 컴파일되지 않는다.
27
-
28
- ```typescript
29
- // 0) imports 추가 — SdCheckbox, tablerEraser, tablerRestore
30
- // 타입 확장 — IFilter에 isIncludeDeleted, ICustomer에 isDeleted 추가
31
- interface IFilter {
32
- searchText?: string;
33
- isIncludeDeleted: boolean; // ← 추가
34
- }
35
-
36
- interface ICustomer {
37
- // ...기존 필드...
38
- isDeleted: boolean; // ← 추가
39
- }
40
-
41
- // filter/lastFilter 기본값에 isIncludeDeleted 추가
42
- filter = signal<IFilter>({ isIncludeDeleted: false });
43
- lastFilter = signal<IFilter>({ isIncludeDeleted: false });
44
-
45
- // onAddItemButtonClick — 신규 행에 isDeleted: false 포함
46
- onAddItemButtonClick(): void {
47
- this.items.update((list) => [{ isDeleted: false }, ...list]);
48
- }
49
-
50
- // 1) 상태·파생 추가
51
- selectedItems = signal<ICustomer[]>([]);
52
-
53
- hasSelectedDeleted = computed(() => this.selectedItems().some((it) => it.isDeleted));
54
- hasSelectedNotDeleted = computed(() => this.selectedItems().some((it) => !it.isDeleted));
55
-
56
- // 삭제 행 취소선 스타일
57
- getItemCellStyleFn = (item: ICustomer): string | undefined =>
58
- item.isDeleted ? "text-decoration: line-through;" : undefined;
59
-
60
- // 2) _search — isIncludeDeleted 필터 + select에 isDeleted 추가
61
- // (기존 _search의 qr1 뒤에 추가)
62
- if (!filter.isIncludeDeleted) {
63
- qr1 = qr1.where((item) => [expr.eq(item.isDeleted, false)]);
64
- }
65
- // select에 isDeleted 추가:
66
- // isDeleted: item.isDeleted,
67
-
68
- // _upsertItem — upsert 데이터에 isDeleted 추가, 중복 검사에 isDeleted 조건 추가
69
- // 중복 검사: expr.eq(c.isDeleted, false) 조건 추가
70
- // upsert: isDeleted: item.isDeleted 추가
71
-
72
- // 3) 선택 삭제/복구 메서드 — confirm → 즉시 DB 반영 → refresh → 토스트
73
- async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
74
- if (!confirm(del ? "정말 삭제하시겠습니까?" : "정말 복구하시겠습니까?")) return;
75
-
76
- this.busyCount.update((v) => v + 1);
77
- await this._sdToast.try(async () => {
78
- const targets = this.selectedItems().filter((it) => it.id != null);
79
- await this._appOrm.connectAsync(async (db) => {
80
- for (const item of targets) {
81
- await db.customer()
82
- .where((c) => [expr.eq(c.id, item.id!)])
83
- .update(() => ({ isDeleted: del }));
84
-
85
- await db.customer().insertDataLogAsync({
86
- type: del ? "삭제" : "복구",
87
- itemId: item.id!,
88
- valueJson: undefined,
89
- userId: this._appAuth.authInfo()!.user.id,
90
- });
91
- }
92
- });
93
- await this._appSharedData.emitAsync(
94
- this.SHARED_DATA_KEY,
95
- targets.map((it) => it.id!),
96
- );
97
- this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
98
- await this._refresh();
99
- });
100
- this.busyCount.update((v) => v - 1);
101
- }
102
-
103
- // 4) _refresh 재정의 — 확장 A의 _refresh 본문에 "선택 유지 블록"만 삽입한다.
104
- // 삽입 위치: items/pageLength 세팅 후, _itemsSnapshot 갱신 전.
105
- // 다른 줄(r.items 세팅, _itemsSnapshot 갱신)은 확장 A와 동일하며 여기서 다시 쓰지 않는다.
106
- private async _refresh(): Promise<void> {
107
- const r = await this._search(true);
108
- this.items.set(r.items);
109
- this.pageLength.set(r.pageLength);
110
-
111
- // ── 확장 B 추가 블록 시작 ──
112
- // 현재 페이지에 남아 있는 항목만 선택 유지
113
- const currKeys = new Set(r.items.map((it) => this.trackByFn(it)));
114
- this.selectedItems.update((sel) => sel.filter((it) => currKeys.has(this.trackByFn(it))));
115
- // ── 확장 B 추가 블록 끝 ──
116
-
117
- this._itemsSnapshot = obj.clone(r.items);
118
- }
119
-
120
- // template — 필터에 "삭제항목 포함" 체크박스, 도구 dock에 선택 삭제/복구 버튼,
121
- // <sd-sheet>에 selectMode + selectedItems + getItemCellStyleFn 바인딩 추가
122
- `
123
- <sd-dock class="p-default">
124
- <sd-form (formSubmit)="onFilterSubmit()">
125
- <div class="form-box-inline">
126
- <!-- ...기존 필터 필드... -->
127
- <div class="form-box-item">
128
- <sd-checkbox [(value)]="filter().isIncludeDeleted" (valueChange)="mark(filter)">
129
- 삭제항목 포함
130
- </sd-checkbox>
131
- </div>
132
- </div>
133
- </sd-form>
134
- </sd-dock>
135
-
136
- <!-- 확장 A가 도입한 도구 dock에 버튼 추가 (등록 버튼 우측) -->
137
- @if (canEdit() && viewType() === "page") {
138
- <sd-dock class="flex-row gap-sm p-xs-default">
139
- <sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddItemButtonClick()">
140
- <ng-icon [svg]="tablerCirclePlus" /> 등록
141
- </sd-button>
142
- <!-- ↓ 확장 B가 추가 -->
143
- <sd-button [size]="'sm'" [theme]="'link-danger'"
144
- (click)="onToggleDeleteItemsButtonClick(true)"
145
- [disabled]="!hasSelectedNotDeleted()">
146
- <ng-icon [svg]="tablerEraser" /> 선택 삭제
147
- </sd-button>
148
- @if (hasSelectedDeleted()) {
149
- <sd-button [size]="'sm'" [theme]="'link-warning'"
150
- (click)="onToggleDeleteItemsButtonClick(false)">
151
- <ng-icon [svg]="tablerRestore" /> 선택 복구
152
- </sd-button>
153
- }
154
- </sd-dock>
155
- }
156
-
157
- <sd-form #formCtrl (formSubmit)="onSubmit()" class="block fill p-default pt-0">
158
- <!--
159
- 확장 B가 추가하는 속성:
160
- [selectMode]="'multi'"
161
- [(selectedItems)]="selectedItems"
162
- [getItemCellStyleFn]="getItemCellStyleFn" (삭제 행 취소선)
163
- -->
164
- <sd-sheet
165
- [key]="'customer-list-sheet'"
166
- [items]="items()"
167
- [(currentPage)]="page"
168
- [totalPageCount]="pageLength()"
169
- [(sorts)]="sortingDefs"
170
- [selectMode]="'multi'"
171
- [(selectedItems)]="selectedItems"
172
- [trackByFn]="trackByFn"
173
- [getItemCellStyleFn]="getItemCellStyleFn"
174
- >
175
- <!-- 컬럼들은 확장 A와 동일 -->
176
- </sd-sheet>
177
- </sd-form>
178
- `
179
- ```
180
-
181
- **포인트:**
182
-
183
- - **선택 삭제/복구는 confirm 후 즉시 DB 반영한다.** `confirm()` → DB `update` → `emitAsync`(공유 데이터) → 토스트 → `_refresh()`. 확장 A의 inline 편집 일괄 저장 흐름과 별도 경로다 — inline 편집은 셀 수정 → 저장 버튼, 선택 삭제는 체크 → 즉시 반영.
184
- - **`selectMode` 기본값은 `"multi"`로 둔다** — 한 번에 여러 행 삭제/복구가 기본 요구. 단일 선택이 필요한 화면에서는 `"single"`로 바꾼다.
185
- - **페이지 이동/정렬/필터 제출 후 `_refresh` 내부에서 `selectedItems`를 현재 페이지 항목으로만 유지한다.** `trackByFn`이 반환하는 key를 기준으로 남은 항목만 걸러낸다. 페이지 간 누적 선택이 필요하면 [확장 D: 선택 모달 전환](./extension-d-select-modal.md)의 `[cumulativeSelection]`을 참조한다 (선택 모달 전용).
186
-
187
- **🚫 흔한 실수**
188
-
189
- > 공통 규칙(`mark` 저장 감지 오해, 시트 셀 `[inset]/[size]`, 삭제 방식 등)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 본 섹션은 **선택 삭제/복구 확장 고유 실수**만 다룬다.
190
-
191
- ### 신규 행(id == null)을 선택 삭제 대상에 포함한다
192
-
193
- ```typescript
194
- // ❌ selectedItems() 전체를 DB update 대상으로 사용 —
195
- // 신규 행(id == null)은 DB에 존재하지 않으므로 where 조건(expr.eq(c.id, undefined))이
196
- // 예상 밖의 row를 스캔하거나 조용히 실패한다. emitAsync의 ids 단정(!)도 런타임 에러가 된다.
197
- async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
198
- for (const item of this.selectedItems()) {
199
- await db.customer()
200
- .where((c) => [expr.eq(c.id, item.id)])
201
- .update(() => ({ isDeleted: del }));
202
- }
203
- }
204
-
205
- // ✅ id != null 만 통과시켜 실제로 DB에 저장된 row만 대상으로 한다.
206
- async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
207
- const targets = this.selectedItems().filter((it) => it.id != null);
208
- for (const item of targets) {
209
- await db.customer()
210
- .where((c) => [expr.eq(c.id, item.id!)])
211
- .update(() => ({ isDeleted: del }));
212
- }
213
- await this._appSharedData.emitAsync(
214
- this.SHARED_DATA_KEY,
215
- targets.map((it) => it.id!),
216
- );
217
- }
218
- ```
219
-
220
- **근거**: 확장 A의 신규 행(`id == null`)은 DB에 insert되기 전 상태이므로 "선택 삭제"의 의미가 성립하지 않는다. 신규 행 제거는 확장 A의 [`onRemoveNewItemButtonClick`](./extension-a-inline-edit.md)이 담당한다. `emitAsync`로 전달하는 `ids` 배열의 non-null 단정(`it.id!`)도 동일 필터가 선행되어야 런타임 안전하다.
221
-
222
- ## Cross-reference
223
-
224
- - 진입점: [crud-list.md](../crud-list.md)
225
- - 선행: [확장 A: inline 편집/저장](./extension-a-inline-edit.md)
226
- - 다음 확장: [확장 C: inline 삭제/복구](./extension-c-inline-delete.md), [확장 D: 선택 모달 전환](./extension-d-select-modal.md) (이 확장 위에 누적)