@simplysm/angular 14.0.49 → 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 -225
- package/dist/controls/select/sd-select.js +3 -3
- package/dist/layout/dock/sd-dock-container.js +1 -1
- 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 +184 -706
- 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 +196 -787
- package/docs/recipes/data-select-button.md +185 -91
- 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 +5 -5
- 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/layout/dock/sd-dock-container.ts +1 -1
- package/docs/bootstrap.md +0 -38
- package/docs/directives.md +0 -236
- package/docs/features.md +0 -154
- 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 -303
- package/docs/ui-overlay.md +0 -157
- package/docs/ui-visual.md +0 -127
- package/docs/utils.md +0 -244
|
@@ -1,90 +1,67 @@
|
|
|
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
|
-
## 3. 완성 예제
|
|
41
|
-
|
|
42
|
-
아래는 **page + modal 2뷰 동시 지원 + inline 편집** 기준 완성 컴포넌트다. 하나의 화면이 라우트로 진입하면 CRUD 리스트(조회·등록·inline 수정·선택 삭제/복구)로 동작하고, `SdModalProvider.showAsync()`로 열리면 동일 화면이 "선택 모달"로 전환된다(selectMode에 따라 single/multi, 선택 결과를 `close.emit`). inline 삭제 열 / 엑셀 업로드·다운로드 / 모달로 row 편집은 `## 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열을 포함한다 — 도메인 범위는 같지만 뼈대 범위가 다르다.)
|
|
43
40
|
|
|
44
41
|
```typescript
|
|
45
42
|
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";
|
|
43
|
+
import { tablerAlertTriangle, tablerRefresh, tablerSearch } from "@ng-icons/tabler-icons";
|
|
56
44
|
import {
|
|
57
45
|
ChangeDetectionStrategy,
|
|
58
46
|
Component,
|
|
59
|
-
computed,
|
|
60
47
|
effect,
|
|
61
48
|
inject,
|
|
62
|
-
input,
|
|
63
|
-
output,
|
|
64
49
|
signal,
|
|
65
50
|
untracked,
|
|
66
|
-
viewChild,
|
|
67
51
|
ViewEncapsulation,
|
|
68
52
|
} from "@angular/core";
|
|
69
|
-
import {
|
|
70
|
-
import { expr } from "@simplysm/orm-common";
|
|
53
|
+
import { str } from "@simplysm/core-common";
|
|
71
54
|
import {
|
|
72
|
-
FormatPipe,
|
|
73
55
|
injectPermsSignal,
|
|
74
56
|
injectViewTitleSignal,
|
|
75
57
|
injectViewTypeSignal,
|
|
76
58
|
mark,
|
|
77
|
-
SdAnchor,
|
|
78
59
|
SdBusyContainer,
|
|
79
60
|
SdButton,
|
|
80
|
-
SdCheckbox,
|
|
81
61
|
SdCommandDirective,
|
|
82
62
|
SdDock,
|
|
83
63
|
SdDockContainer,
|
|
84
64
|
SdForm,
|
|
85
|
-
SdItemOfTemplate,
|
|
86
|
-
type SdSelectModal,
|
|
87
|
-
SdSharedDataSelect,
|
|
88
65
|
SdSheet,
|
|
89
66
|
SdSheetColumn,
|
|
90
67
|
SdSheetColumnCellTemplate,
|
|
@@ -92,28 +69,19 @@ import {
|
|
|
92
69
|
SdToastProvider,
|
|
93
70
|
SdTopbar,
|
|
94
71
|
SdTopbarContainer,
|
|
95
|
-
type SelectModalOutputResult,
|
|
96
|
-
setupCanDeactivate,
|
|
97
72
|
type SortingDef,
|
|
98
73
|
} from "@simplysm/angular";
|
|
99
|
-
// 앱별 대체: ORM
|
|
100
|
-
import { AppOrmProvider
|
|
101
|
-
import type { MainDbContext } from "@adtek/db-main";
|
|
102
|
-
import { AppAuthProvider } from "../../../providers/AppAuthProvider";
|
|
74
|
+
// 앱별 대체: ORM provider + DbContext. simplysm 패키지가 아니라 각 앱이 소유한다.
|
|
75
|
+
import { AppOrmProvider } from "@adtek/client-common";
|
|
103
76
|
|
|
104
77
|
interface IFilter {
|
|
105
78
|
searchText?: string;
|
|
106
|
-
isIncludeDeleted: boolean;
|
|
107
79
|
}
|
|
108
80
|
|
|
109
81
|
interface ICustomer {
|
|
110
|
-
id
|
|
111
|
-
name
|
|
82
|
+
id: number;
|
|
83
|
+
name: string;
|
|
112
84
|
phone?: string;
|
|
113
|
-
categoryId?: number;
|
|
114
|
-
isDeleted: boolean;
|
|
115
|
-
lastModifiedAt?: DateTime;
|
|
116
|
-
lastModifiedBy?: string;
|
|
117
85
|
}
|
|
118
86
|
|
|
119
87
|
@Component({
|
|
@@ -125,21 +93,19 @@ interface ICustomer {
|
|
|
125
93
|
SdBusyContainer, SdTopbarContainer, SdTopbar,
|
|
126
94
|
SdDockContainer, SdDock,
|
|
127
95
|
SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
|
|
128
|
-
SdButton,
|
|
129
|
-
|
|
130
|
-
NgIcon, FormatPipe,
|
|
96
|
+
SdButton, SdTextfield,
|
|
97
|
+
NgIcon,
|
|
131
98
|
],
|
|
132
99
|
hostDirectives: [
|
|
133
|
-
{ directive: SdCommandDirective, outputs: ["sdRefreshCommand"
|
|
100
|
+
{ directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
|
|
134
101
|
],
|
|
135
102
|
host: {
|
|
136
103
|
"(sdRefreshCommand)": "onRefreshButtonClick()",
|
|
137
|
-
"(sdSaveCommand)": "onSaveButtonClick()",
|
|
138
104
|
},
|
|
139
105
|
template: `
|
|
140
106
|
<sd-busy-container [busy]="busyCount() > 0">
|
|
141
107
|
@if (initialized()) {
|
|
142
|
-
@if (!
|
|
108
|
+
@if (!perms().includes("use")) {
|
|
143
109
|
<div class="fill tx-theme-gray-light p-xxl tx-center">
|
|
144
110
|
<br />
|
|
145
111
|
<ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
|
|
@@ -158,13 +124,6 @@ interface ICustomer {
|
|
|
158
124
|
새로고침
|
|
159
125
|
<small>(CTRL+ALT+L)</small>
|
|
160
126
|
</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
127
|
</sd-topbar>
|
|
169
128
|
}
|
|
170
129
|
|
|
@@ -188,166 +147,37 @@ interface ICustomer {
|
|
|
188
147
|
(valueChange)="mark(filter)"
|
|
189
148
|
/>
|
|
190
149
|
</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
150
|
</div>
|
|
200
151
|
</sd-form>
|
|
201
152
|
</sd-dock>
|
|
202
153
|
|
|
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
154
|
<!-- 시트 (main 영역) -->
|
|
237
|
-
<sd-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
[
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
>
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
}
|
|
155
|
+
<sd-sheet
|
|
156
|
+
[key]="'customer-list-sheet'"
|
|
157
|
+
[items]="items()"
|
|
158
|
+
[(currentPage)]="page"
|
|
159
|
+
[totalPageCount]="pageLength()"
|
|
160
|
+
[(sorts)]="sortingDefs"
|
|
161
|
+
[trackByFn]="trackByFn"
|
|
162
|
+
>
|
|
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'">
|
|
170
|
+
<ng-template [cell]="items()" let-item="item">
|
|
171
|
+
<div class="p-xs-sm">{{ item.name }}</div>
|
|
172
|
+
</ng-template>
|
|
173
|
+
</sd-sheet-column>
|
|
174
|
+
|
|
175
|
+
<sd-sheet-column [header]="'전화번호'" [key]="'phone'">
|
|
176
|
+
<ng-template [cell]="items()" let-item="item">
|
|
177
|
+
<div class="p-xs-sm">{{ item.phone }}</div>
|
|
178
|
+
</ng-template>
|
|
179
|
+
</sd-sheet-column>
|
|
180
|
+
</sd-sheet>
|
|
351
181
|
</sd-dock-container>
|
|
352
182
|
</sd-topbar-container>
|
|
353
183
|
}
|
|
@@ -355,63 +185,37 @@ interface ICustomer {
|
|
|
355
185
|
</sd-busy-container>
|
|
356
186
|
`,
|
|
357
187
|
})
|
|
358
|
-
export class
|
|
188
|
+
export class CustomerList {
|
|
359
189
|
//== DI ==
|
|
360
190
|
private readonly _appOrm = inject(AppOrmProvider);
|
|
361
|
-
private readonly _appAuth = inject(AppAuthProvider);
|
|
362
|
-
private readonly _appSharedData = inject(AppSharedDataProvider);
|
|
363
191
|
private readonly _sdToast = inject(SdToastProvider);
|
|
364
192
|
|
|
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
193
|
//== 식별 / 권한 ==
|
|
374
|
-
|
|
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");
|
|
194
|
+
perms = injectPermsSignal(["sales.customer"], ["use"]);
|
|
379
195
|
|
|
380
196
|
viewType = injectViewTypeSignal();
|
|
381
197
|
viewTitle = injectViewTitleSignal();
|
|
382
198
|
|
|
383
|
-
//== 공유 데이터 ==
|
|
384
|
-
sharedCategories = useSharedSignal("카테고리");
|
|
385
|
-
|
|
386
199
|
//== 상태 ==
|
|
387
|
-
initialized = signal(false);
|
|
388
|
-
busyCount = signal(0);
|
|
200
|
+
initialized = signal(false); // 최초 조회 완료 — @if (initialized()) 가드 해제
|
|
201
|
+
busyCount = signal(0); // 중첩 비동기 작업 카운트 (0 초과 시 busy 표시)
|
|
389
202
|
|
|
390
|
-
private _itemsSnapshot: ICustomer[] = [];
|
|
391
203
|
items = signal<ICustomer[]>([]);
|
|
392
|
-
selectedItems = signal<ICustomer[]>([]);
|
|
393
|
-
diffs = computed(() => this.items().oneWayDiffs(this._itemsSnapshot, "id"));
|
|
394
204
|
|
|
395
205
|
page = signal(0);
|
|
396
206
|
pageLength = signal(0);
|
|
397
207
|
sortingDefs = signal<SortingDef[]>([]);
|
|
398
208
|
|
|
399
|
-
filter = signal<IFilter>({
|
|
400
|
-
lastFilter = signal<IFilter>({
|
|
401
|
-
|
|
402
|
-
//== 파생 ==
|
|
403
|
-
hasSelectedDeleted = computed(() => this.selectedItems().some((it) => it.isDeleted));
|
|
404
|
-
hasSelectedNotDeleted = computed(() => this.selectedItems().some((it) => !it.isDeleted));
|
|
209
|
+
filter = signal<IFilter>({}); // 입력 버퍼. onFilterSubmit 시 lastFilter로 스냅샷
|
|
210
|
+
lastFilter = signal<IFilter>({}); // 조회 트리거 — effect 의존성은 이 signal
|
|
405
211
|
|
|
406
212
|
//== 시트 fn ==
|
|
407
213
|
trackByFn = (item: ICustomer) => item.id;
|
|
408
|
-
getItemCellStyleFn = (item: ICustomer): string | undefined =>
|
|
409
|
-
item.isDeleted ? "text-decoration: line-through;" : undefined;
|
|
410
214
|
|
|
411
215
|
constructor() {
|
|
412
216
|
// 필터/페이지/정렬/perms 변경 시 재조회
|
|
413
217
|
effect(() => {
|
|
414
|
-
if (!this.
|
|
218
|
+
if (!this.perms().includes("use")) {
|
|
415
219
|
this.initialized.set(true);
|
|
416
220
|
return;
|
|
417
221
|
}
|
|
@@ -429,29 +233,6 @@ export class CustomerListPage implements SdSelectModal<ICustomer> {
|
|
|
429
233
|
this.initialized.set(true);
|
|
430
234
|
});
|
|
431
235
|
});
|
|
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
236
|
}
|
|
456
237
|
|
|
457
238
|
//== Handlers ==
|
|
@@ -462,96 +243,20 @@ export class CustomerListPage implements SdSelectModal<ICustomer> {
|
|
|
462
243
|
|
|
463
244
|
onRefreshButtonClick(): void {
|
|
464
245
|
if (this.busyCount() > 0) return;
|
|
465
|
-
if (!this.
|
|
466
|
-
if (!this._checkIgnoreChanges()) return;
|
|
467
|
-
|
|
468
|
-
mark(this.lastFilter);
|
|
469
|
-
}
|
|
246
|
+
if (!this.perms().includes("use")) return;
|
|
470
247
|
|
|
471
|
-
|
|
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
|
-
}
|
|
248
|
+
mark(this.lastFilter); // 값 변경 없이 참조만 갱신 → effect 재실행
|
|
534
249
|
}
|
|
535
250
|
|
|
536
251
|
//== Internals ==
|
|
537
|
-
private _checkIgnoreChanges(): boolean {
|
|
538
|
-
return this.diffs().length === 0 || confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// 로드+snapshot만 담당. busy/try는 호출부에서 처리.
|
|
542
252
|
private async _refresh(): Promise<void> {
|
|
543
253
|
const r = await this._search(true);
|
|
544
254
|
this.items.set(r.items);
|
|
545
255
|
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
256
|
}
|
|
552
257
|
|
|
553
258
|
private async _search(
|
|
554
|
-
usePagination: boolean,
|
|
259
|
+
usePagination: boolean, // false는 전체 조회 (엑셀 다운로드 등에서 재사용)
|
|
555
260
|
): Promise<{ items: ICustomer[]; pageLength: number }> {
|
|
556
261
|
const filter = this.lastFilter();
|
|
557
262
|
const sortingDefs = this.sortingDefs();
|
|
@@ -563,22 +268,17 @@ export class CustomerListPage implements SdSelectModal<ICustomer> {
|
|
|
563
268
|
if (!str.isNullOrEmpty(filter.searchText)) {
|
|
564
269
|
qr1 = qr1.search((item) => [item.name, item.phone], filter.searchText);
|
|
565
270
|
}
|
|
566
|
-
if (!filter.isIncludeDeleted) {
|
|
567
|
-
qr1 = qr1.where((item) => [expr.eq(item.isDeleted, false)]);
|
|
568
|
-
}
|
|
569
271
|
|
|
272
|
+
// 페이지당 50건 — 시트 화면 표준치 (조정 시 UX 확인 필요)
|
|
570
273
|
const pageLength = usePagination ? Math.ceil((await qr1.count()) / 50) : 0;
|
|
571
274
|
|
|
572
|
-
let qr2 = qr1.
|
|
275
|
+
let qr2 = qr1.select((item) => ({
|
|
573
276
|
id: item.id,
|
|
574
277
|
name: item.name,
|
|
575
278
|
phone: item.phone,
|
|
576
|
-
categoryId: item.categoryId,
|
|
577
|
-
isDeleted: item.isDeleted,
|
|
578
|
-
lastModifiedAt: item.lastDataLog?.dateTime,
|
|
579
|
-
lastModifiedBy: item.lastDataLog?.userName,
|
|
580
279
|
}));
|
|
581
280
|
|
|
281
|
+
// orderBy는 string overload 사용 — 람다+obj.getChainValue는 Anti-patterns 참조
|
|
582
282
|
for (const sortingDef of sortingDefs) {
|
|
583
283
|
qr2 = qr2.orderBy(sortingDef.key, sortingDef.desc ? "DESC" : "ASC");
|
|
584
284
|
}
|
|
@@ -595,443 +295,152 @@ export class CustomerListPage implements SdSelectModal<ICustomer> {
|
|
|
595
295
|
});
|
|
596
296
|
}
|
|
597
297
|
|
|
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
298
|
//== 아이콘 ==
|
|
642
299
|
protected readonly tablerAlertTriangle = tablerAlertTriangle;
|
|
643
|
-
protected readonly tablerCirclePlus = tablerCirclePlus;
|
|
644
|
-
protected readonly tablerDeviceFloppy = tablerDeviceFloppy;
|
|
645
|
-
protected readonly tablerEraser = tablerEraser;
|
|
646
300
|
protected readonly tablerRefresh = tablerRefresh;
|
|
647
|
-
protected readonly tablerRestore = tablerRestore;
|
|
648
301
|
protected readonly tablerSearch = tablerSearch;
|
|
649
|
-
protected readonly tablerX = tablerX;
|
|
650
302
|
protected readonly mark = mark;
|
|
651
303
|
}
|
|
652
304
|
```
|
|
653
305
|
|
|
654
|
-
|
|
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`를 기준으로 아래 변경을 적용한다.
|
|
306
|
+
본 섹션에 등장하는 개별 API의 단독 사용법은 각 문서를 참조한다:
|
|
717
307
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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>
|
|
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-사용-패턴)
|
|
747
315
|
|
|
748
|
-
|
|
749
|
-
protected onToggleDeleteItemButtonClick(item: ICustomer): void {
|
|
750
|
-
item.isDeleted = !item.isDeleted;
|
|
751
|
-
mark(this.items); // OnPush 재렌더 + effect 알림
|
|
752
|
-
}
|
|
753
|
-
```
|
|
316
|
+
### 조건부 요소 포함 기준
|
|
754
317
|
|
|
755
|
-
|
|
318
|
+
최소 뼈대의 인프라·라이프사이클 요소는 화면의 필요에 따라 포함·생략한다. 필요 없는 요소를 기계적으로 포함하지 않는다.
|
|
756
319
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
-
|
|
760
|
-
|
|
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에 등록되지 않은 데이터를 다루는 화면 |
|
|
761
329
|
|
|
762
|
-
|
|
330
|
+
## 변형 (Variation)
|
|
763
331
|
|
|
764
|
-
|
|
332
|
+
아래 확장 중 필요한 것만 선택적으로 얹는다. 각 확장은 self-contained 문서에서 최소 뼈대 대비 diff를 제공한다.
|
|
765
333
|
|
|
766
|
-
|
|
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) |
|
|
767
343
|
|
|
768
|
-
|
|
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
|
-
}
|
|
344
|
+
## 🚫 흔한 실수 (Anti-patterns)
|
|
802
345
|
|
|
803
|
-
|
|
804
|
-
event.preventDefault();
|
|
805
|
-
event.stopPropagation();
|
|
806
|
-
await this._editItem(item);
|
|
807
|
-
}
|
|
346
|
+
### modal = 선택 모달로 반사적 부착
|
|
808
347
|
|
|
809
|
-
|
|
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`으로 내려받는다.
|
|
348
|
+
`viewType() === "modal"`이라는 사실만으로 "선택 모달"이라고 단정하고 `SdSelectModal<T>` 계약을 부착하지 않는다. modal 용도는 최소 두 가지다 — (a) 선택 모달(확장 D): 항목을 골라 `close.emit`으로 돌려줌 / (b) 조회 전용(확장 E): 부모 레코드의 자식 목록·이력을 읽기 전용으로 표시, 닫기는 SdModal 기본 "X".
|
|
852
349
|
|
|
853
350
|
```typescript
|
|
854
|
-
//
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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);
|
|
351
|
+
// ❌ viewType() === "modal"만 보고 선택 모달 계약을 반사적으로 부착
|
|
352
|
+
export class CustomerList implements SdSelectModal<ICustomer> {
|
|
353
|
+
selectMode = input<"single" | "multi">();
|
|
354
|
+
selectedItemKeys = input<any[]>([]);
|
|
355
|
+
close = output<SelectModalOutputResult<ICustomer>>();
|
|
356
|
+
// ... 조회 전용인데도 하단 "선택 해제 / 확인" 바, cumulativeSelection까지 전부 이식
|
|
915
357
|
}
|
|
916
358
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
}
|
|
359
|
+
// ✅ modal 용도를 먼저 확정한다
|
|
360
|
+
// (a) 선택 모달이면 확장 D 스켈레톤부터 시작
|
|
361
|
+
// (b) 조회 전용이면 확장 E 스켈레톤부터 시작 — SdSelectModal 계약 부착 안 함
|
|
939
362
|
```
|
|
940
363
|
|
|
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]` 생략 가능
|
|
364
|
+
**근거**: 조회 전용 modal에 선택 계약을 부착하면 호출되지 않아 죽은 코드가 된다. LLM이 풀 스택 합본을 복사하면서 "modal 지원"이라는 이유로 반사적으로 이식하는 회귀가 잦다. [확장 E](./crud-list/extension-e-readonly-modal.md) 참조.
|
|
980
365
|
|
|
981
|
-
###
|
|
366
|
+
### viewType 추측으로 3뷰 모두 박기 / 완전 분리 블록 작성
|
|
982
367
|
|
|
983
|
-
|
|
984
|
-
- 삭제 의사 표현은 **`item.isDeleted = true` 플래그**로 하고 `"update"` diff로 전송 (서버가 soft-delete 처리)
|
|
985
|
-
- inline 편집 시 `items` 배열에서 row를 제거하지 말 것. diff에서 해당 row가 누락되어 서버가 변경을 감지할 수 없다
|
|
368
|
+
화면이 실제 어떤 뷰로 쓰이는지 확정하지 않은 채 page·modal·control 3뷰용 조각을 모두 배치하지 않는다(당장 쓰지 않는 뷰의 계약·분기는 죽은 코드가 된다). 또한 시트 페이지를 page와 modal로 겸용할 때(확장 D)도 page 블록과 modal 블록을 각각 완성하면서 필터·시트를 중복 작성하지 않는다. 하나의 `<sd-topbar-container>` + `<sd-dock-container>` 공통 껍데기 위에 뷰별로 다른 조각만 `@if`로 얹는다.
|
|
986
369
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
-
|
|
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>
|
|
380
|
+
}
|
|
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>
|
|
388
|
+
}
|
|
995
389
|
|
|
996
|
-
|
|
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>
|
|
398
|
+
```
|
|
997
399
|
|
|
998
|
-
|
|
400
|
+
**근거**: 필터 한 줄을 수정할 때 두 블록을 모두 고쳐야 하는 상황이 생기면 구조가 잘못된 것이다. "완전 분리"는 확장 D에서 modal 하단 바 같은 **뷰별 고유 조각**에만 적용한다.
|
|
999
401
|
|
|
1000
|
-
### `
|
|
402
|
+
### `orderBy` 람다 + `obj.getChainValue` 회귀
|
|
1001
403
|
|
|
1002
|
-
|
|
1003
|
-
- 오래 걸리는 작업(대량 엑셀 업로드·집계 등)에 진행 문구가 필요하면 **필요한 화면에만** `busyMessage = signal<string | undefined>(undefined)` 추가 + `[message]="busyMessage()"` 바인딩 + 구간별 `busyMessage.set(...)`/`set(undefined)` 제어. 미사용 시 선언·바인딩 모두 생략.
|
|
404
|
+
`Queryable.orderBy`는 string overload를 지원하므로(`packages/orm-common/src/exec/queryable.ts:420`), 람다 + `obj.getChainValue` 우회 코드를 쓰지 않는다.
|
|
1004
405
|
|
|
1005
|
-
|
|
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
|
+
}
|
|
1006
414
|
|
|
1007
|
-
|
|
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
|
+
```
|
|
1008
420
|
|
|
1009
|
-
|
|
421
|
+
**근거**: 람다 형태는 타입 추론을 깨뜨리고 `as any` 캐스팅을 강제한다. string overload는 체인 경로까지 타입 안전하게 지원한다.
|
|
1010
422
|
|
|
1011
|
-
|
|
423
|
+
### 테스트용 public API 노출
|
|
1012
424
|
|
|
1013
|
-
|
|
425
|
+
테스트에서 호출하려고 private 메서드의 얇은 public wrapper를 노출하지 않는다.
|
|
1014
426
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
427
|
+
```typescript
|
|
428
|
+
// ❌ "테스트에서 호출하려고" private 메서드를 public으로 노출
|
|
429
|
+
async submit(diffs: IDiff[]): Promise<void> {
|
|
430
|
+
await this._submitAsync(diffs);
|
|
431
|
+
}
|
|
1018
432
|
|
|
1019
|
-
|
|
433
|
+
// ✅ TestBed fixture + click/dispatch 이벤트 경로 또는 host의 sdSaveCommand 트리거
|
|
434
|
+
fixture.nativeElement.dispatchEvent(
|
|
435
|
+
new KeyboardEvent("keydown", { key: "s", ctrlKey: true }),
|
|
436
|
+
);
|
|
437
|
+
```
|
|
1020
438
|
|
|
1021
|
-
|
|
1022
|
-
- 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**
|
|
1023
|
-
- **"저장 감지"가 아니다.** `obj.equal`이 deep equal로 값 차이를 감지하므로, `item.name = "new"` 같은 mutation은 `mark` 없이도 `diffs()` / submit에서 감지된다
|
|
1024
|
-
- Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요
|
|
1025
|
-
- ❌ "mark 없으면 저장이 안 된다" 식 서술 금지
|
|
439
|
+
**근거**: public wrapper는 캡슐화를 깨고 컴포넌트의 외부 API 인상을 오염시킨다. 실제 사용자가 쓰지 않는 진입점을 공개 API로 만들면 추후 리팩토링 시 제약이 된다.
|
|
1026
440
|
|
|
1027
|
-
|
|
441
|
+
## 관련 Entry
|
|
1028
442
|
|
|
1029
|
-
- `
|
|
1030
|
-
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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 도입 전 우회 코드였다
|
|
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뷰 재사용 공통 껍데기만
|