@simplysm/angular 14.0.47 → 14.0.48

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 (128) hide show
  1. package/README.md +468 -0
  2. package/dist/controls/dropdown/sd-dropdown-popup.d.ts +1 -0
  3. package/dist/controls/dropdown/sd-dropdown-popup.d.ts.map +1 -1
  4. package/dist/controls/dropdown/sd-dropdown-popup.js +6 -1
  5. package/dist/controls/dropdown/sd-dropdown.d.ts.map +1 -1
  6. package/dist/controls/dropdown/sd-dropdown.js +6 -2
  7. package/dist/controls/select/sd-select.d.ts.map +1 -1
  8. package/dist/controls/select/sd-select.js +12 -34
  9. package/dist/core/routing/injectViewTypeSignal.d.ts +1 -1
  10. package/dist/core/routing/injectViewTypeSignal.d.ts.map +1 -1
  11. package/dist/core/routing/injectViewTypeSignal.js +15 -7
  12. package/dist/core/selection/useSelectionManager.d.ts +1 -0
  13. package/dist/core/selection/useSelectionManager.d.ts.map +1 -1
  14. package/dist/core/selection/useSelectionManager.js +54 -14
  15. package/dist/data/shared-data/sd-shared-data-select-button.d.ts +11 -5
  16. package/dist/data/shared-data/sd-shared-data-select-button.d.ts.map +1 -1
  17. package/dist/data/shared-data/sd-shared-data-select-button.js +68 -29
  18. package/dist/data/sheet/sd-sheet.d.ts +4 -1
  19. package/dist/data/sheet/sd-sheet.d.ts.map +1 -1
  20. package/dist/data/sheet/sd-sheet.js +23 -4
  21. package/dist/index.d.ts +0 -13
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +0 -17
  24. package/docs/bootstrap.md +38 -0
  25. package/docs/directives.md +236 -0
  26. package/docs/features.md +169 -0
  27. package/docs/pipes.md +32 -0
  28. package/docs/plugins.md +37 -0
  29. package/docs/provider-types.md +283 -0
  30. package/docs/providers.md +379 -0
  31. package/docs/recipes/crud-detail.md +860 -0
  32. package/docs/recipes/crud-list.md +779 -0
  33. package/docs/recipes/data-select-button.md +319 -0
  34. package/docs/recipes/page-modal-container.md +178 -0
  35. package/docs/styling.md +222 -0
  36. package/docs/type-utilities.md +250 -0
  37. package/docs/ui-data.md +333 -0
  38. package/docs/ui-form.md +502 -0
  39. package/docs/ui-layout.md +140 -0
  40. package/docs/ui-navigation.md +273 -0
  41. package/docs/ui-overlay.md +157 -0
  42. package/docs/ui-visual.md +127 -0
  43. package/docs/utils.md +244 -0
  44. package/package.json +19 -18
  45. package/src/controls/dropdown/sd-dropdown-popup.ts +6 -2
  46. package/src/controls/dropdown/sd-dropdown.ts +3 -4
  47. package/src/controls/select/sd-select.ts +6 -30
  48. package/src/core/routing/injectViewTypeSignal.ts +17 -10
  49. package/src/core/selection/useSelectionManager.ts +62 -12
  50. package/src/data/shared-data/sd-shared-data-select-button.ts +49 -14
  51. package/src/data/sheet/sd-sheet.ts +21 -0
  52. package/src/index.ts +0 -29
  53. package/dist/core/commons.d.ts +0 -2
  54. package/dist/core/commons.d.ts.map +0 -1
  55. package/dist/core/commons.js +0 -4
  56. package/dist/core/injectParent.d.ts +0 -7
  57. package/dist/core/injectParent.d.ts.map +0 -1
  58. package/dist/core/injectParent.js +0 -30
  59. package/dist/core/selection/setupCumulateSelectedKeys.d.ts +0 -9
  60. package/dist/core/selection/setupCumulateSelectedKeys.d.ts.map +0 -1
  61. package/dist/core/selection/setupCumulateSelectedKeys.js +0 -27
  62. package/dist/core/withBusy.d.ts +0 -3
  63. package/dist/core/withBusy.d.ts.map +0 -1
  64. package/dist/core/withBusy.js +0 -9
  65. package/dist/data/data-detail/sd-data-detail.base.d.ts +0 -51
  66. package/dist/data/data-detail/sd-data-detail.base.d.ts.map +0 -1
  67. package/dist/data/data-detail/sd-data-detail.base.js +0 -127
  68. package/dist/data/data-detail/sd-data-detail.d.ts +0 -30
  69. package/dist/data/data-detail/sd-data-detail.d.ts.map +0 -1
  70. package/dist/data/data-detail/sd-data-detail.js +0 -495
  71. package/dist/data/data-select-button/sd-data-select-button.base.d.ts +0 -24
  72. package/dist/data/data-select-button/sd-data-select-button.base.d.ts.map +0 -1
  73. package/dist/data/data-select-button/sd-data-select-button.base.js +0 -68
  74. package/dist/data/data-select-button/sd-data-select-button.d.ts +0 -15
  75. package/dist/data/data-select-button/sd-data-select-button.d.ts.map +0 -1
  76. package/dist/data/data-select-button/sd-data-select-button.js +0 -152
  77. package/dist/data/data-sheet/injectDataSheetExcelManager.d.ts +0 -14
  78. package/dist/data/data-sheet/injectDataSheetExcelManager.d.ts.map +0 -1
  79. package/dist/data/data-sheet/injectDataSheetExcelManager.js +0 -31
  80. package/dist/data/data-sheet/injectDataSheetInlineEditManager.d.ts +0 -26
  81. package/dist/data/data-sheet/injectDataSheetInlineEditManager.d.ts.map +0 -1
  82. package/dist/data/data-sheet/injectDataSheetInlineEditManager.js +0 -54
  83. package/dist/data/data-sheet/injectDataSheetModalEditManager.d.ts +0 -19
  84. package/dist/data/data-sheet/injectDataSheetModalEditManager.d.ts.map +0 -1
  85. package/dist/data/data-sheet/injectDataSheetModalEditManager.js +0 -44
  86. package/dist/data/data-sheet/injectDataSheetRefreshManager.d.ts +0 -25
  87. package/dist/data/data-sheet/injectDataSheetRefreshManager.d.ts.map +0 -1
  88. package/dist/data/data-sheet/injectDataSheetRefreshManager.js +0 -50
  89. package/dist/data/data-sheet/sd-data-sheet-column.d.ts +0 -8
  90. package/dist/data/data-sheet/sd-data-sheet-column.d.ts.map +0 -1
  91. package/dist/data/data-sheet/sd-data-sheet-column.js +0 -15
  92. package/dist/data/data-sheet/sd-data-sheet.base.d.ts +0 -75
  93. package/dist/data/data-sheet/sd-data-sheet.base.d.ts.map +0 -1
  94. package/dist/data/data-sheet/sd-data-sheet.base.js +0 -188
  95. package/dist/data/data-sheet/sd-data-sheet.d.ts +0 -49
  96. package/dist/data/data-sheet/sd-data-sheet.d.ts.map +0 -1
  97. package/dist/data/data-sheet/sd-data-sheet.js +0 -968
  98. package/dist/data/data-sheet/sd-data-sheet.types.d.ts +0 -17
  99. package/dist/data/data-sheet/sd-data-sheet.types.d.ts.map +0 -1
  100. package/dist/data/data-sheet/sd-data-sheet.types.js +0 -1
  101. package/dist/data/data-sheet/setupCloserWhenSingleSelectionChange.d.ts +0 -9
  102. package/dist/data/data-sheet/setupCloserWhenSingleSelectionChange.d.ts.map +0 -1
  103. package/dist/data/data-sheet/setupCloserWhenSingleSelectionChange.js +0 -20
  104. package/dist/data/data-sheet/useDataSheetFilterManager.d.ts +0 -13
  105. package/dist/data/data-sheet/useDataSheetFilterManager.d.ts.map +0 -1
  106. package/dist/data/data-sheet/useDataSheetFilterManager.js +0 -19
  107. package/dist/layout/base-container/sd-base-container.d.ts +0 -27
  108. package/dist/layout/base-container/sd-base-container.d.ts.map +0 -1
  109. package/dist/layout/base-container/sd-base-container.js +0 -193
  110. package/src/core/commons.ts +0 -4
  111. package/src/core/injectParent.ts +0 -45
  112. package/src/core/selection/setupCumulateSelectedKeys.ts +0 -38
  113. package/src/core/withBusy.ts +0 -13
  114. package/src/data/data-detail/sd-data-detail.base.ts +0 -187
  115. package/src/data/data-detail/sd-data-detail.ts +0 -237
  116. package/src/data/data-select-button/sd-data-select-button.base.ts +0 -104
  117. package/src/data/data-select-button/sd-data-select-button.ts +0 -105
  118. package/src/data/data-sheet/injectDataSheetExcelManager.ts +0 -57
  119. package/src/data/data-sheet/injectDataSheetInlineEditManager.ts +0 -89
  120. package/src/data/data-sheet/injectDataSheetModalEditManager.ts +0 -76
  121. package/src/data/data-sheet/injectDataSheetRefreshManager.ts +0 -90
  122. package/src/data/data-sheet/sd-data-sheet-column.ts +0 -10
  123. package/src/data/data-sheet/sd-data-sheet.base.ts +0 -294
  124. package/src/data/data-sheet/sd-data-sheet.ts +0 -479
  125. package/src/data/data-sheet/sd-data-sheet.types.ts +0 -18
  126. package/src/data/data-sheet/setupCloserWhenSingleSelectionChange.ts +0 -29
  127. package/src/data/data-sheet/useDataSheetFilterManager.ts +0 -30
  128. package/src/layout/base-container/sd-base-container.ts +0 -121
@@ -0,0 +1,779 @@
1
+ # Recipe: CRUD 리스트 화면 직접 조립
2
+
3
+ 소비 화면이 `<sd-busy-container>` · `<sd-topbar-container>` · `<sd-topbar>` · `<sd-form>` · `<sd-sheet>` · `<sd-sheet-column>` 표준 컴포넌트를 **직접 조립**하여 CRUD 리스트 화면을 구성한다. 과거 `SdDataSheet` / `SdDataSheetBase` / `SdDataSheetColumn`이 감추고 있던 필터·페이지네이션·정렬·선택·편집·삭제복구·엑셀 업로드·다운로드·단축키 흐름을 화면 내부에 인라인으로 풀어쓴다.
4
+
5
+ ## 1. Overview
6
+
7
+ - 제거된 추상화: `SdDataSheet`(컴포넌트) / `SdDataSheetBase`(추상 클래스) / `SdDataSheetColumn`(디렉티브) / `SdDataSheetItemPropInfo`·`SdDataSheetItemInfo`·`SdDataSheetSearchResult`(타입 3종) / `setupCloserWhenSingleSelectionChange`(단일 선택 시 모달 자동 닫기 유틸) / 내부 Manager 5종(`injectDataSheet{Refresh,InlineEdit,ModalEdit,Excel}Manager`, `useDataSheetFilterManager`)
8
+ - 대체: 소비 컴포넌트가 표준 조각을 직접 조립
9
+ - 조립 요소:
10
+ - `<sd-busy-container [busy] [message]>` — 전체 busy 오버레이
11
+ - `<sd-topbar-container>` + `<sd-topbar>` — 페이지 뷰 상단 헤더
12
+ - `<sd-form (formSubmit)>` — 필터 제출 / inline 편집 저장 트리거
13
+ - `<sd-sheet>` + `<sd-sheet-column>` + `<ng-template [cell]>` — 시트 본체 (items, 페이지네이션, 정렬, 선택, `cumulativeSelection`, 셀 스타일)
14
+ - `injectViewTypeSignal()` — page / modal / control 뷰 판정
15
+ - `injectPermsSignal()` — 권한 signal
16
+ - `useSortingManager()` — 정렬 def 관리 (선택적)
17
+ - `setupCanDeactivate()` — 이탈 방지
18
+ - `mark(sig)` — signal 참조 갱신
19
+ - `SdToastProvider.try(fn, messageFn)` — 에러 래퍼 + busy 카운트
20
+ - `SdModalProvider.showAsync(...)` — 편집 모달 호출
21
+ - `SdFileDialogProvider.showAsync(...)` — 엑셀 업로드 파일 선택
22
+ - `getOrmDataEditToastErrorMessage(err)` — ORM 에러 → 사용자 메시지 변환
23
+ - `SdCommandDirective`(`sdRefreshCommand` / `sdSaveCommand`) — Ctrl+Alt+L / Ctrl+S 단축키
24
+ - 데이터 비교:
25
+ - `Array.prototype.oneWayDiffs(orgItems, keyFn)` (`@simplysm/core-common` side-effect import) — `ArrayOneWayDiffResult<T>[]` 반환 (`type: "create" | "update" | "same"`)
26
+ - `obj.equal(a, b)` — deep equal
27
+
28
+ ## 2. 언제 사용하는가
29
+
30
+ | 상황 | 적용 여부 |
31
+ |---|---|
32
+ | 필터 + 페이지네이션 + 정렬 + 선택이 있는 일반 CRUD 리스트 | 본 레시피 전체 적용 |
33
+ | 행을 직접 수정하는 인라인 편집 화면 | 레시피 + [변형 1: inline 편집 모드](#5-변형-inline-편집-모드) |
34
+ | 다른 화면에서 항목을 고르는 선택 모달 | 레시피 + [변형 2: 선택 모달](#6-변형-선택-모달-뷰) |
35
+ | 엑셀 업로드 / 다운로드가 필요 | 레시피 + [변형 3: 엑셀 업로드·다운로드](#7-변형-엑셀-업로드다운로드) |
36
+ | 상세 폼(단일 레코드 편집) | 본 레시피 대신 [`crud-detail.md`](./crud-detail.md) 사용 |
37
+ | 페이지 / 모달 뷰 분기만 필요한 단순 화면 | [`page-modal-container.md`](./page-modal-container.md) 사용 |
38
+
39
+ ## 3. 완성 예제
40
+
41
+ 아래는 **페이지 뷰 + modal 편집 모드** 기준 완성 컴포넌트다. CRUD 리스트의 가장 일반적 형태(필터 검색 → 페이지네이션 + 정렬 → 모달로 row 편집 → 선택 삭제/복구)를 커버한다. inline / 선택 모달 / 엑셀은 `## 5`~`## 7`의 변형 스니펫으로 교체·추가한다.
42
+
43
+ ```typescript
44
+ import { NgIcon } from "@ng-icons/core";
45
+ import {
46
+ tablerAlertTriangle,
47
+ tablerCirclePlus,
48
+ tablerEdit,
49
+ tablerEraser,
50
+ tablerRefresh,
51
+ tablerRestore,
52
+ tablerSearch,
53
+ } from "@ng-icons/tabler-icons";
54
+ import {
55
+ ChangeDetectionStrategy,
56
+ Component,
57
+ computed,
58
+ effect,
59
+ inject,
60
+ signal,
61
+ untracked,
62
+ ViewEncapsulation,
63
+ } from "@angular/core";
64
+ import {
65
+ getOrmDataEditToastErrorMessage,
66
+ injectCurrentPageCodeSignal,
67
+ injectFullPageCodeSignal,
68
+ injectPermsSignal,
69
+ injectViewTypeSignal,
70
+ mark,
71
+ SdActivatedModalProvider,
72
+ SdAnchor,
73
+ SdAppStructureProvider,
74
+ SdBusyContainer,
75
+ SdButton,
76
+ SdCommandDirective,
77
+ SdForm,
78
+ SdModalProvider,
79
+ SdSheet,
80
+ SdSheetColumn,
81
+ SdSheetColumnCellTemplate,
82
+ SdSystemLogProvider,
83
+ SdTextfield,
84
+ SdToastProvider,
85
+ SdTopbar,
86
+ SdTopbarContainer,
87
+ setupCanDeactivate,
88
+ type SortingDef,
89
+ } from "@simplysm/angular";
90
+ import "@simplysm/core-common"; // Array.prototype.oneWayDiffs 등 프로토타입 확장
91
+
92
+ interface ICustomer {
93
+ id: string;
94
+ name: string;
95
+ phone: string;
96
+ isDeleted: boolean;
97
+ }
98
+
99
+ interface ICustomerFilter {
100
+ search: string;
101
+ }
102
+
103
+ interface ICustomerSearchResult {
104
+ items: ICustomer[];
105
+ pageLength: number;
106
+ summary: Partial<ICustomer>;
107
+ }
108
+
109
+ @Component({
110
+ selector: "app-customer-list",
111
+ changeDetection: ChangeDetectionStrategy.OnPush,
112
+ encapsulation: ViewEncapsulation.None,
113
+ standalone: true,
114
+ imports: [
115
+ SdBusyContainer, SdTopbarContainer, SdTopbar,
116
+ SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
117
+ SdButton, SdAnchor, SdTextfield, NgIcon,
118
+ ],
119
+ hostDirectives: [
120
+ { directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
121
+ ],
122
+ host: {
123
+ "(sdRefreshCommand)": "onRefreshButtonClick()",
124
+ },
125
+ template: `
126
+ <sd-busy-container [busy]="busyCount() > 0" [message]="busyMessage()">
127
+ @if (initialized()) {
128
+ @if (!canUse()) {
129
+ <div class="fill tx-theme-gray-light p-xxl tx-center">
130
+ <br />
131
+ <ng-icon [svg]="icons.tablerAlertTriangle" [size]="'5em'" />
132
+ <br /><br />
133
+ '{{ modalOrPageTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
134
+ </div>
135
+ } @else if (viewType() === "page") {
136
+ <sd-topbar-container>
137
+ <sd-topbar>
138
+ <h4>{{ modalOrPageTitle() }}</h4>
139
+ <sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
140
+ <ng-icon [svg]="icons.tablerRefresh" />
141
+ 새로고침 <small>(CTRL+ALT+L)</small>
142
+ </sd-button>
143
+ </sd-topbar>
144
+
145
+ <div class="flex-column fill">
146
+ <!-- 필터 영역 -->
147
+ <div class="p-default">
148
+ <sd-form (formSubmit)="onFilterSubmit()">
149
+ <div class="form-box-inline">
150
+ <sd-button [type]="'submit'" [theme]="'info'">
151
+ <ng-icon [svg]="icons.tablerSearch" />
152
+ 조회
153
+ </sd-button>
154
+ <sd-textfield
155
+ [type]="'text'"
156
+ [placeholder]="'이름/전화번호 검색'"
157
+ [(value)]="filterSearch"
158
+ [inset]="true"
159
+ [size]="'sm'"
160
+ />
161
+ </div>
162
+ </sd-form>
163
+ </div>
164
+
165
+ <!-- 도구 영역 -->
166
+ @if (canEdit()) {
167
+ <div class="flex-row gap-sm p-xs-default">
168
+ <sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onCreateItemButtonClick()">
169
+ <ng-icon [svg]="icons.tablerCirclePlus" />
170
+ 등록
171
+ </sd-button>
172
+ <sd-button
173
+ [size]="'sm'"
174
+ [theme]="'link-danger'"
175
+ (click)="onToggleDeleteItemsButtonClick(true)"
176
+ [disabled]="!hasSelectedNotDeleted()"
177
+ >
178
+ <ng-icon [svg]="icons.tablerEraser" />
179
+ 선택 삭제
180
+ </sd-button>
181
+ @if (hasSelectedDeleted()) {
182
+ <sd-button
183
+ [size]="'sm'"
184
+ [theme]="'link-warning'"
185
+ (click)="onToggleDeleteItemsButtonClick(false)"
186
+ >
187
+ <ng-icon [svg]="icons.tablerRestore" />
188
+ 선택 복구
189
+ </sd-button>
190
+ }
191
+ </div>
192
+ }
193
+
194
+ <!-- 시트 -->
195
+ <sd-sheet
196
+ [key]="'customer-list-sheet'"
197
+ [items]="items()"
198
+ [(currentPage)]="page"
199
+ [totalPageCount]="pageLength()"
200
+ [(sorts)]="sortingDefs"
201
+ [selectMode]="'multi'"
202
+ [(selectedItems)]="selectedItems"
203
+ [trackByFn]="trackByFn"
204
+ [getItemCellStyleFn]="getItemCellStyleFn"
205
+ class="flex-fill p-default pt-0"
206
+ >
207
+ <sd-sheet-column [key]="'name'" [header]="'이름'" [width]="'200px'">
208
+ <ng-template [cell]="items()" let-item="item">
209
+ <sd-anchor (click)="onEditItemButtonClick(item, $event)" class="flex-row">
210
+ <div class="p-xs-sm">
211
+ <ng-icon [svg]="icons.tablerEdit" />
212
+ </div>
213
+ <div class="flex-fill p-xs-sm">{{ item.name }}</div>
214
+ </sd-anchor>
215
+ </ng-template>
216
+ </sd-sheet-column>
217
+ <sd-sheet-column [key]="'phone'" [header]="'전화번호'">
218
+ <ng-template [cell]="items()" let-item="item">
219
+ <div class="p-xs-sm">{{ item.phone }}</div>
220
+ </ng-template>
221
+ </sd-sheet-column>
222
+ </sd-sheet>
223
+ </div>
224
+ </sd-topbar-container>
225
+ } @else if (viewType() === "modal") {
226
+ <!-- 모달 뷰 분기는 변형 2 "선택 모달" 참조 -->
227
+ } @else {
228
+ <!-- control 뷰: 다른 화면의 영역으로 삽입될 때 -->
229
+ }
230
+ }
231
+ </sd-busy-container>
232
+ `,
233
+ })
234
+ export class CustomerListPage {
235
+ //== DI ==
236
+ private readonly _sdToast = inject(SdToastProvider);
237
+ private readonly _sdModal = inject(SdModalProvider);
238
+ private readonly _sdActivatedModal = inject(SdActivatedModalProvider, { optional: true });
239
+ private readonly _sdAppStructure = inject(SdAppStructureProvider);
240
+ private readonly _sdSystemLog = inject(SdSystemLogProvider);
241
+
242
+ //== 라우팅 / 권한 ==
243
+ private readonly _fullPageCode = injectFullPageCodeSignal();
244
+ private readonly _currPageCode = injectCurrentPageCodeSignal();
245
+ protected readonly viewType = injectViewTypeSignal();
246
+ protected readonly canUse = injectPermsSignal(
247
+ () => ["sales.customer"],
248
+ () => ["use"],
249
+ );
250
+ protected readonly canEdit = injectPermsSignal(
251
+ () => ["sales.customer"],
252
+ () => ["edit"],
253
+ );
254
+
255
+ //== 상태 ==
256
+ protected readonly busyCount = signal(0);
257
+ protected readonly busyMessage = signal<string | undefined>(undefined);
258
+ protected readonly initialized = signal(false);
259
+
260
+ protected readonly items = signal<ICustomer[]>([]);
261
+ protected readonly summaryData = signal<Partial<ICustomer>>({});
262
+ protected readonly selectedItems = signal<ICustomer[]>([]);
263
+ protected readonly page = signal(0);
264
+ protected readonly pageLength = signal(0);
265
+ protected readonly sortingDefs = signal<SortingDef[]>([{ key: "name", desc: false }]);
266
+ protected readonly filterSearch = signal("");
267
+ private readonly _lastFilter = signal<ICustomerFilter>({ search: "" });
268
+ private _snapshot: ICustomer[] = [];
269
+
270
+ //== 파생 ==
271
+ protected readonly modalOrPageTitle = computed(() => {
272
+ try {
273
+ return (
274
+ this._sdActivatedModal?.modalComponent()?.title() ??
275
+ this._sdAppStructure.getTitleByFullCode(this._currPageCode?.() ?? this._fullPageCode())
276
+ );
277
+ } catch (err) {
278
+ void this._sdSystemLog.writeAsync("warn", `title 계산 실패: ${String(err)}`);
279
+ return "";
280
+ }
281
+ });
282
+
283
+ protected readonly hasSelectedDeleted = computed(() =>
284
+ this.selectedItems().some((it) => it.isDeleted),
285
+ );
286
+ protected readonly hasSelectedNotDeleted = computed(() =>
287
+ this.selectedItems().some((it) => !it.isDeleted),
288
+ );
289
+
290
+ protected readonly trackByFn = (item: ICustomer): string => item.id;
291
+
292
+ protected readonly getItemCellStyleFn = (item: ICustomer): string | undefined =>
293
+ item.isDeleted ? "text-decoration: line-through;" : undefined;
294
+
295
+ protected readonly icons = {
296
+ tablerAlertTriangle, tablerCirclePlus, tablerEdit, tablerEraser,
297
+ tablerRefresh, tablerRestore, tablerSearch,
298
+ };
299
+
300
+ //== 라이프사이클 ==
301
+ constructor() {
302
+ // 최초 로딩 + 필터/페이지/정렬 변경 시 재조회
303
+ effect(() => {
304
+ this._lastFilter();
305
+ this.page();
306
+ this.sortingDefs();
307
+ if (!this.canUse()) return;
308
+ untracked(() => {
309
+ void this._refresh();
310
+ });
311
+ });
312
+
313
+ setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
314
+ }
315
+
316
+ //== 메서드 ==
317
+ protected onFilterSubmit(): void {
318
+ this.page.set(0);
319
+ this._lastFilter.set({ search: this.filterSearch() });
320
+ }
321
+
322
+ protected onRefreshButtonClick(): void {
323
+ if (this.busyCount() > 0) return;
324
+ if (!this.canUse()) return;
325
+ if (!this._checkIgnoreChanges()) return;
326
+ mark(this._lastFilter); // 참조만 갱신 → effect 재실행
327
+ }
328
+
329
+ protected async onCreateItemButtonClick(): Promise<void> {
330
+ await this._editItem();
331
+ }
332
+
333
+ protected async onEditItemButtonClick(item: ICustomer, event: MouseEvent): Promise<void> {
334
+ event.preventDefault();
335
+ event.stopPropagation();
336
+ await this._editItem(item);
337
+ }
338
+
339
+ protected async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
340
+ await this._sdToast.try(async () => {
341
+ this.busyCount.update((v) => v + 1);
342
+ try {
343
+ // 서버 호출 (앱별 구현):
344
+ // await this._api.toggleDeleteAsync(this.selectedItems().map((it) => it.id), del);
345
+ await this._refresh();
346
+ } finally {
347
+ this.busyCount.update((v) => v - 1);
348
+ }
349
+ }, getOrmDataEditToastErrorMessage);
350
+ }
351
+
352
+ private _checkIgnoreChanges(): boolean {
353
+ return this._getDiffs().length === 0
354
+ || confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
355
+ }
356
+
357
+ private _getDiffs() {
358
+ return this.items().oneWayDiffs(this._snapshot, "id");
359
+ }
360
+
361
+ private async _refresh(): Promise<void> {
362
+ await this._sdToast.try(async () => {
363
+ this.busyCount.update((v) => v + 1);
364
+ try {
365
+ const r = await this._fetchList(this._lastFilter(), this.page(), this.sortingDefs());
366
+ this.items.set(r.items);
367
+ this.pageLength.set(r.pageLength);
368
+ this.summaryData.set(r.summary);
369
+ // 선택 유지 (현재 items에 없는 item 제외)
370
+ const currKeys = new Set(r.items.map((it) => this.trackByFn(it)));
371
+ this.selectedItems.update((sel) =>
372
+ sel.filter((it) => currKeys.has(this.trackByFn(it))),
373
+ );
374
+ this._snapshot = r.items.map((it) => ({ ...it }));
375
+ this.initialized.set(true);
376
+ } finally {
377
+ this.busyCount.update((v) => v - 1);
378
+ }
379
+ }, getOrmDataEditToastErrorMessage);
380
+ }
381
+
382
+ private async _editItem(item?: ICustomer): Promise<void> {
383
+ // 편집 모달 (소비 앱이 구현한 CustomerEditModal 사용):
384
+ // const r = await this._sdModal.showAsync({
385
+ // title: item == null ? "고객 등록" : "고객 수정",
386
+ // type: CustomerEditModal,
387
+ // inputs: { itemId: item?.id },
388
+ // });
389
+ // if (r != null) await this._refresh();
390
+ }
391
+
392
+ private async _fetchList(
393
+ filter: ICustomerFilter,
394
+ page: number,
395
+ sortingDefs: SortingDef[],
396
+ ): Promise<ICustomerSearchResult> {
397
+ // 예시 (orm-common):
398
+ // let qr = this._dbCtx.customer.where((it) => ... filter.search ... );
399
+ // for (const s of sortingDefs) qr = qr.orderBy(s.key, s.desc ? "DESC" : "ASC");
400
+ // const items = await qr.limit(page * 50, 50).resultAsync();
401
+ // const pageLength = Math.ceil((await qr.countAsync()) / 50);
402
+ // return { items, pageLength, summary: {} };
403
+ throw new Error("구현 필요");
404
+ }
405
+ }
406
+ ```
407
+
408
+ ## 4. 분해 설명
409
+
410
+ 각 블록의 역할과 원본 `SdDataSheet` 코드 대응 지점:
411
+
412
+ | 블록 | 역할 | 원본 대응 |
413
+ |---|---|---|
414
+ | `<sd-busy-container [busy] [message]>` | 전체 busy 오버레이 | `sd-data-sheet.ts:65-71` + `SdBaseContainer` |
415
+ | `@if (initialized())` | 초기 데이터 로딩 전 콘텐츠 숨김 (깜박임 방지) | `sd-data-sheet.base.ts:94`·`_refresh()` 말미 `initialized.set(true)` |
416
+ | `@if (!canUse())` | 권한 없음 메시지 | `sd-base-container.ts:44-51` + `page-modal-container.md` |
417
+ | `@if (viewType() === "page")` / `"modal"` / else | 뷰 타입 분기 (`page-modal-container.md` 참조) | `sd-data-sheet.ts:65` `viewType` → `SdBaseContainer` 내부 분기 |
418
+ | `<sd-topbar-container>` + `<sd-topbar>` | 페이지 뷰 헤더 | `sd-data-sheet.ts:72-87` `pageTopbarTpl` |
419
+ | `<sd-form (formSubmit)>` + `form-box-inline` | 필터 제출 폼 | `sd-data-sheet.ts:111-125` 필터 슬롯 |
420
+ | 도구 영역 (`<sd-button size=sm theme=link-*>`) | 등록 / 선택 삭제 / 선택 복구 | `sd-data-sheet.ts:127-203` 도구 영역 |
421
+ | `<sd-sheet>` + `<sd-sheet-column>` + `<ng-template [cell]>` | 시트 본체, 셀 렌더링 | `sd-data-sheet.ts:205-346` |
422
+ | `[cell]` 템플릿의 `<sd-anchor (click)>` | 셀 클릭 → 편집 모달 진입 | `sd-data-sheet.ts:284-315` |
423
+ | `getItemCellStyleFn` | `isDeleted` 시 취소선 | `sd-data-sheet.base.ts:137-140` |
424
+ | `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-sheet.ts:57-63` |
425
+ | `setupCanDeactivate(() => viewType() === "modal" || checkIgnoreChanges())` | 라우트 이탈 시 변경사항 확인 | `sd-data-sheet.base.ts:227` |
426
+ | `_refresh()` 내 `busyCount.update + try/finally + sdToast.try` | busy 카운트 증감 + 에러 토스트 | `injectDataSheetRefreshManager.ts:33-46` (삭제됨) |
427
+ | `_getDiffs()` = `items.oneWayDiffs(snapshot, "id")` | 변경 감지 | `injectDataSheetRefreshManager.ts`의 `getDiffs()` (삭제됨) |
428
+ | `effect(() => { lastFilter(); page(); sortingDefs(); ... })` | 필터/페이지/정렬 변경 시 재조회 | `injectDataSheetRefreshManager.ts` (삭제됨) |
429
+ | `mark(this._lastFilter)` | lastFilter 참조 갱신 → effect 재실행 (값 변경 없음) | `sd-data-sheet.base.ts:245` |
430
+ | `modalOrPageTitle` computed | `modal title ?? app structure title` | `sd-base-container.ts:102-113` (`header ??` 부분은 필요 시 `input()` 추가) |
431
+
432
+ ### 상태 분해
433
+
434
+ | signal / computed | 역할 |
435
+ |---|---|
436
+ | `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
437
+ | `busyMessage` | busy 오버레이 문구 |
438
+ | `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
439
+ | `items` | 현재 페이지 items |
440
+ | `summaryData` | 서버가 반환한 합계·집계 (선택적으로 시트 summary row에 표시) |
441
+ | `selectedItems` | 선택된 item 배열 (`<sd-sheet [(selectedItems)]>`로 양방향) |
442
+ | `page` / `pageLength` | 0-based 현재 페이지 / 전체 페이지 수 |
443
+ | `sortingDefs` | `SortingDef[]` — `{ key: string; desc: boolean }[]`, `<sd-sheet [(sorts)]>`로 양방향 |
444
+ | `filterSearch` / `_lastFilter` | filter는 입력 버퍼, lastFilter는 "조회" 제출 시점 스냅샷 |
445
+ | `_snapshot` | 최근 `_refresh()` 시점의 items 복사본 (변경 감지용) |
446
+
447
+ ### 메서드 분해
448
+
449
+ | 메서드 | 역할 |
450
+ |---|---|
451
+ | `onFilterSubmit()` | page=0 리셋 + `_lastFilter.set(filter 스냅샷)` |
452
+ | `onRefreshButtonClick()` | busy/권한/변경사항 확인 후 `mark(_lastFilter)` |
453
+ | `_refresh()` | search → items.set + pageLength + summary + 선택 유지 + snapshot 갱신 |
454
+ | `_editItem(item?)` | `SdModalProvider.showAsync(...)`로 편집 모달 실행 후 refresh |
455
+ | `onToggleDeleteItemsButtonClick(del)` | 선택 item ID들을 서버에 전송하여 isDeleted 토글 + refresh |
456
+ | `_checkIgnoreChanges()` | snapshot 대비 diff 없으면 true, 있으면 `confirm` 후 true/false |
457
+ | `_getDiffs()` | `items.oneWayDiffs(_snapshot, "id")` — `ArrayOneWayDiffResult<T>[]` |
458
+
459
+ ## 5. 변형: inline 편집 모드
460
+
461
+ 행을 직접 수정하고 `ArrayOneWayDiffResult` 기반 diff로 일괄 저장한다. `CustomerListPage`를 기준으로 아래 변경을 적용한다:
462
+
463
+ ```typescript
464
+ // 1) imports에 추가: FormatPipe (선택)
465
+ // host에 sdSaveCommand 추가
466
+ hostDirectives: [
467
+ { directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
468
+ ],
469
+ host: {
470
+ "(sdRefreshCommand)": "onRefreshButtonClick()",
471
+ "(sdSaveCommand)": "onSaveButtonClick()",
472
+ },
473
+
474
+ // 2) template — 도구 영역에 "행 추가" 버튼 추가
475
+ <sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddItemButtonClick()">
476
+ <ng-icon [svg]="icons.tablerCirclePlus" />
477
+ 행 추가
478
+ </sd-button>
479
+
480
+ // 3) template — <sd-sheet>를 <sd-form>으로 감싸고 (formSubmit)="onSubmit()" 추가
481
+ <sd-form #formCtrl (formSubmit)="onSubmit()" class="flex-fill p-default pt-0">
482
+ <sd-sheet ...>
483
+ <!-- isDeleted 전용 고정 컬럼 -->
484
+ @if (canEdit()) {
485
+ <sd-sheet-column [fixed]="true" [key]="'isDeleted'">
486
+ <ng-template #headerTpl>
487
+ <div class="p-xs-sm tx-center"><ng-icon [svg]="icons.tablerEraser" /></div>
488
+ </ng-template>
489
+ <ng-template [cell]="items()" let-item="item">
490
+ <div class="p-xs-sm tx-center">
491
+ <sd-anchor
492
+ [theme]="'danger'"
493
+ (click)="onToggleDeleteItemButtonClick(item)"
494
+ >
495
+ <ng-icon [svg]="item.isDeleted ? icons.tablerRestore : icons.tablerEraser" />
496
+ {{ item.isDeleted ? "복구" : "삭제" }}
497
+ </sd-anchor>
498
+ </div>
499
+ </ng-template>
500
+ </sd-sheet-column>
501
+ }
502
+
503
+ <!-- 편집 가능 셀: sd-textfield 등은 [inset]="true" [size]="'sm'" 명시 -->
504
+ <sd-sheet-column [key]="'name'" [header]="'이름'">
505
+ <ng-template [cell]="items()" let-item="item">
506
+ <sd-textfield [type]="'text'" [(value)]="item.name" [inset]="true" [size]="'sm'" />
507
+ </ng-template>
508
+ </sd-sheet-column>
509
+ </sd-sheet>
510
+ </sd-form>
511
+
512
+ // 4) 메서드 추가/대체
513
+ protected readonly formCtrl = viewChild<SdForm>("formCtrl");
514
+
515
+ protected onSaveButtonClick(): void {
516
+ this.formCtrl()?.requestSubmit();
517
+ }
518
+
519
+ protected onAddItemButtonClick(): void {
520
+ const newItem: ICustomer = { id: Uuid.generate().toString(), name: "", phone: "", isDeleted: false };
521
+ this.items.update((list) => [newItem, ...list]);
522
+ }
523
+
524
+ protected onToggleDeleteItemButtonClick(item: ICustomer): void {
525
+ item.isDeleted = !item.isDeleted;
526
+ mark(this.items); // OnPush 재렌더 + effect 알림
527
+ }
528
+
529
+ protected async onSubmit(): Promise<void> {
530
+ const diffs = this._getDiffs();
531
+ if (diffs.length === 0) {
532
+ this._sdToast.info("변경사항이 없습니다.");
533
+ return;
534
+ }
535
+ await this._sdToast.try(async () => {
536
+ this.busyCount.update((v) => v + 1);
537
+ try {
538
+ // 서버 호출: await this._api.submitAsync(diffs);
539
+ // diffs.forEach((d) => {
540
+ // if (d.type === "create") { ... insert ... }
541
+ // else if (d.type === "update") { ... update ... }
542
+ // // "same"은 includeSame=true 옵션에서만 등장. 여기서는 create/update만.
543
+ // });
544
+ // 삭제는 `isDeleted: true`로 업데이트 → 서버가 soft-delete 처리
545
+ await this._refresh();
546
+ } finally {
547
+ this.busyCount.update((v) => v - 1);
548
+ }
549
+ }, getOrmDataEditToastErrorMessage);
550
+ }
551
+ ```
552
+
553
+ > **`oneWayDiffs`는 delete를 다루지 않는다.** `newItems.oneWayDiffs(orgItems, keyFn)`은 `type: "create" | "update" | "same"`만 반환한다. 삭제 의사는 **`item.isDeleted = true` 플래그**로 표현하여 `"update"` diff로 전송된다. 행을 items 배열에서 제거하면 diff에서 누락되므로 절대 삭제하지 않는다.
554
+
555
+ ## 6. 변형: 선택 모달 뷰
556
+
557
+ 화면을 선택 모달로 재사용한다. `<sd-sheet>`가 items 기반이므로 key는 `selectedItems().map((it, i) => trackByFn(it, i))` 수동 변환으로 `SelectModalOutputResult<T>`를 구성한다.
558
+
559
+ ```typescript
560
+ // 1) 컴포넌트 import
561
+ import {
562
+ type SdSelectModal,
563
+ type SelectModalOutputResult,
564
+ } from "@simplysm/angular";
565
+ import { input, output, model } from "@angular/core";
566
+
567
+ // 2) 클래스 선언에 implements 추가
568
+ export class CustomerListPage implements SdSelectModal<ICustomer> {
569
+ // ...기존 필드 유지
570
+
571
+ // SdModalContentDef<SelectModalOutputResult<ICustomer>> 요구 필드
572
+ close = output<SelectModalOutputResult<ICustomer> | undefined>();
573
+
574
+ // SdSelectModal<ICustomer> 요구 필드
575
+ selectMode = input<"single" | "multi">();
576
+ selectedItemKeys = input<string[]>([]);
577
+
578
+ // initialized는 WritableSignal이 아닌 Signal을 요구하므로 computed 또는 기존 signal 그대로 노출
579
+ // (위 완성 예제의 protected readonly initialized = signal(false) 를 그대로 사용)
580
+ }
581
+
582
+ // 3) template — 시트의 selectMode를 input 값으로 연결, 누적 선택 활성화
583
+ <sd-sheet
584
+ ...
585
+ [selectMode]="selectMode()"
586
+ [cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
587
+ [trackByFn]="trackByFn"
588
+ [(selectedItems)]="selectedItems"
589
+ >
590
+ ...
591
+ </sd-sheet>
592
+
593
+ // 4) template — 모달 뷰 분기 (page-modal-container.md 기반)
594
+ } @else if (viewType() === "modal") {
595
+ <div class="flex-column fill">
596
+ <div class="flex-fill">
597
+ <!-- 필터 / 도구 / 시트를 이곳에 동일하게 배치 (page 분기와 같은 구조) -->
598
+ </div>
599
+ <!-- 하단 액션 바 -->
600
+ <div class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest">
601
+ <div class="flex-fill"></div>
602
+ @if (selectedItems().length > 0) {
603
+ <sd-button [size]="'sm'" [theme]="'danger'" (click)="onModalCancelClick()">
604
+ {{ selectMode() === "multi" ? "모두" : "선택" }} 해제
605
+ </sd-button>
606
+ }
607
+ @if (selectMode() === "multi") {
608
+ <sd-button [size]="'sm'" [theme]="'primary'" (click)="onModalConfirmClick()">
609
+ 확인({{ selectedItems().length }})
610
+ </sd-button>
611
+ }
612
+ </div>
613
+ </div>
614
+ }
615
+
616
+ // 5) 메서드 추가 — close.emit 수동 변환
617
+ protected onModalConfirmClick(): void {
618
+ const items = this.selectedItems();
619
+ this.close.emit({
620
+ selectedItemKeys: items.map((it, i) => this.trackByFn(it, i) as string),
621
+ selectedItems: items,
622
+ });
623
+ }
624
+
625
+ protected onModalCancelClick(): void {
626
+ this.selectedItems.set([]);
627
+ this.close.emit({ selectedItemKeys: [], selectedItems: [] });
628
+ }
629
+
630
+ // 6) 최초 진입 시 selectedItemKeys input으로 전달된 key들에 해당하는 item을 미리 선택에 반영
631
+ // (필요한 경우 refresh 직후 effect 하나 추가)
632
+ effect(() => {
633
+ const keys = this.selectedItemKeys();
634
+ if (keys.length === 0) return;
635
+ untracked(() => {
636
+ const selected = this.items().filter((it) => keys.includes(this.trackByFn(it)));
637
+ if (selected.length > 0) this.selectedItems.set(selected);
638
+ });
639
+ });
640
+ ```
641
+
642
+ ### `cumulativeSelection` 동적 바인딩 원칙
643
+
644
+ - `cumulativeSelection` **기본값은 `false`** — `<sd-sheet>`의 items가 교체될 때 `selectedItems`가 빈 배열로 초기화된다
645
+ - 선택 모달(`viewType() === "modal"`) + 다중 선택(`selectMode() === "multi"`) 조합에서는 **여러 페이지를 돌며 선택 누적**이 필요하므로 `true`로 바인딩
646
+ - 그 외(page 뷰 또는 single selectMode)는 현재 뷰의 **일괄 작업**이 일반적 — `false`(기본값)가 맞다
647
+ - 그러므로 정적 `true`가 아니라 **동적 computed**로 바인딩:
648
+ ```html
649
+ <sd-sheet
650
+ [cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
651
+ [trackByFn]="trackByFn"
652
+ ...
653
+ >
654
+ ```
655
+ - 누적 모드 사용 시 `trackByFn` 명시 필수. 기본 `(item) => item`은 reference 기반이라 서버 페이지네이션에서 새 객체 reference가 내려오면 `obj.equal`로 비교해도 실패할 수 있다. `(item) => item.id` 같은 key 추출 함수를 반드시 지정한다.
656
+
657
+ ## 7. 변형: 엑셀 업로드/다운로드
658
+
659
+ `SdFileDialogProvider`로 파일 선택, 서버 API로 처리, `SdToastProvider.try`로 래핑한다.
660
+
661
+ ```typescript
662
+ // 1) import 추가
663
+ import { tablerFileExcel, tablerUpload } from "@ng-icons/tabler-icons";
664
+ import { SdFileDialogProvider } from "@simplysm/angular";
665
+
666
+ // 2) DI 추가
667
+ private readonly _sdFileDialog = inject(SdFileDialogProvider);
668
+
669
+ // 3) icons 맵에 추가
670
+ protected readonly icons = {
671
+ ...existingIcons,
672
+ tablerFileExcel, tablerUpload,
673
+ };
674
+
675
+ // 4) template — 도구 영역 끝에 추가
676
+ @if (canEdit()) {
677
+ <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
678
+ <ng-icon [svg]="icons.tablerUpload" />
679
+ 엑셀 업로드
680
+ </sd-button>
681
+ }
682
+ <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
683
+ <ng-icon [svg]="icons.tablerFileExcel" />
684
+ 엑셀 다운로드
685
+ </sd-button>
686
+
687
+ // 5) 메서드 추가
688
+ protected async onDownloadExcelButtonClick(): Promise<void> {
689
+ await this._sdToast.try(async () => {
690
+ this.busyCount.update((v) => v + 1);
691
+ this.busyMessage.set("엑셀 다운로드 중...");
692
+ try {
693
+ // 전체 조회 (페이지네이션 없이)
694
+ const r = await this._fetchList(this._lastFilter(), 0, this.sortingDefs());
695
+ // 서버 호출 또는 클라이언트 변환 (앱별):
696
+ // await this._api.downloadExcelAsync(r.items);
697
+ } finally {
698
+ this.busyMessage.set(undefined);
699
+ this.busyCount.update((v) => v - 1);
700
+ }
701
+ }, getOrmDataEditToastErrorMessage);
702
+ }
703
+
704
+ protected async onUploadExcelButtonClick(): Promise<void> {
705
+ const file = await this._sdFileDialog.showAsync(false, ".xlsx");
706
+ if (file == null) return;
707
+ await this._sdToast.try(async () => {
708
+ this.busyCount.update((v) => v + 1);
709
+ this.busyMessage.set("엑셀 업로드 중...");
710
+ try {
711
+ // 서버 호출 (앱별):
712
+ // await this._api.uploadExcelAsync(file);
713
+ await this._refresh();
714
+ } finally {
715
+ this.busyMessage.set(undefined);
716
+ this.busyCount.update((v) => v - 1);
717
+ }
718
+ }, getOrmDataEditToastErrorMessage);
719
+ }
720
+ ```
721
+
722
+ ## 8. 뷰 타입 분기
723
+
724
+ `@if (viewType() === "page") ... @else if (viewType() === "modal") ... @else { ... }` 분기 구조와 `modalOrPageTitle` computed 계산은 [`page-modal-container.md`](./page-modal-container.md)의 레시피와 동일하다. 본 레시피의 완성 예제도 그 패턴을 그대로 사용한다. control 뷰(다른 화면의 영역으로 삽입)로만 쓰일 경우에는 `@if` 분기를 생략하고 본문만 작성한다.
725
+
726
+ ## 9. 주의사항 (자주 하는 실수)
727
+
728
+ ### 공통 유틸 재도입 금지
729
+
730
+ - `useCrudList()`, `useDataSheet()`, `setupCumulateSelectedKeys2()` 같은 공통 헬퍼를 도입하지 말 것. 이 레시피가 제거한 추상화를 다시 만드는 행위다. 세 화면이 비슷해 보여도 화면마다 필드·동작 시그니처가 조금씩 다르므로 복사·수정이 낫다
731
+
732
+ ### 시트 셀 스타일 함정
733
+
734
+ - `<sd-sheet-column>`의 `[cell]` 템플릿 내부에 삽입되는 컨트롤(`sd-textfield` / `sd-select` / `sd-checkbox` / `sd-numpad` / `sd-date-range-picker` / `sd-textarea` 등)은 **`[inset]="true" [size]="'sm'"` 명시 필수**. 누락 시 컴파일 에러 없이 스타일만 깨진다(테두리·여백이 시트 셀에 맞지 않음). 예외: 복합 구조(텍스트+컨트롤)는 `[inset]="false"`, 시트 행 높이가 큰 경우는 `[size]` 생략 가능
735
+
736
+ ### `oneWayDiffs`의 삭제 처리
737
+
738
+ - `newItems.oneWayDiffs(orgItems, keyFn)`은 **삭제(delete)를 다루지 않는다**. `type: "create" | "update" | "same"`만 반환
739
+ - 삭제 의사 표현은 **`item.isDeleted = true` 플래그**로 하고 `"update"` diff로 전송 (서버가 soft-delete 처리)
740
+ - inline 편집 시 `items` 배열에서 row를 제거하지 말 것. diff에서 해당 row가 누락되어 서버가 변경을 감지할 수 없다
741
+
742
+ ### `selectedItemKeys` 수동 변환
743
+
744
+ - `<sd-sheet>`는 key 기반이 아니라 item 기반이다. `SelectModalOutputResult<T>.selectedItemKeys`를 구성하려면 `selectedItems().map((it, i) => trackByFn(it, i))`로 수동 변환한다
745
+ - `trackByFn`의 signature는 `(item: T, index: number) => unknown`이므로 두 번째 인자(index)를 전달해야 타입이 맞는다
746
+
747
+ ### `injectViewTypeSignal()` 호출 시점
748
+
749
+ - `injectViewTypeSignal()`은 생성자 실행 중 또는 필드 이니셜라이저에서만 호출한다. `computed`·`effect` 콜백이나 일반 메서드에서 호출하면 `NG0203` 런타임 에러가 발생한다 (Angular `inject()` 제약)
750
+
751
+ ## 10. 레시피 작성 관용 규칙
752
+
753
+ 향후 `crud-detail.md` · `data-select-button.md` 등 데이터 관련 레시피가 추가될 때 아래 3개 규칙을 공통으로 따른다.
754
+
755
+ ### 규칙 1: 시트 셀 내부 컨트롤은 `[inset]="true" [size]="'sm'"` 명시
756
+
757
+ - `<sd-sheet-column>` `[cell]` 템플릿 내부의 `sd-textfield` / `sd-select` / `sd-checkbox` / `sd-numpad` / `sd-date-range-picker` / `sd-textarea`는 레시피에서 **항상** `[inset]="true" [size]="'sm'"`를 함께 노출한다
758
+ - 예외: 복합 구조(텍스트+컨트롤) → `[inset]="false"`. 큰 시트 행 → `[size]` 생략
759
+ - 누락 시 컴파일 에러가 발생하지 않아 LLM이 빠뜨리기 쉽다. 자주 하는 실수 섹션에 명시
760
+
761
+ ### 규칙 2: `mark(sig)`는 "저장 감지"가 아니라 "UI 동기화"
762
+
763
+ - `mark(sig)`는 `WritableSignal`의 값을 shallow copy하여 **참조를 갱신**한다 (배열: `[...v]`, 객체: `{...v}`)
764
+ - 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**
765
+ - **"저장 감지"가 아니다.** `obj.equal`이 deep equal로 값 차이를 감지하므로, `item.name = "new"` 같은 mutation은 `mark` 없이도 `_getDiffs()` / submit에서 감지된다
766
+ - Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요
767
+ - ❌ "mark 없으면 저장이 안 된다" 식 서술 금지
768
+
769
+ ### 규칙 3: `sortingDefs` + `orderBy` 체인은 string overload 사용
770
+
771
+ - `Queryable.orderBy`는 string overload를 지원한다 (`packages/orm-common/src/exec/queryable.ts:420`)
772
+ - 레시피는 아래 형태로 작성:
773
+ ```typescript
774
+ for (const s of sortingDefs) {
775
+ qr = qr.orderBy(s.key, s.desc ? "DESC" : "ASC");
776
+ }
777
+ ```
778
+ - 체인 경로도 string으로 지원: `qr.orderBy("user.name")` 형태
779
+ - 과거 람다 형태 (`qr.orderBy((item) => obj.getChainValue(item, s.key, true) as any, ...)`)는 **쓰지 않는다** — overload 도입 전 우회 코드였다