@simplysm/angular 14.0.49 → 14.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controls/select/sd-select.js +3 -3
- package/dist/data/sheet/sd-sheet.d.ts +9 -12
- package/dist/data/sheet/sd-sheet.d.ts.map +1 -1
- package/dist/data/sheet/sd-sheet.js +150 -168
- package/dist/data/sheet/types.d.ts +1 -0
- package/dist/data/sheet/types.d.ts.map +1 -1
- package/dist/data/sheet/useSheetCellStyling.d.ts +2 -2
- package/dist/data/sheet/useSheetCellStyling.d.ts.map +1 -1
- package/dist/data/sheet/useSheetCellStyling.js +20 -12
- package/dist/data/sheet/useSheetColumnFixing.d.ts +3 -8
- package/dist/data/sheet/useSheetColumnFixing.d.ts.map +1 -1
- package/dist/data/sheet/useSheetColumnFixing.js +19 -27
- package/dist/data/sheet/useSheetLayoutEngine.d.ts.map +1 -1
- package/dist/data/sheet/useSheetLayoutEngine.js +4 -1
- package/dist/layout/dock/sd-dock-container.d.ts.map +1 -1
- package/dist/layout/dock/sd-dock-container.js +4 -4
- package/package.json +7 -8
- package/src/controls/select/sd-select.ts +3 -3
- package/src/core/modal/sd-modal.provider.ts +1 -1
- package/src/core/modal/sd-modal.ts +1 -1
- package/src/core/routing/menu-utils.ts +1 -1
- package/src/core/shared-data/sd-shared-data.provider.ts +7 -7
- package/src/data/shared-data/sd-shared-data-select.ts +2 -2
- package/src/data/sheet/sd-sheet.ts +39 -60
- package/src/data/sheet/types.ts +1 -0
- package/src/data/sheet/useSheetCellStyling.ts +19 -11
- package/src/data/sheet/useSheetColumnFixing.ts +21 -28
- package/src/data/sheet/useSheetLayoutEngine.ts +4 -1
- package/src/layout/dock/sd-dock-container.ts +2 -1
- package/README.md +0 -467
- package/docs/bootstrap.md +0 -38
- package/docs/directives.md +0 -236
- package/docs/features.md +0 -154
- package/docs/pipes.md +0 -32
- package/docs/plugins.md +0 -37
- package/docs/provider-types.md +0 -283
- package/docs/providers.md +0 -379
- package/docs/recipes/crud-detail.md +0 -875
- package/docs/recipes/crud-list.md +0 -1037
- package/docs/recipes/data-select-button.md +0 -318
- package/docs/recipes/page-modal-container.md +0 -178
- package/docs/styling.md +0 -222
- package/docs/type-utilities.md +0 -250
- package/docs/ui-data.md +0 -333
- package/docs/ui-form.md +0 -502
- package/docs/ui-layout.md +0 -140
- package/docs/ui-navigation.md +0 -303
- package/docs/ui-overlay.md +0 -157
- package/docs/ui-visual.md +0 -127
- package/docs/utils.md +0 -244
|
@@ -1,1037 +0,0 @@
|
|
|
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]>` — 전체 busy 오버레이
|
|
11
|
-
- `<sd-topbar-container>` + `<sd-topbar>` — 공통 컨테이너, `<sd-topbar>`는 page 뷰에서만 조건부 렌더
|
|
12
|
-
- `<sd-dock-container>` + `<sd-dock>` — 필터 / 도구 / 모달 하단 바를 dock로 부착, 본문(시트)은 main 영역
|
|
13
|
-
- `<sd-form (formSubmit)>` — 필터 제출 / inline 편집 일괄 저장 트리거
|
|
14
|
-
- `<sd-sheet>` + `<sd-sheet-column>` + `<ng-template [cell]>` — 시트 본체 (items, 페이지네이션, 정렬, 선택, `cumulativeSelection`, 셀 스타일)
|
|
15
|
-
- `injectViewTypeSignal()` — page / modal / control 뷰 판정
|
|
16
|
-
- `injectPermsSignal()` — 권한 signal
|
|
17
|
-
- `setupCanDeactivate()` — 이탈 방지
|
|
18
|
-
- `mark(sig)` — signal 참조 갱신
|
|
19
|
-
- `SdToastProvider.try(fn)` — 에러 래퍼 (busy 카운트는 호출부에서 `busyCount.update`로 직접 제어)
|
|
20
|
-
- `SdSelectModal<T>` — 선택 모달 계약. 소비 화면이 직접 `implements`하여 `selectMode` / `selectedItemKeys` input + `close` output 노출
|
|
21
|
-
- `SdCommandDirective`(`sdRefreshCommand` / `sdSaveCommand`) — Ctrl+Alt+L / Ctrl+S 단축키
|
|
22
|
-
- 선택적:
|
|
23
|
-
- `SdFileDialogProvider.showAsync(...)` — 엑셀 업로드 파일 선택 (`## 7` 변형)
|
|
24
|
-
- 데이터 비교:
|
|
25
|
-
- `Array.prototype.oneWayDiffs(orgItems, keyFn)` (`@simplysm/core-common` side-effect import) — `ArrayOneWayDiffResult<T>[]` 반환 (`type: "create" | "update" | "same"`)
|
|
26
|
-
- `obj.clone(items)` — snapshot 깊은 복제 (`@simplysm/core-common`)
|
|
27
|
-
|
|
28
|
-
## 2. 언제 사용하는가
|
|
29
|
-
|
|
30
|
-
| 상황 | 적용 여부 |
|
|
31
|
-
|---|---|
|
|
32
|
-
| 필터 + 페이지네이션 + 정렬 + 선택 + inline 편집이 있는 일반 CRUD 리스트 | 본 레시피 전체 적용 (기본 예제가 page 뷰) |
|
|
33
|
-
| 다른 화면에서 항목을 고르는 선택 모달로도 재사용 | 기본 예제가 page + modal 2뷰를 함께 지원 — 별도 variant 불필요 |
|
|
34
|
-
| 시트 첫 열에 row별 inline 삭제/복구 버튼이 필요 | 레시피 + [변형 1: inline 삭제 열](#5-변형-inline-삭제-열) |
|
|
35
|
-
| 행 클릭 시 편집 모달을 띄우는 모달 편집 모드 | 레시피 + [변형 2: 모달 편집 모드](#6-변형-모달-편집-모드) |
|
|
36
|
-
| 엑셀 업로드 / 다운로드가 필요 | 레시피 + [변형 3: 엑셀 업로드·다운로드](#7-변형-엑셀-업로드다운로드) |
|
|
37
|
-
| 상세 폼(단일 레코드 편집) | 본 레시피 대신 [`crud-detail.md`](./crud-detail.md) 사용 |
|
|
38
|
-
| 페이지 / 모달 뷰 분기만 필요한 단순 화면 | [`page-modal-container.md`](./page-modal-container.md) 사용 |
|
|
39
|
-
|
|
40
|
-
## 3. 완성 예제
|
|
41
|
-
|
|
42
|
-
아래는 **page + modal 2뷰 동시 지원 + inline 편집** 기준 완성 컴포넌트다. 하나의 화면이 라우트로 진입하면 CRUD 리스트(조회·등록·inline 수정·선택 삭제/복구)로 동작하고, `SdModalProvider.showAsync()`로 열리면 동일 화면이 "선택 모달"로 전환된다(selectMode에 따라 single/multi, 선택 결과를 `close.emit`). inline 삭제 열 / 엑셀 업로드·다운로드 / 모달로 row 편집은 `## 5`~`## 7`의 변형 스니펫으로 교체·추가한다.
|
|
43
|
-
|
|
44
|
-
```typescript
|
|
45
|
-
import { NgIcon } from "@ng-icons/core";
|
|
46
|
-
import {
|
|
47
|
-
tablerAlertTriangle,
|
|
48
|
-
tablerCirclePlus,
|
|
49
|
-
tablerEraser,
|
|
50
|
-
tablerDeviceFloppy,
|
|
51
|
-
tablerRefresh,
|
|
52
|
-
tablerRestore,
|
|
53
|
-
tablerSearch,
|
|
54
|
-
tablerX,
|
|
55
|
-
} from "@ng-icons/tabler-icons";
|
|
56
|
-
import {
|
|
57
|
-
ChangeDetectionStrategy,
|
|
58
|
-
Component,
|
|
59
|
-
computed,
|
|
60
|
-
effect,
|
|
61
|
-
inject,
|
|
62
|
-
input,
|
|
63
|
-
output,
|
|
64
|
-
signal,
|
|
65
|
-
untracked,
|
|
66
|
-
viewChild,
|
|
67
|
-
ViewEncapsulation,
|
|
68
|
-
} from "@angular/core";
|
|
69
|
-
import { ArgumentError, type DateTime, obj, str } from "@simplysm/core-common";
|
|
70
|
-
import { expr } from "@simplysm/orm-common";
|
|
71
|
-
import {
|
|
72
|
-
FormatPipe,
|
|
73
|
-
injectPermsSignal,
|
|
74
|
-
injectViewTitleSignal,
|
|
75
|
-
injectViewTypeSignal,
|
|
76
|
-
mark,
|
|
77
|
-
SdAnchor,
|
|
78
|
-
SdBusyContainer,
|
|
79
|
-
SdButton,
|
|
80
|
-
SdCheckbox,
|
|
81
|
-
SdCommandDirective,
|
|
82
|
-
SdDock,
|
|
83
|
-
SdDockContainer,
|
|
84
|
-
SdForm,
|
|
85
|
-
SdItemOfTemplate,
|
|
86
|
-
type SdSelectModal,
|
|
87
|
-
SdSharedDataSelect,
|
|
88
|
-
SdSheet,
|
|
89
|
-
SdSheetColumn,
|
|
90
|
-
SdSheetColumnCellTemplate,
|
|
91
|
-
SdTextfield,
|
|
92
|
-
SdToastProvider,
|
|
93
|
-
SdTopbar,
|
|
94
|
-
SdTopbarContainer,
|
|
95
|
-
type SelectModalOutputResult,
|
|
96
|
-
setupCanDeactivate,
|
|
97
|
-
type SortingDef,
|
|
98
|
-
} from "@simplysm/angular";
|
|
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
|
-
}
|
|
108
|
-
|
|
109
|
-
interface ICustomer {
|
|
110
|
-
id?: number;
|
|
111
|
-
name?: string;
|
|
112
|
-
phone?: string;
|
|
113
|
-
categoryId?: number;
|
|
114
|
-
isDeleted: boolean;
|
|
115
|
-
lastModifiedAt?: DateTime;
|
|
116
|
-
lastModifiedBy?: string;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
@Component({
|
|
120
|
-
selector: "app-customer-list",
|
|
121
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
122
|
-
encapsulation: ViewEncapsulation.None,
|
|
123
|
-
standalone: true,
|
|
124
|
-
imports: [
|
|
125
|
-
SdBusyContainer, SdTopbarContainer, SdTopbar,
|
|
126
|
-
SdDockContainer, SdDock,
|
|
127
|
-
SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
|
|
128
|
-
SdButton, SdAnchor, SdCheckbox, SdTextfield,
|
|
129
|
-
SdSharedDataSelect, SdItemOfTemplate,
|
|
130
|
-
NgIcon, FormatPipe,
|
|
131
|
-
],
|
|
132
|
-
hostDirectives: [
|
|
133
|
-
{ directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
|
|
134
|
-
],
|
|
135
|
-
host: {
|
|
136
|
-
"(sdRefreshCommand)": "onRefreshButtonClick()",
|
|
137
|
-
"(sdSaveCommand)": "onSaveButtonClick()",
|
|
138
|
-
},
|
|
139
|
-
template: `
|
|
140
|
-
<sd-busy-container [busy]="busyCount() > 0">
|
|
141
|
-
@if (initialized()) {
|
|
142
|
-
@if (!canUse()) {
|
|
143
|
-
<div class="fill tx-theme-gray-light p-xxl tx-center">
|
|
144
|
-
<br />
|
|
145
|
-
<ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
|
|
146
|
-
<br />
|
|
147
|
-
<br />
|
|
148
|
-
'{{ viewTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
|
|
149
|
-
</div>
|
|
150
|
-
} @else {
|
|
151
|
-
<sd-topbar-container>
|
|
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">
|
|
174
|
-
<sd-form (formSubmit)="onFilterSubmit()">
|
|
175
|
-
<div class="form-box-inline">
|
|
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>
|
|
199
|
-
</div>
|
|
200
|
-
</sd-form>
|
|
201
|
-
</sd-dock>
|
|
202
|
-
|
|
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" />
|
|
212
|
-
등록
|
|
213
|
-
</sd-button>
|
|
214
|
-
<sd-button
|
|
215
|
-
[size]="'sm'"
|
|
216
|
-
[theme]="'link-danger'"
|
|
217
|
-
(click)="onToggleDeleteItemsButtonClick(true)"
|
|
218
|
-
[disabled]="!hasSelectedNotDeleted()"
|
|
219
|
-
>
|
|
220
|
-
<ng-icon [svg]="tablerEraser" />
|
|
221
|
-
선택 삭제
|
|
222
|
-
</sd-button>
|
|
223
|
-
@if (hasSelectedDeleted()) {
|
|
224
|
-
<sd-button
|
|
225
|
-
[size]="'sm'"
|
|
226
|
-
[theme]="'link-warning'"
|
|
227
|
-
(click)="onToggleDeleteItemsButtonClick(false)"
|
|
228
|
-
>
|
|
229
|
-
<ng-icon [svg]="tablerRestore" />
|
|
230
|
-
선택 복구
|
|
231
|
-
</sd-button>
|
|
232
|
-
}
|
|
233
|
-
</sd-dock>
|
|
234
|
-
}
|
|
235
|
-
|
|
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" }}
|
|
318
|
-
</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>
|
|
352
|
-
</sd-topbar-container>
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
</sd-busy-container>
|
|
356
|
-
`,
|
|
357
|
-
})
|
|
358
|
-
export class CustomerListPage implements SdSelectModal<ICustomer> {
|
|
359
|
-
//== DI ==
|
|
360
|
-
private readonly _appOrm = inject(AppOrmProvider);
|
|
361
|
-
private readonly _appAuth = inject(AppAuthProvider);
|
|
362
|
-
private readonly _appSharedData = inject(AppSharedDataProvider);
|
|
363
|
-
private readonly _sdToast = inject(SdToastProvider);
|
|
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("카테고리");
|
|
385
|
-
|
|
386
|
-
//== 상태 ==
|
|
387
|
-
initialized = signal(false);
|
|
388
|
-
busyCount = signal(0);
|
|
389
|
-
|
|
390
|
-
private _itemsSnapshot: ICustomer[] = [];
|
|
391
|
-
items = signal<ICustomer[]>([]);
|
|
392
|
-
selectedItems = signal<ICustomer[]>([]);
|
|
393
|
-
diffs = computed(() => this.items().oneWayDiffs(this._itemsSnapshot, "id"));
|
|
394
|
-
|
|
395
|
-
page = signal(0);
|
|
396
|
-
pageLength = signal(0);
|
|
397
|
-
sortingDefs = signal<SortingDef[]>([]);
|
|
398
|
-
|
|
399
|
-
filter = signal<IFilter>({ isIncludeDeleted: false });
|
|
400
|
-
lastFilter = signal<IFilter>({ isIncludeDeleted: false });
|
|
401
|
-
|
|
402
|
-
//== 파생 ==
|
|
403
|
-
hasSelectedDeleted = computed(() => this.selectedItems().some((it) => it.isDeleted));
|
|
404
|
-
hasSelectedNotDeleted = computed(() => this.selectedItems().some((it) => !it.isDeleted));
|
|
405
|
-
|
|
406
|
-
//== 시트 fn ==
|
|
407
|
-
trackByFn = (item: ICustomer) => item.id;
|
|
408
|
-
getItemCellStyleFn = (item: ICustomer): string | undefined =>
|
|
409
|
-
item.isDeleted ? "text-decoration: line-through;" : undefined;
|
|
410
|
-
|
|
411
|
-
constructor() {
|
|
412
|
-
// 필터/페이지/정렬/perms 변경 시 재조회
|
|
413
|
-
effect(() => {
|
|
414
|
-
if (!this.canUse()) {
|
|
415
|
-
this.initialized.set(true);
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
this.lastFilter();
|
|
420
|
-
this.page();
|
|
421
|
-
this.sortingDefs();
|
|
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
|
-
|
|
443
|
-
untracked(() => {
|
|
444
|
-
const sel = currItems.filter((it) => keys.includes(this.trackByFn(it)));
|
|
445
|
-
if (sel.length > 0) this.selectedItems.set(sel);
|
|
446
|
-
});
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
|
|
450
|
-
}
|
|
451
|
-
|
|
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 {
|
|
459
|
-
this.page.set(0);
|
|
460
|
-
this.lastFilter.set({ ...this.filter() });
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
onRefreshButtonClick(): void {
|
|
464
|
-
if (this.busyCount() > 0) return;
|
|
465
|
-
if (!this.canUse()) return;
|
|
466
|
-
if (!this._checkIgnoreChanges()) return;
|
|
467
|
-
|
|
468
|
-
mark(this.lastFilter);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
onSaveButtonClick(): void {
|
|
472
|
-
this.formCtrl()?.requestSubmit();
|
|
473
|
-
}
|
|
474
|
-
|
|
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);
|
|
485
|
-
await this._sdToast.try(async () => {
|
|
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);
|
|
505
|
-
}
|
|
506
|
-
|
|
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
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
//== Internals ==
|
|
537
|
-
private _checkIgnoreChanges(): boolean {
|
|
538
|
-
return this.diffs().length === 0 || confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// 로드+snapshot만 담당. busy/try는 호출부에서 처리.
|
|
542
|
-
private async _refresh(): Promise<void> {
|
|
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);
|
|
551
|
-
}
|
|
552
|
-
|
|
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
|
-
});
|
|
596
|
-
}
|
|
597
|
-
|
|
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;
|
|
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;
|
|
651
|
-
}
|
|
652
|
-
```
|
|
653
|
-
|
|
654
|
-
## 4. 분해 설명
|
|
655
|
-
|
|
656
|
-
각 블록의 역할과 원본 `SdDataSheet` 코드 대응 지점:
|
|
657
|
-
|
|
658
|
-
| 블록 | 역할 | 원본 대응 |
|
|
659
|
-
|---|---|---|
|
|
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)` |
|
|
662
|
-
| `@if (!canUse())` | 권한 없음 메시지 | `sd-base-container.ts:44-51` + `page-modal-container.md` |
|
|
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'"` 필수) | — |
|
|
669
|
-
| `getItemCellStyleFn` | `isDeleted` 시 취소선 | `sd-data-sheet.base.ts:137-140` |
|
|
670
|
-
| modal 하단 `<sd-dock [position]="'bottom'">` | 모달 뷰에서만 "선택 해제 / 확인" 바 노출 | `sd-data-sheet.ts:287-315` 모달 하단 바 |
|
|
671
|
-
| `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-sheet.ts:57-63` |
|
|
672
|
-
| `setupCanDeactivate(() => viewType() === "modal" || checkIgnoreChanges())` | 라우트 이탈 시 변경사항 확인 | `sd-data-sheet.base.ts:227` |
|
|
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` |
|
|
678
|
-
|
|
679
|
-
### 상태 분해
|
|
680
|
-
|
|
681
|
-
| signal / computed | 역할 |
|
|
682
|
-
|---|---|
|
|
683
|
-
| `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
|
|
684
|
-
| `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
|
|
685
|
-
| `items` | 현재 페이지 items |
|
|
686
|
-
| `selectedItems` | 선택된 item 배열 (`<sd-sheet [(selectedItems)]>`로 양방향) |
|
|
687
|
-
| `diffs` | `computed(() => items().oneWayDiffs(_itemsSnapshot, "id"))` — 변경 감지 signal. 템플릿(`getIsItemChanged`)·호출부(`onSubmit`·`_checkIgnoreChanges`) 모두에서 `diffs()`로 참조 |
|
|
688
|
-
| `page` / `pageLength` | 0-based 현재 페이지 / 전체 페이지 수 |
|
|
689
|
-
| `sortingDefs` | `SortingDef[]` — `{ key: string; desc: boolean }[]`, `<sd-sheet [(sorts)]>`로 양방향 |
|
|
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>` 요구 — 모달 결과 전달 |
|
|
695
|
-
|
|
696
|
-
### 메서드 분해
|
|
697
|
-
|
|
698
|
-
| 메서드 | 역할 |
|
|
699
|
-
|---|---|
|
|
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`를 기준으로 아래 변경을 적용한다.
|
|
717
|
-
|
|
718
|
-
```typescript
|
|
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>
|
|
732
|
-
<ng-template [cell]="items()" let-item="item">
|
|
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>
|
|
742
|
-
</ng-template>
|
|
743
|
-
</sd-sheet-column>
|
|
744
|
-
}
|
|
745
|
-
<!-- 나머지 컬럼(id, name, phone, categoryId, ...)은 기본 예제 그대로 -->
|
|
746
|
-
</sd-sheet>
|
|
747
|
-
|
|
748
|
-
// 3) 메서드 추가
|
|
749
|
-
protected onToggleDeleteItemButtonClick(item: ICustomer): void {
|
|
750
|
-
item.isDeleted = !item.isDeleted;
|
|
751
|
-
mark(this.items); // OnPush 재렌더 + effect 알림
|
|
752
|
-
}
|
|
753
|
-
```
|
|
754
|
-
|
|
755
|
-
**포인트:**
|
|
756
|
-
|
|
757
|
-
- inline 삭제도 **`item.isDeleted` 플래그 토글**로 표현. 행을 `items`에서 제거하면 `oneWayDiffs`가 누락한다. DB 반영은 저장 버튼 클릭 시 `onSubmit`에서 일괄 처리(soft-delete).
|
|
758
|
-
- **`canEdit() && viewType() === "page"` 조건**: modal 뷰 / 권한 없음이면 열 자체 숨김 (`canEdit`은 이미 page 한정이지만 명시적으로 쓰면 의도가 분명).
|
|
759
|
-
- 컬럼 key는 **`"_isDeleted"`** (언더스코어 prefix) — 서버 정렬·컬럼 지속성 설정과 충돌하지 않는 임의 키.
|
|
760
|
-
- 기본 예제의 상단 "선택 삭제/복구" 버튼과 **공존** 가능. row별 빠른 처리 + 다건 일괄 처리.
|
|
761
|
-
|
|
762
|
-
> **`oneWayDiffs`는 delete를 다루지 않는다.** `newItems.oneWayDiffs(orgItems, keyFn)`은 `type: "create" | "update" | "same"`만 반환한다. 삭제 의사는 **`item.isDeleted = true` 플래그**로 표현하여 `"update"` diff로 전송된다. 행을 `items` 배열에서 물리적으로 제거하면 diff에서 누락되므로 절대 삭제하지 않는다.
|
|
763
|
-
|
|
764
|
-
## 6. 변형: 모달 편집 모드
|
|
765
|
-
|
|
766
|
-
기본 예제는 시트 셀 직접 수정(inline 편집) + 일괄 저장 방식이지만, 행 클릭 시 **편집 모달을 띄워 한 행씩 편집**하는 모드가 필요하면 아래 변경을 적용한다. 일괄 저장(diff) / snapshot / `_checkIgnoreChanges` / `setupCanDeactivate`는 불필요해져 모두 제거된다.
|
|
767
|
-
|
|
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";
|
|
774
|
-
|
|
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();
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
protected async onEditItemButtonClick(item: ICustomer, event: MouseEvent): Promise<void> {
|
|
804
|
-
event.preventDefault();
|
|
805
|
-
event.stopPropagation();
|
|
806
|
-
await this._editItem(item);
|
|
807
|
-
}
|
|
808
|
-
|
|
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 },
|
|
814
|
-
});
|
|
815
|
-
if (r != null) await this._refresh();
|
|
816
|
-
}
|
|
817
|
-
|
|
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 영역에 직접 배치
|
|
824
|
-
```
|
|
825
|
-
|
|
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
|
-
}
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
## 7. 변형: 엑셀 업로드/다운로드
|
|
850
|
-
|
|
851
|
-
`SdFileDialogProvider`로 파일 선택, `ExcelWrapper`(@simplysm/excel) + `zod` 스키마로 읽기/쓰기. 다운로드는 `_search(false)`로 전체 페이지 조회 후 `@simplysm/core-browser`의 `downloadBlob`으로 내려받는다.
|
|
852
|
-
|
|
853
|
-
```typescript
|
|
854
|
-
// 1) import 추가
|
|
855
|
-
import { tablerFileExcel, tablerUpload } from "@ng-icons/tabler-icons";
|
|
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";
|
|
861
|
-
|
|
862
|
-
// 2) DI 추가
|
|
863
|
-
private readonly _sdFileDialog = inject(SdFileDialogProvider);
|
|
864
|
-
|
|
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
|
-
엑셀 다운로드
|
|
887
|
-
</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>
|
|
895
|
-
|
|
896
|
-
// 5) 메서드 추가
|
|
897
|
-
async onDownloadExcelButtonClick(): Promise<void> {
|
|
898
|
-
if (this.busyCount() > 0) return;
|
|
899
|
-
|
|
900
|
-
this.busyCount.update((v) => v + 1);
|
|
901
|
-
await this._sdToast.try(async () => {
|
|
902
|
-
// 전체 조회 (페이지네이션 없이) — 기본 예제의 _search를 그대로 재사용
|
|
903
|
-
const r = await this._search(false);
|
|
904
|
-
const wb = await this._excelWrapper.write(this.viewTitle(), r.items);
|
|
905
|
-
try {
|
|
906
|
-
downloadBlob(
|
|
907
|
-
await wb.toBlob(),
|
|
908
|
-
`${this.viewTitle()}_${new DateTime().toFormatString("yyMMdd")}.xlsx`,
|
|
909
|
-
);
|
|
910
|
-
} finally {
|
|
911
|
-
await wb.close();
|
|
912
|
-
}
|
|
913
|
-
});
|
|
914
|
-
this.busyCount.update((v) => v - 1);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
async onUploadExcelButtonClick(): Promise<void> {
|
|
918
|
-
const file = await this._sdFileDialog.showAsync(false, ".xlsx");
|
|
919
|
-
if (file == null) return;
|
|
920
|
-
if (Array.isArray(file)) return;
|
|
921
|
-
|
|
922
|
-
this.busyCount.update((v) => v + 1);
|
|
923
|
-
await this._sdToast.try(async () => {
|
|
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);
|
|
938
|
-
}
|
|
939
|
-
```
|
|
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
|
-
|
|
948
|
-
## 8. 뷰 타입 분기
|
|
949
|
-
|
|
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`)이면 페이지 이동 시 선택이 초기화되는데, 일괄 작업 대상 리스트에서 이는 의도적 동작.
|
|
962
|
-
|
|
963
|
-
## 9. 주의사항 (자주 하는 실수)
|
|
964
|
-
|
|
965
|
-
### 공통 유틸 재도입 금지
|
|
966
|
-
|
|
967
|
-
- `useCrudList()`, `useDataSheet()`, `setupCumulateSelectedKeys2()` 같은 공통 헬퍼를 도입하지 말 것. 이 레시피가 제거한 추상화를 다시 만드는 행위다. 세 화면이 비슷해 보여도 화면마다 필드·동작 시그니처가 조금씩 다르므로 복사·수정이 낫다
|
|
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
|
-
|
|
977
|
-
### 시트 셀 스타일 함정
|
|
978
|
-
|
|
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]` 생략 가능
|
|
980
|
-
|
|
981
|
-
### `oneWayDiffs`의 삭제 처리
|
|
982
|
-
|
|
983
|
-
- `newItems.oneWayDiffs(orgItems, keyFn)`은 **삭제(delete)를 다루지 않는다**. `type: "create" | "update" | "same"`만 반환
|
|
984
|
-
- 삭제 의사 표현은 **`item.isDeleted = true` 플래그**로 하고 `"update"` diff로 전송 (서버가 soft-delete 처리)
|
|
985
|
-
- inline 편집 시 `items` 배열에서 row를 제거하지 말 것. diff에서 해당 row가 누락되어 서버가 변경을 감지할 수 없다
|
|
986
|
-
|
|
987
|
-
### `selectedItemKeys`는 `filterExists()`로 undefined 제거
|
|
988
|
-
|
|
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).
|
|
995
|
-
|
|
996
|
-
### `injectViewTypeSignal()` 호출 시점
|
|
997
|
-
|
|
998
|
-
- `injectViewTypeSignal()`은 생성자 실행 중 또는 필드 이니셜라이저에서만 호출한다. `computed`·`effect` 콜백이나 일반 메서드에서 호출하면 `NG0203` 런타임 에러가 발생한다 (Angular `inject()` 제약)
|
|
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
|
-
|
|
1009
|
-
## 10. 레시피 작성 관용 규칙
|
|
1010
|
-
|
|
1011
|
-
향후 `crud-detail.md` · `data-select-button.md` 등 데이터 관련 레시피가 추가될 때 아래 3개 규칙을 공통으로 따른다.
|
|
1012
|
-
|
|
1013
|
-
### 규칙 1: 시트 셀 내부 컨트롤은 `[inset]="true" [size]="'sm'"` 명시
|
|
1014
|
-
|
|
1015
|
-
- `<sd-sheet-column>` `[cell]` 템플릿 내부의 `sd-textfield` / `sd-select` / `sd-checkbox` / `sd-numpad` / `sd-date-range-picker` / `sd-textarea`는 레시피에서 **항상** `[inset]="true" [size]="'sm'"`를 함께 노출한다
|
|
1016
|
-
- 예외: 복합 구조(텍스트+컨트롤) → `[inset]="false"`. 큰 시트 행 → `[size]` 생략
|
|
1017
|
-
- 누락 시 컴파일 에러가 발생하지 않아 LLM이 빠뜨리기 쉽다. 자주 하는 실수 섹션에 명시
|
|
1018
|
-
|
|
1019
|
-
### 규칙 2: `mark(sig)`는 "저장 감지"가 아니라 "UI 동기화"
|
|
1020
|
-
|
|
1021
|
-
- `mark(sig)`는 `WritableSignal`의 값을 shallow copy하여 **참조를 갱신**한다 (배열: `[...v]`, 객체: `{...v}`)
|
|
1022
|
-
- 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**
|
|
1023
|
-
- **"저장 감지"가 아니다.** `obj.equal`이 deep equal로 값 차이를 감지하므로, `item.name = "new"` 같은 mutation은 `mark` 없이도 `diffs()` / submit에서 감지된다
|
|
1024
|
-
- Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요
|
|
1025
|
-
- ❌ "mark 없으면 저장이 안 된다" 식 서술 금지
|
|
1026
|
-
|
|
1027
|
-
### 규칙 3: `sortingDefs` + `orderBy` 체인은 string overload 사용
|
|
1028
|
-
|
|
1029
|
-
- `Queryable.orderBy`는 string overload를 지원한다 (`packages/orm-common/src/exec/queryable.ts:420`)
|
|
1030
|
-
- 레시피는 아래 형태로 작성:
|
|
1031
|
-
```typescript
|
|
1032
|
-
for (const s of sortingDefs) {
|
|
1033
|
-
qr = qr.orderBy(s.key, s.desc ? "DESC" : "ASC");
|
|
1034
|
-
}
|
|
1035
|
-
```
|
|
1036
|
-
- 체인 경로도 string으로 지원: `qr.orderBy("user.name")` 형태
|
|
1037
|
-
- 과거 람다 형태 (`qr.orderBy((item) => obj.getChainValue(item, s.key, true) as any, ...)`)는 **쓰지 않는다** — overload 도입 전 우회 코드였다
|