@simplysm/angular 14.0.48 → 14.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/styles.css +9 -0
- package/docs/features.md +0 -15
- package/docs/recipes/crud-detail.md +379 -364
- package/docs/recipes/crud-list.md +728 -470
- package/docs/recipes/data-select-button.md +12 -13
- package/docs/ui-navigation.md +30 -0
- package/package.json +7 -7
- package/scss/commons/_styles.scss +12 -0
- package/src/index.ts +0 -3
- 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/src/data/getOrmDataEditToastErrorMessage.ts +0 -10
|
@@ -7,16 +7,16 @@
|
|
|
7
7
|
- 제거된 추상화: `SdDataDetail`(컴포넌트) / `SdDataDetailBase<T, R>`(추상 클래스) / `SdDataDetailDataInfo`(타입) / `#toolTpl`·`#prevTpl`·`#contentTpl`·`#nextTpl`·`#modalActionTpl` 슬롯 5종
|
|
8
8
|
- 대체: 소비 컴포넌트가 표준 조각을 직접 조립
|
|
9
9
|
- 조립 요소:
|
|
10
|
-
- `<sd-busy-container [busy]
|
|
11
|
-
- `<sd-topbar-container>` + `<sd-topbar>` —
|
|
10
|
+
- `<sd-busy-container [busy]>` — 전체 busy 오버레이
|
|
11
|
+
- `<sd-topbar-container>` + `<sd-topbar>` — 공통 컨테이너, `<sd-topbar>`는 page 뷰에서만 조건부 렌더
|
|
12
|
+
- `<sd-dock-container>` + `<sd-dock>` — 뷰별 도구 바(control 상단 바 / modal 하단 바) 부착, 본문은 main 영역
|
|
12
13
|
- `<sd-form #formCtrl (formSubmit)>` — Ctrl+S·submit 버튼 트리거
|
|
13
14
|
- `injectViewTypeSignal()` — page / modal / control 뷰 판정
|
|
14
15
|
- `injectPermsSignal()` — 권한 signal
|
|
15
16
|
- `setupCanDeactivate()` — 이탈 방지
|
|
16
|
-
- `SdToastProvider.try(fn
|
|
17
|
+
- `SdToastProvider.try(fn)` — 에러 래퍼 (busy 카운트는 호출부에서 `busyCount.update`로 직접 제어)
|
|
17
18
|
- `SdModalContentDef<R>` — 모달 컨텐츠 인터페이스 (소비 화면이 직접 `implements`)
|
|
18
19
|
- `SdCommandDirective`(`sdRefreshCommand` / `sdSaveCommand`) — Ctrl+Alt+L / Ctrl+S 단축키
|
|
19
|
-
- `getOrmDataEditToastErrorMessage(err)` — ORM 에러 → 사용자 메시지 변환
|
|
20
20
|
- 데이터 비교:
|
|
21
21
|
- `obj.clone(data)` — snapshot 복제 (`@simplysm/core-common`)
|
|
22
22
|
- `obj.equal(a, b)` — deep equal
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
아래는 **page·modal·control 3뷰를 모두 커버하는** 완성 컴포넌트다. 모달로 띄우면 `viewType() === "modal"`로 자동 판정되어 하단 "확인/삭제" 바와 우측 상단 "새로고침" 액션이 표시되고, 라우트로 진입하면 `"page"`로 판정되어 topbar에 저장/새로고침 버튼이 표시된다. 마스터-디테일의 디테일로 `<app-customer-detail class="flex-fill">`처럼 삽입하면 `"control"`로 판정되어 상단 바에 저장/새로고침/삭제 버튼이 표시된다.
|
|
39
39
|
|
|
40
40
|
```typescript
|
|
41
|
-
import { NgTemplateOutlet } from "@angular/common";
|
|
42
41
|
import { NgIcon } from "@ng-icons/core";
|
|
43
42
|
import {
|
|
44
43
|
tablerAlertTriangle,
|
|
@@ -64,7 +63,6 @@ import {
|
|
|
64
63
|
import { type DateTime, obj } from "@simplysm/core-common";
|
|
65
64
|
import {
|
|
66
65
|
FormatPipe,
|
|
67
|
-
getOrmDataEditToastErrorMessage,
|
|
68
66
|
injectCurrentPageCodeSignal,
|
|
69
67
|
injectFullPageCodeSignal,
|
|
70
68
|
injectPermsSignal,
|
|
@@ -75,6 +73,8 @@ import {
|
|
|
75
73
|
SdBusyContainer,
|
|
76
74
|
SdButton,
|
|
77
75
|
SdCommandDirective,
|
|
76
|
+
SdDock,
|
|
77
|
+
SdDockContainer,
|
|
78
78
|
SdForm,
|
|
79
79
|
type SdModalContentDef,
|
|
80
80
|
SdSystemLogProvider,
|
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
} from "@simplysm/angular";
|
|
87
87
|
|
|
88
88
|
interface ICustomer {
|
|
89
|
-
id: number | undefined;
|
|
89
|
+
id: number | undefined; // undefined면 신규
|
|
90
90
|
name: string;
|
|
91
91
|
phone: string;
|
|
92
92
|
isDeleted: boolean;
|
|
@@ -94,14 +94,6 @@ interface ICustomer {
|
|
|
94
94
|
lastModifiedBy: string | undefined;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
// 과거 `SdDataDetailDataInfo`에 해당 — 화면 내부에서 직접 선언한다.
|
|
98
|
-
interface IDataInfo {
|
|
99
|
-
isNew: boolean;
|
|
100
|
-
isDeleted: boolean;
|
|
101
|
-
lastModifiedAt: DateTime | undefined;
|
|
102
|
-
lastModifiedBy: string | undefined;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
97
|
@Component({
|
|
106
98
|
selector: "app-customer-detail",
|
|
107
99
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
@@ -109,103 +101,150 @@ interface IDataInfo {
|
|
|
109
101
|
standalone: true,
|
|
110
102
|
imports: [
|
|
111
103
|
SdBusyContainer, SdTopbarContainer, SdTopbar,
|
|
104
|
+
SdDockContainer, SdDock,
|
|
112
105
|
SdForm, SdButton, SdAnchor, SdTextfield,
|
|
113
|
-
FormatPipe, NgIcon,
|
|
106
|
+
FormatPipe, NgIcon,
|
|
114
107
|
],
|
|
115
108
|
hostDirectives: [
|
|
116
109
|
{ directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
|
|
117
110
|
],
|
|
118
111
|
host: {
|
|
119
112
|
"(sdRefreshCommand)": "onRefreshButtonClick()",
|
|
120
|
-
"(sdSaveCommand)": "
|
|
113
|
+
"(sdSaveCommand)": "onSaveButtonClick()",
|
|
121
114
|
},
|
|
122
115
|
template: `
|
|
123
|
-
<sd-busy-container [busy]="busyCount() > 0"
|
|
116
|
+
<sd-busy-container [busy]="busyCount() > 0">
|
|
124
117
|
@if (initialized()) {
|
|
125
118
|
@if (!canUse()) {
|
|
126
119
|
<div class="fill tx-theme-gray-light p-xxl tx-center">
|
|
127
120
|
<br />
|
|
128
|
-
<ng-icon [svg]="
|
|
121
|
+
<ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
|
|
129
122
|
<br /><br />
|
|
130
123
|
'{{ modalOrPageTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
|
|
131
124
|
</div>
|
|
132
|
-
} @else
|
|
125
|
+
} @else {
|
|
133
126
|
<sd-topbar-container>
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
|
|
127
|
+
@if (viewType() === "page") {
|
|
128
|
+
<sd-topbar>
|
|
129
|
+
<h4>{{ modalOrPageTitle() }}</h4>
|
|
130
|
+
@if (canEdit()) {
|
|
131
|
+
<sd-button [theme]="'link-primary'" (click)="onSaveButtonClick()">
|
|
132
|
+
<ng-icon [svg]="tablerDeviceFloppy" />
|
|
133
|
+
저장
|
|
134
|
+
<small>(CTRL+S)</small>
|
|
135
|
+
</sd-button>
|
|
136
|
+
}
|
|
137
|
+
<sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
|
|
138
|
+
<ng-icon [svg]="tablerRefresh" />
|
|
139
|
+
새로고침
|
|
140
|
+
<small>(CTRL+ALT+L)</small>
|
|
140
141
|
</sd-button>
|
|
142
|
+
</sd-topbar>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
<sd-dock-container>
|
|
146
|
+
<!-- control 뷰 상단 바: 저장/새로고침/삭제 -->
|
|
147
|
+
@if (viewType() === "control" && canEdit()) {
|
|
148
|
+
<sd-dock class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">
|
|
149
|
+
<sd-button [theme]="'primary'" (click)="onSaveButtonClick()">
|
|
150
|
+
<ng-icon [svg]="tablerDeviceFloppy" />
|
|
151
|
+
저장
|
|
152
|
+
<small>(CTRL+S)</small>
|
|
153
|
+
</sd-button>
|
|
154
|
+
<sd-button [theme]="'info'" (click)="onRefreshButtonClick()">
|
|
155
|
+
<ng-icon [svg]="tablerRefresh" />
|
|
156
|
+
새로고침
|
|
157
|
+
<small>(CTRL+ALT+L)</small>
|
|
158
|
+
</sd-button>
|
|
159
|
+
@if (!isNew() && canDelete()) {
|
|
160
|
+
@if (data().isDeleted) {
|
|
161
|
+
<sd-button [theme]="'warning'" (click)="onRestoreButtonClick()">
|
|
162
|
+
<ng-icon [svg]="tablerRestore" />
|
|
163
|
+
복구
|
|
164
|
+
</sd-button>
|
|
165
|
+
} @else {
|
|
166
|
+
<sd-button [theme]="'danger'" (click)="onDeleteButtonClick()">
|
|
167
|
+
<ng-icon [svg]="tablerEraser" />
|
|
168
|
+
삭제
|
|
169
|
+
</sd-button>
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
</sd-dock>
|
|
141
173
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
@if (dataInfo()?.isDeleted) {
|
|
160
|
-
<sd-button [size]="'sm'" [theme]="'warning'" (click)="onRestoreButtonClick()">
|
|
161
|
-
복구
|
|
162
|
-
</sd-button>
|
|
163
|
-
} @else {
|
|
164
|
-
<sd-button [size]="'sm'" [theme]="'danger'" (click)="onDeleteButtonClick()">
|
|
165
|
-
삭제
|
|
166
|
-
</sd-button>
|
|
174
|
+
|
|
175
|
+
<!-- modal 뷰 하단 바: 삭제/복구 + 확인 -->
|
|
176
|
+
@if (viewType() === "modal" && canEdit()) {
|
|
177
|
+
<sd-dock
|
|
178
|
+
[position]="'bottom'"
|
|
179
|
+
class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest"
|
|
180
|
+
>
|
|
181
|
+
@if (!isNew() && canDelete()) {
|
|
182
|
+
@if (data().isDeleted) {
|
|
183
|
+
<sd-button [size]="'sm'" [theme]="'warning'" (click)="onRestoreButtonClick()">
|
|
184
|
+
복구
|
|
185
|
+
</sd-button>
|
|
186
|
+
} @else {
|
|
187
|
+
<sd-button [size]="'sm'" [theme]="'danger'" (click)="onDeleteButtonClick()">
|
|
188
|
+
삭제
|
|
189
|
+
</sd-button>
|
|
190
|
+
}
|
|
167
191
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
확인
|
|
172
|
-
</sd-button>
|
|
173
|
-
</div>
|
|
174
|
-
</div>
|
|
175
|
-
}
|
|
176
|
-
</div>
|
|
177
|
-
} @else {
|
|
178
|
-
<!-- control 뷰: 다른 화면의 영역으로 삽입된 상태 -->
|
|
179
|
-
<div class="flex-column fill">
|
|
180
|
-
@if (canEdit()) {
|
|
181
|
-
<div class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">
|
|
182
|
-
<sd-button [theme]="'primary'" (click)="onSubmitButtonClick()">
|
|
183
|
-
<ng-icon [svg]="icons.tablerDeviceFloppy" />
|
|
184
|
-
저장 <small>(CTRL+S)</small>
|
|
185
|
-
</sd-button>
|
|
186
|
-
<sd-button [theme]="'info'" (click)="onRefreshButtonClick()">
|
|
187
|
-
<ng-icon [svg]="icons.tablerRefresh" />
|
|
188
|
-
새로고침 <small>(CTRL+ALT+L)</small>
|
|
189
|
-
</sd-button>
|
|
190
|
-
@if (!dataInfo()?.isNew && canDelete()) {
|
|
191
|
-
@if (dataInfo()?.isDeleted) {
|
|
192
|
-
<sd-button [theme]="'warning'" (click)="onRestoreButtonClick()">
|
|
193
|
-
<ng-icon [svg]="icons.tablerRestore" />
|
|
194
|
-
복구
|
|
192
|
+
<div class="flex-fill flex-row gap-sm main-align-end">
|
|
193
|
+
<sd-button [size]="'sm'" [theme]="'primary'" (click)="onSaveButtonClick()">
|
|
194
|
+
확인
|
|
195
195
|
</sd-button>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
</div>
|
|
197
|
+
</sd-dock>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
<!-- main: form + 최종수정 -->
|
|
201
|
+
<div class="flex-column fill">
|
|
202
|
+
<sd-form #formCtrl (formSubmit)="onSubmit()" class="flex-fill">
|
|
203
|
+
<div class="p-default">
|
|
204
|
+
<table class="form-table">
|
|
205
|
+
<tbody>
|
|
206
|
+
<tr>
|
|
207
|
+
<th>명칭</th>
|
|
208
|
+
<td>
|
|
209
|
+
<sd-textfield
|
|
210
|
+
[type]="'text'"
|
|
211
|
+
[required]="true"
|
|
212
|
+
[disabled]="!canEdit()"
|
|
213
|
+
[(value)]="data().name"
|
|
214
|
+
/>
|
|
215
|
+
</td>
|
|
216
|
+
</tr>
|
|
217
|
+
<tr>
|
|
218
|
+
<th>전화번호</th>
|
|
219
|
+
<td>
|
|
220
|
+
<sd-textfield
|
|
221
|
+
[type]="'text'"
|
|
222
|
+
[disabled]="!canEdit()"
|
|
223
|
+
[(value)]="data().phone"
|
|
224
|
+
/>
|
|
225
|
+
</td>
|
|
226
|
+
</tr>
|
|
227
|
+
</tbody>
|
|
228
|
+
</table>
|
|
229
|
+
</div>
|
|
230
|
+
</sd-form>
|
|
231
|
+
@if (data().lastModifiedAt || data().lastModifiedBy) {
|
|
232
|
+
<div
|
|
233
|
+
class="p-sm-default"
|
|
234
|
+
[class.bg-theme-gray-lightest]="viewType() === 'modal'"
|
|
235
|
+
>
|
|
236
|
+
최종수정:
|
|
237
|
+
@if (data().lastModifiedAt) {
|
|
238
|
+
{{ data().lastModifiedAt | format: "yyyy-MM-dd HH:mm" }}
|
|
239
|
+
}
|
|
240
|
+
@if (data().lastModifiedBy) {
|
|
241
|
+
({{ data().lastModifiedBy }})
|
|
242
|
+
}
|
|
243
|
+
</div>
|
|
202
244
|
}
|
|
203
245
|
</div>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
<ng-template [ngTemplateOutlet]="formTpl" />
|
|
207
|
-
</div>
|
|
208
|
-
</div>
|
|
246
|
+
</sd-dock-container>
|
|
247
|
+
</sd-topbar-container>
|
|
209
248
|
}
|
|
210
249
|
}
|
|
211
250
|
</sd-busy-container>
|
|
@@ -220,59 +259,9 @@ interface IDataInfo {
|
|
|
220
259
|
(click)="onRefreshButtonClick()"
|
|
221
260
|
title="새로고침(CTRL+ALT+L)"
|
|
222
261
|
>
|
|
223
|
-
<ng-icon [svg]="
|
|
262
|
+
<ng-icon [svg]="tablerRefresh" />
|
|
224
263
|
</sd-anchor>
|
|
225
264
|
</ng-template>
|
|
226
|
-
|
|
227
|
-
<ng-template #formTpl>
|
|
228
|
-
<div class="flex-column fill">
|
|
229
|
-
<div class="flex-fill">
|
|
230
|
-
<sd-form #formCtrl (formSubmit)="onSubmit()">
|
|
231
|
-
<div class="p-default">
|
|
232
|
-
<table class="form-table">
|
|
233
|
-
<tbody>
|
|
234
|
-
<tr>
|
|
235
|
-
<th>명칭</th>
|
|
236
|
-
<td>
|
|
237
|
-
<sd-textfield
|
|
238
|
-
[type]="'text'"
|
|
239
|
-
[required]="true"
|
|
240
|
-
[disabled]="!canEdit()"
|
|
241
|
-
[(value)]="data().name"
|
|
242
|
-
/>
|
|
243
|
-
</td>
|
|
244
|
-
</tr>
|
|
245
|
-
<tr>
|
|
246
|
-
<th>전화번호</th>
|
|
247
|
-
<td>
|
|
248
|
-
<sd-textfield
|
|
249
|
-
[type]="'text'"
|
|
250
|
-
[disabled]="!canEdit()"
|
|
251
|
-
[(value)]="data().phone"
|
|
252
|
-
/>
|
|
253
|
-
</td>
|
|
254
|
-
</tr>
|
|
255
|
-
</tbody>
|
|
256
|
-
</table>
|
|
257
|
-
</div>
|
|
258
|
-
</sd-form>
|
|
259
|
-
</div>
|
|
260
|
-
@if (dataInfo()?.lastModifiedAt || dataInfo()?.lastModifiedBy) {
|
|
261
|
-
<div
|
|
262
|
-
class="p-sm-default"
|
|
263
|
-
[class.bg-theme-gray-lightest]="viewType() === 'modal'"
|
|
264
|
-
>
|
|
265
|
-
최종수정:
|
|
266
|
-
@if (dataInfo()?.lastModifiedAt) {
|
|
267
|
-
{{ dataInfo()!.lastModifiedAt | format: "yyyy-MM-dd HH:mm" }}
|
|
268
|
-
}
|
|
269
|
-
@if (dataInfo()?.lastModifiedBy) {
|
|
270
|
-
({{ dataInfo()?.lastModifiedBy }})
|
|
271
|
-
}
|
|
272
|
-
</div>
|
|
273
|
-
}
|
|
274
|
-
</div>
|
|
275
|
-
</ng-template>
|
|
276
265
|
`,
|
|
277
266
|
})
|
|
278
267
|
export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
@@ -285,6 +274,10 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
285
274
|
//== input ==
|
|
286
275
|
itemId = input<number>();
|
|
287
276
|
|
|
277
|
+
//== viewChild ==
|
|
278
|
+
protected readonly formCtrl = viewChild<SdForm>("formCtrl");
|
|
279
|
+
private readonly _modalActionTpl = viewChild("modalActionTpl", { read: TemplateRef });
|
|
280
|
+
|
|
288
281
|
//== 라우팅 / 권한 ==
|
|
289
282
|
private readonly _fullPageCode = injectFullPageCodeSignal();
|
|
290
283
|
private readonly _currPageCode = injectCurrentPageCodeSignal();
|
|
@@ -302,10 +295,16 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
302
295
|
|
|
303
296
|
//== 상태 ==
|
|
304
297
|
protected readonly busyCount = signal(0);
|
|
305
|
-
protected readonly busyMessage = signal<string | undefined>(undefined);
|
|
306
298
|
protected readonly initialized = signal(false);
|
|
307
|
-
protected readonly data = signal<ICustomer>({
|
|
308
|
-
|
|
299
|
+
protected readonly data = signal<ICustomer>({
|
|
300
|
+
id: undefined,
|
|
301
|
+
name: "",
|
|
302
|
+
phone: "",
|
|
303
|
+
isDeleted: false,
|
|
304
|
+
lastModifiedAt: undefined,
|
|
305
|
+
lastModifiedBy: undefined,
|
|
306
|
+
});
|
|
307
|
+
protected readonly isNew = computed(() => this.data().id == null);
|
|
309
308
|
private _snapshot?: ICustomer;
|
|
310
309
|
|
|
311
310
|
//== SdModalContentDef<boolean | undefined> 요구 필드 ==
|
|
@@ -313,10 +312,6 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
313
312
|
// actionTplRef는 SdModal이 setter 프록시로 감싸므로 필드 선언만으로 충분
|
|
314
313
|
actionTplRef?: TemplateRef<any>;
|
|
315
314
|
|
|
316
|
-
//== viewChild ==
|
|
317
|
-
protected readonly formCtrl = viewChild<SdForm>("formCtrl");
|
|
318
|
-
private readonly _modalActionTpl = viewChild("modalActionTpl", { read: TemplateRef });
|
|
319
|
-
|
|
320
315
|
//== 파생 ==
|
|
321
316
|
protected readonly modalOrPageTitle = computed(() => {
|
|
322
317
|
try {
|
|
@@ -332,22 +327,23 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
332
327
|
}
|
|
333
328
|
});
|
|
334
329
|
|
|
335
|
-
protected readonly icons = {
|
|
336
|
-
tablerAlertTriangle, tablerDeviceFloppy, tablerEraser, tablerRefresh, tablerRestore,
|
|
337
|
-
};
|
|
338
|
-
|
|
339
330
|
//== 라이프사이클 ==
|
|
340
331
|
constructor() {
|
|
341
332
|
// 최초 진입 + itemId 변경 시 재조회.
|
|
342
|
-
// effect의 자체 cleanup이 이중 실행을 방지하므로 queueMicrotask + cancelled 플래그는 불필요.
|
|
343
333
|
effect(() => {
|
|
344
334
|
this.itemId(); // 의존성 등록
|
|
345
335
|
if (!this.canUse()) {
|
|
346
336
|
this.initialized.set(true);
|
|
347
337
|
return;
|
|
348
338
|
}
|
|
349
|
-
|
|
350
|
-
|
|
339
|
+
|
|
340
|
+
void untracked(async () => {
|
|
341
|
+
this.busyCount.update((v) => v + 1);
|
|
342
|
+
await this._sdToast.try(async () => {
|
|
343
|
+
await this._refresh();
|
|
344
|
+
});
|
|
345
|
+
this.busyCount.update((v) => v - 1);
|
|
346
|
+
this.initialized.set(true);
|
|
351
347
|
});
|
|
352
348
|
});
|
|
353
349
|
|
|
@@ -365,37 +361,42 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
365
361
|
if (this.busyCount() > 0) return;
|
|
366
362
|
if (!this.canUse()) return;
|
|
367
363
|
if (!this._checkIgnoreChanges()) return;
|
|
368
|
-
|
|
364
|
+
|
|
365
|
+
this.busyCount.update((v) => v + 1);
|
|
366
|
+
await this._sdToast.try(async () => {
|
|
367
|
+
await this._refresh();
|
|
368
|
+
});
|
|
369
|
+
this.busyCount.update((v) => v - 1);
|
|
369
370
|
}
|
|
370
371
|
|
|
371
|
-
protected
|
|
372
|
+
protected onSaveButtonClick(): void {
|
|
372
373
|
this.formCtrl()?.requestSubmit();
|
|
373
374
|
}
|
|
374
375
|
|
|
375
376
|
protected async onSubmit(): Promise<void> {
|
|
376
377
|
if (this.busyCount() > 0) return;
|
|
377
378
|
if (!this.canEdit()) return;
|
|
378
|
-
const info = this.dataInfo();
|
|
379
|
-
if (info == null) return;
|
|
380
379
|
|
|
381
|
-
//
|
|
382
|
-
if (!
|
|
380
|
+
// 신규면 변경사항 체크 없이 저장. 기존 항목이면 snapshot 대비 변경사항 여부 판정.
|
|
381
|
+
if (!this.isNew() && this._snapshot != null && obj.equal(this.data(), this._snapshot)) {
|
|
383
382
|
this._sdToast.info("변경사항이 없습니다.");
|
|
384
383
|
return;
|
|
385
384
|
}
|
|
386
385
|
|
|
386
|
+
this.busyCount.update((v) => v + 1);
|
|
387
387
|
await this._sdToast.try(async () => {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
388
|
+
// 앱별 ORM upsert — 예:
|
|
389
|
+
// await this._appOrm.connectAsync(async (db) => {
|
|
390
|
+
// await db.customer.upsertAsync(this.data());
|
|
391
|
+
// });
|
|
392
|
+
// 검증 실패 시 throw 하면 sdToast.try가 포착하여 에러 토스트 표시 (이후 흐름 생략).
|
|
393
|
+
|
|
394
|
+
this._sdToast.success("저장되었습니다.");
|
|
395
|
+
this.close.emit(true);
|
|
396
|
+
|
|
397
|
+
await this._refresh();
|
|
398
|
+
});
|
|
399
|
+
this.busyCount.update((v) => v - 1);
|
|
399
400
|
}
|
|
400
401
|
|
|
401
402
|
protected async onDeleteButtonClick(): Promise<void> {
|
|
@@ -412,17 +413,18 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
412
413
|
if (!this.canEdit()) return;
|
|
413
414
|
if (!this.canDelete()) return;
|
|
414
415
|
|
|
416
|
+
this.busyCount.update((v) => v + 1);
|
|
415
417
|
await this._sdToast.try(async () => {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
418
|
+
// 앱별 ORM delete/restore — 예:
|
|
419
|
+
// if (del && !confirm("삭제하시겠습니까?")) return;
|
|
420
|
+
// await this._appOrm.connectAsync(async (db) => {
|
|
421
|
+
// await db.customer.where(...).update({ isDeleted: del });
|
|
422
|
+
// });
|
|
423
|
+
|
|
424
|
+
this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
|
|
425
|
+
this.close.emit(true);
|
|
426
|
+
});
|
|
427
|
+
this.busyCount.update((v) => v - 1);
|
|
426
428
|
}
|
|
427
429
|
|
|
428
430
|
private _checkIgnoreChanges(): boolean {
|
|
@@ -433,60 +435,33 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
433
435
|
);
|
|
434
436
|
}
|
|
435
437
|
|
|
436
|
-
|
|
437
|
-
await this._refresh();
|
|
438
|
-
this.initialized.set(true);
|
|
439
|
-
}
|
|
440
|
-
|
|
438
|
+
// 로드+snapshot만 담당. busy/try는 호출부에서 처리.
|
|
441
439
|
private async _refresh(): Promise<void> {
|
|
442
|
-
|
|
443
|
-
this.busyCount.update((v) => v + 1);
|
|
444
|
-
try {
|
|
445
|
-
const r = await this._loadAsync();
|
|
446
|
-
this.data.set(r.data);
|
|
447
|
-
this.dataInfo.set(r.info);
|
|
448
|
-
// isNew면 스냅샷 저장 생략 — 변경사항 체크를 항상 통과시켜 저장 허용
|
|
449
|
-
this._snapshot = r.info.isNew ? undefined : obj.clone(r.data);
|
|
450
|
-
} finally {
|
|
451
|
-
this.busyCount.update((v) => v - 1);
|
|
452
|
-
}
|
|
453
|
-
}, getOrmDataEditToastErrorMessage);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
//== 앱별 구현 ==
|
|
457
|
-
private async _loadAsync(): Promise<{ data: ICustomer; info: IDataInfo }> {
|
|
440
|
+
let data: ICustomer;
|
|
458
441
|
if (this.itemId() == null) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
info: { isNew: true, isDeleted: false,
|
|
463
|
-
lastModifiedAt: undefined, lastModifiedBy: undefined },
|
|
442
|
+
data = {
|
|
443
|
+
id: undefined, name: "", phone: "",
|
|
444
|
+
isDeleted: false, lastModifiedAt: undefined, lastModifiedBy: undefined,
|
|
464
445
|
};
|
|
446
|
+
} else {
|
|
447
|
+
// 앱별 ORM 조회 — 예:
|
|
448
|
+
// data = await this._appOrm.connectAsync(async (db) =>
|
|
449
|
+
// (await db.customer.where((it) => [expr.eq(it.id, this.itemId())]).single())!
|
|
450
|
+
// );
|
|
451
|
+
throw new Error("구현 필요");
|
|
465
452
|
}
|
|
466
|
-
// ORM 호출 예시:
|
|
467
|
-
// return this._appOrm.connectAsync(async (db) => {
|
|
468
|
-
// const data = (await db.customer
|
|
469
|
-
// .where((it) => [expr.eq(it.id, this.itemId())])
|
|
470
|
-
// .single())!;
|
|
471
|
-
// return {
|
|
472
|
-
// data,
|
|
473
|
-
// info: { isNew: false, isDeleted: data.isDeleted,
|
|
474
|
-
// lastModifiedAt: data.lastModifiedAt, lastModifiedBy: data.lastModifiedBy },
|
|
475
|
-
// };
|
|
476
|
-
// });
|
|
477
|
-
throw new Error("구현 필요");
|
|
478
|
-
}
|
|
479
453
|
|
|
480
|
-
|
|
481
|
-
//
|
|
482
|
-
|
|
454
|
+
this.data.set(data);
|
|
455
|
+
// 신규(id == null)면 스냅샷 저장 생략 — 변경사항 체크를 항상 통과시켜 저장 허용
|
|
456
|
+
this._snapshot = data.id == null ? undefined : obj.clone(data);
|
|
483
457
|
}
|
|
484
458
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
459
|
+
//== 아이콘 ==
|
|
460
|
+
protected readonly tablerAlertTriangle = tablerAlertTriangle;
|
|
461
|
+
protected readonly tablerDeviceFloppy = tablerDeviceFloppy;
|
|
462
|
+
protected readonly tablerEraser = tablerEraser;
|
|
463
|
+
protected readonly tablerRefresh = tablerRefresh;
|
|
464
|
+
protected readonly tablerRestore = tablerRestore;
|
|
490
465
|
}
|
|
491
466
|
```
|
|
492
467
|
|
|
@@ -496,20 +471,18 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
496
471
|
|
|
497
472
|
| 블록 | 역할 | 원본 대응 |
|
|
498
473
|
|---|---|---|
|
|
499
|
-
| `<sd-busy-container [busy]
|
|
474
|
+
| `<sd-busy-container [busy]>` | 전체 busy 오버레이 | `sd-data-detail.ts:53-54` + `SdBaseContainer` |
|
|
500
475
|
| `@if (initialized())` | 최초 조회 완료 전 본문 숨김 | `sd-data-detail.base.ts:92` `initialized.set(true)` |
|
|
501
476
|
| `@if (!canUse())` | 권한 없음 메시지 | `sd-base-container.ts:44-51` + `page-modal-container.md` |
|
|
502
|
-
| `@
|
|
503
|
-
| `<sd-
|
|
504
|
-
| modal 분기 하단 확인/삭제 바 | 모달 하단 액션 | `sd-data-detail.ts:151-175` `#modalBottomTpl` |
|
|
505
|
-
| control 분기 상단 바 (저장/새로고침/삭제) | 마스터-디테일의 디테일 도구 바 | `sd-data-detail.ts:77-109` |
|
|
477
|
+
| `<sd-topbar-container>` 공통 껍데기 + `@if (viewType() === "page")` 내부에 `<sd-topbar>` | 페이지 뷰만 topbar 표시, 나머지 뷰는 topbar 없는 컨테이너로 사용 | `sd-data-detail.ts:53-109` `viewType` → `SdBaseContainer` 내부 분기 |
|
|
478
|
+
| `<sd-dock-container>` + `<sd-dock>` 순서대로 control 상단 바 / modal 하단 바(`[position]="'bottom'"`) / main(form) | 뷰별 도구 바는 `<sd-dock>`으로 부착, 본문은 main 영역에 그대로 | `sd-data-detail.ts:77-175` `#modalBottomTpl` / control 상단 바 |
|
|
506
479
|
| `<ng-template #modalActionTpl>` + `effect(() => actionTplRef = _modalActionTpl())` | 모달 우측 상단 새로고침 액션 (`SdModal`이 setter 프록시로 브릿지) | `sd-data-detail.ts:177-186, 201-207` `#modalActionTpl` + `parent.actionTplRef = ...` |
|
|
507
|
-
| `<sd-form #formCtrl (formSubmit)>` + 최종수정 표시 | 폼 본문 + `lastModifiedAt/By` | `sd-data-detail.ts:121-140` |
|
|
480
|
+
| `<sd-form #formCtrl (formSubmit)>` + 최종수정 표시 | main 영역의 폼 본문 + `lastModifiedAt/By` | `sd-data-detail.ts:121-140` |
|
|
508
481
|
| `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-detail.ts:45-51` |
|
|
509
482
|
| `setupCanDeactivate(() => viewType() === "modal" || checkIgnoreChanges())` | 라우트 이탈 시 변경사항 확인 | `sd-data-detail.base.ts:99` |
|
|
510
|
-
|
|
|
483
|
+
| 호출부(`onRefresh`/`onSubmit`/`_toggleDelete`/초기 effect) 내 `busyCount.update` + `sdToast.try(...)` | busy 카운트 증감 + 에러 토스트 래핑 | `sd-data-detail.base.ts:86-91, 115-119` |
|
|
511
484
|
| `_snapshot = obj.clone(data)` + `obj.equal` 비교 | 변경 감지 | `sd-data-detail.base.ts:66, 127, 102-108, 162` |
|
|
512
|
-
| `effect(() => { itemId(); if (!canUse()) return; untracked(() =>
|
|
485
|
+
| `effect(() => { itemId(); if (!canUse()) return; untracked(async () => { busy/try + _refresh() + initialized.set(true) }); })` | 최초 로드 + `itemId` 변경 시 자동 reload | `sd-data-detail.base.ts:69-97` + `prepareRefreshEffect` 슬롯 |
|
|
513
486
|
| `close = output<R | undefined>()` + `implements SdModalContentDef<R>` | 모달 컨텐츠 계약 | `sd-data-detail.base.ts:29-30, 59` |
|
|
514
487
|
|
|
515
488
|
### 상태 분해
|
|
@@ -517,11 +490,10 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
517
490
|
| signal / 필드 | 역할 |
|
|
518
491
|
|---|---|
|
|
519
492
|
| `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
|
|
520
|
-
| `busyMessage` | busy 오버레이 문구 |
|
|
521
493
|
| `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
|
|
522
|
-
| `data` | 현재 편집 중인 데이터 (load 결과) |
|
|
523
|
-
| `
|
|
524
|
-
| `_snapshot` | 직전 `_refresh()` 시점의 data 깊은 복제본 (변경 감지용). `
|
|
494
|
+
| `data` | 현재 편집 중인 데이터 (load 결과). `isDeleted`·`lastModifiedAt`·`lastModifiedBy` 포함 |
|
|
495
|
+
| `isNew` | `computed(() => data().id == null)` — 신규 레코드 여부 (저장·삭제 분기) |
|
|
496
|
+
| `_snapshot` | 직전 `_refresh()` 시점의 data 깊은 복제본 (변경 감지용). 신규(`id == null`)면 `undefined` |
|
|
525
497
|
| `close` | 모달 결과 output (`SdModalContentDef` 요구) |
|
|
526
498
|
| `actionTplRef` | 모달 우측 상단 액션 슬롯. `SdModal`이 setter 프록시로 자동 브릿지 |
|
|
527
499
|
|
|
@@ -529,14 +501,13 @@ export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
|
529
501
|
|
|
530
502
|
| 메서드 | 역할 |
|
|
531
503
|
|---|---|
|
|
532
|
-
| `onRefreshButtonClick()` | busy/권한/변경사항 가드
|
|
533
|
-
| `
|
|
534
|
-
| `onSubmit()` | `canEdit` 체크
|
|
504
|
+
| `onRefreshButtonClick()` | busy/권한/변경사항 가드 → `busyCount` 증가 → `_sdToast.try(_refresh)` → `busyCount` 감소 |
|
|
505
|
+
| `onSaveButtonClick()` | `formCtrl()?.requestSubmit()` — Ctrl+S와 동일 경로. `host`의 `sdSaveCommand`와 어휘 일치 |
|
|
506
|
+
| `onSubmit()` | `canEdit` 체크 → `isNew()`가 아니면 snapshot 대비 변경사항 판정 → `busyCount` 증가 → `_sdToast.try(ORM upsert + close.emit + _refresh)` → `busyCount` 감소. 실제 upsert 호출은 `_sdToast.try` 내부에 인라인 (단일 호출처이므로 별도 메서드로 분리하지 않음) |
|
|
535
507
|
| `onDeleteButtonClick()` / `onRestoreButtonClick()` | `_toggleDelete(del)` 호출 |
|
|
536
|
-
| `_toggleDelete(del)` | busy/권한 가드
|
|
508
|
+
| `_toggleDelete(del)` | busy/권한 가드 → `busyCount` 증가 → `_sdToast.try(ORM delete/restore + close.emit)` → `busyCount` 감소. delete/restore는 인라인 |
|
|
537
509
|
| `_checkIgnoreChanges()` | snapshot 없거나 동일하면 true, 아니면 `confirm(...)` |
|
|
538
|
-
| `
|
|
539
|
-
| `_loadAsync()` / `_saveAsync(data)` / `_deleteAsync(del)` | 앱별 ORM/API 구현 |
|
|
510
|
+
| `_refresh()` | `itemId() == null`이면 빈 객체, 아니면 앱별 ORM 조회 → `data.set` + snapshot 갱신 (`data().id == null`이면 미저장). busy/try는 호출부 책임. 조회 로직은 호출처가 한 곳뿐이므로 `_refresh` 내부에 인라인 |
|
|
540
511
|
|
|
541
512
|
## 5. 변형: 보조 기능 영역
|
|
542
513
|
|
|
@@ -553,17 +524,17 @@ import { SdSharedDataSelect } from "@simplysm/angular";
|
|
|
553
524
|
protected readonly permCopySourceId = signal<number | undefined>(undefined);
|
|
554
525
|
protected readonly sharedUsers = useSharedSignal("사용자"); // 앱 공용 provider
|
|
555
526
|
|
|
556
|
-
// 3) template — control
|
|
557
|
-
// 아래는 control 뷰 분기
|
|
558
|
-
@if (viewType() === "control") {
|
|
559
|
-
<
|
|
527
|
+
// 3) template — control 뷰의 <sd-dock>(상단 바) 내부, 또는 modal 뷰의 <sd-dock [position]="'bottom'">(하단 바) 옆에
|
|
528
|
+
// 보조 form을 인라인한다. 아래는 control 뷰 분기 안에서 저장/새로고침/삭제 버튼과 같은 <sd-dock> 안에 추가하는 예시.
|
|
529
|
+
@if (viewType() === "control" && canEdit()) {
|
|
530
|
+
<sd-dock class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">
|
|
560
531
|
<!-- 기본 저장/새로고침/삭제 버튼 -->
|
|
561
532
|
<!-- ... -->
|
|
562
533
|
|
|
563
534
|
<!-- 보조 기능: 다른 사용자로부터 가져오기 -->
|
|
564
535
|
<sd-form (formSubmit)="onImportFormSubmit()">
|
|
565
536
|
<div class="form-box-inline">
|
|
566
|
-
<div>
|
|
537
|
+
<div class="form-box-item">
|
|
567
538
|
<label>가져오기</label>
|
|
568
539
|
<sd-shared-data-select
|
|
569
540
|
[items]="sharedUsers.items()"
|
|
@@ -572,14 +543,14 @@ protected readonly sharedUsers = useSharedSignal("사용자"); // 앱 공용 pr
|
|
|
572
543
|
[size]="'sm'"
|
|
573
544
|
/>
|
|
574
545
|
</div>
|
|
575
|
-
<div>
|
|
546
|
+
<div class="form-box-item">
|
|
576
547
|
<sd-button [type]="'submit'" [disabled]="permCopySourceId() == null">
|
|
577
548
|
가져오기
|
|
578
549
|
</sd-button>
|
|
579
550
|
</div>
|
|
580
551
|
</div>
|
|
581
552
|
</sd-form>
|
|
582
|
-
</
|
|
553
|
+
</sd-dock>
|
|
583
554
|
}
|
|
584
555
|
|
|
585
556
|
// 4) 메서드 추가
|
|
@@ -588,16 +559,13 @@ protected async onImportFormSubmit(): Promise<void> {
|
|
|
588
559
|
if (this.permCopySourceId() == null) return;
|
|
589
560
|
if (!this._checkIgnoreChanges()) return;
|
|
590
561
|
|
|
562
|
+
this.busyCount.update((v) => v + 1);
|
|
591
563
|
await this._sdToast.try(async () => {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
} finally {
|
|
598
|
-
this.busyCount.update((v) => v - 1);
|
|
599
|
-
}
|
|
600
|
-
}, getOrmDataEditToastErrorMessage);
|
|
564
|
+
// 서버 호출로 다른 사용자의 데이터를 조회
|
|
565
|
+
// const src = await this._api.fetchByIdAsync(this.permCopySourceId()!);
|
|
566
|
+
// this.data.set({ ...this.data(), ...src });
|
|
567
|
+
});
|
|
568
|
+
this.busyCount.update((v) => v - 1);
|
|
601
569
|
}
|
|
602
570
|
```
|
|
603
571
|
|
|
@@ -636,96 +604,93 @@ interface ICustomerBox {
|
|
|
636
604
|
isDeleted: boolean;
|
|
637
605
|
}
|
|
638
606
|
|
|
639
|
-
// 3) template —
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
607
|
+
// 3) template — main 영역(<sd-dock-container> 안쪽, <sd-form> 내부) 단일 필드 아래에
|
|
608
|
+
// 하위 컬렉션 도구·시트 중첩. §3 기본 예제의 main 영역을 다음 구조로 교체:
|
|
609
|
+
<div class="flex-column fill">
|
|
610
|
+
<sd-form #formCtrl (formSubmit)="onSubmit()" class="flex-fill flex-column">
|
|
611
|
+
<!-- 상단 단일 필드 -->
|
|
612
|
+
<div class="p-default">
|
|
613
|
+
<table class="form-table">
|
|
614
|
+
<tbody>
|
|
615
|
+
<tr>
|
|
616
|
+
<th>명칭</th>
|
|
617
|
+
<td>
|
|
618
|
+
<sd-textfield
|
|
619
|
+
[type]="'text'"
|
|
620
|
+
[required]="true"
|
|
621
|
+
[disabled]="!canEdit()"
|
|
622
|
+
[(value)]="data().name"
|
|
623
|
+
/>
|
|
624
|
+
</td>
|
|
625
|
+
</tr>
|
|
626
|
+
</tbody>
|
|
627
|
+
</table>
|
|
628
|
+
</div>
|
|
629
|
+
|
|
630
|
+
<!-- 하위 컬렉션 도구 영역 -->
|
|
631
|
+
@if (canEdit()) {
|
|
632
|
+
<div class="flex-row gap-sm p-xs-default">
|
|
633
|
+
<sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddBoxButtonClick()">
|
|
634
|
+
<ng-icon [svg]="tablerCirclePlus" />
|
|
635
|
+
박스 추가
|
|
636
|
+
</sd-button>
|
|
637
|
+
</div>
|
|
638
|
+
}
|
|
662
639
|
|
|
663
|
-
|
|
640
|
+
<!-- 하위 컬렉션 시트 -->
|
|
641
|
+
<div class="flex-fill">
|
|
642
|
+
<sd-sheet
|
|
643
|
+
[items]="data().boxes"
|
|
644
|
+
[trackByFn]="boxTrackByFn"
|
|
645
|
+
[getItemCellStyleFn]="getBoxCellStyleFn"
|
|
646
|
+
>
|
|
664
647
|
@if (canEdit()) {
|
|
665
|
-
<
|
|
666
|
-
<
|
|
667
|
-
<
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
648
|
+
<sd-sheet-column [fixed]="true" [key]="'_isDeleted'">
|
|
649
|
+
<ng-template #headerTpl>
|
|
650
|
+
<div class="p-xs-sm tx-center">
|
|
651
|
+
<ng-icon [svg]="tablerEraser" />
|
|
652
|
+
</div>
|
|
653
|
+
</ng-template>
|
|
654
|
+
<ng-template [cell]="data().boxes" let-item="item">
|
|
655
|
+
<div class="p-xs-sm tx-center">
|
|
656
|
+
<sd-anchor
|
|
657
|
+
[theme]="'danger'"
|
|
658
|
+
(click)="onToggleDeleteBoxButtonClick(item)"
|
|
659
|
+
>
|
|
660
|
+
<ng-icon [svg]="item.isDeleted ? tablerRestore : tablerEraser" />
|
|
661
|
+
</sd-anchor>
|
|
662
|
+
</div>
|
|
663
|
+
</ng-template>
|
|
664
|
+
</sd-sheet-column>
|
|
671
665
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
</ng-template>
|
|
697
|
-
</sd-sheet-column>
|
|
698
|
-
}
|
|
699
|
-
<sd-sheet-column [key]="'seq'" [header]="'박스#'">
|
|
700
|
-
<ng-template [cell]="data().boxes" let-item="item">
|
|
701
|
-
<sd-textfield
|
|
702
|
-
[type]="'number'"
|
|
703
|
-
[required]="true"
|
|
704
|
-
[disabled]="!canEdit()"
|
|
705
|
-
[(value)]="item.seq"
|
|
706
|
-
[inset]="true"
|
|
707
|
-
[size]="'sm'"
|
|
708
|
-
/>
|
|
709
|
-
</ng-template>
|
|
710
|
-
</sd-sheet-column>
|
|
711
|
-
<sd-sheet-column [key]="'note'" [header]="'비고'">
|
|
712
|
-
<ng-template [cell]="data().boxes" let-item="item">
|
|
713
|
-
<sd-textfield
|
|
714
|
-
[type]="'text'"
|
|
715
|
-
[disabled]="!canEdit()"
|
|
716
|
-
[(value)]="item.note"
|
|
717
|
-
[inset]="true"
|
|
718
|
-
[size]="'sm'"
|
|
719
|
-
/>
|
|
720
|
-
</ng-template>
|
|
721
|
-
</sd-sheet-column>
|
|
722
|
-
</sd-sheet>
|
|
723
|
-
</div>
|
|
724
|
-
</sd-form>
|
|
666
|
+
<sd-sheet-column [key]="'seq'" [header]="'박스#'">
|
|
667
|
+
<ng-template [cell]="data().boxes" let-item="item">
|
|
668
|
+
<sd-textfield
|
|
669
|
+
[type]="'number'"
|
|
670
|
+
[required]="true"
|
|
671
|
+
[disabled]="!canEdit()"
|
|
672
|
+
[(value)]="item.seq"
|
|
673
|
+
[inset]="true"
|
|
674
|
+
[size]="'sm'"
|
|
675
|
+
/>
|
|
676
|
+
</ng-template>
|
|
677
|
+
</sd-sheet-column>
|
|
678
|
+
<sd-sheet-column [key]="'note'" [header]="'비고'">
|
|
679
|
+
<ng-template [cell]="data().boxes" let-item="item">
|
|
680
|
+
<sd-textfield
|
|
681
|
+
[type]="'text'"
|
|
682
|
+
[disabled]="!canEdit()"
|
|
683
|
+
[(value)]="item.note"
|
|
684
|
+
[inset]="true"
|
|
685
|
+
[size]="'sm'"
|
|
686
|
+
/>
|
|
687
|
+
</ng-template>
|
|
688
|
+
</sd-sheet-column>
|
|
689
|
+
</sd-sheet>
|
|
725
690
|
</div>
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
</
|
|
691
|
+
</sd-form>
|
|
692
|
+
<!-- 최종수정 표시는 §3 기본 예제와 동일 -->
|
|
693
|
+
</div>
|
|
729
694
|
|
|
730
695
|
// 4) 메서드 추가
|
|
731
696
|
protected readonly boxTrackByFn = (item: ICustomerBox): string => item.id;
|
|
@@ -749,30 +714,31 @@ protected onToggleDeleteBoxButtonClick(item: ICustomerBox): void {
|
|
|
749
714
|
mark(this.data);
|
|
750
715
|
}
|
|
751
716
|
|
|
752
|
-
// 5)
|
|
753
|
-
|
|
717
|
+
// 5) onSubmit의 `_sdToast.try(...)` 블록 내부를 아래로 교체 — diff 계산 + 일괄 제출
|
|
718
|
+
await this._sdToast.try(async () => {
|
|
754
719
|
// 삭제 플래그가 섞여 있으면 confirm
|
|
755
|
-
if (data.boxes.some((b) => b.isDeleted)) {
|
|
756
|
-
if (!confirm("삭제 표시된 박스가 있습니다. 정말 저장하시겠습니까?"))
|
|
757
|
-
return false;
|
|
758
|
-
}
|
|
720
|
+
if (this.data().boxes.some((b) => b.isDeleted)) {
|
|
721
|
+
if (!confirm("삭제 표시된 박스가 있습니다. 정말 저장하시겠습니까?")) return;
|
|
759
722
|
}
|
|
760
723
|
|
|
761
724
|
// 하위 컬렉션 diff 계산 — `type: "create" | "update" | "same"`
|
|
762
725
|
const snapshotBoxes = this._snapshot?.boxes ?? [];
|
|
763
|
-
const boxDiffs = data.boxes.oneWayDiffs(snapshotBoxes, "id");
|
|
726
|
+
const boxDiffs = this.data().boxes.oneWayDiffs(snapshotBoxes, "id");
|
|
764
727
|
|
|
765
|
-
// ORM
|
|
728
|
+
// 앱별 ORM 호출:
|
|
766
729
|
// await this._appOrm.connectAsync(async (db) => {
|
|
767
|
-
// await db.customer.upsertAsync(data);
|
|
730
|
+
// await db.customer.upsertAsync(this.data());
|
|
768
731
|
// for (const d of boxDiffs) {
|
|
769
732
|
// if (d.type === "create") await db.customerBox.insertAsync(d.target);
|
|
770
733
|
// else if (d.type === "update") await db.customerBox.updateAsync(d.target);
|
|
771
734
|
// }
|
|
772
735
|
// });
|
|
773
736
|
|
|
774
|
-
|
|
775
|
-
|
|
737
|
+
this._sdToast.success("저장되었습니다.");
|
|
738
|
+
this.close.emit(true);
|
|
739
|
+
|
|
740
|
+
await this._refresh();
|
|
741
|
+
});
|
|
776
742
|
```
|
|
777
743
|
|
|
778
744
|
**포인트:**
|
|
@@ -784,13 +750,33 @@ private async _saveAsync(data: ICustomer): Promise<boolean> {
|
|
|
784
750
|
|
|
785
751
|
## 7. 뷰 타입 분기
|
|
786
752
|
|
|
787
|
-
|
|
753
|
+
세 뷰(page / modal / control)는 **하나의 `<sd-topbar-container>` + `<sd-dock-container>` 공통 껍데기** 위에 뷰별로 다른 조각만 `@if`로 얹어 구성한다. 페이지·모달·컨트롤별로 별도 블록을 전체 복제하지 않는다.
|
|
754
|
+
|
|
755
|
+
| 뷰 | topbar | dock (도구 바) | main (form) |
|
|
756
|
+
|---|---|---|---|
|
|
757
|
+
| page | `<sd-topbar>` (저장/새로고침) | 없음 | form + 최종수정 |
|
|
758
|
+
| modal | 없음 | `<sd-dock [position]="'bottom'">` (삭제/복구 + 확인) | 동일 |
|
|
759
|
+
| control | 없음 | `<sd-dock>` 상단 (저장/새로고침/삭제) | 동일 |
|
|
760
|
+
|
|
761
|
+
```html
|
|
762
|
+
<sd-topbar-container>
|
|
763
|
+
@if (viewType() === "page") { <sd-topbar>...</sd-topbar> }
|
|
764
|
+
<sd-dock-container>
|
|
765
|
+
@if (viewType() === "control" && canEdit()) { <sd-dock>...</sd-dock> }
|
|
766
|
+
@if (viewType() === "modal" && canEdit()) {
|
|
767
|
+
<sd-dock [position]="'bottom'">...</sd-dock>
|
|
768
|
+
}
|
|
769
|
+
<!-- main: form + 최종수정 (모든 뷰 공통) -->
|
|
770
|
+
</sd-dock-container>
|
|
771
|
+
</sd-topbar-container>
|
|
772
|
+
```
|
|
788
773
|
|
|
789
774
|
상세 폼 특화 사항:
|
|
790
775
|
|
|
791
776
|
- **모달 우측 상단 액션**: `SdModalProvider`는 모달 컨텐츠 컴포넌트 생성 시에만 setter 프록시를 설치한다(`sd-modal.provider.ts:141` `if ("actionTplRef" in contentRef.instance)`). 모달 뷰에서는 `this.actionTplRef = ...` 할당이 프록시를 통해 `SdModal.actionTplRef` input으로 자동 전달되어 헤더에 렌더된다. page/control 뷰에서는 프록시가 설치되지 않으므로 할당이 인스턴스 필드에만 저장되고 부작용이 없다(`<ng-template #modalActionTpl>` 선언 자체는 뷰 타입과 무관하게 `viewChild`로 `TemplateRef` 인스턴스를 반환하지만, 그 TemplateRef를 소비할 SdModal이 없으므로 결과적으로 아무 일도 일어나지 않는다).
|
|
792
|
-
-
|
|
793
|
-
- **
|
|
777
|
+
- **`<sd-dock>` position 명시**: control 뷰 상단 바는 `position` 생략(기본 `"top"`). **modal 하단 바는 반드시 `[position]="'bottom'"`를 명시**한다 — 기본값이 top이라 누락하면 상단에 쌓여 필터/도구와 겹친다(`packages/angular/src/layout/dock/sd-dock.ts:97`).
|
|
778
|
+
- **modal 하단 확인/삭제 바**: `<sd-dock [position]="'bottom'" class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest">` 블록으로 구성. 버튼은 `[size]="'sm'"`로 모달에 맞춤.
|
|
779
|
+
- **control 뷰 상단 바**: page 뷰의 topbar가 없고 modal 뷰의 하단 바가 없는 대신, `<sd-dock class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">` 상단 dock에 `저장`/`새로고침`/`삭제` 버튼을 가로로 배치.
|
|
794
780
|
|
|
795
781
|
## 8. 주의사항 (자주 하는 실수)
|
|
796
782
|
|
|
@@ -812,7 +798,7 @@ private async _saveAsync(data: ICustomer): Promise<boolean> {
|
|
|
812
798
|
|
|
813
799
|
### `effect` 내부 `untracked`
|
|
814
800
|
|
|
815
|
-
- `effect(() => { this.itemId(); ... })` 안에서 비동기 `_refresh()`를 호출할 때 반드시 `untracked(() =>
|
|
801
|
+
- `effect(() => { this.itemId(); ... })` 안에서 비동기 `_refresh()`를 호출할 때 반드시 `void untracked(async () => { ... await this._refresh(); ... })`로 감싼다. 그렇지 않으면 `_refresh` 내부의 signal 읽기(인라인된 ORM 조회 포함)가 effect 의존성으로 등록되어 무한 루프가 발생한다.
|
|
816
802
|
|
|
817
803
|
### `setupCanDeactivate`는 생성자에서만
|
|
818
804
|
|
|
@@ -826,6 +812,36 @@ private async _saveAsync(data: ICustomer): Promise<boolean> {
|
|
|
826
812
|
|
|
827
813
|
- `this._snapshot = this.data()` 같은 얕은 참조 대입은 `data().field = "x"` mutation을 snapshot까지 오염시켜 변경 감지가 실패한다. 반드시 `obj.clone(data)`로 깊은 복제.
|
|
828
814
|
|
|
815
|
+
### `busyMessage`는 필요할 때만 추가
|
|
816
|
+
|
|
817
|
+
- 기본 예제는 `<sd-busy-container [busy]="busyCount() > 0">`만 사용하고 `busyMessage` signal을 두지 않는다. 짧은 CRUD 작업은 progress 아이콘만으로 충분하기 때문.
|
|
818
|
+
- 오래 걸리는 작업(대량 저장·삭제, 파일 업로드, 집계 등) 구간에 진행 문구가 필요하면 **필요한 화면에만** 다음을 추가한다:
|
|
819
|
+
```typescript
|
|
820
|
+
protected readonly busyMessage = signal<string | undefined>(undefined);
|
|
821
|
+
```
|
|
822
|
+
```html
|
|
823
|
+
<sd-busy-container [busy]="busyCount() > 0" [message]="busyMessage()">
|
|
824
|
+
```
|
|
825
|
+
```typescript
|
|
826
|
+
// onSubmit / _toggleDelete 등
|
|
827
|
+
this.busyCount.update((v) => v + 1);
|
|
828
|
+
this.busyMessage.set("저장 중...");
|
|
829
|
+
await this._sdToast.try(async () => {
|
|
830
|
+
// 단계가 여러 개면 구간마다 set 갱신
|
|
831
|
+
this.busyMessage.set("하위 항목 정리 중...");
|
|
832
|
+
// ...
|
|
833
|
+
});
|
|
834
|
+
this.busyMessage.set(undefined);
|
|
835
|
+
this.busyCount.update((v) => v - 1);
|
|
836
|
+
```
|
|
837
|
+
- 진행 문구가 필요 없는 화면에 "혹시 몰라서" 선언·바인딩을 넣지 않는다. 미사용 필드로 남는다.
|
|
838
|
+
|
|
839
|
+
### `isNew`는 `data().id == null` 기반 computed — PK 형태에 따라 대안 필요
|
|
840
|
+
|
|
841
|
+
- 이 레시피는 `isNew = computed(() => data().id == null)`로 신규 여부를 파생한다. `_refresh()` 내부의 `itemId() == null` 분기가 `id: undefined`로 초기화하므로 자동으로 `true`가 된다.
|
|
842
|
+
- **PK가 자동증가 숫자가 아닌 경우 이 판정이 깨진다.** 예컨대 클라이언트에서 UUID를 미리 생성해 `id`에 채워 넣는 스키마, 자연키(복합키 포함) 스키마에서는 신규 상태에도 `id`가 존재한다.
|
|
843
|
+
- 대안: `isNew`를 `signal<boolean>(false)`로 유지하고 `_refresh()` 내부에서 신규 여부를 별도로 세팅한다. 이때 snapshot 분기(`data.id == null ? undefined : obj.clone(data)`)도 `this.isNew() ? undefined : obj.clone(data)`로 바꿔야 한다.
|
|
844
|
+
|
|
829
845
|
## 9. 레시피 작성 관용 규칙
|
|
830
846
|
|
|
831
847
|
향후 데이터 관련 레시피(CRUD 리스트·상세·선택 버튼 등) 전반에서 아래 규칙을 공통으로 따른다.
|
|
@@ -857,4 +873,3 @@ private async _saveAsync(data: ICustomer): Promise<boolean> {
|
|
|
857
873
|
- 페이지/모달 컨테이너 분기 — → [`page-modal-container.md`](./page-modal-container.md)
|
|
858
874
|
- `SdModalContentDef<R>` — 모달 컨텐츠 인터페이스. → [`../provider-types.md`](../provider-types.md)
|
|
859
875
|
- `SdModalProvider.showAsync()` — 프로그래밍 방식 모달 호출. → [`../providers.md`](../providers.md)
|
|
860
|
-
- `getOrmDataEditToastErrorMessage` — 저장 에러 메시지 변환. `SdToastProvider.try(fn, getOrmDataEditToastErrorMessage)` 패턴으로 사용.
|