@simplysm/angular 14.0.49 → 14.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controls/select/sd-select.js +3 -3
- package/dist/data/sheet/sd-sheet.d.ts +9 -12
- package/dist/data/sheet/sd-sheet.d.ts.map +1 -1
- package/dist/data/sheet/sd-sheet.js +150 -168
- package/dist/data/sheet/types.d.ts +1 -0
- package/dist/data/sheet/types.d.ts.map +1 -1
- package/dist/data/sheet/useSheetCellStyling.d.ts +2 -2
- package/dist/data/sheet/useSheetCellStyling.d.ts.map +1 -1
- package/dist/data/sheet/useSheetCellStyling.js +20 -12
- package/dist/data/sheet/useSheetColumnFixing.d.ts +3 -8
- package/dist/data/sheet/useSheetColumnFixing.d.ts.map +1 -1
- package/dist/data/sheet/useSheetColumnFixing.js +19 -27
- package/dist/data/sheet/useSheetLayoutEngine.d.ts.map +1 -1
- package/dist/data/sheet/useSheetLayoutEngine.js +4 -1
- package/dist/layout/dock/sd-dock-container.d.ts.map +1 -1
- package/dist/layout/dock/sd-dock-container.js +4 -4
- package/package.json +7 -8
- package/src/controls/select/sd-select.ts +3 -3
- package/src/core/modal/sd-modal.provider.ts +1 -1
- package/src/core/modal/sd-modal.ts +1 -1
- package/src/core/routing/menu-utils.ts +1 -1
- package/src/core/shared-data/sd-shared-data.provider.ts +7 -7
- package/src/data/shared-data/sd-shared-data-select.ts +2 -2
- package/src/data/sheet/sd-sheet.ts +39 -60
- package/src/data/sheet/types.ts +1 -0
- package/src/data/sheet/useSheetCellStyling.ts +19 -11
- package/src/data/sheet/useSheetColumnFixing.ts +21 -28
- package/src/data/sheet/useSheetLayoutEngine.ts +4 -1
- package/src/layout/dock/sd-dock-container.ts +2 -1
- package/README.md +0 -467
- package/docs/bootstrap.md +0 -38
- package/docs/directives.md +0 -236
- package/docs/features.md +0 -154
- package/docs/pipes.md +0 -32
- package/docs/plugins.md +0 -37
- package/docs/provider-types.md +0 -283
- package/docs/providers.md +0 -379
- package/docs/recipes/crud-detail.md +0 -875
- package/docs/recipes/crud-list.md +0 -1037
- package/docs/recipes/data-select-button.md +0 -318
- package/docs/recipes/page-modal-container.md +0 -178
- package/docs/styling.md +0 -222
- package/docs/type-utilities.md +0 -250
- package/docs/ui-data.md +0 -333
- package/docs/ui-form.md +0 -502
- package/docs/ui-layout.md +0 -140
- package/docs/ui-navigation.md +0 -303
- package/docs/ui-overlay.md +0 -157
- package/docs/ui-visual.md +0 -127
- package/docs/utils.md +0 -244
|
@@ -1,875 +0,0 @@
|
|
|
1
|
-
# Recipe: CRUD 상세폼 화면 직접 조립
|
|
2
|
-
|
|
3
|
-
소비 화면이 `<sd-busy-container>` · `<sd-topbar-container>` · `<sd-topbar>` · `<sd-form>` 표준 컴포넌트를 **직접 조립**하여 단일 레코드 CRUD 상세폼을 구성한다. 과거 `SdDataDetail` / `SdDataDetailBase`가 감추고 있던 load·save·delete 라이프사이클, `obj.equal` snapshot 기반 변경 감지, `setupCanDeactivate` 이탈 방지, busy 카운트, `SdToastProvider.try` 에러 래핑, Ctrl+S/Ctrl+Alt+L 단축키를 화면 내부에 인라인으로 풀어쓴다.
|
|
4
|
-
|
|
5
|
-
## 1. Overview
|
|
6
|
-
|
|
7
|
-
- 제거된 추상화: `SdDataDetail`(컴포넌트) / `SdDataDetailBase<T, R>`(추상 클래스) / `SdDataDetailDataInfo`(타입) / `#toolTpl`·`#prevTpl`·`#contentTpl`·`#nextTpl`·`#modalActionTpl` 슬롯 5종
|
|
8
|
-
- 대체: 소비 컴포넌트가 표준 조각을 직접 조립
|
|
9
|
-
- 조립 요소:
|
|
10
|
-
- `<sd-busy-container [busy]>` — 전체 busy 오버레이
|
|
11
|
-
- `<sd-topbar-container>` + `<sd-topbar>` — 공통 컨테이너, `<sd-topbar>`는 page 뷰에서만 조건부 렌더
|
|
12
|
-
- `<sd-dock-container>` + `<sd-dock>` — 뷰별 도구 바(control 상단 바 / modal 하단 바) 부착, 본문은 main 영역
|
|
13
|
-
- `<sd-form #formCtrl (formSubmit)>` — Ctrl+S·submit 버튼 트리거
|
|
14
|
-
- `injectViewTypeSignal()` — page / modal / control 뷰 판정
|
|
15
|
-
- `injectPermsSignal()` — 권한 signal
|
|
16
|
-
- `setupCanDeactivate()` — 이탈 방지
|
|
17
|
-
- `SdToastProvider.try(fn)` — 에러 래퍼 (busy 카운트는 호출부에서 `busyCount.update`로 직접 제어)
|
|
18
|
-
- `SdModalContentDef<R>` — 모달 컨텐츠 인터페이스 (소비 화면이 직접 `implements`)
|
|
19
|
-
- `SdCommandDirective`(`sdRefreshCommand` / `sdSaveCommand`) — Ctrl+Alt+L / Ctrl+S 단축키
|
|
20
|
-
- 데이터 비교:
|
|
21
|
-
- `obj.clone(data)` — snapshot 복제 (`@simplysm/core-common`)
|
|
22
|
-
- `obj.equal(a, b)` — deep equal
|
|
23
|
-
|
|
24
|
-
## 2. 언제 사용하는가
|
|
25
|
-
|
|
26
|
-
| 상황 | 적용 여부 |
|
|
27
|
-
|---|---|
|
|
28
|
-
| 단일 레코드 상세 폼 (로딩/저장/삭제/복구) | 본 레시피 전체 적용 |
|
|
29
|
-
| 페이지 뷰와 모달 뷰 모두에서 재사용 | 3뷰 분기 구조(§3) 그대로 사용 |
|
|
30
|
-
| 마스터-디테일의 "디테일" 영역 | control 뷰 분기 활용 |
|
|
31
|
-
| 메인 폼과 별개의 "가져오기/출력" 보조 기능 필요 | 레시피 + [변형 1: 보조 기능 영역](#5-변형-보조-기능-영역) |
|
|
32
|
-
| 상세 폼 내부에 하위 컬렉션(박스 목록 등) 편집 | 레시피 + [변형 2: 복합 상세 (내부 `<sd-sheet>`)](#6-변형-복합-상세-내부-sd-sheet) |
|
|
33
|
-
| CRUD 리스트(시트) 화면 | 본 레시피 대신 [`crud-list.md`](./crud-list.md) 사용 |
|
|
34
|
-
| 페이지/모달 뷰 분기만 필요한 단순 화면 | [`page-modal-container.md`](./page-modal-container.md) 사용 |
|
|
35
|
-
|
|
36
|
-
## 3. 완성 예제
|
|
37
|
-
|
|
38
|
-
아래는 **page·modal·control 3뷰를 모두 커버하는** 완성 컴포넌트다. 모달로 띄우면 `viewType() === "modal"`로 자동 판정되어 하단 "확인/삭제" 바와 우측 상단 "새로고침" 액션이 표시되고, 라우트로 진입하면 `"page"`로 판정되어 topbar에 저장/새로고침 버튼이 표시된다. 마스터-디테일의 디테일로 `<app-customer-detail class="flex-fill">`처럼 삽입하면 `"control"`로 판정되어 상단 바에 저장/새로고침/삭제 버튼이 표시된다.
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
import { NgIcon } from "@ng-icons/core";
|
|
42
|
-
import {
|
|
43
|
-
tablerAlertTriangle,
|
|
44
|
-
tablerDeviceFloppy,
|
|
45
|
-
tablerEraser,
|
|
46
|
-
tablerRefresh,
|
|
47
|
-
tablerRestore,
|
|
48
|
-
} from "@ng-icons/tabler-icons";
|
|
49
|
-
import {
|
|
50
|
-
ChangeDetectionStrategy,
|
|
51
|
-
Component,
|
|
52
|
-
computed,
|
|
53
|
-
effect,
|
|
54
|
-
inject,
|
|
55
|
-
input,
|
|
56
|
-
output,
|
|
57
|
-
signal,
|
|
58
|
-
TemplateRef,
|
|
59
|
-
untracked,
|
|
60
|
-
viewChild,
|
|
61
|
-
ViewEncapsulation,
|
|
62
|
-
} from "@angular/core";
|
|
63
|
-
import { type DateTime, obj } from "@simplysm/core-common";
|
|
64
|
-
import {
|
|
65
|
-
FormatPipe,
|
|
66
|
-
injectCurrentPageCodeSignal,
|
|
67
|
-
injectFullPageCodeSignal,
|
|
68
|
-
injectPermsSignal,
|
|
69
|
-
injectViewTypeSignal,
|
|
70
|
-
SdActivatedModalProvider,
|
|
71
|
-
SdAnchor,
|
|
72
|
-
SdAppStructureProvider,
|
|
73
|
-
SdBusyContainer,
|
|
74
|
-
SdButton,
|
|
75
|
-
SdCommandDirective,
|
|
76
|
-
SdDock,
|
|
77
|
-
SdDockContainer,
|
|
78
|
-
SdForm,
|
|
79
|
-
type SdModalContentDef,
|
|
80
|
-
SdSystemLogProvider,
|
|
81
|
-
SdTextfield,
|
|
82
|
-
SdToastProvider,
|
|
83
|
-
SdTopbar,
|
|
84
|
-
SdTopbarContainer,
|
|
85
|
-
setupCanDeactivate,
|
|
86
|
-
} from "@simplysm/angular";
|
|
87
|
-
|
|
88
|
-
interface ICustomer {
|
|
89
|
-
id: number | undefined; // undefined면 신규
|
|
90
|
-
name: string;
|
|
91
|
-
phone: string;
|
|
92
|
-
isDeleted: boolean;
|
|
93
|
-
lastModifiedAt: DateTime | undefined;
|
|
94
|
-
lastModifiedBy: string | undefined;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
@Component({
|
|
98
|
-
selector: "app-customer-detail",
|
|
99
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
100
|
-
encapsulation: ViewEncapsulation.None,
|
|
101
|
-
standalone: true,
|
|
102
|
-
imports: [
|
|
103
|
-
SdBusyContainer, SdTopbarContainer, SdTopbar,
|
|
104
|
-
SdDockContainer, SdDock,
|
|
105
|
-
SdForm, SdButton, SdAnchor, SdTextfield,
|
|
106
|
-
FormatPipe, NgIcon,
|
|
107
|
-
],
|
|
108
|
-
hostDirectives: [
|
|
109
|
-
{ directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
|
|
110
|
-
],
|
|
111
|
-
host: {
|
|
112
|
-
"(sdRefreshCommand)": "onRefreshButtonClick()",
|
|
113
|
-
"(sdSaveCommand)": "onSaveButtonClick()",
|
|
114
|
-
},
|
|
115
|
-
template: `
|
|
116
|
-
<sd-busy-container [busy]="busyCount() > 0">
|
|
117
|
-
@if (initialized()) {
|
|
118
|
-
@if (!canUse()) {
|
|
119
|
-
<div class="fill tx-theme-gray-light p-xxl tx-center">
|
|
120
|
-
<br />
|
|
121
|
-
<ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
|
|
122
|
-
<br /><br />
|
|
123
|
-
'{{ modalOrPageTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
|
|
124
|
-
</div>
|
|
125
|
-
} @else {
|
|
126
|
-
<sd-topbar-container>
|
|
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>
|
|
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>
|
|
173
|
-
}
|
|
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
|
-
}
|
|
191
|
-
}
|
|
192
|
-
<div class="flex-fill flex-row gap-sm main-align-end">
|
|
193
|
-
<sd-button [size]="'sm'" [theme]="'primary'" (click)="onSaveButtonClick()">
|
|
194
|
-
확인
|
|
195
|
-
</sd-button>
|
|
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>
|
|
244
|
-
}
|
|
245
|
-
</div>
|
|
246
|
-
</sd-dock-container>
|
|
247
|
-
</sd-topbar-container>
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
</sd-busy-container>
|
|
251
|
-
|
|
252
|
-
<!-- 모달 뷰에서만 사용되는 우측 상단 액션 템플릿.
|
|
253
|
-
SdModalProvider가 contentComponent.actionTplRef를 setter 프록시로 감싸 SdModal로 브릿지한다.
|
|
254
|
-
(packages/angular/src/core/modal/sd-modal.provider.ts:140-150) -->
|
|
255
|
-
<ng-template #modalActionTpl>
|
|
256
|
-
<sd-anchor
|
|
257
|
-
[theme]="'gray'"
|
|
258
|
-
class="p-sm-default"
|
|
259
|
-
(click)="onRefreshButtonClick()"
|
|
260
|
-
title="새로고침(CTRL+ALT+L)"
|
|
261
|
-
>
|
|
262
|
-
<ng-icon [svg]="tablerRefresh" />
|
|
263
|
-
</sd-anchor>
|
|
264
|
-
</ng-template>
|
|
265
|
-
`,
|
|
266
|
-
})
|
|
267
|
-
export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
268
|
-
//== DI ==
|
|
269
|
-
private readonly _sdToast = inject(SdToastProvider);
|
|
270
|
-
private readonly _sdActivatedModal = inject(SdActivatedModalProvider, { optional: true });
|
|
271
|
-
private readonly _sdAppStructure = inject(SdAppStructureProvider);
|
|
272
|
-
private readonly _sdSystemLog = inject(SdSystemLogProvider);
|
|
273
|
-
|
|
274
|
-
//== input ==
|
|
275
|
-
itemId = input<number>();
|
|
276
|
-
|
|
277
|
-
//== viewChild ==
|
|
278
|
-
protected readonly formCtrl = viewChild<SdForm>("formCtrl");
|
|
279
|
-
private readonly _modalActionTpl = viewChild("modalActionTpl", { read: TemplateRef });
|
|
280
|
-
|
|
281
|
-
//== 라우팅 / 권한 ==
|
|
282
|
-
private readonly _fullPageCode = injectFullPageCodeSignal();
|
|
283
|
-
private readonly _currPageCode = injectCurrentPageCodeSignal();
|
|
284
|
-
protected readonly viewType = injectViewTypeSignal();
|
|
285
|
-
protected readonly canUse = injectPermsSignal(
|
|
286
|
-
() => ["sales.customer"],
|
|
287
|
-
() => ["use"],
|
|
288
|
-
);
|
|
289
|
-
protected readonly canEdit = injectPermsSignal(
|
|
290
|
-
() => ["sales.customer"],
|
|
291
|
-
() => ["edit"],
|
|
292
|
-
);
|
|
293
|
-
// canDelete를 canEdit와 분리해서 제한하고 싶으면 별도 권한 signal로 교체
|
|
294
|
-
protected readonly canDelete = computed(() => this.canEdit());
|
|
295
|
-
|
|
296
|
-
//== 상태 ==
|
|
297
|
-
protected readonly busyCount = signal(0);
|
|
298
|
-
protected readonly initialized = signal(false);
|
|
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);
|
|
308
|
-
private _snapshot?: ICustomer;
|
|
309
|
-
|
|
310
|
-
//== SdModalContentDef<boolean | undefined> 요구 필드 ==
|
|
311
|
-
close = output<boolean | undefined>();
|
|
312
|
-
// actionTplRef는 SdModal이 setter 프록시로 감싸므로 필드 선언만으로 충분
|
|
313
|
-
actionTplRef?: TemplateRef<any>;
|
|
314
|
-
|
|
315
|
-
//== 파생 ==
|
|
316
|
-
protected readonly modalOrPageTitle = computed(() => {
|
|
317
|
-
try {
|
|
318
|
-
return (
|
|
319
|
-
this._sdActivatedModal?.modalComponent()?.title() ??
|
|
320
|
-
this._sdAppStructure.getTitleByFullCode(
|
|
321
|
-
this._currPageCode?.() ?? this._fullPageCode(),
|
|
322
|
-
)
|
|
323
|
-
);
|
|
324
|
-
} catch (err) {
|
|
325
|
-
void this._sdSystemLog.writeAsync("warn", `title 계산 실패: ${String(err)}`);
|
|
326
|
-
return "";
|
|
327
|
-
}
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
//== 라이프사이클 ==
|
|
331
|
-
constructor() {
|
|
332
|
-
// 최초 진입 + itemId 변경 시 재조회.
|
|
333
|
-
effect(() => {
|
|
334
|
-
this.itemId(); // 의존성 등록
|
|
335
|
-
if (!this.canUse()) {
|
|
336
|
-
this.initialized.set(true);
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
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);
|
|
347
|
-
});
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// 모달 뷰에서는 SdActivatedModal의 canDeactivateFn에, 페이지 뷰에서는 라우트 guard에 등록된다.
|
|
351
|
-
setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
|
|
352
|
-
|
|
353
|
-
// 모달 헤더 우측 액션 슬롯 브릿지 (control/page 뷰에선 _modalActionTpl이 렌더되지 않아 undefined)
|
|
354
|
-
effect(() => {
|
|
355
|
-
this.actionTplRef = this._modalActionTpl();
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
//== 이벤트 핸들러 ==
|
|
360
|
-
protected async onRefreshButtonClick(): Promise<void> {
|
|
361
|
-
if (this.busyCount() > 0) return;
|
|
362
|
-
if (!this.canUse()) return;
|
|
363
|
-
if (!this._checkIgnoreChanges()) return;
|
|
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);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
protected onSaveButtonClick(): void {
|
|
373
|
-
this.formCtrl()?.requestSubmit();
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
protected async onSubmit(): Promise<void> {
|
|
377
|
-
if (this.busyCount() > 0) return;
|
|
378
|
-
if (!this.canEdit()) return;
|
|
379
|
-
|
|
380
|
-
// 신규면 변경사항 체크 없이 저장. 기존 항목이면 snapshot 대비 변경사항 여부 판정.
|
|
381
|
-
if (!this.isNew() && this._snapshot != null && obj.equal(this.data(), this._snapshot)) {
|
|
382
|
-
this._sdToast.info("변경사항이 없습니다.");
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
this.busyCount.update((v) => v + 1);
|
|
387
|
-
await this._sdToast.try(async () => {
|
|
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);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
protected async onDeleteButtonClick(): Promise<void> {
|
|
403
|
-
await this._toggleDelete(true);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
protected async onRestoreButtonClick(): Promise<void> {
|
|
407
|
-
await this._toggleDelete(false);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
//== 내부 메서드 ==
|
|
411
|
-
private async _toggleDelete(del: boolean): Promise<void> {
|
|
412
|
-
if (this.busyCount() > 0) return;
|
|
413
|
-
if (!this.canEdit()) return;
|
|
414
|
-
if (!this.canDelete()) return;
|
|
415
|
-
|
|
416
|
-
this.busyCount.update((v) => v + 1);
|
|
417
|
-
await this._sdToast.try(async () => {
|
|
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);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
private _checkIgnoreChanges(): boolean {
|
|
431
|
-
return (
|
|
432
|
-
this._snapshot == null ||
|
|
433
|
-
obj.equal(this.data(), this._snapshot) ||
|
|
434
|
-
confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?")
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// 로드+snapshot만 담당. busy/try는 호출부에서 처리.
|
|
439
|
-
private async _refresh(): Promise<void> {
|
|
440
|
-
let data: ICustomer;
|
|
441
|
-
if (this.itemId() == null) {
|
|
442
|
-
data = {
|
|
443
|
-
id: undefined, name: "", phone: "",
|
|
444
|
-
isDeleted: false, lastModifiedAt: undefined, lastModifiedBy: undefined,
|
|
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("구현 필요");
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
this.data.set(data);
|
|
455
|
-
// 신규(id == null)면 스냅샷 저장 생략 — 변경사항 체크를 항상 통과시켜 저장 허용
|
|
456
|
-
this._snapshot = data.id == null ? undefined : obj.clone(data);
|
|
457
|
-
}
|
|
458
|
-
|
|
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;
|
|
465
|
-
}
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
## 4. 분해 설명
|
|
469
|
-
|
|
470
|
-
각 블록의 역할과 원본 `SdDataDetail`/`SdDataDetailBase` 코드 대응 지점:
|
|
471
|
-
|
|
472
|
-
| 블록 | 역할 | 원본 대응 |
|
|
473
|
-
|---|---|---|
|
|
474
|
-
| `<sd-busy-container [busy]>` | 전체 busy 오버레이 | `sd-data-detail.ts:53-54` + `SdBaseContainer` |
|
|
475
|
-
| `@if (initialized())` | 최초 조회 완료 전 본문 숨김 | `sd-data-detail.base.ts:92` `initialized.set(true)` |
|
|
476
|
-
| `@if (!canUse())` | 권한 없음 메시지 | `sd-base-container.ts:44-51` + `page-modal-container.md` |
|
|
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 상단 바 |
|
|
479
|
-
| `<ng-template #modalActionTpl>` + `effect(() => actionTplRef = _modalActionTpl())` | 모달 우측 상단 새로고침 액션 (`SdModal`이 setter 프록시로 브릿지) | `sd-data-detail.ts:177-186, 201-207` `#modalActionTpl` + `parent.actionTplRef = ...` |
|
|
480
|
-
| `<sd-form #formCtrl (formSubmit)>` + 최종수정 표시 | main 영역의 폼 본문 + `lastModifiedAt/By` | `sd-data-detail.ts:121-140` |
|
|
481
|
-
| `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-detail.ts:45-51` |
|
|
482
|
-
| `setupCanDeactivate(() => viewType() === "modal" || checkIgnoreChanges())` | 라우트 이탈 시 변경사항 확인 | `sd-data-detail.base.ts:99` |
|
|
483
|
-
| 호출부(`onRefresh`/`onSubmit`/`_toggleDelete`/초기 effect) 내 `busyCount.update` + `sdToast.try(...)` | busy 카운트 증감 + 에러 토스트 래핑 | `sd-data-detail.base.ts:86-91, 115-119` |
|
|
484
|
-
| `_snapshot = obj.clone(data)` + `obj.equal` 비교 | 변경 감지 | `sd-data-detail.base.ts:66, 127, 102-108, 162` |
|
|
485
|
-
| `effect(() => { itemId(); if (!canUse()) return; untracked(async () => { busy/try + _refresh() + initialized.set(true) }); })` | 최초 로드 + `itemId` 변경 시 자동 reload | `sd-data-detail.base.ts:69-97` + `prepareRefreshEffect` 슬롯 |
|
|
486
|
-
| `close = output<R | undefined>()` + `implements SdModalContentDef<R>` | 모달 컨텐츠 계약 | `sd-data-detail.base.ts:29-30, 59` |
|
|
487
|
-
|
|
488
|
-
### 상태 분해
|
|
489
|
-
|
|
490
|
-
| signal / 필드 | 역할 |
|
|
491
|
-
|---|---|
|
|
492
|
-
| `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
|
|
493
|
-
| `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
|
|
494
|
-
| `data` | 현재 편집 중인 데이터 (load 결과). `isDeleted`·`lastModifiedAt`·`lastModifiedBy` 포함 |
|
|
495
|
-
| `isNew` | `computed(() => data().id == null)` — 신규 레코드 여부 (저장·삭제 분기) |
|
|
496
|
-
| `_snapshot` | 직전 `_refresh()` 시점의 data 깊은 복제본 (변경 감지용). 신규(`id == null`)면 `undefined` |
|
|
497
|
-
| `close` | 모달 결과 output (`SdModalContentDef` 요구) |
|
|
498
|
-
| `actionTplRef` | 모달 우측 상단 액션 슬롯. `SdModal`이 setter 프록시로 자동 브릿지 |
|
|
499
|
-
|
|
500
|
-
### 메서드 분해
|
|
501
|
-
|
|
502
|
-
| 메서드 | 역할 |
|
|
503
|
-
|---|---|
|
|
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` 내부에 인라인 (단일 호출처이므로 별도 메서드로 분리하지 않음) |
|
|
507
|
-
| `onDeleteButtonClick()` / `onRestoreButtonClick()` | `_toggleDelete(del)` 호출 |
|
|
508
|
-
| `_toggleDelete(del)` | busy/권한 가드 → `busyCount` 증가 → `_sdToast.try(ORM delete/restore + close.emit)` → `busyCount` 감소. delete/restore는 인라인 |
|
|
509
|
-
| `_checkIgnoreChanges()` | snapshot 없거나 동일하면 true, 아니면 `confirm(...)` |
|
|
510
|
-
| `_refresh()` | `itemId() == null`이면 빈 객체, 아니면 앱별 ORM 조회 → `data.set` + snapshot 갱신 (`data().id == null`이면 미저장). busy/try는 호출부 책임. 조회 로직은 호출처가 한 곳뿐이므로 `_refresh` 내부에 인라인 |
|
|
511
|
-
|
|
512
|
-
## 5. 변형: 보조 기능 영역
|
|
513
|
-
|
|
514
|
-
메인 폼의 submit과 별개인 보조 기능(예: 다른 사용자로부터 권한 복사, 출력 등)은 control 뷰 상단 바 또는 모달 하단 바 옆에 별도의 `<sd-form>`으로 배치한다. 과거 `#toolTpl` 슬롯이 담당하던 역할을 소비 화면에 직접 인라인한다.
|
|
515
|
-
|
|
516
|
-
`CustomerDetail`을 기준으로 아래 변경을 적용한다.
|
|
517
|
-
|
|
518
|
-
```typescript
|
|
519
|
-
// 1) imports 추가
|
|
520
|
-
import { SdSharedDataSelect } from "@simplysm/angular";
|
|
521
|
-
// ... (앱 공용 useSharedSignal 등)
|
|
522
|
-
|
|
523
|
-
// 2) 클래스에 필드 추가
|
|
524
|
-
protected readonly permCopySourceId = signal<number | undefined>(undefined);
|
|
525
|
-
protected readonly sharedUsers = useSharedSignal("사용자"); // 앱 공용 provider
|
|
526
|
-
|
|
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">
|
|
531
|
-
<!-- 기본 저장/새로고침/삭제 버튼 -->
|
|
532
|
-
<!-- ... -->
|
|
533
|
-
|
|
534
|
-
<!-- 보조 기능: 다른 사용자로부터 가져오기 -->
|
|
535
|
-
<sd-form (formSubmit)="onImportFormSubmit()">
|
|
536
|
-
<div class="form-box-inline">
|
|
537
|
-
<div class="form-box-item">
|
|
538
|
-
<label>가져오기</label>
|
|
539
|
-
<sd-shared-data-select
|
|
540
|
-
[items]="sharedUsers.items()"
|
|
541
|
-
[(value)]="permCopySourceId"
|
|
542
|
-
[inset]="true"
|
|
543
|
-
[size]="'sm'"
|
|
544
|
-
/>
|
|
545
|
-
</div>
|
|
546
|
-
<div class="form-box-item">
|
|
547
|
-
<sd-button [type]="'submit'" [disabled]="permCopySourceId() == null">
|
|
548
|
-
가져오기
|
|
549
|
-
</sd-button>
|
|
550
|
-
</div>
|
|
551
|
-
</div>
|
|
552
|
-
</sd-form>
|
|
553
|
-
</sd-dock>
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// 4) 메서드 추가
|
|
557
|
-
protected async onImportFormSubmit(): Promise<void> {
|
|
558
|
-
if (this.busyCount() > 0 || !this.canEdit()) return;
|
|
559
|
-
if (this.permCopySourceId() == null) return;
|
|
560
|
-
if (!this._checkIgnoreChanges()) return;
|
|
561
|
-
|
|
562
|
-
this.busyCount.update((v) => v + 1);
|
|
563
|
-
await this._sdToast.try(async () => {
|
|
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);
|
|
569
|
-
}
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
**포인트:**
|
|
573
|
-
|
|
574
|
-
- 보조 `<sd-form>`과 메인 `<sd-form #formCtrl>`은 **별도의 form**이다. 보조 form의 submit 버튼은 Ctrl+S와 연동되지 않는다(`SdCommandDirective`의 `sdSaveCommand`는 메인 `formCtrl`의 `requestSubmit()`에만 연결).
|
|
575
|
-
- 보조 form의 작업 후에도 `_checkIgnoreChanges()`를 호출하여 메인 폼의 미저장 변경사항을 보호한다.
|
|
576
|
-
- 출력·엑셀 다운로드 같은 read-only 보조 기능은 `formSubmit` 대신 버튼의 `(click)`으로 처리 가능. 이 경우 `<sd-form>` 래핑은 생략한다.
|
|
577
|
-
|
|
578
|
-
## 6. 변형: 복합 상세 (내부 `<sd-sheet>`)
|
|
579
|
-
|
|
580
|
-
상세 폼 안에 하위 컬렉션(박스 목록, 품목 라인 등)을 편집할 때 `<sd-form>` 본문 내부에 `<sd-sheet>`를 중첩한다. 하위 컬렉션의 행 추가·수정·삭제는 `item.isDeleted = true` 플래그로 soft-delete하여 `ArrayOneWayDiffResult` 기반 일괄 저장에 포함시킨다.
|
|
581
|
-
|
|
582
|
-
```typescript
|
|
583
|
-
// 1) imports 추가
|
|
584
|
-
import { SdSheet, SdSheetColumn, SdSheetColumnCellTemplate } from "@simplysm/angular";
|
|
585
|
-
import { mark, Uuid } from "@simplysm/core-common";
|
|
586
|
-
import { tablerCirclePlus } from "@ng-icons/tabler-icons";
|
|
587
|
-
import "@simplysm/core-common"; // Array.prototype.oneWayDiffs 프로토타입 확장
|
|
588
|
-
|
|
589
|
-
// 2) 데이터 타입 확장
|
|
590
|
-
interface ICustomer {
|
|
591
|
-
id: number | undefined;
|
|
592
|
-
name: string;
|
|
593
|
-
phone: string;
|
|
594
|
-
isDeleted: boolean;
|
|
595
|
-
lastModifiedAt: DateTime | undefined;
|
|
596
|
-
lastModifiedBy: string | undefined;
|
|
597
|
-
boxes: ICustomerBox[]; // 하위 컬렉션 추가
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
interface ICustomerBox {
|
|
601
|
-
id: string; // 클라이언트 생성 UUID (서버 저장 시 교체 가능)
|
|
602
|
-
seq: number;
|
|
603
|
-
note: string;
|
|
604
|
-
isDeleted: boolean;
|
|
605
|
-
}
|
|
606
|
-
|
|
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
|
-
}
|
|
639
|
-
|
|
640
|
-
<!-- 하위 컬렉션 시트 -->
|
|
641
|
-
<div class="flex-fill">
|
|
642
|
-
<sd-sheet
|
|
643
|
-
[items]="data().boxes"
|
|
644
|
-
[trackByFn]="boxTrackByFn"
|
|
645
|
-
[getItemCellStyleFn]="getBoxCellStyleFn"
|
|
646
|
-
>
|
|
647
|
-
@if (canEdit()) {
|
|
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>
|
|
665
|
-
}
|
|
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>
|
|
690
|
-
</div>
|
|
691
|
-
</sd-form>
|
|
692
|
-
<!-- 최종수정 표시는 §3 기본 예제와 동일 -->
|
|
693
|
-
</div>
|
|
694
|
-
|
|
695
|
-
// 4) 메서드 추가
|
|
696
|
-
protected readonly boxTrackByFn = (item: ICustomerBox): string => item.id;
|
|
697
|
-
|
|
698
|
-
protected readonly getBoxCellStyleFn = (item: ICustomerBox): string | undefined =>
|
|
699
|
-
item.isDeleted ? "text-decoration: line-through;" : undefined;
|
|
700
|
-
|
|
701
|
-
protected onAddBoxButtonClick(): void {
|
|
702
|
-
const newBox: ICustomerBox = {
|
|
703
|
-
id: Uuid.generate().toString(),
|
|
704
|
-
seq: (this.data().boxes.at(-1)?.seq ?? 0) + 1,
|
|
705
|
-
note: "",
|
|
706
|
-
isDeleted: false,
|
|
707
|
-
};
|
|
708
|
-
this.data().boxes.push(newBox);
|
|
709
|
-
mark(this.data);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
protected onToggleDeleteBoxButtonClick(item: ICustomerBox): void {
|
|
713
|
-
item.isDeleted = !item.isDeleted;
|
|
714
|
-
mark(this.data);
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// 5) onSubmit의 `_sdToast.try(...)` 블록 내부를 아래로 교체 — diff 계산 + 일괄 제출
|
|
718
|
-
await this._sdToast.try(async () => {
|
|
719
|
-
// 삭제 플래그가 섞여 있으면 confirm
|
|
720
|
-
if (this.data().boxes.some((b) => b.isDeleted)) {
|
|
721
|
-
if (!confirm("삭제 표시된 박스가 있습니다. 정말 저장하시겠습니까?")) return;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// 하위 컬렉션 diff 계산 — `type: "create" | "update" | "same"`
|
|
725
|
-
const snapshotBoxes = this._snapshot?.boxes ?? [];
|
|
726
|
-
const boxDiffs = this.data().boxes.oneWayDiffs(snapshotBoxes, "id");
|
|
727
|
-
|
|
728
|
-
// 앱별 ORM 호출:
|
|
729
|
-
// await this._appOrm.connectAsync(async (db) => {
|
|
730
|
-
// await db.customer.upsertAsync(this.data());
|
|
731
|
-
// for (const d of boxDiffs) {
|
|
732
|
-
// if (d.type === "create") await db.customerBox.insertAsync(d.target);
|
|
733
|
-
// else if (d.type === "update") await db.customerBox.updateAsync(d.target);
|
|
734
|
-
// }
|
|
735
|
-
// });
|
|
736
|
-
|
|
737
|
-
this._sdToast.success("저장되었습니다.");
|
|
738
|
-
this.close.emit(true);
|
|
739
|
-
|
|
740
|
-
await this._refresh();
|
|
741
|
-
});
|
|
742
|
-
```
|
|
743
|
-
|
|
744
|
-
**포인트:**
|
|
745
|
-
|
|
746
|
-
- 하위 컬렉션의 **삭제는 `isDeleted: true` 플래그로 표현**한다. `data().boxes`에서 row를 물리적으로 제거하면 `oneWayDiffs`가 해당 row를 감지하지 못한다 (`oneWayDiffs`는 `type: "create" | "update" | "same"`만 반환, **`"delete"` 없음**). 서버가 soft-delete 처리.
|
|
747
|
-
- 시트 셀 내부 컨트롤은 **`[inset]="true" [size]="'sm'"` 명시 필수** (§9 관용 규칙 1 참조).
|
|
748
|
-
- `data().boxes.push(newBox)` 같은 배열 mutation 후에는 `mark(this.data)`로 signal 참조를 갱신해야 OnPush 템플릿이 재렌더링된다(§9 관용 규칙 2 참조).
|
|
749
|
-
- `id`는 **클라이언트에서 UUID로 생성**하여 `trackByFn` + `oneWayDiffs`의 key로 사용. 서버가 발급한 PK가 별도로 있다면 별도 컬럼으로 관리하고 클라이언트 UUID는 row 식별자로만 사용.
|
|
750
|
-
|
|
751
|
-
## 7. 뷰 타입 분기
|
|
752
|
-
|
|
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
|
-
```
|
|
773
|
-
|
|
774
|
-
상세 폼 특화 사항:
|
|
775
|
-
|
|
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이 없으므로 결과적으로 아무 일도 일어나지 않는다).
|
|
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에 `저장`/`새로고침`/`삭제` 버튼을 가로로 배치.
|
|
780
|
-
|
|
781
|
-
## 8. 주의사항 (자주 하는 실수)
|
|
782
|
-
|
|
783
|
-
### 공통 유틸 재도입 금지
|
|
784
|
-
|
|
785
|
-
- `useDataDetail()`, `useCrudDetail()`, `setupDataDetail()` 같은 공통 헬퍼를 도입하지 말 것. 이 레시피가 제거한 추상화를 다시 만드는 행위다. 세 화면이 비슷해 보여도 화면마다 필드·동작 시그니처가 조금씩 다르므로 복사·수정이 낫다.
|
|
786
|
-
|
|
787
|
-
### `_sdSharedData.wait()` 선택적 호출
|
|
788
|
-
|
|
789
|
-
- 과거 `SdDataDetailBase`는 `_refresh()` 직전에 `await this._sdSharedData.wait()`를 호출했다. **공유 데이터를 화면에서 실제로 사용하지 않는다면 이 호출은 불필요**. 사용한다면 `_refresh()` 맨 앞에 `await inject(SdSharedDataProvider).wait();`를 삽입한다.
|
|
790
|
-
|
|
791
|
-
### 커스텀 close 결과 (`R` 제네릭)
|
|
792
|
-
|
|
793
|
-
- 모달이 boolean 대신 사용자 정의 결과를 돌려주어야 할 때, `close = output<FooResult | undefined>()` + `implements SdModalContentDef<FooResult | undefined>`로 선언한다. 호출 측은 `const result: FooResult | undefined = await sdModal.showAsync({ type: CustomerDetail, ... })`로 받는다.
|
|
794
|
-
|
|
795
|
-
### 조용한 저장 (`hideNoChangeMessage` 대응)
|
|
796
|
-
|
|
797
|
-
- 과거 `doSubmit({ hideNoChangeMessage: true })`는 "변경사항이 없습니다" info 토스트를 억제했다. 레시피에서는 `onSubmit()`의 `this._sdToast.info("변경사항이 없습니다.")` 호출부를 **조건으로 감싸** 직접 제어한다. 예: `if (!silent) this._sdToast.info("변경사항이 없습니다.");`. 프로그래밍 저장 메서드(`onPrintButtonClick` 등)는 `silent=true`로 호출.
|
|
798
|
-
|
|
799
|
-
### `effect` 내부 `untracked`
|
|
800
|
-
|
|
801
|
-
- `effect(() => { this.itemId(); ... })` 안에서 비동기 `_refresh()`를 호출할 때 반드시 `void untracked(async () => { ... await this._refresh(); ... })`로 감싼다. 그렇지 않으면 `_refresh` 내부의 signal 읽기(인라인된 ORM 조회 포함)가 effect 의존성으로 등록되어 무한 루프가 발생한다.
|
|
802
|
-
|
|
803
|
-
### `setupCanDeactivate`는 생성자에서만
|
|
804
|
-
|
|
805
|
-
- `setupCanDeactivate`는 `inject()`를 사용하므로 **생성자(또는 필드 이니셜라이저)**에서만 호출해야 한다. `computed`/`effect` 콜백 또는 일반 메서드에서 호출하면 `NG0203` 런타임 에러가 발생한다.
|
|
806
|
-
|
|
807
|
-
### `injectViewTypeSignal()` 호출 시점
|
|
808
|
-
|
|
809
|
-
- 동일하게 `injectViewTypeSignal()`도 생성자 또는 필드 이니셜라이저에서만 호출한다.
|
|
810
|
-
|
|
811
|
-
### snapshot은 `obj.clone`로 깊은 복제
|
|
812
|
-
|
|
813
|
-
- `this._snapshot = this.data()` 같은 얕은 참조 대입은 `data().field = "x"` mutation을 snapshot까지 오염시켜 변경 감지가 실패한다. 반드시 `obj.clone(data)`로 깊은 복제.
|
|
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
|
-
|
|
845
|
-
## 9. 레시피 작성 관용 규칙
|
|
846
|
-
|
|
847
|
-
향후 데이터 관련 레시피(CRUD 리스트·상세·선택 버튼 등) 전반에서 아래 규칙을 공통으로 따른다.
|
|
848
|
-
|
|
849
|
-
### 규칙 1: 시트 셀 내부 컨트롤은 `[inset]="true" [size]="'sm'"` 명시
|
|
850
|
-
|
|
851
|
-
- `<sd-sheet-column>` `[cell]` 템플릿 내부의 `sd-textfield` / `sd-select` / `sd-checkbox` / `sd-numpad` / `sd-date-range-picker` / `sd-textarea`는 레시피에서 **항상** `[inset]="true" [size]="'sm'"`를 함께 노출한다.
|
|
852
|
-
- 예외: 복합 구조(텍스트+컨트롤) → `[inset]="false"`. 큰 시트 행 → `[size]` 생략.
|
|
853
|
-
- 누락 시 컴파일 에러가 발생하지 않아 LLM이 빠뜨리기 쉽다.
|
|
854
|
-
|
|
855
|
-
### 규칙 2: `mark(sig)`는 "저장 감지"가 아니라 "UI 동기화"
|
|
856
|
-
|
|
857
|
-
- `mark(sig)`는 `WritableSignal`의 값을 shallow copy하여 **참조를 갱신**한다 (배열: `[...v]`, 객체: `{...v}`).
|
|
858
|
-
- 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**.
|
|
859
|
-
- **"저장 감지"가 아니다.** `obj.equal`(`packages/core-common/src/utils/obj.ts:172`)이 deep equal로 snapshot과 값 차이를 감지하므로, `data().field = value` 같은 mutation은 `mark` 없이도 `_checkIgnoreChanges()` / `onSubmit()`의 snapshot 비교에서 감지된다.
|
|
860
|
-
- Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요.
|
|
861
|
-
- ❌ "mark 없으면 저장이 안 된다" 식 서술 금지.
|
|
862
|
-
|
|
863
|
-
### 규칙 3: `setupCanDeactivate`는 뷰 타입에 따라 분기
|
|
864
|
-
|
|
865
|
-
- 모달 뷰에서는 `SdActivatedModalProvider.canDeactivateFn`에 등록되고, 페이지 뷰에서는 라우트 guard로 등록된다. control 뷰에서는 아무 동작 하지 않는다 (`sd-data-detail.base.ts:99` + `setupCanDeactivate.ts:5`).
|
|
866
|
-
- 레시피는 항상 `setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges())` 형태로 호출한다. 모달에서는 `true`를 돌려 항상 이탈 허용하고(모달 자체 취소 버튼으로 제어), 페이지에서는 `_checkIgnoreChanges()`의 confirm 결과로 제어.
|
|
867
|
-
|
|
868
|
-
---
|
|
869
|
-
|
|
870
|
-
## Cross-reference
|
|
871
|
-
|
|
872
|
-
- CRUD 리스트(시트) 화면 — `<sd-sheet>` 직접 조립 레시피. → [`crud-list.md`](./crud-list.md)
|
|
873
|
-
- 페이지/모달 컨테이너 분기 — → [`page-modal-container.md`](./page-modal-container.md)
|
|
874
|
-
- `SdModalContentDef<R>` — 모달 컨텐츠 인터페이스. → [`../provider-types.md`](../provider-types.md)
|
|
875
|
-
- `SdModalProvider.showAsync()` — 프로그래밍 방식 모달 호출. → [`../providers.md`](../providers.md)
|