@simplysm/angular 14.0.48 → 14.0.49

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.
@@ -7,49 +7,51 @@
7
7
  - 제거된 추상화: `SdDataSheet`(컴포넌트) / `SdDataSheetBase`(추상 클래스) / `SdDataSheetColumn`(디렉티브) / `SdDataSheetItemPropInfo`·`SdDataSheetItemInfo`·`SdDataSheetSearchResult`(타입 3종) / `setupCloserWhenSingleSelectionChange`(단일 선택 시 모달 자동 닫기 유틸) / 내부 Manager 5종(`injectDataSheet{Refresh,InlineEdit,ModalEdit,Excel}Manager`, `useDataSheetFilterManager`)
8
8
  - 대체: 소비 컴포넌트가 표준 조각을 직접 조립
9
9
  - 조립 요소:
10
- - `<sd-busy-container [busy] [message]>` — 전체 busy 오버레이
11
- - `<sd-topbar-container>` + `<sd-topbar>` — 페이지 상단 헤더
12
- - `<sd-form (formSubmit)>` — 필터 제출 / inline 편집 저장 트리거
10
+ - `<sd-busy-container [busy]>` — 전체 busy 오버레이
11
+ - `<sd-topbar-container>` + `<sd-topbar>` — 공통 컨테이너, `<sd-topbar>`는 page 뷰에서만 조건부 렌더
12
+ - `<sd-dock-container>` + `<sd-dock>` — 필터 / 도구 / 모달 하단 바를 dock로 부착, 본문(시트)은 main 영역
13
+ - `<sd-form (formSubmit)>` — 필터 제출 / inline 편집 일괄 저장 트리거
13
14
  - `<sd-sheet>` + `<sd-sheet-column>` + `<ng-template [cell]>` — 시트 본체 (items, 페이지네이션, 정렬, 선택, `cumulativeSelection`, 셀 스타일)
14
15
  - `injectViewTypeSignal()` — page / modal / control 뷰 판정
15
16
  - `injectPermsSignal()` — 권한 signal
16
- - `useSortingManager()` — 정렬 def 관리 (선택적)
17
17
  - `setupCanDeactivate()` — 이탈 방지
18
18
  - `mark(sig)` — signal 참조 갱신
19
- - `SdToastProvider.try(fn, messageFn)` — 에러 래퍼 + busy 카운트
20
- - `SdModalProvider.showAsync(...)`편집 모달 호출
21
- - `SdFileDialogProvider.showAsync(...)` — 엑셀 업로드 파일 선택
22
- - `getOrmDataEditToastErrorMessage(err)` — ORM 에러 → 사용자 메시지 변환
19
+ - `SdToastProvider.try(fn)` — 에러 래퍼 (busy 카운트는 호출부에서 `busyCount.update`로 직접 제어)
20
+ - `SdSelectModal<T>`선택 모달 계약. 소비 화면이 직접 `implements`하여 `selectMode` / `selectedItemKeys` input + `close` output 노출
23
21
  - `SdCommandDirective`(`sdRefreshCommand` / `sdSaveCommand`) — Ctrl+Alt+L / Ctrl+S 단축키
22
+ - 선택적:
23
+ - `SdFileDialogProvider.showAsync(...)` — 엑셀 업로드 파일 선택 (`## 7` 변형)
24
24
  - 데이터 비교:
25
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
26
+ - `obj.clone(items)` — snapshot 깊은 복제 (`@simplysm/core-common`)
27
27
 
28
28
  ## 2. 언제 사용하는가
29
29
 
30
30
  | 상황 | 적용 여부 |
31
31
  |---|---|
32
- | 필터 + 페이지네이션 + 정렬 + 선택이 있는 일반 CRUD 리스트 | 본 레시피 전체 적용 |
33
- | 행을 직접 수정하는 인라인 편집 화면 | 레시피 + [변형 1: inline 편집 모드](#5-변형-inline-편집-모드) |
34
- | 다른 화면에서 항목을 고르는 선택 모달 | 레시피 + [변형 2: 선택 모달](#6-변형-선택-모달-뷰) |
32
+ | 필터 + 페이지네이션 + 정렬 + 선택 + inline 편집이 있는 일반 CRUD 리스트 | 본 레시피 전체 적용 (기본 예제가 page 뷰) |
33
+ | 다른 화면에서 항목을 고르는 선택 모달로도 재사용 | 기본 예제가 page + modal 2뷰를 함께 지원 별도 variant 불필요 |
34
+ | 시트 열에 row별 inline 삭제/복구 버튼이 필요 | 레시피 + [변형 1: inline 삭제 열](#5-변형-inline-삭제-열) |
35
+ | 행 클릭 시 편집 모달을 띄우는 모달 편집 모드 | 레시피 + [변형 2: 모달 편집 모드](#6-변형-모달-편집-모드) |
35
36
  | 엑셀 업로드 / 다운로드가 필요 | 레시피 + [변형 3: 엑셀 업로드·다운로드](#7-변형-엑셀-업로드다운로드) |
36
37
  | 상세 폼(단일 레코드 편집) | 본 레시피 대신 [`crud-detail.md`](./crud-detail.md) 사용 |
37
38
  | 페이지 / 모달 뷰 분기만 필요한 단순 화면 | [`page-modal-container.md`](./page-modal-container.md) 사용 |
38
39
 
39
40
  ## 3. 완성 예제
40
41
 
41
- 아래는 **페이지 뷰 + modal 편집 모드** 기준 완성 컴포넌트다. CRUD 리스트의 가장 일반적 형태(필터 검색 페이지네이션 + 정렬 모달로 row 편집 선택 삭제/복구) 커버한다. inline / 선택 모달 / 엑셀은 `## 5`~`## 7`의 변형 스니펫으로 교체·추가한다.
42
+ 아래는 **page + modal 2동시 지원 + inline 편집** 기준 완성 컴포넌트다. 하나의 화면이 라우트로 진입하면 CRUD 리스트(조회·등록·inline 수정·선택 삭제/복구)로 동작하고, `SdModalProvider.showAsync()`로 열리면 동일 화면이 "선택 모달"로 전환된다(selectMode에 따라 single/multi, 선택 결과를 `close.emit`). inline 삭제 열 / 엑셀 업로드·다운로드 / 모달로 row 편집은 `## 5`~`## 7`의 변형 스니펫으로 교체·추가한다.
42
43
 
43
44
  ```typescript
44
45
  import { NgIcon } from "@ng-icons/core";
45
46
  import {
46
47
  tablerAlertTriangle,
47
48
  tablerCirclePlus,
48
- tablerEdit,
49
49
  tablerEraser,
50
+ tablerDeviceFloppy,
50
51
  tablerRefresh,
51
52
  tablerRestore,
52
53
  tablerSearch,
54
+ tablerX,
53
55
  } from "@ng-icons/tabler-icons";
54
56
  import {
55
57
  ChangeDetectionStrategy,
@@ -57,53 +59,61 @@ import {
57
59
  computed,
58
60
  effect,
59
61
  inject,
62
+ input,
63
+ output,
60
64
  signal,
61
65
  untracked,
66
+ viewChild,
62
67
  ViewEncapsulation,
63
68
  } from "@angular/core";
69
+ import { ArgumentError, type DateTime, obj, str } from "@simplysm/core-common";
70
+ import { expr } from "@simplysm/orm-common";
64
71
  import {
65
- getOrmDataEditToastErrorMessage,
66
- injectCurrentPageCodeSignal,
67
- injectFullPageCodeSignal,
72
+ FormatPipe,
68
73
  injectPermsSignal,
74
+ injectViewTitleSignal,
69
75
  injectViewTypeSignal,
70
76
  mark,
71
- SdActivatedModalProvider,
72
77
  SdAnchor,
73
- SdAppStructureProvider,
74
78
  SdBusyContainer,
75
79
  SdButton,
80
+ SdCheckbox,
76
81
  SdCommandDirective,
82
+ SdDock,
83
+ SdDockContainer,
77
84
  SdForm,
78
- SdModalProvider,
85
+ SdItemOfTemplate,
86
+ type SdSelectModal,
87
+ SdSharedDataSelect,
79
88
  SdSheet,
80
89
  SdSheetColumn,
81
90
  SdSheetColumnCellTemplate,
82
- SdSystemLogProvider,
83
91
  SdTextfield,
84
92
  SdToastProvider,
85
93
  SdTopbar,
86
94
  SdTopbarContainer,
95
+ type SelectModalOutputResult,
87
96
  setupCanDeactivate,
88
97
  type SortingDef,
89
98
  } from "@simplysm/angular";
90
- import "@simplysm/core-common"; // Array.prototype.oneWayDiffs 프로토타입 확장
99
+ // 앱별 대체: ORM/공유 데이터/인증 provider + DbContext. simplysm 패키지가 아니라 각 앱이 소유한다.
100
+ import { AppOrmProvider, AppSharedDataProvider, useSharedSignal } from "@adtek/client-common";
101
+ import type { MainDbContext } from "@adtek/db-main";
102
+ import { AppAuthProvider } from "../../../providers/AppAuthProvider";
103
+
104
+ interface IFilter {
105
+ searchText?: string;
106
+ isIncludeDeleted: boolean;
107
+ }
91
108
 
92
109
  interface ICustomer {
93
- id: string;
94
- name: string;
95
- phone: string;
110
+ id?: number;
111
+ name?: string;
112
+ phone?: string;
113
+ categoryId?: number;
96
114
  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>;
115
+ lastModifiedAt?: DateTime;
116
+ lastModifiedBy?: string;
107
117
  }
108
118
 
109
119
  @Component({
@@ -113,60 +123,92 @@ interface ICustomerSearchResult {
113
123
  standalone: true,
114
124
  imports: [
115
125
  SdBusyContainer, SdTopbarContainer, SdTopbar,
126
+ SdDockContainer, SdDock,
116
127
  SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
117
- SdButton, SdAnchor, SdTextfield, NgIcon,
128
+ SdButton, SdAnchor, SdCheckbox, SdTextfield,
129
+ SdSharedDataSelect, SdItemOfTemplate,
130
+ NgIcon, FormatPipe,
118
131
  ],
119
132
  hostDirectives: [
120
- { directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
133
+ { directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
121
134
  ],
122
135
  host: {
123
136
  "(sdRefreshCommand)": "onRefreshButtonClick()",
137
+ "(sdSaveCommand)": "onSaveButtonClick()",
124
138
  },
125
139
  template: `
126
- <sd-busy-container [busy]="busyCount() > 0" [message]="busyMessage()">
140
+ <sd-busy-container [busy]="busyCount() > 0">
127
141
  @if (initialized()) {
128
142
  @if (!canUse()) {
129
143
  <div class="fill tx-theme-gray-light p-xxl tx-center">
130
144
  <br />
131
- <ng-icon [svg]="icons.tablerAlertTriangle" [size]="'5em'" />
132
- <br /><br />
133
- '{{ modalOrPageTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
145
+ <ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
146
+ <br />
147
+ <br />
148
+ '{{ viewTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
134
149
  </div>
135
- } @else if (viewType() === "page") {
150
+ } @else {
136
151
  <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">
152
+ @if (viewType() === "page") {
153
+ <sd-topbar>
154
+ <h4>{{ viewTitle() }}</h4>
155
+
156
+ <sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
157
+ <ng-icon [svg]="tablerRefresh" />
158
+ 새로고침
159
+ <small>(CTRL+ALT+L)</small>
160
+ </sd-button>
161
+ @if (canEdit()) {
162
+ <sd-button [theme]="'link-primary'" (click)="onSaveButtonClick()">
163
+ <ng-icon [svg]="tablerDeviceFloppy" />
164
+ 저장
165
+ <small>(CTRL+S)</small>
166
+ </sd-button>
167
+ }
168
+ </sd-topbar>
169
+ }
170
+
171
+ <sd-dock-container>
172
+ <!-- 필터 -->
173
+ <sd-dock class="p-default">
148
174
  <sd-form (formSubmit)="onFilterSubmit()">
149
175
  <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
- />
176
+ <div class="form-box-item">
177
+ <sd-button [type]="'submit'" [theme]="'info'">
178
+ <ng-icon [svg]="tablerSearch" />
179
+ 조회
180
+ </sd-button>
181
+ </div>
182
+ <div class="form-box-item">
183
+ <label>검색어</label>
184
+ <sd-textfield
185
+ [type]="'text'"
186
+ [placeholder]="'이름/전화번호'"
187
+ [(value)]="filter().searchText"
188
+ (valueChange)="mark(filter)"
189
+ />
190
+ </div>
191
+ <div class="form-box-item">
192
+ <sd-checkbox
193
+ [(value)]="filter().isIncludeDeleted"
194
+ (valueChange)="mark(filter)"
195
+ >
196
+ 삭제항목 포함
197
+ </sd-checkbox>
198
+ </div>
161
199
  </div>
162
200
  </sd-form>
163
- </div>
201
+ </sd-dock>
164
202
 
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" />
203
+ <!-- 도구 (inline 편집용, page 뷰에서만) -->
204
+ @if (canEdit() && viewType() === "page") {
205
+ <sd-dock class="flex-row gap-sm p-xs-default">
206
+ <sd-button
207
+ [size]="'sm'"
208
+ [theme]="'link-primary'"
209
+ (click)="onAddItemButtonClick()"
210
+ >
211
+ <ng-icon [svg]="tablerCirclePlus" />
170
212
  등록
171
213
  </sd-button>
172
214
  <sd-button
@@ -175,7 +217,7 @@ interface ICustomerSearchResult {
175
217
  (click)="onToggleDeleteItemsButtonClick(true)"
176
218
  [disabled]="!hasSelectedNotDeleted()"
177
219
  >
178
- <ng-icon [svg]="icons.tablerEraser" />
220
+ <ng-icon [svg]="tablerEraser" />
179
221
  선택 삭제
180
222
  </sd-button>
181
223
  @if (hasSelectedDeleted()) {
@@ -184,224 +226,428 @@ interface ICustomerSearchResult {
184
226
  [theme]="'link-warning'"
185
227
  (click)="onToggleDeleteItemsButtonClick(false)"
186
228
  >
187
- <ng-icon [svg]="icons.tablerRestore" />
229
+ <ng-icon [svg]="tablerRestore" />
188
230
  선택 복구
189
231
  </sd-button>
190
232
  }
191
- </div>
233
+ </sd-dock>
192
234
  }
193
235
 
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" />
236
+ <!-- 시트 (main 영역) -->
237
+ <sd-form #formCtrl (formSubmit)="onSubmit()" class="block fill p-default pt-0">
238
+ <sd-sheet
239
+ [key]="'customer-list-sheet'"
240
+ [items]="items()"
241
+ [(currentPage)]="page"
242
+ [totalPageCount]="pageLength()"
243
+ [(sorts)]="sortingDefs"
244
+ [selectMode]="selectMode() ?? 'multi'"
245
+ [(selectedItems)]="selectedItems"
246
+ [trackByFn]="trackByFn"
247
+ [getItemCellStyleFn]="getItemCellStyleFn"
248
+ [cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
249
+ >
250
+ <sd-sheet-column [fixed]="true" [key]="'id'" [header]="'#'">
251
+ <ng-template [cell]="items()" let-item="item">
252
+ <div
253
+ class="p-xs-sm"
254
+ [class.tx-right]="item.id"
255
+ [style.background]="getIsItemChanged(item) ? 'yellow' : ''"
256
+ >
257
+ @if (item.id) {
258
+ {{ item.id }}
259
+ } @else if (canEdit()) {
260
+ <sd-anchor (click)="onRemoveNewItemButtonClick(item)">
261
+ <ng-icon [svg]="tablerX" />
262
+ </sd-anchor>
263
+ }
264
+ </div>
265
+ </ng-template>
266
+ </sd-sheet-column>
267
+
268
+ <sd-sheet-column [key]="'name'" [header]="'이름'">
269
+ <ng-template [cell]="items()" let-item="item" let-edit="edit">
270
+ <sd-textfield
271
+ [type]="'text'"
272
+ [inset]="true"
273
+ [size]="'sm'"
274
+ [required]="true"
275
+ [disabled]="!canEdit()"
276
+ [readonly]="!edit"
277
+ [(value)]="item.name"
278
+ (valueChange)="mark(items)"
279
+ />
280
+ </ng-template>
281
+ </sd-sheet-column>
282
+
283
+ <sd-sheet-column [key]="'phone'" [header]="'전화번호'">
284
+ <ng-template [cell]="items()" let-item="item" let-edit="edit">
285
+ <sd-textfield
286
+ [type]="'text'"
287
+ [inset]="true"
288
+ [size]="'sm'"
289
+ [disabled]="!canEdit()"
290
+ [readonly]="!edit"
291
+ [(value)]="item.phone"
292
+ (valueChange)="mark(items)"
293
+ />
294
+ </ng-template>
295
+ </sd-sheet-column>
296
+
297
+ <sd-sheet-column [key]="'categoryId'" [header]="'카테고리'">
298
+ <ng-template [cell]="items()" let-item="item">
299
+ <sd-shared-data-select
300
+ [inset]="true"
301
+ [size]="'sm'"
302
+ [disabled]="!canEdit()"
303
+ [items]="sharedCategories.items()"
304
+ [(value)]="item.categoryId"
305
+ (valueChange)="mark(items)"
306
+ >
307
+ <ng-template [itemOf]="sharedCategories.items()" let-cat>
308
+ {{ cat.name }}
309
+ </ng-template>
310
+ </sd-shared-data-select>
311
+ </ng-template>
312
+ </sd-sheet-column>
313
+
314
+ <sd-sheet-column [key]="'lastModifiedAt'" [header]="'수정일시'" [hidden]="true">
315
+ <ng-template [cell]="items()" let-item="item">
316
+ <div class="p-xs-sm tx-center">
317
+ {{ item.lastModifiedAt | format: "yyyy-MM-dd HH:mm" }}
212
318
  </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>
319
+ </ng-template>
320
+ </sd-sheet-column>
321
+
322
+ <sd-sheet-column [key]="'lastModifiedBy'" [header]="'수정자'" [hidden]="true">
323
+ <ng-template [cell]="items()" let-item="item">
324
+ <div class="p-xs-sm tx-center">{{ item.lastModifiedBy }}</div>
325
+ </ng-template>
326
+ </sd-sheet-column>
327
+ </sd-sheet>
328
+ </sd-form>
329
+
330
+ <!-- modal 하단 확인 바 -->
331
+ @if (viewType() === "modal") {
332
+ <sd-dock
333
+ [position]="'bottom'"
334
+ class="p-sm-default flex-row main-align-end gap-sm bdt bdt-theme-gray-lightest"
335
+ >
336
+ <sd-button
337
+ [size]="'sm'"
338
+ [theme]="'danger'"
339
+ (click)="onModalCancelClick()"
340
+ [disabled]="selectedItems().length < 1"
341
+ >
342
+ 선택 해제
343
+ </sd-button>
344
+ @if (selectMode() === "multi") {
345
+ <sd-button [size]="'sm'" [theme]="'primary'" (click)="onModalConfirmClick()">
346
+ 확인({{ selectedItems().length }})
347
+ </sd-button>
348
+ }
349
+ </sd-dock>
350
+ }
351
+ </sd-dock-container>
224
352
  </sd-topbar-container>
225
- } @else if (viewType() === "modal") {
226
- <!-- 모달 뷰 분기는 변형 2 "선택 모달" 참조 -->
227
- } @else {
228
- <!-- control 뷰: 다른 화면의 영역으로 삽입될 때 -->
229
353
  }
230
354
  }
231
355
  </sd-busy-container>
232
356
  `,
233
357
  })
234
- export class CustomerListPage {
358
+ export class CustomerListPage implements SdSelectModal<ICustomer> {
235
359
  //== DI ==
360
+ private readonly _appOrm = inject(AppOrmProvider);
361
+ private readonly _appAuth = inject(AppAuthProvider);
362
+ private readonly _appSharedData = inject(AppSharedDataProvider);
236
363
  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
- );
364
+
365
+ //== SdSelectModal<ICustomer> 계약 ==
366
+ selectMode = input<"single" | "multi" | undefined>();
367
+ selectedItemKeys = input<(number | undefined)[]>([]);
368
+ close = output<SelectModalOutputResult<ICustomer> | undefined>();
369
+
370
+ //== viewChild ==
371
+ formCtrl = viewChild<SdForm>("formCtrl");
372
+
373
+ //== 식별 / 권한 ==
374
+ SHARED_DATA_KEY = "고객" as const;
375
+
376
+ perms = injectPermsSignal(["sales.customer"], ["use", "edit"]);
377
+ canUse = computed(() => this.perms().includes("use"));
378
+ canEdit = computed(() => this.perms().includes("edit") && this.viewType() === "page");
379
+
380
+ viewType = injectViewTypeSignal();
381
+ viewTitle = injectViewTitleSignal();
382
+
383
+ //== 공유 데이터 ==
384
+ sharedCategories = useSharedSignal("카테고리");
254
385
 
255
386
  //== 상태 ==
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[] = [];
387
+ initialized = signal(false);
388
+ busyCount = signal(0);
269
389
 
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
- });
390
+ private _itemsSnapshot: ICustomer[] = [];
391
+ items = signal<ICustomer[]>([]);
392
+ selectedItems = signal<ICustomer[]>([]);
393
+ diffs = computed(() => this.items().oneWayDiffs(this._itemsSnapshot, "id"));
282
394
 
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
- );
395
+ page = signal(0);
396
+ pageLength = signal(0);
397
+ sortingDefs = signal<SortingDef[]>([]);
289
398
 
290
- protected readonly trackByFn = (item: ICustomer): string => item.id;
399
+ filter = signal<IFilter>({ isIncludeDeleted: false });
400
+ lastFilter = signal<IFilter>({ isIncludeDeleted: false });
291
401
 
292
- protected readonly getItemCellStyleFn = (item: ICustomer): string | undefined =>
293
- item.isDeleted ? "text-decoration: line-through;" : undefined;
402
+ //== 파생 ==
403
+ hasSelectedDeleted = computed(() => this.selectedItems().some((it) => it.isDeleted));
404
+ hasSelectedNotDeleted = computed(() => this.selectedItems().some((it) => !it.isDeleted));
294
405
 
295
- protected readonly icons = {
296
- tablerAlertTriangle, tablerCirclePlus, tablerEdit, tablerEraser,
297
- tablerRefresh, tablerRestore, tablerSearch,
298
- };
406
+ //== 시트 fn ==
407
+ trackByFn = (item: ICustomer) => item.id;
408
+ getItemCellStyleFn = (item: ICustomer): string | undefined =>
409
+ item.isDeleted ? "text-decoration: line-through;" : undefined;
299
410
 
300
- //== 라이프사이클 ==
301
411
  constructor() {
302
- // 최초 로딩 + 필터/페이지/정렬 변경 시 재조회
412
+ // 필터/페이지/정렬/perms 변경 시 재조회
303
413
  effect(() => {
304
- this._lastFilter();
414
+ if (!this.canUse()) {
415
+ this.initialized.set(true);
416
+ return;
417
+ }
418
+
419
+ this.lastFilter();
305
420
  this.page();
306
421
  this.sortingDefs();
307
- if (!this.canUse()) return;
422
+
423
+ void untracked(async () => {
424
+ this.busyCount.update((v) => v + 1);
425
+ await this._sdToast.try(async () => {
426
+ await this._refresh();
427
+ });
428
+ this.busyCount.update((v) => v - 1);
429
+ this.initialized.set(true);
430
+ });
431
+ });
432
+
433
+ // 모달 뷰: selectedItemKeys → selectedItems 복원
434
+ effect(() => {
435
+ if (this.viewType() !== "modal") return;
436
+
437
+ const keys = this.selectedItemKeys();
438
+ if (keys.length === 0) return;
439
+
440
+ const currItems = this.items();
441
+ if (currItems.length === 0) return;
442
+
308
443
  untracked(() => {
309
- void this._refresh();
444
+ const sel = currItems.filter((it) => keys.includes(this.trackByFn(it)));
445
+ if (sel.length > 0) this.selectedItems.set(sel);
310
446
  });
311
447
  });
312
448
 
313
449
  setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
314
450
  }
315
451
 
316
- //== 메서드 ==
317
- protected onFilterSubmit(): void {
452
+ getIsItemChanged(item: ICustomer): boolean {
453
+ if (item.id == null) return true;
454
+ return this.diffs().some((diff) => diff.item.id === item.id);
455
+ }
456
+
457
+ //== Handlers ==
458
+ onFilterSubmit(): void {
318
459
  this.page.set(0);
319
- this._lastFilter.set({ search: this.filterSearch() });
460
+ this.lastFilter.set({ ...this.filter() });
320
461
  }
321
462
 
322
- protected onRefreshButtonClick(): void {
463
+ onRefreshButtonClick(): void {
323
464
  if (this.busyCount() > 0) return;
324
465
  if (!this.canUse()) return;
325
466
  if (!this._checkIgnoreChanges()) return;
326
- mark(this._lastFilter); // 참조만 갱신 → effect 재실행
327
- }
328
467
 
329
- protected async onCreateItemButtonClick(): Promise<void> {
330
- await this._editItem();
468
+ mark(this.lastFilter);
331
469
  }
332
470
 
333
- protected async onEditItemButtonClick(item: ICustomer, event: MouseEvent): Promise<void> {
334
- event.preventDefault();
335
- event.stopPropagation();
336
- await this._editItem(item);
471
+ onSaveButtonClick(): void {
472
+ this.formCtrl()?.requestSubmit();
337
473
  }
338
474
 
339
- protected async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
475
+ async onSubmit(): Promise<void> {
476
+ if (this.busyCount() > 0) return;
477
+
478
+ const diffs = this.diffs();
479
+ if (diffs.length === 0) {
480
+ this._sdToast.info("변경사항이 없습니다.");
481
+ return;
482
+ }
483
+
484
+ this.busyCount.update((v) => v + 1);
340
485
  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);
486
+ const changedIds: number[] = [];
487
+ await this._appOrm.connectAsync(async (db) => {
488
+ for (const diff of diffs) {
489
+ const changedId = await this._upsertItem(
490
+ db,
491
+ diff.item,
492
+ diff.type === "create" ? "등록" : "수정",
493
+ );
494
+ changedIds.push(changedId);
495
+ }
496
+ });
497
+ await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, changedIds);
498
+
499
+ this._sdToast.success("저장되었습니다.");
500
+
501
+ await this._refresh();
502
+ });
503
+
504
+ this.busyCount.update((v) => v - 1);
350
505
  }
351
506
 
352
- private _checkIgnoreChanges(): boolean {
353
- return this._getDiffs().length === 0
354
- || confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
507
+ onAddItemButtonClick(): void {
508
+ this.items.update((list) => [{ isDeleted: false }, ...list]);
509
+ }
510
+
511
+ onRemoveNewItemButtonClick(item: ICustomer): void {
512
+ this.items.update((list) => list.filter((it) => it !== item));
513
+ }
514
+
515
+ onToggleDeleteItemsButtonClick(del: boolean): void {
516
+ for (const it of this.selectedItems()) it.isDeleted = del;
517
+ mark(this.items);
518
+ }
519
+
520
+ onModalConfirmClick(): void {
521
+ const sel = this.selectedItems();
522
+ this.close.emit({
523
+ selectedItemKeys: sel.map((it) => this.trackByFn(it)).filterExists(),
524
+ selectedItems: sel,
525
+ });
526
+ }
527
+
528
+ onModalCancelClick(): void {
529
+ this.selectedItems.set([]);
530
+
531
+ if (this.selectMode() === "single") {
532
+ this.close.emit({ selectedItemKeys: [], selectedItems: [] });
533
+ }
355
534
  }
356
535
 
357
- private _getDiffs() {
358
- return this.items().oneWayDiffs(this._snapshot, "id");
536
+ //== Internals ==
537
+ private _checkIgnoreChanges(): boolean {
538
+ return this.diffs().length === 0 || confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
359
539
  }
360
540
 
541
+ // 로드+snapshot만 담당. busy/try는 호출부에서 처리.
361
542
  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);
543
+ const r = await this._search(true);
544
+ this.items.set(r.items);
545
+ this.pageLength.set(r.pageLength);
546
+
547
+ const currKeys = new Set(r.items.map((it) => this.trackByFn(it)));
548
+ this.selectedItems.update((sel) => sel.filter((it) => currKeys.has(this.trackByFn(it))));
549
+
550
+ this._itemsSnapshot = obj.clone(r.items);
380
551
  }
381
552
 
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();
553
+ private async _search(
554
+ usePagination: boolean,
555
+ ): Promise<{ items: ICustomer[]; pageLength: number }> {
556
+ const filter = this.lastFilter();
557
+ const sortingDefs = this.sortingDefs();
558
+ const page = this.page();
559
+
560
+ return this._appOrm.connectAsync(async (db) => {
561
+ let qr1 = db.customer();
562
+
563
+ if (!str.isNullOrEmpty(filter.searchText)) {
564
+ qr1 = qr1.search((item) => [item.name, item.phone], filter.searchText);
565
+ }
566
+ if (!filter.isIncludeDeleted) {
567
+ qr1 = qr1.where((item) => [expr.eq(item.isDeleted, false)]);
568
+ }
569
+
570
+ const pageLength = usePagination ? Math.ceil((await qr1.count()) / 50) : 0;
571
+
572
+ let qr2 = qr1.joinLastDataLog().select((item) => ({
573
+ id: item.id,
574
+ name: item.name,
575
+ phone: item.phone,
576
+ categoryId: item.categoryId,
577
+ isDeleted: item.isDeleted,
578
+ lastModifiedAt: item.lastDataLog?.dateTime,
579
+ lastModifiedBy: item.lastDataLog?.userName,
580
+ }));
581
+
582
+ for (const sortingDef of sortingDefs) {
583
+ qr2 = qr2.orderBy(sortingDef.key, sortingDef.desc ? "DESC" : "ASC");
584
+ }
585
+ if (!sortingDefs.some((s) => s.key === "name")) {
586
+ qr2 = qr2.orderBy((item) => item.name);
587
+ }
588
+
589
+ if (usePagination) {
590
+ qr2 = qr2.limit(page * 50, 50);
591
+ }
592
+
593
+ const items = await qr2.execute();
594
+ return { items, pageLength };
595
+ });
390
596
  }
391
597
 
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("구현 필요");
598
+ private async _upsertItem(
599
+ db: MainDbContext,
600
+ item: ICustomer,
601
+ logType: string,
602
+ ): Promise<number> {
603
+ if (
604
+ !item.isDeleted &&
605
+ (await db
606
+ .customer()
607
+ .where((c) => [
608
+ expr.eq(c.name, item.name),
609
+ expr.not(expr.eq(c.id, item.id)),
610
+ expr.eq(c.isDeleted, false),
611
+ ])
612
+ .exists())
613
+ ) {
614
+ throw new ArgumentError("동일한 명칭이 이미 등록되어 있습니다.", { 명칭: item.name });
615
+ }
616
+
617
+ const upsertResult = await db
618
+ .customer()
619
+ .where((c) => [expr.eq(c.id, item.id)])
620
+ .upsert(
621
+ () => ({
622
+ name: item.name!,
623
+ phone: item.phone,
624
+ categoryId: item.categoryId,
625
+ isDeleted: item.isDeleted,
626
+ }),
627
+ ["id"],
628
+ );
629
+ const upsertId = upsertResult[0].id;
630
+
631
+ await db.customer().insertDataLogAsync({
632
+ type: logType,
633
+ itemId: upsertId,
634
+ valueJson: undefined,
635
+ userId: this._appAuth.authInfo()!.user.id,
636
+ });
637
+
638
+ return upsertId;
404
639
  }
640
+
641
+ //== 아이콘 ==
642
+ protected readonly tablerAlertTriangle = tablerAlertTriangle;
643
+ protected readonly tablerCirclePlus = tablerCirclePlus;
644
+ protected readonly tablerDeviceFloppy = tablerDeviceFloppy;
645
+ protected readonly tablerEraser = tablerEraser;
646
+ protected readonly tablerRefresh = tablerRefresh;
647
+ protected readonly tablerRestore = tablerRestore;
648
+ protected readonly tablerSearch = tablerSearch;
649
+ protected readonly tablerX = tablerX;
650
+ protected readonly mark = mark;
405
651
  }
406
652
  ```
407
653
 
@@ -411,317 +657,308 @@ export class CustomerListPage {
411
657
 
412
658
  | 블록 | 역할 | 원본 대응 |
413
659
  |---|---|---|
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)` |
660
+ | `<sd-busy-container [busy]>` | 전체 busy 오버레이 | `sd-data-sheet.ts:65-71` + `SdBaseContainer` |
661
+ | `@if (initialized())` | 초기 데이터 로딩 전 콘텐츠 숨김 (깜박임 방지) | `sd-data-sheet.base.ts:94`·initial effect 말미 `initialized.set(true)` |
416
662
  | `@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` |
663
+ | `<sd-topbar-container>` 공통 껍데기 + `@if (viewType() === "page")` 내부 `<sd-topbar>` | page 뷰만 topbar 표시, modal/control은 topbar 없는 컨테이너로 사용 | `sd-data-sheet.ts:65-87` `pageTopbarTpl` |
664
+ | `<sd-dock-container>` + `<sd-dock>` (필터 / inline 도구 / modal 하단 바) | 필터·도구·modal 하단 바를 dock로 부착, 본문(`<sd-form>` + `<sd-sheet>`)은 main 영역 | `sd-data-sheet.ts:89-203` |
665
+ | `<sd-form (formSubmit)>` + `form-box-inline` + `form-box-item` | 필터 제출 폼 — 각 입력을 `<div class="form-box-item">`로 감싸 label/버튼 배치 | `sd-data-sheet.ts:111-125` 필터 슬롯 |
666
+ | inline 도구 `<sd-dock>` (page 뷰 + canEdit에만) | 등록 / 선택 삭제 / 선택 복구 | `sd-data-sheet.ts:127-203` 도구 영역 |
667
+ | `<sd-form #formCtrl (formSubmit)="onSubmit()">` + `<sd-sheet>` | main 영역의 일괄 저장 form + 시트 본체 | `sd-data-sheet.ts:205-346` + `inline-edit` 매니저 |
668
+ | `[cell]` 템플릿의 `let-edit="edit"` + `[readonly]="!edit"` | inline 편집 가능 (`[inset]="true" [size]="'sm'"` 필수) | |
423
669
  | `getItemCellStyleFn` | `isDeleted` 시 취소선 | `sd-data-sheet.base.ts:137-140` |
670
+ | modal 하단 `<sd-dock [position]="'bottom'">` | 모달 뷰에서만 "선택 해제 / 확인" 바 노출 | `sd-data-sheet.ts:287-315` 모달 하단 바 |
424
671
  | `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-sheet.ts:57-63` |
425
672
  | `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()` 추가) |
673
+ | 호출부(`onRefresh`/`onSubmit`/초기 effect) 내 `busyCount.update` + `sdToast.try(...)` | busy 카운트 증감 + 에러 토스트 래핑 | `injectDataSheetRefreshManager.ts:33-46` (삭제됨) |
674
+ | `diffs = computed(() => items.oneWayDiffs(_itemsSnapshot, "id"))` | 변경 감지 signal — 템플릿·호출부 모두에서 `this.diffs()`로 참조 | `injectDataSheetRefreshManager.ts`의 `getDiffs()` (삭제됨) |
675
+ | `effect(() => { if (!canUse()) ...; lastFilter(); page(); sortingDefs(); untracked(async ...); })` | 필터/페이지/정렬/perms 변경 시 재조회 + 초기 로드 | `injectDataSheetRefreshManager.ts` (삭제됨) |
676
+ | `effect(() => { if (viewType() !== "modal") return; selectedItemKeys() selectedItems })` | 모달 뷰 초기 selection 복원 | `sd-data-sheet.base.ts:165-183` |
677
+ | `mark(this.lastFilter)` | lastFilter 참조 갱신 effect 재실행 (값 변경 없음) | `sd-data-sheet.base.ts:245` |
431
678
 
432
679
  ### 상태 분해
433
680
 
434
681
  | signal / computed | 역할 |
435
682
  |---|---|
436
683
  | `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
437
- | `busyMessage` | busy 오버레이 문구 |
438
684
  | `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
439
685
  | `items` | 현재 페이지 items |
440
- | `summaryData` | 서버가 반환한 합계·집계 (선택적으로 시트 summary row에 표시) |
441
686
  | `selectedItems` | 선택된 item 배열 (`<sd-sheet [(selectedItems)]>`로 양방향) |
687
+ | `diffs` | `computed(() => items().oneWayDiffs(_itemsSnapshot, "id"))` — 변경 감지 signal. 템플릿(`getIsItemChanged`)·호출부(`onSubmit`·`_checkIgnoreChanges`) 모두에서 `diffs()`로 참조 |
442
688
  | `page` / `pageLength` | 0-based 현재 페이지 / 전체 페이지 수 |
443
689
  | `sortingDefs` | `SortingDef[]` — `{ key: string; desc: boolean }[]`, `<sd-sheet [(sorts)]>`로 양방향 |
444
- | `filterSearch` / `_lastFilter` | filter 입력 버퍼, lastFilter "조회" 제출 시점 스냅샷 |
445
- | `_snapshot` | 최근 `_refresh()` 시점의 items 복사본 (변경 감지용) |
690
+ | `filter` / `lastFilter` | `filter`는 입력 버퍼, `lastFilter`는 "조회" 제출 시점 스냅샷 (effect 의존성) |
691
+ | `_itemsSnapshot` | 최근 `_refresh()` 시점의 items 깊은 복제 (변경 감지용) |
692
+ | `hasSelectedDeleted` / `hasSelectedNotDeleted` | 선택 항목의 삭제 상태 — 선택 삭제/복구 버튼 조건 |
693
+ | `perms` / `canUse` / `canEdit` | 권한. `canEdit`은 page 뷰 + edit 권한일 때만 true (modal에선 항상 false) |
694
+ | `close` (output) | `SdSelectModal<T>` 요구 — 모달 결과 전달 |
446
695
 
447
696
  ### 메서드 분해
448
697
 
449
698
  | 메서드 | 역할 |
450
699
  |---|---|
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`를 기준으로 아래 변경을 적용한다:
700
+ | `onFilterSubmit()` | page=0 리셋 + `lastFilter.set({ ...filter() })` |
701
+ | `onRefreshButtonClick()` | busy/권한/변경사항 확인 후 `mark(lastFilter)` — 참조 갱신으로 effect 재실행 |
702
+ | `onSaveButtonClick()` | `formCtrl()?.requestSubmit()` Ctrl+S와 동일 경로. `host`의 `sdSaveCommand`와 어휘 일치 |
703
+ | `onSubmit()` | diff 0건이면 정보 토스트 → `busyCount` 증가 → `_sdToast.try(diff 일괄 upsert + emit + _refresh)` → `busyCount` 감소 |
704
+ | `onAddItemButtonClick()` | `items.update((list) => [{ isDeleted: false }, ...list])` 신규 행을 맨 앞에 삽입 |
705
+ | `onToggleDeleteItemsButtonClick(del)` | 선택 항목의 `isDeleted = del` 토글 + `mark(items)`. 실제 DB 반영은 저장 버튼 클릭 시 `onSubmit`에서 일괄 처리 |
706
+ | `onModalConfirmClick()` / `onModalCancelClick()` | 모달 결과 emit. 취소는 `selectMode === "single"`일 때만 즉시 close |
707
+ | `_checkIgnoreChanges()` | `diffs()` 길이 0이면 true, 아니면 `confirm` 후 true/false |
708
+ | `getIsItemChanged(item)` | row 하이라이트 판정 — `item.id == null`(신규) 또는 `diffs()`에 해당 id가 포함되면 true |
709
+ | `onRemoveNewItemButtonClick(item)` | 저장 전 신규 row(`id == null`) 제거 — reference 기반 `filter((it) => it !== item)` |
710
+ | `_refresh()` | `_search(true)` `items.set` + `pageLength.set` + 선택 유지 + `_itemsSnapshot = obj.clone(r.items)`. busy/try는 호출부 책임 |
711
+ | `_search(usePagination)` | ORM 쿼리 (filter/sort/limit) — `_refresh`와 엑셀 다운로드 등에서 재사용 |
712
+ | `_upsertItem(db, item, logType)` | 중복 검사 → `upsert(() => record, ["id"])` → `insertDataLogAsync` |
713
+
714
+ ## 5. 변형: inline 삭제 열
715
+
716
+ 기본 예제는 상단 "선택 삭제 / 선택 복구" 버튼만 사용하지만, row별 inline 삭제/복구 버튼을 함께 제공하고 싶으면 시트 맨 앞 고정 컬럼에 `<sd-anchor>`를 추가한다. `CustomerListPage`를 기준으로 아래 변경을 적용한다.
462
717
 
463
718
  ```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]="'이름'">
719
+ // 1) imports 추가
720
+ import { SdAnchor } from "@simplysm/angular";
721
+ // @Component imports 배열에도 SdAnchor 추가
722
+
723
+ // 2) template — <sd-sheet> 가장 앞에 isDeleted 고정 컬럼 삽입
724
+ <sd-sheet ...>
725
+ @if (canEdit() && viewType() === "page") {
726
+ <sd-sheet-column [fixed]="true" [key]="'_isDeleted'">
727
+ <ng-template #headerTpl>
728
+ <div class="p-xs-sm tx-center">
729
+ <ng-icon [svg]="tablerEraser" />
730
+ </div>
731
+ </ng-template>
505
732
  <ng-template [cell]="items()" let-item="item">
506
- <sd-textfield [type]="'text'" [(value)]="item.name" [inset]="true" [size]="'sm'" />
733
+ <div class="p-xs-sm tx-center">
734
+ <sd-anchor
735
+ [theme]="'danger'"
736
+ (click)="onToggleDeleteItemButtonClick(item)"
737
+ >
738
+ <ng-icon [svg]="item.isDeleted ? tablerRestore : tablerEraser" />
739
+ {{ item.isDeleted ? "복구" : "삭제" }}
740
+ </sd-anchor>
741
+ </div>
507
742
  </ng-template>
508
743
  </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
- }
744
+ }
745
+ <!-- 나머지 컬럼(id, name, phone, categoryId, ...)은 기본 예제 그대로 -->
746
+ </sd-sheet>
523
747
 
748
+ // 3) 메서드 추가
524
749
  protected onToggleDeleteItemButtonClick(item: ICustomer): void {
525
750
  item.isDeleted = !item.isDeleted;
526
751
  mark(this.items); // OnPush 재렌더 + effect 알림
527
752
  }
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
753
  ```
552
754
 
553
- > **`oneWayDiffs`는 delete를 다루지 않는다.** `newItems.oneWayDiffs(orgItems, keyFn)`은 `type: "create" | "update" | "same"`만 반환한다. 삭제 의사는 **`item.isDeleted = true` 플래그**로 표현하여 `"update"` diff로 전송된다. 행을 items 배열에서 제거하면 diff에서 누락되므로 절대 삭제하지 않는다.
554
-
555
- ## 6. 변형: 선택 모달 뷰
755
+ **포인트:**
556
756
 
557
- 화면을 선택 모달로 재사용한다. `<sd-sheet>`가 items 기반이므로 key는 `selectedItems().map((it, i) => trackByFn(it, i))` 수동 변환으로 `SelectModalOutputResult<T>`를 구성한다.
757
+ - inline 삭제도 **`item.isDeleted` 플래그 토글**로 표현. 행을 `items`에서 제거하면 `oneWayDiffs`가 누락한다. DB 반영은 저장 버튼 클릭 시 `onSubmit`에서 일괄 처리(soft-delete).
758
+ - **`canEdit() && viewType() === "page"` 조건**: modal 뷰 / 권한 없음이면 열 자체 숨김 (`canEdit`은 이미 page 한정이지만 명시적으로 쓰면 의도가 분명).
759
+ - 컬럼 key는 **`"_isDeleted"`** (언더스코어 prefix) — 서버 정렬·컬럼 지속성 설정과 충돌하지 않는 임의 키.
760
+ - 기본 예제의 상단 "선택 삭제/복구" 버튼과 **공존** 가능. row별 빠른 처리 + 다건 일괄 처리.
558
761
 
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";
762
+ > **`oneWayDiffs`는 delete를 다루지 않는다.** `newItems.oneWayDiffs(orgItems, keyFn)`은 `type: "create" | "update" | "same"`만 반환한다. 삭제 의사는 **`item.isDeleted = true` 플래그**로 표현하여 `"update"` diff로 전송된다. 행을 `items` 배열에서 물리적으로 제거하면 diff에서 누락되므로 절대 삭제하지 않는다.
566
763
 
567
- // 2) 클래스 선언에 implements 추가
568
- export class CustomerListPage implements SdSelectModal<ICustomer> {
569
- // ...기존 필드 유지
764
+ ## 6. 변형: 모달 편집 모드
570
765
 
571
- // SdModalContentDef<SelectModalOutputResult<ICustomer>> 요구 필드
572
- close = output<SelectModalOutputResult<ICustomer> | undefined>();
766
+ 기본 예제는 시트 셀 직접 수정(inline 편집) + 일괄 저장 방식이지만, 행 클릭 시 **편집 모달을 띄워 한 행씩 편집**하는 모드가 필요하면 아래 변경을 적용한다. 일괄 저장(diff) / snapshot / `_checkIgnoreChanges` / `setupCanDeactivate`는 불필요해져 모두 제거된다.
573
767
 
574
- // SdSelectModal<ICustomer> 요구 필드
575
- selectMode = input<"single" | "multi">();
576
- selectedItemKeys = input<string[]>([]);
768
+ ```typescript
769
+ // 1) imports 추가
770
+ import { SdAnchor, SdModalProvider } from "@simplysm/angular";
771
+ import { tablerEdit } from "@ng-icons/tabler-icons";
772
+ // 앱별 편집 모달 컴포넌트 (crud-detail.md 레시피로 작성):
773
+ import { CustomerEditModal } from "./CustomerEditModal";
577
774
 
578
- // initialized는 WritableSignal이 아닌 Signal을 요구하므로 computed 또는 기존 signal 그대로 노출
579
- // (위 완성 예제의 protected readonly initialized = signal(false) 를 그대로 사용)
775
+ // 2) DI 추가
776
+ private readonly _sdModal = inject(SdModalProvider);
777
+
778
+ // 3) 클래스 필드 추가 — 편집 버튼 아이콘
779
+ protected readonly tablerEdit = tablerEdit;
780
+
781
+ // 4) template — 이름 컬럼 셀을 <sd-anchor> + 편집 아이콘으로 교체.
782
+ // inline 편집용 <sd-textfield>/let-edit 제거.
783
+ <sd-sheet-column [key]="'name'" [header]="'이름'">
784
+ <ng-template [cell]="items()" let-item="item">
785
+ <sd-anchor (click)="onEditItemButtonClick(item, $event)" class="flex-row">
786
+ <div class="p-xs-sm">
787
+ <ng-icon [svg]="tablerEdit" />
788
+ </div>
789
+ <div class="flex-fill p-xs-sm">{{ item.name }}</div>
790
+ </sd-anchor>
791
+ </ng-template>
792
+ </sd-sheet-column>
793
+
794
+ // 5) template — inline 편집용 도구 <sd-dock>의 "등록" 버튼은 _editItem() 호출로 바꿈.
795
+ // "선택 삭제/복구"는 bulk API로 별도 처리(개별 _refresh 필요).
796
+ <sd-button ... (click)="onCreateItemButtonClick()">등록</sd-button>
797
+
798
+ // 6) 메서드 교체
799
+ protected async onCreateItemButtonClick(): Promise<void> {
800
+ await this._editItem();
580
801
  }
581
802
 
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>
803
+ protected async onEditItemButtonClick(item: ICustomer, event: MouseEvent): Promise<void> {
804
+ event.preventDefault();
805
+ event.stopPropagation();
806
+ await this._editItem(item);
614
807
  }
615
808
 
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,
809
+ private async _editItem(item?: ICustomer): Promise<void> {
810
+ const r = await this._sdModal.showAsync({
811
+ title: item == null ? "고객 등록" : "고객 수정",
812
+ type: CustomerEditModal,
813
+ inputs: { itemId: item?.id },
622
814
  });
815
+ if (r != null) await this._refresh();
623
816
  }
624
817
 
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
- });
818
+ // 7) 제거:
819
+ // - hostDirectives의 sdSaveCommand / host의 (sdSaveCommand)
820
+ // - onSaveButtonClick / onSubmit / onAddItemButtonClick
821
+ // - diffs computed / _itemsSnapshot / _checkIgnoreChanges / _upsertItem / getIsItemChanged / onRemoveNewItemButtonClick
822
+ // - setupCanDeactivate(...) 호출 (다른 화면의 이탈 방지는 편집 모달이 책임짐)
823
+ // - <sd-form #formCtrl (formSubmit)="onSubmit()"> 래퍼 <sd-sheet>를 main 영역에 직접 배치
640
824
  ```
641
825
 
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
- >
826
+ **포인트:**
827
+
828
+ - 모달 편집 모드에서는 **inline diff 개념이 없다.** 개별 item 변경은 `CustomerEditModal`(상세 폼) 내부에서 즉시 upsert하고 결과를 `close.emit(true)`로 전달. 리스트는 모달 close `_refresh()`로 재조회.
829
+ - `CustomerEditModal`은 [`crud-detail.md`](./crud-detail.md) 레시피로 별도 작성. modal 분기를 그대로 활용.
830
+ - 시트 `[cell]` 템플릿에 **`let-edit="edit"` / `[readonly]="!edit"`는 불필요** (inline 편집 아님). 읽기 전용 표시만.
831
+ - "선택 삭제/복구"를 남길 경우 `onToggleDeleteItemsButtonClick(del)` 내부를 diff 방식 대신 **bulk API 호출 + `_refresh()`** 로 구현한다:
832
+ ```typescript
833
+ protected async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
834
+ if (this.busyCount() > 0) return;
835
+ const ids = this.selectedItems().map((it) => this.trackByFn(it)).filterExists();
836
+ if (ids.length === 0) return;
837
+
838
+ this.busyCount.update((v) => v + 1);
839
+ await this._sdToast.try(async () => {
840
+ await this._appOrm.connectAsync((db) => db.customer().where((c) => [expr.in(c.id, ids)]).updateAsync(() => ({ isDeleted: del })));
841
+ await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, ids);
842
+ this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
843
+ await this._refresh();
844
+ });
845
+ this.busyCount.update((v) => v - 1);
846
+ }
654
847
  ```
655
- - 누적 모드 사용 시 `trackByFn` 명시 필수. 기본 `(item) => item`은 reference 기반이라 서버 페이지네이션에서 새 객체 reference가 내려오면 `obj.equal`로 비교해도 실패할 수 있다. `(item) => item.id` 같은 key 추출 함수를 반드시 지정한다.
656
848
 
657
849
  ## 7. 변형: 엑셀 업로드/다운로드
658
850
 
659
- `SdFileDialogProvider`로 파일 선택, 서버 API로 처리, `SdToastProvider.try`로 래핑한다.
851
+ `SdFileDialogProvider`로 파일 선택, `ExcelWrapper`(@simplysm/excel) + `zod` 스키마로 읽기/쓰기. 다운로드는 `_search(false)`로 전체 페이지 조회 후 `@simplysm/core-browser`의 `downloadBlob`으로 내려받는다.
660
852
 
661
853
  ```typescript
662
854
  // 1) import 추가
663
855
  import { tablerFileExcel, tablerUpload } from "@ng-icons/tabler-icons";
664
856
  import { SdFileDialogProvider } from "@simplysm/angular";
857
+ import { DateTime } from "@simplysm/core-common";
858
+ import { downloadBlob } from "@simplysm/core-browser";
859
+ import { ExcelWrapper } from "@simplysm/excel";
860
+ import { z } from "zod";
665
861
 
666
862
  // 2) DI 추가
667
863
  private readonly _sdFileDialog = inject(SdFileDialogProvider);
668
864
 
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
- 엑셀 업로드
865
+ // 3) 클래스 필드 추가 — 아이콘 + ExcelWrapper (zod 스키마로 컬럼 정의)
866
+ protected readonly tablerFileExcel = tablerFileExcel;
867
+ protected readonly tablerUpload = tablerUpload;
868
+
869
+ private readonly _excelWrapper = new ExcelWrapper(
870
+ z.object({
871
+ id: z.number().optional().describe("ID"),
872
+ name: z.string().describe("이름"),
873
+ phone: z.string().optional().describe("전화번호"),
874
+ categoryId: z.number().optional().describe("카테고리.ID"),
875
+ isDeleted: z.boolean().describe("삭제"),
876
+ lastModifiedAt: z.custom<DateTime>().optional().describe("최종수정일시"),
877
+ lastModifiedBy: z.string().optional().describe("최종수정자"),
878
+ }),
879
+ );
880
+
881
+ // 4) template — page 뷰 topbar에 엑셀 버튼 2개 추가
882
+ <sd-topbar>
883
+ <!-- 기존 "새로고침" / "저장" 버튼 옆 -->
884
+ <sd-button [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
885
+ <ng-icon [svg]="tablerFileExcel" />
886
+ 엑셀 다운로드
680
887
  </sd-button>
681
- }
682
- <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
683
- <ng-icon [svg]="icons.tablerFileExcel" />
684
- 엑셀 다운로드
685
- </sd-button>
888
+ @if (canEdit()) {
889
+ <sd-button [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
890
+ <ng-icon [svg]="tablerUpload" />
891
+ 엑셀 업로드
892
+ </sd-button>
893
+ }
894
+ </sd-topbar>
686
895
 
687
896
  // 5) 메서드 추가
688
- protected async onDownloadExcelButtonClick(): Promise<void> {
897
+ async onDownloadExcelButtonClick(): Promise<void> {
898
+ if (this.busyCount() > 0) return;
899
+
900
+ this.busyCount.update((v) => v + 1);
689
901
  await this._sdToast.try(async () => {
690
- this.busyCount.update((v) => v + 1);
691
- this.busyMessage.set("엑셀 다운로드 중...");
902
+ // 전체 조회 (페이지네이션 없이) 기본 예제의 _search를 그대로 재사용
903
+ const r = await this._search(false);
904
+ const wb = await this._excelWrapper.write(this.viewTitle(), r.items);
692
905
  try {
693
- // 전체 조회 (페이지네이션 없이)
694
- const r = await this._fetchList(this._lastFilter(), 0, this.sortingDefs());
695
- // 서버 호출 또는 클라이언트 변환 (앱별):
696
- // await this._api.downloadExcelAsync(r.items);
906
+ downloadBlob(
907
+ await wb.toBlob(),
908
+ `${this.viewTitle()}_${new DateTime().toFormatString("yyMMdd")}.xlsx`,
909
+ );
697
910
  } finally {
698
- this.busyMessage.set(undefined);
699
- this.busyCount.update((v) => v - 1);
911
+ await wb.close();
700
912
  }
701
- }, getOrmDataEditToastErrorMessage);
913
+ });
914
+ this.busyCount.update((v) => v - 1);
702
915
  }
703
916
 
704
- protected async onUploadExcelButtonClick(): Promise<void> {
917
+ async onUploadExcelButtonClick(): Promise<void> {
705
918
  const file = await this._sdFileDialog.showAsync(false, ".xlsx");
706
919
  if (file == null) return;
920
+ if (Array.isArray(file)) return;
921
+
922
+ this.busyCount.update((v) => v + 1);
707
923
  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);
924
+ const excelItems = await this._excelWrapper.read(file);
925
+ const changedIds: number[] = [];
926
+ await this._appOrm.connectAsync(async (db) => {
927
+ for (const raw of excelItems) {
928
+ changedIds.push(await this._upsertItem(db, raw, "엑셀업로드"));
929
+ }
930
+ });
931
+ await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, changedIds);
932
+
933
+ this._sdToast.success("업로드되었습니다.");
934
+
935
+ await this._refresh();
936
+ });
937
+ this.busyCount.update((v) => v - 1);
719
938
  }
720
939
  ```
721
940
 
941
+ **포인트:**
942
+
943
+ - 다운로드는 **`_search(false)`**(페이지네이션 없이 전체)로 쿼리. 페이지당 50건 제한이 걸리면 현재 페이지만 다운로드되는 실수가 생기므로 `usePagination: false` 명시 필수.
944
+ - 업로드는 `_excelWrapper.read(file)` → `_upsertItem`(기본 예제의 메서드) **재사용**. 중복 검사·DataLog 기록 로직이 동일하게 적용됨.
945
+ - 엑셀의 텍스트 컬럼(고객사명·MPN 등)을 FK id로 변환해야 하면 **DB 재조회 대신 `useSharedSignal(...)`로 이미 로드된 공유 데이터를 재사용**한다. 예: `this.sharedCustomers.items().toMapValues((it) => it.name, (it) => it.orderBy((v) => (v.__isHidden ? 1 : 0))[0])`. 같은 키에 숨김·비숨김 항목이 섞여 있으면 `orderBy`로 비숨김(`__isHidden: false`)을 우선순위로 정렬한다. 별도 `_buildIdMap` 같은 helper로 분리하지 말고 `toMapValues`를 `onUploadExcelButtonClick` 내부에 직접 인라인한다 (단일 호출처).
946
+ - 오래 걸리는 대량 업로드에는 §9 "`busyMessage`는 필요할 때만 추가"를 참조하여 `busyMessage.set("엑셀 업로드 중...")`을 선택적으로 부착.
947
+
722
948
  ## 8. 뷰 타입 분기
723
949
 
724
- `@if (viewType() === "page") ... @else if (viewType() === "modal") ... @else { ... }` 분기 구조와 `modalOrPageTitle` computed 계산은 [`page-modal-container.md`](./page-modal-container.md)의 레시피와 동일하다. 레시피의 완성 예제도 패턴을 그대로 사용한다. control 뷰(다른 화면의 영역으로 삽입)로만 쓰일 경우에는 `@if` 분기를 생략하고 본문만 작성한다.
950
+ page·modal·control 뷰는 **하나의 `<sd-topbar-container>` + `<sd-dock-container>` 공통 껍데기** 위에 뷰별로 다른 조각만 `@if`로 얹어 구성한다. 뷰별로 별도 블록을 전체 복제하지 않는다.
951
+
952
+ | 뷰 | topbar | dock (도구 바) | main (시트) | 하단 바 |
953
+ |---|---|---|---|---|
954
+ | page | `<sd-topbar>` (새로고침/저장/...) | inline 도구 `<sd-dock>` (canEdit) | `<sd-form>` + `<sd-sheet>` | 없음 |
955
+ | modal | 없음 | inline 도구 숨김 (`canEdit` = false) | 동일 | `<sd-dock [position]="'bottom'">` (선택 해제 / 확인) |
956
+ | control | 없음 | 필요 시 주석만 | 동일 | 없음 |
957
+
958
+ - **`<sd-dock>` position 명시**: 필터·inline 도구는 기본 `"top"`. **modal 하단 바는 반드시 `[position]="'bottom'"`를 명시**한다 — 누락하면 상단에 쌓여 필터·도구와 겹친다(`packages/angular/src/layout/dock/sd-dock.ts:97`).
959
+ - **`canEdit = perms().includes("edit") && viewType() === "page"`**: edit 권한이 있어도 modal 뷰에서는 항상 false. 시트 셀의 `[readonly]="!edit"`·inline 도구 바 모두 자동으로 비활성화.
960
+ - **`selectMode ?? 'multi'`**: modal로 호출할 때 input으로 `"single"` / `"multi"` 전달. 기본은 page 뷰용 `'multi'`로 fallback.
961
+ - **`cumulativeSelection="viewType() === 'modal' && selectMode() === 'multi'"`**: 모달 + 다중 선택 조합에서만 페이지를 넘어 선택 누적. 기본값(`false`)이면 페이지 이동 시 선택이 초기화되는데, 일괄 작업 대상 리스트에서 이는 의도적 동작.
725
962
 
726
963
  ## 9. 주의사항 (자주 하는 실수)
727
964
 
@@ -729,6 +966,14 @@ protected async onUploadExcelButtonClick(): Promise<void> {
729
966
 
730
967
  - `useCrudList()`, `useDataSheet()`, `setupCumulateSelectedKeys2()` 같은 공통 헬퍼를 도입하지 말 것. 이 레시피가 제거한 추상화를 다시 만드는 행위다. 세 화면이 비슷해 보여도 화면마다 필드·동작 시그니처가 조금씩 다르므로 복사·수정이 낫다
731
968
 
969
+ ### 뷰 분기를 "완전 분리 블록"으로 쓰지 않는다
970
+
971
+ - 시트 페이지를 modal로도 쓰는 경우, LLM이 page 블록과 modal 블록을 각각 완성하면서 **필터·시트를 중복 작성**하기 쉽다. 본 레시피의 기본 예제처럼 **하나의 껍데기 + 차이점만 `@if`** 로 얹어야 한다. 필터 하나를 수정할 때 두 블록을 모두 고치는 상황이 나오면 구조가 잘못된 것.
972
+
973
+ ### `<sd-dock>` position 누락
974
+
975
+ - `<sd-dock>`의 `position` input 기본값은 `"top"`이다(`sd-dock.ts:97`). 모달 하단 "확인 바"에 `[position]="'bottom'"`을 빠뜨리면 필터 위에 쌓여 레이아웃이 깨진다.
976
+
732
977
  ### 시트 셀 스타일 함정
733
978
 
734
979
  - `<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]` 생략 가능
@@ -739,15 +984,28 @@ protected async onUploadExcelButtonClick(): Promise<void> {
739
984
  - 삭제 의사 표현은 **`item.isDeleted = true` 플래그**로 하고 `"update"` diff로 전송 (서버가 soft-delete 처리)
740
985
  - inline 편집 시 `items` 배열에서 row를 제거하지 말 것. diff에서 해당 row가 누락되어 서버가 변경을 감지할 수 없다
741
986
 
742
- ### `selectedItemKeys` 수동 변환
987
+ ### `selectedItemKeys`는 `filterExists()`로 undefined 제거
743
988
 
744
- - `<sd-sheet>`는 key 기반이 아니라 item 기반이다. `SelectModalOutputResult<T>.selectedItemKeys`를 구성하려면 `selectedItems().map((it, i) => trackByFn(it, i))`로 수동 변환한다
745
- - `trackByFn`의 signature는 `(item: T, index: number) => unknown`이므로 번째 인자(index) 전달해야 타입이 맞는다
989
+ - `<sd-sheet>`는 key 기반이 아니라 item 기반이므로 `SelectModalOutputResult<T>.selectedItemKeys`는 수동 변환한다: `selectedItems().map((it) => trackByFn(it)).filterExists()`.
990
+ - **index fallback(`trackByFn(it, i) ?? i`) 금지.** id가 `undefined`인 신규 row가 있을 때 0, 1, 2 같은 index 값이 가짜 key로 들어가 호출 측이 잘못된 selection을 돌려받는다. `filterExists()`로 `undefined`를 제거하는 게 안전하다.
991
+
992
+ ### 모달 "선택 해제"는 single 모드에서만 close
993
+
994
+ - `onModalCancelClick`에서 `this.close.emit`을 무조건 호출하면 multi 모드에서 "선택 해제 = 취소 + 닫기"가 되어 다시 선택하려면 모달을 재오픈해야 한다. multi에서는 `selectedItems.set([])`만 하고 close는 호출하지 않는다(사용자가 "확인" 버튼으로 최종 emit).
746
995
 
747
996
  ### `injectViewTypeSignal()` 호출 시점
748
997
 
749
998
  - `injectViewTypeSignal()`은 생성자 실행 중 또는 필드 이니셜라이저에서만 호출한다. `computed`·`effect` 콜백이나 일반 메서드에서 호출하면 `NG0203` 런타임 에러가 발생한다 (Angular `inject()` 제약)
750
999
 
1000
+ ### `busyMessage`는 필요할 때만 추가
1001
+
1002
+ - 기본 예제는 `<sd-busy-container [busy]="busyCount() > 0">`만 사용하고 `busyMessage` signal을 두지 않는다. 짧은 CRUD는 progress 아이콘만으로 충분.
1003
+ - 오래 걸리는 작업(대량 엑셀 업로드·집계 등)에 진행 문구가 필요하면 **필요한 화면에만** `busyMessage = signal<string | undefined>(undefined)` 추가 + `[message]="busyMessage()"` 바인딩 + 구간별 `busyMessage.set(...)`/`set(undefined)` 제어. 미사용 시 선언·바인딩 모두 생략.
1004
+
1005
+ ### 테스트만을 위한 public API 금지
1006
+
1007
+ - `async submit(diffs) { await this._submitAsync(diffs); }` 같이 "테스트에서 호출하려고" private 메서드의 얇은 public wrapper를 노출하지 않는다. 캡슐화를 깨고 컴포넌트의 외부 API 인상을 오염시킨다. 테스트는 TestBed fixture + click/dispatch 이벤트 경로 또는 host의 `sdSaveCommand` 트리거로 수행.
1008
+
751
1009
  ## 10. 레시피 작성 관용 규칙
752
1010
 
753
1011
  향후 `crud-detail.md` · `data-select-button.md` 등 데이터 관련 레시피가 추가될 때 아래 3개 규칙을 공통으로 따른다.
@@ -762,7 +1020,7 @@ protected async onUploadExcelButtonClick(): Promise<void> {
762
1020
 
763
1021
  - `mark(sig)`는 `WritableSignal`의 값을 shallow copy하여 **참조를 갱신**한다 (배열: `[...v]`, 객체: `{...v}`)
764
1022
  - 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**
765
- - **"저장 감지"가 아니다.** `obj.equal`이 deep equal로 값 차이를 감지하므로, `item.name = "new"` 같은 mutation은 `mark` 없이도 `_getDiffs()` / submit에서 감지된다
1023
+ - **"저장 감지"가 아니다.** `obj.equal`이 deep equal로 값 차이를 감지하므로, `item.name = "new"` 같은 mutation은 `mark` 없이도 `diffs()` / submit에서 감지된다
766
1024
  - Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요
767
1025
  - ❌ "mark 없으면 저장이 안 된다" 식 서술 금지
768
1026