@simplysm/angular 14.0.48 → 14.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -226
- package/dist/controls/select/sd-select.js +3 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/layout/dock/sd-dock-container.js +1 -1
- package/dist/styles.css +9 -0
- package/docs/bootstrap/provide-sd-angular.md +37 -0
- package/docs/bootstrap/sd-angular-config-provider.md +16 -0
- package/docs/directives/sd-command-directive.md +30 -0
- package/docs/directives/sd-events.md +25 -0
- package/docs/directives/sd-intersection-directive.md +36 -0
- package/docs/directives/sd-invalid.md +24 -0
- package/docs/directives/sd-resize-directive.md +42 -0
- package/docs/directives/sd-ripple.md +23 -0
- package/docs/directives/sd-router-link.md +38 -0
- package/docs/directives/sd-show-effect.md +18 -0
- package/docs/directives/sd-typed-template.md +69 -0
- package/docs/features/sd-address-search-modal.md +50 -0
- package/docs/features/sd-permission-table.md +20 -0
- package/docs/features/sd-shared-data-components.md +158 -0
- package/docs/features/sd-tiptap-editor.md +26 -0
- package/docs/{pipes.md → pipes/format-pipe.md} +14 -5
- package/docs/plugins/sd-global-error-handler.md +23 -0
- package/docs/{plugins.md → plugins/sd-option-event-plugin.md} +9 -12
- package/docs/provider-types/sd-menu.md +65 -0
- package/docs/provider-types/sd-modal-content-def.md +148 -0
- package/docs/provider-types/sd-toast-content-def.md +73 -0
- package/docs/provider-types/shared-data-base.md +59 -0
- package/docs/providers/sd-activated-modal-provider.md +34 -0
- package/docs/providers/sd-app-structure-provider.md +81 -0
- package/docs/providers/sd-busy-provider.md +18 -0
- package/docs/providers/sd-file-dialog-provider.md +40 -0
- package/docs/providers/sd-local-storage-provider.md +20 -0
- package/docs/providers/sd-modal-provider.md +67 -0
- package/docs/providers/sd-navigate-window-provider.md +18 -0
- package/docs/providers/sd-print-provider.md +25 -0
- package/docs/providers/sd-service-client-factory-provider.md +43 -0
- package/docs/providers/sd-shared-data-provider.md +64 -0
- package/docs/providers/sd-system-config-provider.md +46 -0
- package/docs/providers/sd-system-log-provider.md +18 -0
- package/docs/providers/sd-theme-provider.md +38 -0
- package/docs/providers/sd-toast-provider.md +65 -0
- package/docs/recipes/_common-rules.md +244 -0
- package/docs/recipes/crud-detail/extension-a-edit-save.md +230 -0
- package/docs/recipes/crud-detail/extension-b-delete-restore.md +142 -0
- package/docs/recipes/crud-detail/extension-c-modal-view.md +214 -0
- package/docs/recipes/crud-detail/extension-d-control-view.md +103 -0
- package/docs/recipes/crud-detail/extension-e-auxiliary.md +87 -0
- package/docs/recipes/crud-detail/extension-f-complex-detail.md +234 -0
- package/docs/recipes/crud-detail.md +215 -722
- package/docs/recipes/crud-list/extension-a-inline-edit.md +410 -0
- package/docs/recipes/crud-list/extension-b-selection.md +226 -0
- package/docs/recipes/crud-list/extension-c-inline-delete.md +87 -0
- package/docs/recipes/crud-list/extension-d-select-modal.md +207 -0
- package/docs/recipes/crud-list/extension-e-readonly-modal.md +165 -0
- package/docs/recipes/crud-list/extension-f-modal-edit.md +177 -0
- package/docs/recipes/crud-list/extension-g-excel.md +157 -0
- package/docs/recipes/crud-list.md +293 -626
- package/docs/recipes/data-select-button.md +194 -101
- package/docs/recipes/page-modal-container.md +168 -86
- package/docs/styling/classes.md +149 -0
- package/docs/styling/mixins.md +100 -0
- package/docs/styling/themes.md +35 -0
- package/docs/styling/variables.md +147 -0
- package/docs/{type-utilities.md → type-utilities/directive-input-signals.md} +17 -35
- package/docs/ui-data/sd-list.md +37 -0
- package/docs/ui-data/sd-sheet.md +227 -0
- package/docs/ui-form/sd-additional-button.md +26 -0
- package/docs/ui-form/sd-anchor.md +31 -0
- package/docs/ui-form/sd-button.md +105 -0
- package/docs/ui-form/sd-checkbox-group.md +39 -0
- package/docs/ui-form/sd-checkbox.md +81 -0
- package/docs/ui-form/sd-date-range-picker.md +27 -0
- package/docs/ui-form/sd-form.md +89 -0
- package/docs/ui-form/sd-modal-select-button.md +54 -0
- package/docs/ui-form/sd-numpad.md +26 -0
- package/docs/ui-form/sd-range.md +26 -0
- package/docs/ui-form/sd-select.md +68 -0
- package/docs/ui-form/sd-shared-data-select.md +52 -0
- package/docs/ui-form/sd-state-preset.md +37 -0
- package/docs/ui-form/sd-switch.md +27 -0
- package/docs/ui-form/sd-textarea.md +33 -0
- package/docs/ui-form/sd-textfield.md +145 -0
- package/docs/ui-layout/sd-dock-container.md +64 -0
- package/docs/ui-layout/sd-dock.md +37 -0
- package/docs/ui-layout/sd-gap.md +26 -0
- package/docs/{ui-layout.md → ui-layout/sd-kanban-board.md} +41 -85
- package/docs/ui-layout/sd-kanban-lane.md +34 -0
- package/docs/ui-layout/sd-kanban.md +29 -0
- package/docs/ui-navigation/sd-collapse.md +35 -0
- package/docs/ui-navigation/sd-pagination.md +26 -0
- package/docs/ui-navigation/sd-sidebar-container.md +49 -0
- package/docs/ui-navigation/sd-sidebar-menu.md +22 -0
- package/docs/ui-navigation/sd-sidebar-user.md +43 -0
- package/docs/ui-navigation/sd-tab.md +51 -0
- package/docs/ui-navigation/sd-topbar-container.md +97 -0
- package/docs/ui-navigation/sd-topbar-menu.md +23 -0
- package/docs/ui-navigation/sd-topbar-user.md +38 -0
- package/docs/ui-navigation/sd-topbar.md +30 -0
- package/docs/ui-overlay/sd-busy-container.md +69 -0
- package/docs/ui-overlay/sd-confirm-modal.md +30 -0
- package/docs/ui-overlay/sd-dropdown.md +40 -0
- package/docs/ui-overlay/sd-modal.md +34 -0
- package/docs/ui-overlay/sd-prompt-modal.md +30 -0
- package/docs/ui-overlay/sd-toast.md +35 -0
- package/docs/ui-visual/sd-barcode.md +36 -0
- package/docs/ui-visual/sd-calendar.md +34 -0
- package/docs/ui-visual/sd-echarts.md +32 -0
- package/docs/ui-visual/sd-label.md +24 -0
- package/docs/ui-visual/sd-note.md +23 -0
- package/docs/ui-visual/sd-progress.md +23 -0
- package/docs/utils/inject-routing-signals.md +161 -0
- package/docs/utils/inject-sd-system-config-resource.md +35 -0
- package/docs/utils/mark.md +43 -0
- package/docs/utils/selection-managers.md +96 -0
- package/docs/utils/set-safe-style.md +19 -0
- package/docs/utils/setup-functions.md +93 -0
- package/package.json +7 -7
- package/scss/commons/_styles.scss +12 -0
- 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/index.ts +0 -3
- package/src/layout/dock/sd-dock-container.ts +1 -1
- package/dist/data/getOrmDataEditToastErrorMessage.d.ts +0 -2
- package/dist/data/getOrmDataEditToastErrorMessage.d.ts.map +0 -1
- package/dist/data/getOrmDataEditToastErrorMessage.js +0 -8
- package/docs/bootstrap.md +0 -38
- package/docs/directives.md +0 -236
- package/docs/features.md +0 -169
- package/docs/provider-types.md +0 -283
- package/docs/providers.md +0 -379
- package/docs/styling.md +0 -222
- package/docs/ui-data.md +0 -333
- package/docs/ui-form.md +0 -502
- package/docs/ui-navigation.md +0 -273
- package/docs/ui-overlay.md +0 -157
- package/docs/ui-visual.md +0 -127
- package/docs/utils.md +0 -244
- package/src/data/getOrmDataEditToastErrorMessage.ts +0 -10
|
@@ -1,109 +1,87 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
아래는 **페이지 뷰 + modal 편집 모드** 기준 완성 컴포넌트다. CRUD 리스트의 가장 일반적 형태(필터 검색 → 페이지네이션 + 정렬 → 모달로 row 편집 → 선택 삭제/복구)를 커버한다. inline / 선택 모달 / 엑셀은 `## 5`~`## 7`의 변형 스니펫으로 교체·추가한다.
|
|
1
|
+
# CRUD 리스트 화면 직접 조립
|
|
2
|
+
|
|
3
|
+
`<sd-busy-container>` · `<sd-topbar-container>` · `<sd-topbar>` · `<sd-form>` · `<sd-sheet>` · `<sd-sheet-column>` 표준 컴포넌트를 소비 화면이 **직접 조립**하여 CRUD 리스트 화면을 구성하는 레시피. 최소 뼈대(조회 전용 page) 위에 편집·선택·모달·엑셀 등 필요한 확장만 선택적으로 얹는다.
|
|
4
|
+
|
|
5
|
+
> **modal 뷰는 용도(선택 모달 vs 조회 전용)를 먼저 확정한다.** 상세와 회피 방법은 아래 [🚫 흔한 실수](#-흔한-실수-anti-patterns)의 "modal = 선택 모달로 반사적 부착" 참조.
|
|
6
|
+
|
|
7
|
+
## When to use / When NOT to use
|
|
8
|
+
|
|
9
|
+
- ✅ 검색·페이지네이션·정렬을 갖춘 리스트 조회 화면
|
|
10
|
+
- ✅ 셀 직접 편집·일괄 저장·선택 삭제·엑셀 업로드/다운로드가 필요한 리스트 CRUD
|
|
11
|
+
- ✅ 같은 리스트 화면을 page·select-modal·readonly-modal 등으로 재사용
|
|
12
|
+
- ❌ 단일 레코드 상세 폼 → [`crud-detail.md`](./crud-detail.md)
|
|
13
|
+
- ❌ 모달 선택 버튼(항목을 골라 받는 버튼) 조립 → [`data-select-button.md`](./data-select-button.md)
|
|
14
|
+
- ❌ 뷰 분기(page/modal/control) 공통 껍데기만 필요 → [`page-modal-container.md`](./page-modal-container.md)
|
|
15
|
+
|
|
16
|
+
### 뷰 범위 선택
|
|
17
|
+
|
|
18
|
+
- **page**: 라우트로 진입하는 주 리스트 화면 → 본 파일의 [기본 레시피](#기본-레시피) 그대로
|
|
19
|
+
- **modal (선택)**: 다른 화면에서 항목을 골라 `close.emit`으로 돌려주는 selector → [확장 D](./crud-list/extension-d-select-modal.md)
|
|
20
|
+
- **modal (조회 전용)**: 부모 레코드의 자식 목록·이력을 input으로 받아 읽기 전용으로 표시(닫기는 SdModal 기본 "X") → [확장 E](./crud-list/extension-e-readonly-modal.md)
|
|
21
|
+
- **control**: 마스터-디테일의 리스트 영역으로 임베딩 → [공통 규칙 "page가 topbar 소유"](./_common-rules.md#page-컴포넌트가-sd-topbar-container와-sd-topbar를-소유한다) + 필요 시 확장 D/E 참조
|
|
22
|
+
|
|
23
|
+
## 전제조건
|
|
24
|
+
|
|
25
|
+
- Provider: 앱 루트에 `provideSdAngular`가 등록되어 있음
|
|
26
|
+
- peer: Angular 21 standalone, zoneless, `@simplysm/angular`
|
|
27
|
+
- 앱별: ORM provider + `DbContext` (예제의 `AppOrmProvider`·`@adtek/client-common`은 각 앱이 소유하는 placeholder)
|
|
28
|
+
- 횡단 규칙: [`_common-rules.md`](./_common-rules.md)에 정의된 공통 규칙을 모두 준수한다. 본 파일에서 재정의하지 않는다
|
|
29
|
+
- [`injectViewTypeSignal()` 호출 시점](./_common-rules.md#injectviewtypesignal은-생성자-또는-필드-이니셜라이저에서만-호출한다)
|
|
30
|
+
- [page 컴포넌트가 `<sd-topbar>`를 소유](./_common-rules.md#page-컴포넌트가-sd-topbar-container와-sd-topbar를-소유한다)
|
|
31
|
+
- [시트 셀 컨트롤에 `[inset]="true" [size]="'sm'"` 명시](./_common-rules.md#시트-셀-내부-컨트롤에-insettrue-sizesm을-명시한다)
|
|
32
|
+
- [input 변경을 effect에서 filter·lastFilter·page에 반영](./_common-rules.md#input-변경을-effect-내부에서-filterlastfilterpage에-반영한다)
|
|
33
|
+
- [`void this._initAsync()` 금지](./_common-rules.md#input-의존-데이터-로딩에-void-this_initasync를-사용하지-않는다) / [signal 필드 초기값에서 다른 signal 읽기 금지](./_common-rules.md#signal-필드-초기값에서-다른-signal을-읽지-않는다) / [`mark()`를 저장 감지 수단으로 오해 금지](./_common-rules.md#marksig를-저장-감지-수단으로-사용하지-않는다)
|
|
34
|
+
|
|
35
|
+
## 기본 레시피
|
|
36
|
+
|
|
37
|
+
조회 전용 page 기준의 최소 뼈대 완성 컴포넌트. 라우트로 진입하면 검색 + 페이지네이션 + 정렬이 동작하는 읽기 전용 리스트로 표시된다. 편집·선택·모달·엑셀은 아래 [변형](#변형-variation) 표에서 필요한 확장을 선택적으로 얹는다.
|
|
38
|
+
|
|
39
|
+
> **이 최소 뼈대가 포함하지 않는 것:** 감사 필드(`lastModifiedAt`/`lastModifiedBy`) · 카테고리 등 FK 표시 컬럼 · 편집 권한(`"edit"`) · 편집용 컨트롤은 모두 확장 A 이후에 도입된다. (참고: `crud-detail.md` 최소 뼈대는 page 전용 읽기 폼이므로 감사 필드 2열을 포함한다 — 도메인 범위는 같지만 뼈대 범위가 다르다.)
|
|
42
40
|
|
|
43
41
|
```typescript
|
|
44
42
|
import { NgIcon } from "@ng-icons/core";
|
|
45
|
-
import {
|
|
46
|
-
tablerAlertTriangle,
|
|
47
|
-
tablerCirclePlus,
|
|
48
|
-
tablerEdit,
|
|
49
|
-
tablerEraser,
|
|
50
|
-
tablerRefresh,
|
|
51
|
-
tablerRestore,
|
|
52
|
-
tablerSearch,
|
|
53
|
-
} from "@ng-icons/tabler-icons";
|
|
43
|
+
import { tablerAlertTriangle, tablerRefresh, tablerSearch } from "@ng-icons/tabler-icons";
|
|
54
44
|
import {
|
|
55
45
|
ChangeDetectionStrategy,
|
|
56
46
|
Component,
|
|
57
|
-
computed,
|
|
58
47
|
effect,
|
|
59
48
|
inject,
|
|
60
49
|
signal,
|
|
61
50
|
untracked,
|
|
62
51
|
ViewEncapsulation,
|
|
63
52
|
} from "@angular/core";
|
|
53
|
+
import { str } from "@simplysm/core-common";
|
|
64
54
|
import {
|
|
65
|
-
getOrmDataEditToastErrorMessage,
|
|
66
|
-
injectCurrentPageCodeSignal,
|
|
67
|
-
injectFullPageCodeSignal,
|
|
68
55
|
injectPermsSignal,
|
|
56
|
+
injectViewTitleSignal,
|
|
69
57
|
injectViewTypeSignal,
|
|
70
58
|
mark,
|
|
71
|
-
SdActivatedModalProvider,
|
|
72
|
-
SdAnchor,
|
|
73
|
-
SdAppStructureProvider,
|
|
74
59
|
SdBusyContainer,
|
|
75
60
|
SdButton,
|
|
76
61
|
SdCommandDirective,
|
|
62
|
+
SdDock,
|
|
63
|
+
SdDockContainer,
|
|
77
64
|
SdForm,
|
|
78
|
-
SdModalProvider,
|
|
79
65
|
SdSheet,
|
|
80
66
|
SdSheetColumn,
|
|
81
67
|
SdSheetColumnCellTemplate,
|
|
82
|
-
SdSystemLogProvider,
|
|
83
68
|
SdTextfield,
|
|
84
69
|
SdToastProvider,
|
|
85
70
|
SdTopbar,
|
|
86
71
|
SdTopbarContainer,
|
|
87
|
-
setupCanDeactivate,
|
|
88
72
|
type SortingDef,
|
|
89
73
|
} from "@simplysm/angular";
|
|
90
|
-
|
|
74
|
+
// 앱별 대체: ORM provider + DbContext. simplysm 패키지가 아니라 각 앱이 소유한다.
|
|
75
|
+
import { AppOrmProvider } from "@adtek/client-common";
|
|
91
76
|
|
|
92
|
-
interface
|
|
93
|
-
|
|
94
|
-
name: string;
|
|
95
|
-
phone: string;
|
|
96
|
-
isDeleted: boolean;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
interface ICustomerFilter {
|
|
100
|
-
search: string;
|
|
77
|
+
interface IFilter {
|
|
78
|
+
searchText?: string;
|
|
101
79
|
}
|
|
102
80
|
|
|
103
|
-
interface
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
81
|
+
interface ICustomer {
|
|
82
|
+
id: number;
|
|
83
|
+
name: string;
|
|
84
|
+
phone?: string;
|
|
107
85
|
}
|
|
108
86
|
|
|
109
87
|
@Component({
|
|
@@ -113,8 +91,10 @@ interface ICustomerSearchResult {
|
|
|
113
91
|
standalone: true,
|
|
114
92
|
imports: [
|
|
115
93
|
SdBusyContainer, SdTopbarContainer, SdTopbar,
|
|
94
|
+
SdDockContainer, SdDock,
|
|
116
95
|
SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
|
|
117
|
-
SdButton,
|
|
96
|
+
SdButton, SdTextfield,
|
|
97
|
+
NgIcon,
|
|
118
98
|
],
|
|
119
99
|
hostDirectives: [
|
|
120
100
|
{ directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
|
|
@@ -123,657 +103,344 @@ interface ICustomerSearchResult {
|
|
|
123
103
|
"(sdRefreshCommand)": "onRefreshButtonClick()",
|
|
124
104
|
},
|
|
125
105
|
template: `
|
|
126
|
-
<sd-busy-container [busy]="busyCount() > 0"
|
|
106
|
+
<sd-busy-container [busy]="busyCount() > 0">
|
|
127
107
|
@if (initialized()) {
|
|
128
|
-
@if (!
|
|
108
|
+
@if (!perms().includes("use")) {
|
|
129
109
|
<div class="fill tx-theme-gray-light p-xxl tx-center">
|
|
130
110
|
<br />
|
|
131
|
-
<ng-icon [svg]="
|
|
132
|
-
<br
|
|
133
|
-
|
|
111
|
+
<ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
|
|
112
|
+
<br />
|
|
113
|
+
<br />
|
|
114
|
+
'{{ viewTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
|
|
134
115
|
</div>
|
|
135
|
-
} @else
|
|
116
|
+
} @else {
|
|
136
117
|
<sd-topbar-container>
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
118
|
+
@if (viewType() === "page") {
|
|
119
|
+
<sd-topbar>
|
|
120
|
+
<h4>{{ viewTitle() }}</h4>
|
|
121
|
+
|
|
122
|
+
<sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
|
|
123
|
+
<ng-icon [svg]="tablerRefresh" />
|
|
124
|
+
새로고침
|
|
125
|
+
<small>(CTRL+ALT+L)</small>
|
|
126
|
+
</sd-button>
|
|
127
|
+
</sd-topbar>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
<sd-dock-container>
|
|
131
|
+
<!-- 필터 -->
|
|
132
|
+
<sd-dock class="p-default">
|
|
148
133
|
<sd-form (formSubmit)="onFilterSubmit()">
|
|
149
134
|
<div class="form-box-inline">
|
|
150
|
-
<
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
135
|
+
<div class="form-box-item">
|
|
136
|
+
<sd-button [type]="'submit'" [theme]="'info'">
|
|
137
|
+
<ng-icon [svg]="tablerSearch" />
|
|
138
|
+
조회
|
|
139
|
+
</sd-button>
|
|
140
|
+
</div>
|
|
141
|
+
<div class="form-box-item">
|
|
142
|
+
<label>검색어</label>
|
|
143
|
+
<sd-textfield
|
|
144
|
+
[type]="'text'"
|
|
145
|
+
[placeholder]="'이름/전화번호'"
|
|
146
|
+
[(value)]="filter().searchText"
|
|
147
|
+
(valueChange)="mark(filter)"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
161
150
|
</div>
|
|
162
151
|
</sd-form>
|
|
163
|
-
</
|
|
164
|
-
|
|
165
|
-
<!--
|
|
166
|
-
@if (canEdit()) {
|
|
167
|
-
<div class="flex-row gap-sm p-xs-default">
|
|
168
|
-
<sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onCreateItemButtonClick()">
|
|
169
|
-
<ng-icon [svg]="icons.tablerCirclePlus" />
|
|
170
|
-
등록
|
|
171
|
-
</sd-button>
|
|
172
|
-
<sd-button
|
|
173
|
-
[size]="'sm'"
|
|
174
|
-
[theme]="'link-danger'"
|
|
175
|
-
(click)="onToggleDeleteItemsButtonClick(true)"
|
|
176
|
-
[disabled]="!hasSelectedNotDeleted()"
|
|
177
|
-
>
|
|
178
|
-
<ng-icon [svg]="icons.tablerEraser" />
|
|
179
|
-
선택 삭제
|
|
180
|
-
</sd-button>
|
|
181
|
-
@if (hasSelectedDeleted()) {
|
|
182
|
-
<sd-button
|
|
183
|
-
[size]="'sm'"
|
|
184
|
-
[theme]="'link-warning'"
|
|
185
|
-
(click)="onToggleDeleteItemsButtonClick(false)"
|
|
186
|
-
>
|
|
187
|
-
<ng-icon [svg]="icons.tablerRestore" />
|
|
188
|
-
선택 복구
|
|
189
|
-
</sd-button>
|
|
190
|
-
}
|
|
191
|
-
</div>
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
<!-- 시트 -->
|
|
152
|
+
</sd-dock>
|
|
153
|
+
|
|
154
|
+
<!-- 시트 (main 영역) -->
|
|
195
155
|
<sd-sheet
|
|
196
156
|
[key]="'customer-list-sheet'"
|
|
197
157
|
[items]="items()"
|
|
198
158
|
[(currentPage)]="page"
|
|
199
159
|
[totalPageCount]="pageLength()"
|
|
200
160
|
[(sorts)]="sortingDefs"
|
|
201
|
-
[selectMode]="'multi'"
|
|
202
|
-
[(selectedItems)]="selectedItems"
|
|
203
161
|
[trackByFn]="trackByFn"
|
|
204
|
-
[getItemCellStyleFn]="getItemCellStyleFn"
|
|
205
|
-
class="flex-fill p-default pt-0"
|
|
206
162
|
>
|
|
207
|
-
<sd-sheet-column [
|
|
163
|
+
<sd-sheet-column [fixed]="true" [header]="'#'" [key]="'id'">
|
|
164
|
+
<ng-template [cell]="items()" let-item="item">
|
|
165
|
+
<div class="p-xs-sm tx-right">{{ item.id }}</div>
|
|
166
|
+
</ng-template>
|
|
167
|
+
</sd-sheet-column>
|
|
168
|
+
|
|
169
|
+
<sd-sheet-column [header]="'이름'" [key]="'name'">
|
|
208
170
|
<ng-template [cell]="items()" let-item="item">
|
|
209
|
-
<
|
|
210
|
-
<div class="p-xs-sm">
|
|
211
|
-
<ng-icon [svg]="icons.tablerEdit" />
|
|
212
|
-
</div>
|
|
213
|
-
<div class="flex-fill p-xs-sm">{{ item.name }}</div>
|
|
214
|
-
</sd-anchor>
|
|
171
|
+
<div class="p-xs-sm">{{ item.name }}</div>
|
|
215
172
|
</ng-template>
|
|
216
173
|
</sd-sheet-column>
|
|
217
|
-
|
|
174
|
+
|
|
175
|
+
<sd-sheet-column [header]="'전화번호'" [key]="'phone'">
|
|
218
176
|
<ng-template [cell]="items()" let-item="item">
|
|
219
177
|
<div class="p-xs-sm">{{ item.phone }}</div>
|
|
220
178
|
</ng-template>
|
|
221
179
|
</sd-sheet-column>
|
|
222
180
|
</sd-sheet>
|
|
223
|
-
</
|
|
181
|
+
</sd-dock-container>
|
|
224
182
|
</sd-topbar-container>
|
|
225
|
-
} @else if (viewType() === "modal") {
|
|
226
|
-
<!-- 모달 뷰 분기는 변형 2 "선택 모달" 참조 -->
|
|
227
|
-
} @else {
|
|
228
|
-
<!-- control 뷰: 다른 화면의 영역으로 삽입될 때 -->
|
|
229
183
|
}
|
|
230
184
|
}
|
|
231
185
|
</sd-busy-container>
|
|
232
186
|
`,
|
|
233
187
|
})
|
|
234
|
-
export class
|
|
188
|
+
export class CustomerList {
|
|
235
189
|
//== DI ==
|
|
190
|
+
private readonly _appOrm = inject(AppOrmProvider);
|
|
236
191
|
private readonly _sdToast = inject(SdToastProvider);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
);
|
|
192
|
+
|
|
193
|
+
//== 식별 / 권한 ==
|
|
194
|
+
perms = injectPermsSignal(["sales.customer"], ["use"]);
|
|
195
|
+
|
|
196
|
+
viewType = injectViewTypeSignal();
|
|
197
|
+
viewTitle = injectViewTitleSignal();
|
|
254
198
|
|
|
255
199
|
//== 상태 ==
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
protected readonly items = signal<ICustomer[]>([]);
|
|
261
|
-
protected readonly summaryData = signal<Partial<ICustomer>>({});
|
|
262
|
-
protected readonly selectedItems = signal<ICustomer[]>([]);
|
|
263
|
-
protected readonly page = signal(0);
|
|
264
|
-
protected readonly pageLength = signal(0);
|
|
265
|
-
protected readonly sortingDefs = signal<SortingDef[]>([{ key: "name", desc: false }]);
|
|
266
|
-
protected readonly filterSearch = signal("");
|
|
267
|
-
private readonly _lastFilter = signal<ICustomerFilter>({ search: "" });
|
|
268
|
-
private _snapshot: ICustomer[] = [];
|
|
269
|
-
|
|
270
|
-
//== 파생 ==
|
|
271
|
-
protected readonly modalOrPageTitle = computed(() => {
|
|
272
|
-
try {
|
|
273
|
-
return (
|
|
274
|
-
this._sdActivatedModal?.modalComponent()?.title() ??
|
|
275
|
-
this._sdAppStructure.getTitleByFullCode(this._currPageCode?.() ?? this._fullPageCode())
|
|
276
|
-
);
|
|
277
|
-
} catch (err) {
|
|
278
|
-
void this._sdSystemLog.writeAsync("warn", `title 계산 실패: ${String(err)}`);
|
|
279
|
-
return "";
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
protected readonly hasSelectedDeleted = computed(() =>
|
|
284
|
-
this.selectedItems().some((it) => it.isDeleted),
|
|
285
|
-
);
|
|
286
|
-
protected readonly hasSelectedNotDeleted = computed(() =>
|
|
287
|
-
this.selectedItems().some((it) => !it.isDeleted),
|
|
288
|
-
);
|
|
200
|
+
initialized = signal(false); // 최초 조회 완료 — @if (initialized()) 가드 해제
|
|
201
|
+
busyCount = signal(0); // 중첩 비동기 작업 카운트 (0 초과 시 busy 표시)
|
|
202
|
+
|
|
203
|
+
items = signal<ICustomer[]>([]);
|
|
289
204
|
|
|
290
|
-
|
|
205
|
+
page = signal(0);
|
|
206
|
+
pageLength = signal(0);
|
|
207
|
+
sortingDefs = signal<SortingDef[]>([]);
|
|
291
208
|
|
|
292
|
-
|
|
293
|
-
|
|
209
|
+
filter = signal<IFilter>({}); // 입력 버퍼. onFilterSubmit 시 lastFilter로 스냅샷
|
|
210
|
+
lastFilter = signal<IFilter>({}); // 조회 트리거 — effect 의존성은 이 signal
|
|
294
211
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
tablerRefresh, tablerRestore, tablerSearch,
|
|
298
|
-
};
|
|
212
|
+
//== 시트 fn ==
|
|
213
|
+
trackByFn = (item: ICustomer) => item.id;
|
|
299
214
|
|
|
300
|
-
//== 라이프사이클 ==
|
|
301
215
|
constructor() {
|
|
302
|
-
//
|
|
216
|
+
// 필터/페이지/정렬/perms 변경 시 재조회
|
|
303
217
|
effect(() => {
|
|
304
|
-
this.
|
|
218
|
+
if (!this.perms().includes("use")) {
|
|
219
|
+
this.initialized.set(true);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this.lastFilter();
|
|
305
224
|
this.page();
|
|
306
225
|
this.sortingDefs();
|
|
307
|
-
|
|
308
|
-
untracked(() => {
|
|
309
|
-
|
|
226
|
+
|
|
227
|
+
void untracked(async () => {
|
|
228
|
+
this.busyCount.update((v) => v + 1);
|
|
229
|
+
await this._sdToast.try(async () => {
|
|
230
|
+
await this._refresh();
|
|
231
|
+
});
|
|
232
|
+
this.busyCount.update((v) => v - 1);
|
|
233
|
+
this.initialized.set(true);
|
|
310
234
|
});
|
|
311
235
|
});
|
|
312
|
-
|
|
313
|
-
setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
|
|
314
236
|
}
|
|
315
237
|
|
|
316
|
-
//==
|
|
317
|
-
|
|
238
|
+
//== Handlers ==
|
|
239
|
+
onFilterSubmit(): void {
|
|
318
240
|
this.page.set(0);
|
|
319
|
-
this.
|
|
241
|
+
this.lastFilter.set({ ...this.filter() });
|
|
320
242
|
}
|
|
321
243
|
|
|
322
|
-
|
|
244
|
+
onRefreshButtonClick(): void {
|
|
323
245
|
if (this.busyCount() > 0) return;
|
|
324
|
-
if (!this.
|
|
325
|
-
if (!this._checkIgnoreChanges()) return;
|
|
326
|
-
mark(this._lastFilter); // 참조만 갱신 → effect 재실행
|
|
327
|
-
}
|
|
246
|
+
if (!this.perms().includes("use")) return;
|
|
328
247
|
|
|
329
|
-
|
|
330
|
-
await this._editItem();
|
|
248
|
+
mark(this.lastFilter); // 값 변경 없이 참조만 갱신 → effect 재실행
|
|
331
249
|
}
|
|
332
250
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
251
|
+
//== Internals ==
|
|
252
|
+
private async _refresh(): Promise<void> {
|
|
253
|
+
const r = await this._search(true);
|
|
254
|
+
this.items.set(r.items);
|
|
255
|
+
this.pageLength.set(r.pageLength);
|
|
337
256
|
}
|
|
338
257
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
258
|
+
private async _search(
|
|
259
|
+
usePagination: boolean, // false는 전체 조회 (엑셀 다운로드 등에서 재사용)
|
|
260
|
+
): Promise<{ items: ICustomer[]; pageLength: number }> {
|
|
261
|
+
const filter = this.lastFilter();
|
|
262
|
+
const sortingDefs = this.sortingDefs();
|
|
263
|
+
const page = this.page();
|
|
264
|
+
|
|
265
|
+
return this._appOrm.connectAsync(async (db) => {
|
|
266
|
+
let qr1 = db.customer();
|
|
267
|
+
|
|
268
|
+
if (!str.isNullOrEmpty(filter.searchText)) {
|
|
269
|
+
qr1 = qr1.search((item) => [item.name, item.phone], filter.searchText);
|
|
348
270
|
}
|
|
349
|
-
}, getOrmDataEditToastErrorMessage);
|
|
350
|
-
}
|
|
351
271
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|| confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
|
|
355
|
-
}
|
|
272
|
+
// 페이지당 50건 — 시트 화면 표준치 (조정 시 UX 확인 필요)
|
|
273
|
+
const pageLength = usePagination ? Math.ceil((await qr1.count()) / 50) : 0;
|
|
356
274
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
275
|
+
let qr2 = qr1.select((item) => ({
|
|
276
|
+
id: item.id,
|
|
277
|
+
name: item.name,
|
|
278
|
+
phone: item.phone,
|
|
279
|
+
}));
|
|
360
280
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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);
|
|
281
|
+
// orderBy는 string overload 사용 — 람다+obj.getChainValue는 Anti-patterns 참조
|
|
282
|
+
for (const sortingDef of sortingDefs) {
|
|
283
|
+
qr2 = qr2.orderBy(sortingDef.key, sortingDef.desc ? "DESC" : "ASC");
|
|
284
|
+
}
|
|
285
|
+
if (!sortingDefs.some((s) => s.key === "name")) {
|
|
286
|
+
qr2 = qr2.orderBy((item) => item.name);
|
|
378
287
|
}
|
|
379
|
-
}, getOrmDataEditToastErrorMessage);
|
|
380
|
-
}
|
|
381
288
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
// title: item == null ? "고객 등록" : "고객 수정",
|
|
386
|
-
// type: CustomerEditModal,
|
|
387
|
-
// inputs: { itemId: item?.id },
|
|
388
|
-
// });
|
|
389
|
-
// if (r != null) await this._refresh();
|
|
390
|
-
}
|
|
289
|
+
if (usePagination) {
|
|
290
|
+
qr2 = qr2.limit(page * 50, 50);
|
|
291
|
+
}
|
|
391
292
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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("구현 필요");
|
|
293
|
+
const items = await qr2.execute();
|
|
294
|
+
return { items, pageLength };
|
|
295
|
+
});
|
|
404
296
|
}
|
|
297
|
+
|
|
298
|
+
//== 아이콘 ==
|
|
299
|
+
protected readonly tablerAlertTriangle = tablerAlertTriangle;
|
|
300
|
+
protected readonly tablerRefresh = tablerRefresh;
|
|
301
|
+
protected readonly tablerSearch = tablerSearch;
|
|
302
|
+
protected readonly mark = mark;
|
|
405
303
|
}
|
|
406
304
|
```
|
|
407
305
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
각 블록의 역할과 원본 `SdDataSheet` 코드 대응 지점:
|
|
411
|
-
|
|
412
|
-
| 블록 | 역할 | 원본 대응 |
|
|
413
|
-
|---|---|---|
|
|
414
|
-
| `<sd-busy-container [busy] [message]>` | 전체 busy 오버레이 | `sd-data-sheet.ts:65-71` + `SdBaseContainer` |
|
|
415
|
-
| `@if (initialized())` | 초기 데이터 로딩 전 콘텐츠 숨김 (깜박임 방지) | `sd-data-sheet.base.ts:94`·`_refresh()` 말미 `initialized.set(true)` |
|
|
416
|
-
| `@if (!canUse())` | 권한 없음 메시지 | `sd-base-container.ts:44-51` + `page-modal-container.md` |
|
|
417
|
-
| `@if (viewType() === "page")` / `"modal"` / else | 뷰 타입 분기 (`page-modal-container.md` 참조) | `sd-data-sheet.ts:65` `viewType` → `SdBaseContainer` 내부 분기 |
|
|
418
|
-
| `<sd-topbar-container>` + `<sd-topbar>` | 페이지 뷰 헤더 | `sd-data-sheet.ts:72-87` `pageTopbarTpl` |
|
|
419
|
-
| `<sd-form (formSubmit)>` + `form-box-inline` | 필터 제출 폼 | `sd-data-sheet.ts:111-125` 필터 슬롯 |
|
|
420
|
-
| 도구 영역 (`<sd-button size=sm theme=link-*>`) | 등록 / 선택 삭제 / 선택 복구 | `sd-data-sheet.ts:127-203` 도구 영역 |
|
|
421
|
-
| `<sd-sheet>` + `<sd-sheet-column>` + `<ng-template [cell]>` | 시트 본체, 셀 렌더링 | `sd-data-sheet.ts:205-346` |
|
|
422
|
-
| `[cell]` 템플릿의 `<sd-anchor (click)>` | 셀 클릭 → 편집 모달 진입 | `sd-data-sheet.ts:284-315` |
|
|
423
|
-
| `getItemCellStyleFn` | `isDeleted` 시 취소선 | `sd-data-sheet.base.ts:137-140` |
|
|
424
|
-
| `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-sheet.ts:57-63` |
|
|
425
|
-
| `setupCanDeactivate(() => viewType() === "modal" || checkIgnoreChanges())` | 라우트 이탈 시 변경사항 확인 | `sd-data-sheet.base.ts:227` |
|
|
426
|
-
| `_refresh()` 내 `busyCount.update + try/finally + sdToast.try` | busy 카운트 증감 + 에러 토스트 | `injectDataSheetRefreshManager.ts:33-46` (삭제됨) |
|
|
427
|
-
| `_getDiffs()` = `items.oneWayDiffs(snapshot, "id")` | 변경 감지 | `injectDataSheetRefreshManager.ts`의 `getDiffs()` (삭제됨) |
|
|
428
|
-
| `effect(() => { lastFilter(); page(); sortingDefs(); ... })` | 필터/페이지/정렬 변경 시 재조회 | `injectDataSheetRefreshManager.ts` (삭제됨) |
|
|
429
|
-
| `mark(this._lastFilter)` | lastFilter 참조 갱신 → effect 재실행 (값 변경 없음) | `sd-data-sheet.base.ts:245` |
|
|
430
|
-
| `modalOrPageTitle` computed | `modal title ?? app structure title` | `sd-base-container.ts:102-113` (`header ??` 부분은 필요 시 `input()` 추가) |
|
|
431
|
-
|
|
432
|
-
### 상태 분해
|
|
433
|
-
|
|
434
|
-
| signal / computed | 역할 |
|
|
435
|
-
|---|---|
|
|
436
|
-
| `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
|
|
437
|
-
| `busyMessage` | busy 오버레이 문구 |
|
|
438
|
-
| `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
|
|
439
|
-
| `items` | 현재 페이지 items |
|
|
440
|
-
| `summaryData` | 서버가 반환한 합계·집계 (선택적으로 시트 summary row에 표시) |
|
|
441
|
-
| `selectedItems` | 선택된 item 배열 (`<sd-sheet [(selectedItems)]>`로 양방향) |
|
|
442
|
-
| `page` / `pageLength` | 0-based 현재 페이지 / 전체 페이지 수 |
|
|
443
|
-
| `sortingDefs` | `SortingDef[]` — `{ key: string; desc: boolean }[]`, `<sd-sheet [(sorts)]>`로 양방향 |
|
|
444
|
-
| `filterSearch` / `_lastFilter` | filter는 입력 버퍼, lastFilter는 "조회" 제출 시점 스냅샷 |
|
|
445
|
-
| `_snapshot` | 최근 `_refresh()` 시점의 items 복사본 (변경 감지용) |
|
|
446
|
-
|
|
447
|
-
### 메서드 분해
|
|
448
|
-
|
|
449
|
-
| 메서드 | 역할 |
|
|
450
|
-
|---|---|
|
|
451
|
-
| `onFilterSubmit()` | page=0 리셋 + `_lastFilter.set(filter 스냅샷)` |
|
|
452
|
-
| `onRefreshButtonClick()` | busy/권한/변경사항 확인 후 `mark(_lastFilter)` |
|
|
453
|
-
| `_refresh()` | search → items.set + pageLength + summary + 선택 유지 + snapshot 갱신 |
|
|
454
|
-
| `_editItem(item?)` | `SdModalProvider.showAsync(...)`로 편집 모달 실행 후 refresh |
|
|
455
|
-
| `onToggleDeleteItemsButtonClick(del)` | 선택 item ID들을 서버에 전송하여 isDeleted 토글 + refresh |
|
|
456
|
-
| `_checkIgnoreChanges()` | snapshot 대비 diff 없으면 true, 있으면 `confirm` 후 true/false |
|
|
457
|
-
| `_getDiffs()` | `items.oneWayDiffs(_snapshot, "id")` — `ArrayOneWayDiffResult<T>[]` |
|
|
458
|
-
|
|
459
|
-
## 5. 변형: inline 편집 모드
|
|
460
|
-
|
|
461
|
-
행을 직접 수정하고 `ArrayOneWayDiffResult` 기반 diff로 일괄 저장한다. `CustomerListPage`를 기준으로 아래 변경을 적용한다:
|
|
306
|
+
본 섹션에 등장하는 개별 API의 단독 사용법은 각 문서를 참조한다:
|
|
462
307
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
]
|
|
469
|
-
|
|
470
|
-
"(sdRefreshCommand)": "onRefreshButtonClick()",
|
|
471
|
-
"(sdSaveCommand)": "onSaveButtonClick()",
|
|
472
|
-
},
|
|
473
|
-
|
|
474
|
-
// 2) template — 도구 영역에 "행 추가" 버튼 추가
|
|
475
|
-
<sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddItemButtonClick()">
|
|
476
|
-
<ng-icon [svg]="icons.tablerCirclePlus" />
|
|
477
|
-
행 추가
|
|
478
|
-
</sd-button>
|
|
479
|
-
|
|
480
|
-
// 3) template — <sd-sheet>를 <sd-form>으로 감싸고 (formSubmit)="onSubmit()" 추가
|
|
481
|
-
<sd-form #formCtrl (formSubmit)="onSubmit()" class="flex-fill p-default pt-0">
|
|
482
|
-
<sd-sheet ...>
|
|
483
|
-
<!-- isDeleted 전용 고정 컬럼 -->
|
|
484
|
-
@if (canEdit()) {
|
|
485
|
-
<sd-sheet-column [fixed]="true" [key]="'isDeleted'">
|
|
486
|
-
<ng-template #headerTpl>
|
|
487
|
-
<div class="p-xs-sm tx-center"><ng-icon [svg]="icons.tablerEraser" /></div>
|
|
488
|
-
</ng-template>
|
|
489
|
-
<ng-template [cell]="items()" let-item="item">
|
|
490
|
-
<div class="p-xs-sm tx-center">
|
|
491
|
-
<sd-anchor
|
|
492
|
-
[theme]="'danger'"
|
|
493
|
-
(click)="onToggleDeleteItemButtonClick(item)"
|
|
494
|
-
>
|
|
495
|
-
<ng-icon [svg]="item.isDeleted ? icons.tablerRestore : icons.tablerEraser" />
|
|
496
|
-
{{ item.isDeleted ? "복구" : "삭제" }}
|
|
497
|
-
</sd-anchor>
|
|
498
|
-
</div>
|
|
499
|
-
</ng-template>
|
|
500
|
-
</sd-sheet-column>
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
<!-- 편집 가능 셀: sd-textfield 등은 [inset]="true" [size]="'sm'" 명시 -->
|
|
504
|
-
<sd-sheet-column [key]="'name'" [header]="'이름'">
|
|
505
|
-
<ng-template [cell]="items()" let-item="item">
|
|
506
|
-
<sd-textfield [type]="'text'" [(value)]="item.name" [inset]="true" [size]="'sm'" />
|
|
507
|
-
</ng-template>
|
|
508
|
-
</sd-sheet-column>
|
|
509
|
-
</sd-sheet>
|
|
510
|
-
</sd-form>
|
|
511
|
-
|
|
512
|
-
// 4) 메서드 추가/대체
|
|
513
|
-
protected readonly formCtrl = viewChild<SdForm>("formCtrl");
|
|
514
|
-
|
|
515
|
-
protected onSaveButtonClick(): void {
|
|
516
|
-
this.formCtrl()?.requestSubmit();
|
|
517
|
-
}
|
|
308
|
+
- [`<sd-busy-container>`](../ui-overlay/sd-busy-container.md) — busy 오버레이 + busyCount 카운트 패턴
|
|
309
|
+
- [`<sd-topbar-container>` · `<sd-topbar>`](../ui-navigation/sd-topbar-container.md) — 탑바
|
|
310
|
+
- [`<sd-dock-container>` · `<sd-dock>`](../ui-layout/sd-dock-container.md) — 도킹 레이아웃
|
|
311
|
+
- [`<sd-form>`](../ui-form/sd-form.md) — 폼 래퍼 + `(formSubmit)` + `requestSubmit()`
|
|
312
|
+
- [`<sd-button>`](../ui-form/sd-button.md) · [`<sd-textfield>`](../ui-form/sd-textfield.md)
|
|
313
|
+
- [`<sd-sheet>` · `<sd-sheet-column>` · `<ng-template [cell]>`](../ui-data/sd-sheet.md)
|
|
314
|
+
- [`injectViewTypeSignal`](../utils/inject-routing-signals.md#injectviewtypesignal) · [`injectViewTitleSignal`](../utils/inject-routing-signals.md#injectviewtitlesignal) · [`mark`](../utils/mark.md) · [`injectPermsSignal`](../utils/inject-routing-signals.md#injectpermssignal) · [`SdToastProvider.try`](../providers/sd-toast-provider.md#try-사용-패턴)
|
|
518
315
|
|
|
519
|
-
|
|
520
|
-
const newItem: ICustomer = { id: Uuid.generate().toString(), name: "", phone: "", isDeleted: false };
|
|
521
|
-
this.items.update((list) => [newItem, ...list]);
|
|
522
|
-
}
|
|
316
|
+
### 조건부 요소 포함 기준
|
|
523
317
|
|
|
524
|
-
|
|
525
|
-
item.isDeleted = !item.isDeleted;
|
|
526
|
-
mark(this.items); // OnPush 재렌더 + effect 알림
|
|
527
|
-
}
|
|
318
|
+
최소 뼈대의 인프라·라이프사이클 요소는 화면의 필요에 따라 포함·생략한다. 필요 없는 요소를 기계적으로 포함하지 않는다.
|
|
528
319
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
```
|
|
320
|
+
| 요소 | 포함 조건 | 생략하는 경우 예시 |
|
|
321
|
+
|------|----------|-------------------|
|
|
322
|
+
| `<sd-topbar-container>` + `<sd-topbar>` | routes로 연결된 페이지에서 헤더를 표시할 때 | route 미연결 컴포넌트(control, 래퍼 등) |
|
|
323
|
+
| `injectViewTitleSignal()` | 타이틀이 필요할 때. topbar에 타이틀을 표시하는 page에는 보통 포함 | topbar가 없거나 타이틀 표시가 불필요한 화면 |
|
|
324
|
+
| `injectViewTypeSignal()` + `@if (viewType() === "page")` 가드 | 해당 컴포넌트가 page 외에 modal 또는 control로도 겸용될 때 | page 전용 리스트, page 전용 대시보드 |
|
|
325
|
+
| `injectPermsSignal()` + 권한 없음 메시지 | 권한 제어가 있는 화면. 권한 제어 자체가 있으면 필수 | 권한 제어가 없는 화면 |
|
|
326
|
+
| `<sd-busy-container>` + `busyCount` | 화면에 비동기 작업(DB 조회, API 호출 등)이 있어서 busy 표시가 필요할 때 | 비동기 로딩 없이 동기적으로 렌더되는 래퍼/레이아웃 화면 |
|
|
327
|
+
| `initialized` + `@if (initialized())` 가드 | 초기 데이터 로딩이 완료되기 전에는 화면을 그리면 안 되는 경우 (깜박임 방지) | 초기 로딩이 필요 없거나, 빈 상태로 보여줘도 무방한 화면 |
|
|
328
|
+
| `SHARED_DATA_KEY` + `emitAsync()` 호출 | 해당 화면에서 `SdSharedDataProvider`에 등록된 데이터를 변경(생성/수정/삭제)하는 경우 | 조회만 하는 화면, sharedData에 등록되지 않은 데이터를 다루는 화면 |
|
|
552
329
|
|
|
553
|
-
|
|
330
|
+
## 변형 (Variation)
|
|
554
331
|
|
|
555
|
-
|
|
332
|
+
아래 확장 중 필요한 것만 선택적으로 얹는다. 각 확장은 self-contained 문서에서 최소 뼈대 대비 diff를 제공한다.
|
|
556
333
|
|
|
557
|
-
|
|
334
|
+
| 확장 | 언제 쓰나 | 전제 | 문서 |
|
|
335
|
+
|---|---|---|---|
|
|
336
|
+
| A | 셀 직접 편집 + 일괄 저장(inline 편집) | 없음 | [extension-a-inline-edit.md](./crud-list/extension-a-inline-edit.md) |
|
|
337
|
+
| B | 선택 체크박스 + 선택 삭제·복구 | A | [extension-b-selection.md](./crud-list/extension-b-selection.md) |
|
|
338
|
+
| C | 시트 맨 앞 고정 열에 row별 inline 삭제·복구 | A + B | [extension-c-inline-delete.md](./crud-list/extension-c-inline-delete.md) |
|
|
339
|
+
| D | 선택 모달 전환(항목을 골라 `close.emit`으로 돌려줌) | A + B | [extension-d-select-modal.md](./crud-list/extension-d-select-modal.md) |
|
|
340
|
+
| E | 조회 전용 modal(부모 레코드의 자식 목록·이력을 input으로 받아 읽기 전용 표시) | 없음 (최소 뼈대에 직접) | [extension-e-readonly-modal.md](./crud-list/extension-e-readonly-modal.md) |
|
|
341
|
+
| F | 모달 편집 모드(행 클릭 → 편집 모달). A와 상호 배타 | 없음 | [extension-f-modal-edit.md](./crud-list/extension-f-modal-edit.md) |
|
|
342
|
+
| G | 엑셀 업로드/다운로드 (`_upsertItem` 재사용) | A | [extension-g-excel.md](./crud-list/extension-g-excel.md) |
|
|
558
343
|
|
|
559
|
-
|
|
560
|
-
// 1) 컴포넌트 import
|
|
561
|
-
import {
|
|
562
|
-
type SdSelectModal,
|
|
563
|
-
type SelectModalOutputResult,
|
|
564
|
-
} from "@simplysm/angular";
|
|
565
|
-
import { input, output, model } from "@angular/core";
|
|
344
|
+
## 🚫 흔한 실수 (Anti-patterns)
|
|
566
345
|
|
|
567
|
-
|
|
568
|
-
export class CustomerListPage implements SdSelectModal<ICustomer> {
|
|
569
|
-
// ...기존 필드 유지
|
|
346
|
+
### modal = 선택 모달로 반사적 부착
|
|
570
347
|
|
|
571
|
-
|
|
572
|
-
close = output<SelectModalOutputResult<ICustomer> | undefined>();
|
|
348
|
+
`viewType() === "modal"`이라는 사실만으로 "선택 모달"이라고 단정하고 `SdSelectModal<T>` 계약을 부착하지 않는다. modal 용도는 최소 두 가지다 — (a) 선택 모달(확장 D): 항목을 골라 `close.emit`으로 돌려줌 / (b) 조회 전용(확장 E): 부모 레코드의 자식 목록·이력을 읽기 전용으로 표시, 닫기는 SdModal 기본 "X".
|
|
573
349
|
|
|
574
|
-
|
|
350
|
+
```typescript
|
|
351
|
+
// ❌ viewType() === "modal"만 보고 선택 모달 계약을 반사적으로 부착
|
|
352
|
+
export class CustomerList implements SdSelectModal<ICustomer> {
|
|
575
353
|
selectMode = input<"single" | "multi">();
|
|
576
|
-
selectedItemKeys = input<
|
|
577
|
-
|
|
578
|
-
//
|
|
579
|
-
// (위 완성 예제의 protected readonly initialized = signal(false) 를 그대로 사용)
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// 3) template — 시트의 selectMode를 input 값으로 연결, 누적 선택 활성화
|
|
583
|
-
<sd-sheet
|
|
584
|
-
...
|
|
585
|
-
[selectMode]="selectMode()"
|
|
586
|
-
[cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
|
|
587
|
-
[trackByFn]="trackByFn"
|
|
588
|
-
[(selectedItems)]="selectedItems"
|
|
589
|
-
>
|
|
590
|
-
...
|
|
591
|
-
</sd-sheet>
|
|
592
|
-
|
|
593
|
-
// 4) template — 모달 뷰 분기 (page-modal-container.md 기반)
|
|
594
|
-
} @else if (viewType() === "modal") {
|
|
595
|
-
<div class="flex-column fill">
|
|
596
|
-
<div class="flex-fill">
|
|
597
|
-
<!-- 필터 / 도구 / 시트를 이곳에 동일하게 배치 (page 분기와 같은 구조) -->
|
|
598
|
-
</div>
|
|
599
|
-
<!-- 하단 액션 바 -->
|
|
600
|
-
<div class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest">
|
|
601
|
-
<div class="flex-fill"></div>
|
|
602
|
-
@if (selectedItems().length > 0) {
|
|
603
|
-
<sd-button [size]="'sm'" [theme]="'danger'" (click)="onModalCancelClick()">
|
|
604
|
-
{{ selectMode() === "multi" ? "모두" : "선택" }} 해제
|
|
605
|
-
</sd-button>
|
|
606
|
-
}
|
|
607
|
-
@if (selectMode() === "multi") {
|
|
608
|
-
<sd-button [size]="'sm'" [theme]="'primary'" (click)="onModalConfirmClick()">
|
|
609
|
-
확인({{ selectedItems().length }})
|
|
610
|
-
</sd-button>
|
|
611
|
-
}
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// 5) 메서드 추가 — close.emit 수동 변환
|
|
617
|
-
protected onModalConfirmClick(): void {
|
|
618
|
-
const items = this.selectedItems();
|
|
619
|
-
this.close.emit({
|
|
620
|
-
selectedItemKeys: items.map((it, i) => this.trackByFn(it, i) as string),
|
|
621
|
-
selectedItems: items,
|
|
622
|
-
});
|
|
354
|
+
selectedItemKeys = input<any[]>([]);
|
|
355
|
+
close = output<SelectModalOutputResult<ICustomer>>();
|
|
356
|
+
// ... 조회 전용인데도 하단 "선택 해제 / 확인" 바, cumulativeSelection까지 전부 이식
|
|
623
357
|
}
|
|
624
358
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
});
|
|
359
|
+
// ✅ modal 용도를 먼저 확정한다
|
|
360
|
+
// (a) 선택 모달이면 확장 D 스켈레톤부터 시작
|
|
361
|
+
// (b) 조회 전용이면 확장 E 스켈레톤부터 시작 — SdSelectModal 계약 부착 안 함
|
|
640
362
|
```
|
|
641
363
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
- `cumulativeSelection` **기본값은 `false`** — `<sd-sheet>`의 items가 교체될 때 `selectedItems`가 빈 배열로 초기화된다
|
|
645
|
-
- 선택 모달(`viewType() === "modal"`) + 다중 선택(`selectMode() === "multi"`) 조합에서는 **여러 페이지를 돌며 선택 누적**이 필요하므로 `true`로 바인딩
|
|
646
|
-
- 그 외(page 뷰 또는 single selectMode)는 현재 뷰의 **일괄 작업**이 일반적 — `false`(기본값)가 맞다
|
|
647
|
-
- 그러므로 정적 `true`가 아니라 **동적 computed**로 바인딩:
|
|
648
|
-
```html
|
|
649
|
-
<sd-sheet
|
|
650
|
-
[cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
|
|
651
|
-
[trackByFn]="trackByFn"
|
|
652
|
-
...
|
|
653
|
-
>
|
|
654
|
-
```
|
|
655
|
-
- 누적 모드 사용 시 `trackByFn` 명시 필수. 기본 `(item) => item`은 reference 기반이라 서버 페이지네이션에서 새 객체 reference가 내려오면 `obj.equal`로 비교해도 실패할 수 있다. `(item) => item.id` 같은 key 추출 함수를 반드시 지정한다.
|
|
364
|
+
**근거**: 조회 전용 modal에 선택 계약을 부착하면 호출되지 않아 죽은 코드가 된다. LLM이 풀 스택 합본을 복사하면서 "modal 지원"이라는 이유로 반사적으로 이식하는 회귀가 잦다. [확장 E](./crud-list/extension-e-readonly-modal.md) 참조.
|
|
656
365
|
|
|
657
|
-
|
|
366
|
+
### viewType 추측으로 3뷰 모두 박기 / 완전 분리 블록 작성
|
|
658
367
|
|
|
659
|
-
|
|
368
|
+
화면이 실제 어떤 뷰로 쓰이는지 확정하지 않은 채 page·modal·control 3뷰용 조각을 모두 배치하지 않는다(당장 쓰지 않는 뷰의 계약·분기는 죽은 코드가 된다). 또한 시트 페이지를 page와 modal로 겸용할 때(확장 D)도 page 블록과 modal 블록을 각각 완성하면서 필터·시트를 중복 작성하지 않는다. 하나의 `<sd-topbar-container>` + `<sd-dock-container>` 공통 껍데기 위에 뷰별로 다른 조각만 `@if`로 얹는다.
|
|
660
369
|
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
...existingIcons,
|
|
672
|
-
tablerFileExcel, tablerUpload,
|
|
673
|
-
};
|
|
674
|
-
|
|
675
|
-
// 4) template — 도구 영역 끝에 추가
|
|
676
|
-
@if (canEdit()) {
|
|
677
|
-
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
|
|
678
|
-
<ng-icon [svg]="icons.tablerUpload" />
|
|
679
|
-
엑셀 업로드
|
|
680
|
-
</sd-button>
|
|
370
|
+
```html
|
|
371
|
+
<!-- ❌ page 블록과 modal 블록을 각각 완성 — 필터·시트가 중복되어 수정 시 양쪽을 고쳐야 함 -->
|
|
372
|
+
@if (viewType() === "page") {
|
|
373
|
+
<sd-topbar-container>
|
|
374
|
+
<sd-topbar>...</sd-topbar>
|
|
375
|
+
<sd-dock-container>
|
|
376
|
+
<sd-dock class="p-default"><sd-form>...</sd-form></sd-dock>
|
|
377
|
+
<sd-sheet ...>...</sd-sheet>
|
|
378
|
+
</sd-dock-container>
|
|
379
|
+
</sd-topbar-container>
|
|
681
380
|
}
|
|
682
|
-
|
|
683
|
-
<
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
await this._sdToast.try(async () => {
|
|
690
|
-
this.busyCount.update((v) => v + 1);
|
|
691
|
-
this.busyMessage.set("엑셀 다운로드 중...");
|
|
692
|
-
try {
|
|
693
|
-
// 전체 조회 (페이지네이션 없이)
|
|
694
|
-
const r = await this._fetchList(this._lastFilter(), 0, this.sortingDefs());
|
|
695
|
-
// 서버 호출 또는 클라이언트 변환 (앱별):
|
|
696
|
-
// await this._api.downloadExcelAsync(r.items);
|
|
697
|
-
} finally {
|
|
698
|
-
this.busyMessage.set(undefined);
|
|
699
|
-
this.busyCount.update((v) => v - 1);
|
|
700
|
-
}
|
|
701
|
-
}, getOrmDataEditToastErrorMessage);
|
|
381
|
+
@if (viewType() === "modal") {
|
|
382
|
+
<sd-topbar-container>
|
|
383
|
+
<sd-dock-container>
|
|
384
|
+
<sd-dock class="p-default"><sd-form>...</sd-form></sd-dock>
|
|
385
|
+
<sd-sheet ...>...</sd-sheet>
|
|
386
|
+
</sd-dock-container>
|
|
387
|
+
</sd-topbar-container>
|
|
702
388
|
}
|
|
703
389
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
if (
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
// await this._api.uploadExcelAsync(file);
|
|
713
|
-
await this._refresh();
|
|
714
|
-
} finally {
|
|
715
|
-
this.busyMessage.set(undefined);
|
|
716
|
-
this.busyCount.update((v) => v - 1);
|
|
717
|
-
}
|
|
718
|
-
}, getOrmDataEditToastErrorMessage);
|
|
719
|
-
}
|
|
390
|
+
<!-- ✅ 하나의 껍데기 + 차이점만 @if로 얹는다 -->
|
|
391
|
+
<sd-topbar-container>
|
|
392
|
+
@if (viewType() === "page") { <sd-topbar>...</sd-topbar> }
|
|
393
|
+
<sd-dock-container>
|
|
394
|
+
<sd-dock class="p-default"><sd-form>...</sd-form></sd-dock>
|
|
395
|
+
<sd-sheet ...>...</sd-sheet>
|
|
396
|
+
</sd-dock-container>
|
|
397
|
+
</sd-topbar-container>
|
|
720
398
|
```
|
|
721
399
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
`@if (viewType() === "page") ... @else if (viewType() === "modal") ... @else { ... }` 분기 구조와 `modalOrPageTitle` computed 계산은 [`page-modal-container.md`](./page-modal-container.md)의 레시피와 동일하다. 본 레시피의 완성 예제도 그 패턴을 그대로 사용한다. control 뷰(다른 화면의 영역으로 삽입)로만 쓰일 경우에는 `@if` 분기를 생략하고 본문만 작성한다.
|
|
725
|
-
|
|
726
|
-
## 9. 주의사항 (자주 하는 실수)
|
|
727
|
-
|
|
728
|
-
### 공통 유틸 재도입 금지
|
|
729
|
-
|
|
730
|
-
- `useCrudList()`, `useDataSheet()`, `setupCumulateSelectedKeys2()` 같은 공통 헬퍼를 도입하지 말 것. 이 레시피가 제거한 추상화를 다시 만드는 행위다. 세 화면이 비슷해 보여도 화면마다 필드·동작 시그니처가 조금씩 다르므로 복사·수정이 낫다
|
|
731
|
-
|
|
732
|
-
### 시트 셀 스타일 함정
|
|
400
|
+
**근거**: 필터 한 줄을 수정할 때 두 블록을 모두 고쳐야 하는 상황이 생기면 구조가 잘못된 것이다. "완전 분리"는 확장 D에서 modal 하단 바 같은 **뷰별 고유 조각**에만 적용한다.
|
|
733
401
|
|
|
734
|
-
|
|
402
|
+
### `orderBy` 람다 + `obj.getChainValue` 회귀
|
|
735
403
|
|
|
736
|
-
|
|
404
|
+
`Queryable.orderBy`는 string overload를 지원하므로(`packages/orm-common/src/exec/queryable.ts:420`), 람다 + `obj.getChainValue` 우회 코드를 쓰지 않는다.
|
|
737
405
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
### `injectViewTypeSignal()` 호출 시점
|
|
406
|
+
```typescript
|
|
407
|
+
// ❌ overload 도입 전 우회 코드 — LLM 훈련 데이터에 잔존
|
|
408
|
+
for (const s of sortingDefs) {
|
|
409
|
+
qr2 = qr2.orderBy(
|
|
410
|
+
(item) => obj.getChainValue(item, s.key, true) as any,
|
|
411
|
+
s.desc ? "DESC" : "ASC",
|
|
412
|
+
);
|
|
413
|
+
}
|
|
748
414
|
|
|
749
|
-
|
|
415
|
+
// ✅ string overload 사용. 체인 경로도 string으로 지원: qr.orderBy("user.name")
|
|
416
|
+
for (const s of sortingDefs) {
|
|
417
|
+
qr2 = qr2.orderBy(s.key, s.desc ? "DESC" : "ASC");
|
|
418
|
+
}
|
|
419
|
+
```
|
|
750
420
|
|
|
751
|
-
|
|
421
|
+
**근거**: 람다 형태는 타입 추론을 깨뜨리고 `as any` 캐스팅을 강제한다. string overload는 체인 경로까지 타입 안전하게 지원한다.
|
|
752
422
|
|
|
753
|
-
|
|
423
|
+
### 테스트용 public API 노출
|
|
754
424
|
|
|
755
|
-
|
|
425
|
+
테스트에서 호출하려고 private 메서드의 얇은 public wrapper를 노출하지 않는다.
|
|
756
426
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
427
|
+
```typescript
|
|
428
|
+
// ❌ "테스트에서 호출하려고" private 메서드를 public으로 노출
|
|
429
|
+
async submit(diffs: IDiff[]): Promise<void> {
|
|
430
|
+
await this._submitAsync(diffs);
|
|
431
|
+
}
|
|
760
432
|
|
|
761
|
-
|
|
433
|
+
// ✅ TestBed fixture + click/dispatch 이벤트 경로 또는 host의 sdSaveCommand 트리거
|
|
434
|
+
fixture.nativeElement.dispatchEvent(
|
|
435
|
+
new KeyboardEvent("keydown", { key: "s", ctrlKey: true }),
|
|
436
|
+
);
|
|
437
|
+
```
|
|
762
438
|
|
|
763
|
-
|
|
764
|
-
- 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**
|
|
765
|
-
- **"저장 감지"가 아니다.** `obj.equal`이 deep equal로 값 차이를 감지하므로, `item.name = "new"` 같은 mutation은 `mark` 없이도 `_getDiffs()` / submit에서 감지된다
|
|
766
|
-
- Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요
|
|
767
|
-
- ❌ "mark 없으면 저장이 안 된다" 식 서술 금지
|
|
439
|
+
**근거**: public wrapper는 캡슐화를 깨고 컴포넌트의 외부 API 인상을 오염시킨다. 실제 사용자가 쓰지 않는 진입점을 공개 API로 만들면 추후 리팩토링 시 제약이 된다.
|
|
768
440
|
|
|
769
|
-
|
|
441
|
+
## 관련 Entry
|
|
770
442
|
|
|
771
|
-
- `
|
|
772
|
-
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
qr = qr.orderBy(s.key, s.desc ? "DESC" : "ASC");
|
|
776
|
-
}
|
|
777
|
-
```
|
|
778
|
-
- 체인 경로도 string으로 지원: `qr.orderBy("user.name")` 형태
|
|
779
|
-
- 과거 람다 형태 (`qr.orderBy((item) => obj.getChainValue(item, s.key, true) as any, ...)`)는 **쓰지 않는다** — overload 도입 전 우회 코드였다
|
|
443
|
+
- [`_common-rules.md`](./_common-rules.md) — 4계열 레시피 횡단 공통 규칙. 본 파일에서 재정의하지 않는 모든 규칙은 여기에 있음
|
|
444
|
+
- [`crud-detail.md`](./crud-detail.md) — 차이: 단일 레코드 상세 폼(뷰 범위 선택·편집·저장·삭제/복구)
|
|
445
|
+
- [`data-select-button.md`](./data-select-button.md) — 차이: 모달 기반 선택 버튼 직접 조립(`<sd-modal-select-button>`)
|
|
446
|
+
- [`page-modal-container.md`](./page-modal-container.md) — 차이: page/modal/control 3뷰 재사용 공통 껍데기만
|