@simplysm/angular 14.0.48 → 14.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -226
- package/dist/controls/select/sd-select.js +3 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/layout/dock/sd-dock-container.js +1 -1
- package/dist/styles.css +9 -0
- package/docs/bootstrap/provide-sd-angular.md +37 -0
- package/docs/bootstrap/sd-angular-config-provider.md +16 -0
- package/docs/directives/sd-command-directive.md +30 -0
- package/docs/directives/sd-events.md +25 -0
- package/docs/directives/sd-intersection-directive.md +36 -0
- package/docs/directives/sd-invalid.md +24 -0
- package/docs/directives/sd-resize-directive.md +42 -0
- package/docs/directives/sd-ripple.md +23 -0
- package/docs/directives/sd-router-link.md +38 -0
- package/docs/directives/sd-show-effect.md +18 -0
- package/docs/directives/sd-typed-template.md +69 -0
- package/docs/features/sd-address-search-modal.md +50 -0
- package/docs/features/sd-permission-table.md +20 -0
- package/docs/features/sd-shared-data-components.md +158 -0
- package/docs/features/sd-tiptap-editor.md +26 -0
- package/docs/{pipes.md → pipes/format-pipe.md} +14 -5
- package/docs/plugins/sd-global-error-handler.md +23 -0
- package/docs/{plugins.md → plugins/sd-option-event-plugin.md} +9 -12
- package/docs/provider-types/sd-menu.md +65 -0
- package/docs/provider-types/sd-modal-content-def.md +148 -0
- package/docs/provider-types/sd-toast-content-def.md +73 -0
- package/docs/provider-types/shared-data-base.md +59 -0
- package/docs/providers/sd-activated-modal-provider.md +34 -0
- package/docs/providers/sd-app-structure-provider.md +81 -0
- package/docs/providers/sd-busy-provider.md +18 -0
- package/docs/providers/sd-file-dialog-provider.md +40 -0
- package/docs/providers/sd-local-storage-provider.md +20 -0
- package/docs/providers/sd-modal-provider.md +67 -0
- package/docs/providers/sd-navigate-window-provider.md +18 -0
- package/docs/providers/sd-print-provider.md +25 -0
- package/docs/providers/sd-service-client-factory-provider.md +43 -0
- package/docs/providers/sd-shared-data-provider.md +64 -0
- package/docs/providers/sd-system-config-provider.md +46 -0
- package/docs/providers/sd-system-log-provider.md +18 -0
- package/docs/providers/sd-theme-provider.md +38 -0
- package/docs/providers/sd-toast-provider.md +65 -0
- package/docs/recipes/_common-rules.md +244 -0
- package/docs/recipes/crud-detail/extension-a-edit-save.md +230 -0
- package/docs/recipes/crud-detail/extension-b-delete-restore.md +142 -0
- package/docs/recipes/crud-detail/extension-c-modal-view.md +214 -0
- package/docs/recipes/crud-detail/extension-d-control-view.md +103 -0
- package/docs/recipes/crud-detail/extension-e-auxiliary.md +87 -0
- package/docs/recipes/crud-detail/extension-f-complex-detail.md +234 -0
- package/docs/recipes/crud-detail.md +215 -722
- package/docs/recipes/crud-list/extension-a-inline-edit.md +410 -0
- package/docs/recipes/crud-list/extension-b-selection.md +226 -0
- package/docs/recipes/crud-list/extension-c-inline-delete.md +87 -0
- package/docs/recipes/crud-list/extension-d-select-modal.md +207 -0
- package/docs/recipes/crud-list/extension-e-readonly-modal.md +165 -0
- package/docs/recipes/crud-list/extension-f-modal-edit.md +177 -0
- package/docs/recipes/crud-list/extension-g-excel.md +157 -0
- package/docs/recipes/crud-list.md +293 -626
- package/docs/recipes/data-select-button.md +194 -101
- package/docs/recipes/page-modal-container.md +168 -86
- package/docs/styling/classes.md +149 -0
- package/docs/styling/mixins.md +100 -0
- package/docs/styling/themes.md +35 -0
- package/docs/styling/variables.md +147 -0
- package/docs/{type-utilities.md → type-utilities/directive-input-signals.md} +17 -35
- package/docs/ui-data/sd-list.md +37 -0
- package/docs/ui-data/sd-sheet.md +227 -0
- package/docs/ui-form/sd-additional-button.md +26 -0
- package/docs/ui-form/sd-anchor.md +31 -0
- package/docs/ui-form/sd-button.md +105 -0
- package/docs/ui-form/sd-checkbox-group.md +39 -0
- package/docs/ui-form/sd-checkbox.md +81 -0
- package/docs/ui-form/sd-date-range-picker.md +27 -0
- package/docs/ui-form/sd-form.md +89 -0
- package/docs/ui-form/sd-modal-select-button.md +54 -0
- package/docs/ui-form/sd-numpad.md +26 -0
- package/docs/ui-form/sd-range.md +26 -0
- package/docs/ui-form/sd-select.md +68 -0
- package/docs/ui-form/sd-shared-data-select.md +52 -0
- package/docs/ui-form/sd-state-preset.md +37 -0
- package/docs/ui-form/sd-switch.md +27 -0
- package/docs/ui-form/sd-textarea.md +33 -0
- package/docs/ui-form/sd-textfield.md +145 -0
- package/docs/ui-layout/sd-dock-container.md +64 -0
- package/docs/ui-layout/sd-dock.md +37 -0
- package/docs/ui-layout/sd-gap.md +26 -0
- package/docs/{ui-layout.md → ui-layout/sd-kanban-board.md} +41 -85
- package/docs/ui-layout/sd-kanban-lane.md +34 -0
- package/docs/ui-layout/sd-kanban.md +29 -0
- package/docs/ui-navigation/sd-collapse.md +35 -0
- package/docs/ui-navigation/sd-pagination.md +26 -0
- package/docs/ui-navigation/sd-sidebar-container.md +49 -0
- package/docs/ui-navigation/sd-sidebar-menu.md +22 -0
- package/docs/ui-navigation/sd-sidebar-user.md +43 -0
- package/docs/ui-navigation/sd-tab.md +51 -0
- package/docs/ui-navigation/sd-topbar-container.md +97 -0
- package/docs/ui-navigation/sd-topbar-menu.md +23 -0
- package/docs/ui-navigation/sd-topbar-user.md +38 -0
- package/docs/ui-navigation/sd-topbar.md +30 -0
- package/docs/ui-overlay/sd-busy-container.md +69 -0
- package/docs/ui-overlay/sd-confirm-modal.md +30 -0
- package/docs/ui-overlay/sd-dropdown.md +40 -0
- package/docs/ui-overlay/sd-modal.md +34 -0
- package/docs/ui-overlay/sd-prompt-modal.md +30 -0
- package/docs/ui-overlay/sd-toast.md +35 -0
- package/docs/ui-visual/sd-barcode.md +36 -0
- package/docs/ui-visual/sd-calendar.md +34 -0
- package/docs/ui-visual/sd-echarts.md +32 -0
- package/docs/ui-visual/sd-label.md +24 -0
- package/docs/ui-visual/sd-note.md +23 -0
- package/docs/ui-visual/sd-progress.md +23 -0
- package/docs/utils/inject-routing-signals.md +161 -0
- package/docs/utils/inject-sd-system-config-resource.md +35 -0
- package/docs/utils/mark.md +43 -0
- package/docs/utils/selection-managers.md +96 -0
- package/docs/utils/set-safe-style.md +19 -0
- package/docs/utils/setup-functions.md +93 -0
- package/package.json +7 -7
- package/scss/commons/_styles.scss +12 -0
- package/src/controls/select/sd-select.ts +3 -3
- package/src/core/modal/sd-modal.provider.ts +1 -1
- package/src/core/modal/sd-modal.ts +1 -1
- package/src/core/routing/menu-utils.ts +1 -1
- package/src/core/shared-data/sd-shared-data.provider.ts +7 -7
- package/src/data/shared-data/sd-shared-data-select.ts +2 -2
- package/src/index.ts +0 -3
- package/src/layout/dock/sd-dock-container.ts +1 -1
- package/dist/data/getOrmDataEditToastErrorMessage.d.ts +0 -2
- package/dist/data/getOrmDataEditToastErrorMessage.d.ts.map +0 -1
- package/dist/data/getOrmDataEditToastErrorMessage.js +0 -8
- package/docs/bootstrap.md +0 -38
- package/docs/directives.md +0 -236
- package/docs/features.md +0 -169
- package/docs/provider-types.md +0 -283
- package/docs/providers.md +0 -379
- package/docs/styling.md +0 -222
- package/docs/ui-data.md +0 -333
- package/docs/ui-form.md +0 -502
- package/docs/ui-navigation.md +0 -273
- package/docs/ui-overlay.md +0 -157
- package/docs/ui-visual.md +0 -127
- package/docs/utils.md +0 -244
- package/src/data/getOrmDataEditToastErrorMessage.ts +0 -10
|
@@ -1,103 +1,60 @@
|
|
|
1
1
|
# Recipe: CRUD 상세폼 화면 직접 조립
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- `getOrmDataEditToastErrorMessage(err)` — ORM 에러 → 사용자 메시지 변환
|
|
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) 사용 |
|
|
3
|
+
단일 레코드 CRUD 상세폼을, `<sd-busy-container>` · `<sd-topbar-container>` · `<sd-topbar>` · `<sd-form>` 표준 컴포넌트를 소비 화면이 **직접 조립**하여 구성한다. 과거 `SdDataDetail` / `SdDataDetailBase`가 감추고 있던 load·save·delete 라이프사이클, snapshot 기반 변경 감지, 이탈 방지, busy 카운트, Ctrl+S/Ctrl+Alt+L 단축키를 화면 내부에 인라인으로 풀어쓴다.
|
|
4
|
+
|
|
5
|
+
## When to use / When NOT to use
|
|
6
|
+
|
|
7
|
+
- ✅ 단일 레코드(고객, 주문 헤더 등)의 CRUD 상세폼을 만들 때
|
|
8
|
+
- ✅ page / modal / control 뷰 중 **필요한 뷰만 선택적으로** 지원해야 할 때
|
|
9
|
+
- ✅ 편집·저장·삭제·복구·보조 기능·복합 상세(내부 시트)를 상황에 따라 누적해야 할 때
|
|
10
|
+
- ❌ CRUD 리스트(시트) 화면 → [`crud-list.md`](./crud-list.md)
|
|
11
|
+
- ❌ 페이지/모달 뷰 분기만 필요한 단순 컨테이너(라이프사이클 없음) → [`page-modal-container.md`](./page-modal-container.md)
|
|
12
|
+
- ❌ 다른 화면에서 데이터를 고르는 선택 버튼 → [`data-select-button.md`](./data-select-button.md)
|
|
13
|
+
|
|
14
|
+
## 전제조건
|
|
15
|
+
|
|
16
|
+
- `provideSdAngular({ clientName })` 부트스트랩 완료
|
|
17
|
+
- 앱별 ORM provider (예: `AppOrmProvider`) — `@simplysm/angular`가 아니라 각 앱이 소유
|
|
18
|
+
- 권한 제어가 있는 경우 `SdAppStructureProvider` 등록 (`injectPermsSignal` 전제)
|
|
35
19
|
|
|
36
|
-
##
|
|
20
|
+
## 기본 레시피 (page 뷰, 읽기 전용)
|
|
37
21
|
|
|
38
|
-
|
|
22
|
+
라우트 진입 시 `itemId`를 받아 단일 레코드를 로드하고 읽기 전용 필드로 표시하는 최소 완성 컴포넌트다. 편집·삭제·modal·control·보조·복합이 필요하면 [변형](#변형-확장-a-f-인덱스)의 확장을 선택적으로 얹는다.
|
|
39
23
|
|
|
40
24
|
```typescript
|
|
41
|
-
import { NgTemplateOutlet } from "@angular/common";
|
|
42
25
|
import { NgIcon } from "@ng-icons/core";
|
|
43
|
-
import {
|
|
44
|
-
tablerAlertTriangle,
|
|
45
|
-
tablerDeviceFloppy,
|
|
46
|
-
tablerEraser,
|
|
47
|
-
tablerRefresh,
|
|
48
|
-
tablerRestore,
|
|
49
|
-
} from "@ng-icons/tabler-icons";
|
|
26
|
+
import { tablerAlertTriangle, tablerRefresh } from "@ng-icons/tabler-icons";
|
|
50
27
|
import {
|
|
51
28
|
ChangeDetectionStrategy,
|
|
52
29
|
Component,
|
|
53
|
-
computed,
|
|
54
30
|
effect,
|
|
55
31
|
inject,
|
|
56
32
|
input,
|
|
57
|
-
output,
|
|
58
33
|
signal,
|
|
59
|
-
TemplateRef,
|
|
60
34
|
untracked,
|
|
61
|
-
viewChild,
|
|
62
35
|
ViewEncapsulation,
|
|
63
36
|
} from "@angular/core";
|
|
64
|
-
import {
|
|
37
|
+
import type { DateTime } from "@simplysm/core-common";
|
|
65
38
|
import {
|
|
66
39
|
FormatPipe,
|
|
67
|
-
getOrmDataEditToastErrorMessage,
|
|
68
|
-
injectCurrentPageCodeSignal,
|
|
69
|
-
injectFullPageCodeSignal,
|
|
70
40
|
injectPermsSignal,
|
|
71
|
-
|
|
72
|
-
SdActivatedModalProvider,
|
|
73
|
-
SdAnchor,
|
|
74
|
-
SdAppStructureProvider,
|
|
41
|
+
injectViewTitleSignal,
|
|
75
42
|
SdBusyContainer,
|
|
76
43
|
SdButton,
|
|
77
44
|
SdCommandDirective,
|
|
78
45
|
SdForm,
|
|
79
|
-
type SdModalContentDef,
|
|
80
|
-
SdSystemLogProvider,
|
|
81
46
|
SdTextfield,
|
|
82
47
|
SdToastProvider,
|
|
83
48
|
SdTopbar,
|
|
84
49
|
SdTopbarContainer,
|
|
85
|
-
setupCanDeactivate,
|
|
86
50
|
} from "@simplysm/angular";
|
|
51
|
+
// 앱별 대체: ORM provider + DbContext. @simplysm/angular가 아니라 각 앱이 소유한다.
|
|
52
|
+
import { AppOrmProvider } from "@adtek/client-common";
|
|
87
53
|
|
|
88
54
|
interface ICustomer {
|
|
89
|
-
id: number | undefined;
|
|
55
|
+
id: number | undefined; // undefined면 신규 — 확장 A 편집/저장 시 활용
|
|
90
56
|
name: string;
|
|
91
57
|
phone: string;
|
|
92
|
-
isDeleted: boolean;
|
|
93
|
-
lastModifiedAt: DateTime | undefined;
|
|
94
|
-
lastModifiedBy: string | undefined;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// 과거 `SdDataDetailDataInfo`에 해당 — 화면 내부에서 직접 선언한다.
|
|
98
|
-
interface IDataInfo {
|
|
99
|
-
isNew: boolean;
|
|
100
|
-
isDeleted: boolean;
|
|
101
58
|
lastModifiedAt: DateTime | undefined;
|
|
102
59
|
lastModifiedBy: string | undefined;
|
|
103
60
|
}
|
|
@@ -109,752 +66,288 @@ interface IDataInfo {
|
|
|
109
66
|
standalone: true,
|
|
110
67
|
imports: [
|
|
111
68
|
SdBusyContainer, SdTopbarContainer, SdTopbar,
|
|
112
|
-
SdForm, SdButton,
|
|
113
|
-
FormatPipe, NgIcon,
|
|
69
|
+
SdForm, SdButton, SdTextfield,
|
|
70
|
+
FormatPipe, NgIcon,
|
|
114
71
|
],
|
|
115
72
|
hostDirectives: [
|
|
116
|
-
{ directive: SdCommandDirective, outputs: ["sdRefreshCommand"
|
|
73
|
+
{ directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
|
|
117
74
|
],
|
|
118
75
|
host: {
|
|
119
76
|
"(sdRefreshCommand)": "onRefreshButtonClick()",
|
|
120
|
-
"(sdSaveCommand)": "onSubmitButtonClick()",
|
|
121
77
|
},
|
|
122
78
|
template: `
|
|
123
|
-
<sd-busy-container [busy]="busyCount() > 0"
|
|
79
|
+
<sd-busy-container [busy]="busyCount() > 0">
|
|
124
80
|
@if (initialized()) {
|
|
125
|
-
@if (!
|
|
81
|
+
@if (!perms().includes("use")) {
|
|
126
82
|
<div class="fill tx-theme-gray-light p-xxl tx-center">
|
|
127
83
|
<br />
|
|
128
|
-
<ng-icon [svg]="
|
|
129
|
-
<br
|
|
130
|
-
|
|
84
|
+
<ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
|
|
85
|
+
<br />
|
|
86
|
+
<br />
|
|
87
|
+
'{{ viewTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
|
|
131
88
|
</div>
|
|
132
|
-
} @else
|
|
89
|
+
} @else {
|
|
133
90
|
<sd-topbar-container>
|
|
134
91
|
<sd-topbar>
|
|
135
|
-
<h4>{{
|
|
136
|
-
|
|
137
|
-
<sd-button [theme]="'link-primary'" (click)="onSubmitButtonClick()">
|
|
138
|
-
<ng-icon [svg]="icons.tablerDeviceFloppy" />
|
|
139
|
-
저장 <small>(CTRL+S)</small>
|
|
140
|
-
</sd-button>
|
|
141
|
-
}
|
|
92
|
+
<h4>{{ viewTitle() }}</h4>
|
|
93
|
+
|
|
142
94
|
<sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
|
|
143
|
-
<ng-icon [svg]="
|
|
144
|
-
새로고침
|
|
95
|
+
<ng-icon [svg]="tablerRefresh" />
|
|
96
|
+
새로고침
|
|
97
|
+
<small>(CTRL+ALT+L)</small>
|
|
145
98
|
</sd-button>
|
|
146
99
|
</sd-topbar>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
100
|
+
|
|
101
|
+
<div class="flex-column fill">
|
|
102
|
+
<sd-form class="flex-fill">
|
|
103
|
+
<div class="p-default">
|
|
104
|
+
<table class="form-table">
|
|
105
|
+
<tbody>
|
|
106
|
+
<tr>
|
|
107
|
+
<th>명칭</th>
|
|
108
|
+
<td>
|
|
109
|
+
<sd-textfield
|
|
110
|
+
[type]="'text'"
|
|
111
|
+
[readonly]="true"
|
|
112
|
+
[(value)]="data().name"
|
|
113
|
+
/>
|
|
114
|
+
</td>
|
|
115
|
+
</tr>
|
|
116
|
+
<tr>
|
|
117
|
+
<th>전화번호</th>
|
|
118
|
+
<td>
|
|
119
|
+
<sd-textfield
|
|
120
|
+
[type]="'text'"
|
|
121
|
+
[readonly]="true"
|
|
122
|
+
[(value)]="data().phone"
|
|
123
|
+
/>
|
|
124
|
+
</td>
|
|
125
|
+
</tr>
|
|
126
|
+
</tbody>
|
|
127
|
+
</table>
|
|
173
128
|
</div>
|
|
174
|
-
</
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
복구
|
|
195
|
-
</sd-button>
|
|
196
|
-
} @else {
|
|
197
|
-
<sd-button [theme]="'danger'" (click)="onDeleteButtonClick()">
|
|
198
|
-
<ng-icon [svg]="icons.tablerEraser" />
|
|
199
|
-
삭제
|
|
200
|
-
</sd-button>
|
|
129
|
+
</sd-form>
|
|
130
|
+
|
|
131
|
+
@if (data().lastModifiedAt || data().lastModifiedBy) {
|
|
132
|
+
<div class="p-sm-default">
|
|
133
|
+
최종수정:
|
|
134
|
+
@if (data().lastModifiedAt) {
|
|
135
|
+
{{ data().lastModifiedAt | format: "yyyy-MM-dd HH:mm" }}
|
|
201
136
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
137
|
+
@if (data().lastModifiedBy) {
|
|
138
|
+
({{ data().lastModifiedBy }})
|
|
139
|
+
}
|
|
140
|
+
</div>
|
|
141
|
+
}
|
|
207
142
|
</div>
|
|
208
|
-
</
|
|
143
|
+
</sd-topbar-container>
|
|
209
144
|
}
|
|
210
145
|
}
|
|
211
146
|
</sd-busy-container>
|
|
212
|
-
|
|
213
|
-
<!-- 모달 뷰에서만 사용되는 우측 상단 액션 템플릿.
|
|
214
|
-
SdModalProvider가 contentComponent.actionTplRef를 setter 프록시로 감싸 SdModal로 브릿지한다.
|
|
215
|
-
(packages/angular/src/core/modal/sd-modal.provider.ts:140-150) -->
|
|
216
|
-
<ng-template #modalActionTpl>
|
|
217
|
-
<sd-anchor
|
|
218
|
-
[theme]="'gray'"
|
|
219
|
-
class="p-sm-default"
|
|
220
|
-
(click)="onRefreshButtonClick()"
|
|
221
|
-
title="새로고침(CTRL+ALT+L)"
|
|
222
|
-
>
|
|
223
|
-
<ng-icon [svg]="icons.tablerRefresh" />
|
|
224
|
-
</sd-anchor>
|
|
225
|
-
</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
147
|
`,
|
|
277
148
|
})
|
|
278
|
-
export class CustomerDetail
|
|
149
|
+
export class CustomerDetail {
|
|
279
150
|
//== DI ==
|
|
151
|
+
private readonly _appOrm = inject(AppOrmProvider);
|
|
280
152
|
private readonly _sdToast = inject(SdToastProvider);
|
|
281
|
-
private readonly _sdActivatedModal = inject(SdActivatedModalProvider, { optional: true });
|
|
282
|
-
private readonly _sdAppStructure = inject(SdAppStructureProvider);
|
|
283
|
-
private readonly _sdSystemLog = inject(SdSystemLogProvider);
|
|
284
153
|
|
|
285
154
|
//== input ==
|
|
286
155
|
itemId = input<number>();
|
|
287
156
|
|
|
288
|
-
//==
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
protected readonly viewType = injectViewTypeSignal();
|
|
292
|
-
protected readonly canUse = injectPermsSignal(
|
|
293
|
-
() => ["sales.customer"],
|
|
294
|
-
() => ["use"],
|
|
295
|
-
);
|
|
296
|
-
protected readonly canEdit = injectPermsSignal(
|
|
297
|
-
() => ["sales.customer"],
|
|
298
|
-
() => ["edit"],
|
|
299
|
-
);
|
|
300
|
-
// canDelete를 canEdit와 분리해서 제한하고 싶으면 별도 권한 signal로 교체
|
|
301
|
-
protected readonly canDelete = computed(() => this.canEdit());
|
|
157
|
+
//== 타이틀 / 권한 ==
|
|
158
|
+
protected readonly viewTitle = injectViewTitleSignal();
|
|
159
|
+
perms = injectPermsSignal(["sales.customer"], ["use"]);
|
|
302
160
|
|
|
303
161
|
//== 상태 ==
|
|
304
162
|
protected readonly busyCount = signal(0);
|
|
305
|
-
protected readonly busyMessage = signal<string | undefined>(undefined);
|
|
306
163
|
protected readonly initialized = signal(false);
|
|
307
|
-
protected readonly data = signal<ICustomer>({
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// actionTplRef는 SdModal이 setter 프록시로 감싸므로 필드 선언만으로 충분
|
|
314
|
-
actionTplRef?: TemplateRef<any>;
|
|
315
|
-
|
|
316
|
-
//== viewChild ==
|
|
317
|
-
protected readonly formCtrl = viewChild<SdForm>("formCtrl");
|
|
318
|
-
private readonly _modalActionTpl = viewChild("modalActionTpl", { read: TemplateRef });
|
|
319
|
-
|
|
320
|
-
//== 파생 ==
|
|
321
|
-
protected readonly modalOrPageTitle = computed(() => {
|
|
322
|
-
try {
|
|
323
|
-
return (
|
|
324
|
-
this._sdActivatedModal?.modalComponent()?.title() ??
|
|
325
|
-
this._sdAppStructure.getTitleByFullCode(
|
|
326
|
-
this._currPageCode?.() ?? this._fullPageCode(),
|
|
327
|
-
)
|
|
328
|
-
);
|
|
329
|
-
} catch (err) {
|
|
330
|
-
void this._sdSystemLog.writeAsync("warn", `title 계산 실패: ${String(err)}`);
|
|
331
|
-
return "";
|
|
332
|
-
}
|
|
164
|
+
protected readonly data = signal<ICustomer>({
|
|
165
|
+
id: undefined,
|
|
166
|
+
name: "",
|
|
167
|
+
phone: "",
|
|
168
|
+
lastModifiedAt: undefined,
|
|
169
|
+
lastModifiedBy: undefined,
|
|
333
170
|
});
|
|
334
171
|
|
|
335
|
-
protected readonly icons = {
|
|
336
|
-
tablerAlertTriangle, tablerDeviceFloppy, tablerEraser, tablerRefresh, tablerRestore,
|
|
337
|
-
};
|
|
338
|
-
|
|
339
172
|
//== 라이프사이클 ==
|
|
340
173
|
constructor() {
|
|
341
174
|
// 최초 진입 + itemId 변경 시 재조회.
|
|
342
|
-
// effect의 자체 cleanup이 이중 실행을 방지하므로 queueMicrotask + cancelled 플래그는 불필요.
|
|
343
175
|
effect(() => {
|
|
344
176
|
this.itemId(); // 의존성 등록
|
|
345
|
-
if (!this.
|
|
177
|
+
if (!this.perms().includes("use")) {
|
|
346
178
|
this.initialized.set(true);
|
|
347
179
|
return;
|
|
348
180
|
}
|
|
349
|
-
untracked(() => {
|
|
350
|
-
void this._initRefresh();
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// 모달 뷰에서는 SdActivatedModal의 canDeactivateFn에, 페이지 뷰에서는 라우트 guard에 등록된다.
|
|
355
|
-
setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
|
|
356
181
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
182
|
+
void untracked(async () => {
|
|
183
|
+
this.busyCount.update((v) => v + 1);
|
|
184
|
+
await this._sdToast.try(async () => {
|
|
185
|
+
await this._refresh();
|
|
186
|
+
});
|
|
187
|
+
this.busyCount.update((v) => v - 1);
|
|
188
|
+
this.initialized.set(true);
|
|
189
|
+
});
|
|
360
190
|
});
|
|
361
191
|
}
|
|
362
192
|
|
|
363
193
|
//== 이벤트 핸들러 ==
|
|
364
194
|
protected async onRefreshButtonClick(): Promise<void> {
|
|
365
195
|
if (this.busyCount() > 0) return;
|
|
366
|
-
if (!this.
|
|
367
|
-
if (!this._checkIgnoreChanges()) return;
|
|
368
|
-
await this._refresh();
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
protected onSubmitButtonClick(): void {
|
|
372
|
-
this.formCtrl()?.requestSubmit();
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
protected async onSubmit(): Promise<void> {
|
|
376
|
-
if (this.busyCount() > 0) return;
|
|
377
|
-
if (!this.canEdit()) return;
|
|
378
|
-
const info = this.dataInfo();
|
|
379
|
-
if (info == null) return;
|
|
380
|
-
|
|
381
|
-
// isNew면 변경사항 체크 없이 저장. 기존 항목이면 snapshot 대비 변경사항 여부 판정.
|
|
382
|
-
if (!info.isNew && this._snapshot != null && obj.equal(this.data(), this._snapshot)) {
|
|
383
|
-
this._sdToast.info("변경사항이 없습니다.");
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
196
|
+
if (!this.perms().includes("use")) return;
|
|
386
197
|
|
|
198
|
+
this.busyCount.update((v) => v + 1);
|
|
387
199
|
await this._sdToast.try(async () => {
|
|
388
|
-
this.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (!ok) return;
|
|
392
|
-
this._sdToast.success("저장되었습니다.");
|
|
393
|
-
this.close.emit(true);
|
|
394
|
-
await this._refresh();
|
|
395
|
-
} finally {
|
|
396
|
-
this.busyCount.update((v) => v - 1);
|
|
397
|
-
}
|
|
398
|
-
}, getOrmDataEditToastErrorMessage);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
protected async onDeleteButtonClick(): Promise<void> {
|
|
402
|
-
await this._toggleDelete(true);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
protected async onRestoreButtonClick(): Promise<void> {
|
|
406
|
-
await this._toggleDelete(false);
|
|
200
|
+
await this._refresh();
|
|
201
|
+
});
|
|
202
|
+
this.busyCount.update((v) => v - 1);
|
|
407
203
|
}
|
|
408
204
|
|
|
409
205
|
//== 내부 메서드 ==
|
|
410
|
-
private async _toggleDelete(del: boolean): Promise<void> {
|
|
411
|
-
if (this.busyCount() > 0) return;
|
|
412
|
-
if (!this.canEdit()) return;
|
|
413
|
-
if (!this.canDelete()) return;
|
|
414
|
-
|
|
415
|
-
await this._sdToast.try(async () => {
|
|
416
|
-
this.busyCount.update((v) => v + 1);
|
|
417
|
-
try {
|
|
418
|
-
const ok = await this._deleteAsync(del);
|
|
419
|
-
if (!ok) return;
|
|
420
|
-
this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
|
|
421
|
-
this.close.emit(true);
|
|
422
|
-
} finally {
|
|
423
|
-
this.busyCount.update((v) => v - 1);
|
|
424
|
-
}
|
|
425
|
-
}, getOrmDataEditToastErrorMessage);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
private _checkIgnoreChanges(): boolean {
|
|
429
|
-
return (
|
|
430
|
-
this._snapshot == null ||
|
|
431
|
-
obj.equal(this.data(), this._snapshot) ||
|
|
432
|
-
confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?")
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
private async _initRefresh(): Promise<void> {
|
|
437
|
-
await this._refresh();
|
|
438
|
-
this.initialized.set(true);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
206
|
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 }> {
|
|
207
|
+
let data: ICustomer;
|
|
458
208
|
if (this.itemId() == null) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
info: { isNew: true, isDeleted: false,
|
|
463
|
-
lastModifiedAt: undefined, lastModifiedBy: undefined },
|
|
209
|
+
data = {
|
|
210
|
+
id: undefined, name: "", phone: "",
|
|
211
|
+
lastModifiedAt: undefined, lastModifiedBy: undefined,
|
|
464
212
|
};
|
|
213
|
+
} else {
|
|
214
|
+
// 앱별 ORM 조회 — 예:
|
|
215
|
+
// data = await this._appOrm.connectAsync(async (db) =>
|
|
216
|
+
// (await db.customer.where((it) => [expr.eq(it.id, this.itemId())]).single())!
|
|
217
|
+
// );
|
|
218
|
+
throw new Error("구현 필요");
|
|
465
219
|
}
|
|
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
220
|
|
|
480
|
-
|
|
481
|
-
// ORM upsert 후 true 반환. 검증 실패 시 undefined 반환하면 close/토스트 생략.
|
|
482
|
-
throw new Error("구현 필요");
|
|
221
|
+
this.data.set(data);
|
|
483
222
|
}
|
|
484
223
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
throw new Error("구현 필요");
|
|
489
|
-
}
|
|
224
|
+
//== for Template ==
|
|
225
|
+
protected readonly tablerAlertTriangle = tablerAlertTriangle;
|
|
226
|
+
protected readonly tablerRefresh = tablerRefresh;
|
|
490
227
|
}
|
|
491
228
|
```
|
|
492
229
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
각 블록의 역할과 원본 `SdDataDetail`/`SdDataDetailBase` 코드 대응 지점:
|
|
496
|
-
|
|
497
|
-
| 블록 | 역할 | 원본 대응 |
|
|
498
|
-
|---|---|---|
|
|
499
|
-
| `<sd-busy-container [busy] [message]>` | 전체 busy 오버레이 | `sd-data-detail.ts:53-54` + `SdBaseContainer` |
|
|
500
|
-
| `@if (initialized())` | 최초 조회 완료 전 본문 숨김 | `sd-data-detail.base.ts:92` `initialized.set(true)` |
|
|
501
|
-
| `@if (!canUse())` | 권한 없음 메시지 | `sd-base-container.ts:44-51` + `page-modal-container.md` |
|
|
502
|
-
| `@else if (viewType() === "page")` / `"modal"` / `@else` | 뷰 타입 분기 (`page-modal-container.md` 참조) | `sd-data-detail.ts:53, 56-58` `viewType` → `SdBaseContainer` 내부 분기 |
|
|
503
|
-
| `<sd-topbar-container>` + `<sd-topbar>` | 페이지 뷰 헤더 (저장/새로고침) | `sd-data-detail.ts:60-73` `#pageTopbarTpl` |
|
|
504
|
-
| modal 분기 하단 확인/삭제 바 | 모달 하단 액션 | `sd-data-detail.ts:151-175` `#modalBottomTpl` |
|
|
505
|
-
| control 분기 상단 바 (저장/새로고침/삭제) | 마스터-디테일의 디테일 도구 바 | `sd-data-detail.ts:77-109` |
|
|
506
|
-
| `<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` |
|
|
508
|
-
| `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-detail.ts:45-51` |
|
|
509
|
-
| `setupCanDeactivate(() => viewType() === "modal" || checkIgnoreChanges())` | 라우트 이탈 시 변경사항 확인 | `sd-data-detail.base.ts:99` |
|
|
510
|
-
| `_refresh()` 내 `busyCount.update + try/finally + sdToast.try` | busy 카운트 증감 + 에러 토스트 | `sd-data-detail.base.ts:86-91, 115-119` |
|
|
511
|
-
| `_snapshot = obj.clone(data)` + `obj.equal` 비교 | 변경 감지 | `sd-data-detail.base.ts:66, 127, 102-108, 162` |
|
|
512
|
-
| `effect(() => { itemId(); if (!canUse()) return; untracked(() => void this._initRefresh()); })` | 최초 로드 + input 변경 시 자동 reload | `sd-data-detail.base.ts:69-97` + `prepareRefreshEffect` 슬롯 |
|
|
513
|
-
| `close = output<R | undefined>()` + `implements SdModalContentDef<R>` | 모달 컨텐츠 계약 | `sd-data-detail.base.ts:29-30, 59` |
|
|
514
|
-
|
|
515
|
-
### 상태 분해
|
|
516
|
-
|
|
517
|
-
| signal / 필드 | 역할 |
|
|
518
|
-
|---|---|
|
|
519
|
-
| `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
|
|
520
|
-
| `busyMessage` | busy 오버레이 문구 |
|
|
521
|
-
| `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
|
|
522
|
-
| `data` | 현재 편집 중인 데이터 (load 결과) |
|
|
523
|
-
| `dataInfo` | isNew/isDeleted/lastModifiedAt/lastModifiedBy 메타 정보 |
|
|
524
|
-
| `_snapshot` | 직전 `_refresh()` 시점의 data 깊은 복제본 (변경 감지용). `isNew`면 `undefined` |
|
|
525
|
-
| `close` | 모달 결과 output (`SdModalContentDef` 요구) |
|
|
526
|
-
| `actionTplRef` | 모달 우측 상단 액션 슬롯. `SdModal`이 setter 프록시로 자동 브릿지 |
|
|
527
|
-
|
|
528
|
-
### 메서드 분해
|
|
529
|
-
|
|
530
|
-
| 메서드 | 역할 |
|
|
531
|
-
|---|---|
|
|
532
|
-
| `onRefreshButtonClick()` | busy/권한/변경사항 가드 후 `_refresh()` |
|
|
533
|
-
| `onSubmitButtonClick()` | `formCtrl()?.requestSubmit()` — Ctrl+S와 동일 경로 |
|
|
534
|
-
| `onSubmit()` | `canEdit` 체크 + snapshot 대비 변경사항 여부 판정 + `_saveAsync(data)` + 성공 토스트 + `close.emit(true)` + `_refresh()` |
|
|
535
|
-
| `onDeleteButtonClick()` / `onRestoreButtonClick()` | `_toggleDelete(del)` 호출 |
|
|
536
|
-
| `_toggleDelete(del)` | busy/권한 가드 + `_deleteAsync(del)` + 토스트 + `close.emit(true)` |
|
|
537
|
-
| `_checkIgnoreChanges()` | snapshot 없거나 동일하면 true, 아니면 `confirm(...)` |
|
|
538
|
-
| `_initRefresh()` / `_refresh()` | `_loadAsync()` → `data.set` + `dataInfo.set` + snapshot 갱신 (isNew면 미저장) |
|
|
539
|
-
| `_loadAsync()` / `_saveAsync(data)` / `_deleteAsync(del)` | 앱별 ORM/API 구현 |
|
|
230
|
+
### 조건부 요소 포함 기준
|
|
540
231
|
|
|
541
|
-
|
|
232
|
+
인프라·라이프사이클 요소는 화면 요건에 따라 포함·생략한다. 필요 없는 요소를 기계적으로 포함하지 않는다.
|
|
542
233
|
|
|
543
|
-
|
|
234
|
+
| 요소 | 포함 조건 | 생략 예시 |
|
|
235
|
+
|------|----------|----------|
|
|
236
|
+
| `<sd-topbar-container>` + `<sd-topbar>` | routes로 연결된 page에서 헤더가 필요할 때. 기본 레시피는 page 전용이라 조건 없이 렌더 | route 미연결(control·래퍼) |
|
|
237
|
+
| `injectViewTitleSignal()` | topbar에 타이틀을 표시할 때 | topbar 없음·타이틀 불필요 |
|
|
238
|
+
| `injectViewTypeSignal()` + 분기 | page 외에 modal/control로도 겸용될 때. 기본 레시피는 page 전용이므로 **미포함** — [확장 C](./crud-detail/extension-c-modal-view.md) / [확장 D](./crud-detail/extension-d-control-view.md)에서 도입 | page 전용 |
|
|
239
|
+
| `injectPermsSignal()` + 권한 없음 블록 | 권한 제어가 있는 화면 | 권한 제어 없음 |
|
|
240
|
+
| `<sd-busy-container>` + `busyCount` | 비동기 작업(DB 조회 등)이 있어 busy 표시가 필요할 때 | 동기 래퍼·레이아웃 |
|
|
241
|
+
| `initialized` + `@if (initialized())` | 초기 로딩 완료 전 깜박임 방지가 필요할 때 | 빈 상태 렌더가 무방 |
|
|
544
242
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
```typescript
|
|
548
|
-
// 1) imports 추가
|
|
549
|
-
import { SdSharedDataSelect } from "@simplysm/angular";
|
|
550
|
-
// ... (앱 공용 useSharedSignal 등)
|
|
551
|
-
|
|
552
|
-
// 2) 클래스에 필드 추가
|
|
553
|
-
protected readonly permCopySourceId = signal<number | undefined>(undefined);
|
|
554
|
-
protected readonly sharedUsers = useSharedSignal("사용자"); // 앱 공용 provider
|
|
555
|
-
|
|
556
|
-
// 3) template — control 분기 또는 modal 분기 상단/하단에 보조 form 삽입.
|
|
557
|
-
// 아래는 control 뷰 분기 내부에 추가하는 예시 (위치: "저장/새로고침/삭제" 바 옆).
|
|
558
|
-
@if (viewType() === "control") {
|
|
559
|
-
<div class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">
|
|
560
|
-
<!-- 기본 저장/새로고침/삭제 버튼 -->
|
|
561
|
-
<!-- ... -->
|
|
562
|
-
|
|
563
|
-
<!-- 보조 기능: 다른 사용자로부터 가져오기 -->
|
|
564
|
-
<sd-form (formSubmit)="onImportFormSubmit()">
|
|
565
|
-
<div class="form-box-inline">
|
|
566
|
-
<div>
|
|
567
|
-
<label>가져오기</label>
|
|
568
|
-
<sd-shared-data-select
|
|
569
|
-
[items]="sharedUsers.items()"
|
|
570
|
-
[(value)]="permCopySourceId"
|
|
571
|
-
[inset]="true"
|
|
572
|
-
[size]="'sm'"
|
|
573
|
-
/>
|
|
574
|
-
</div>
|
|
575
|
-
<div>
|
|
576
|
-
<sd-button [type]="'submit'" [disabled]="permCopySourceId() == null">
|
|
577
|
-
가져오기
|
|
578
|
-
</sd-button>
|
|
579
|
-
</div>
|
|
580
|
-
</div>
|
|
581
|
-
</sd-form>
|
|
582
|
-
</div>
|
|
583
|
-
}
|
|
243
|
+
## 변형 (확장 A~F 인덱스)
|
|
584
244
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
245
|
+
| 상황 | 시작 지점 + 필요한 확장 |
|
|
246
|
+
|---|---|
|
|
247
|
+
| 단일 레코드 **읽기 전용 상세 폼** (page 뷰, 감사 필드 표시) | 기본 레시피만 |
|
|
248
|
+
| 편집·저장 가능한 단일 레코드 상세 폼 | 기본 + [확장 A](./crud-detail/extension-a-edit-save.md) |
|
|
249
|
+
| 삭제·복구 토글 포함 | 기본 + A + [확장 B](./crud-detail/extension-b-delete-restore.md) |
|
|
250
|
+
| page + **modal 뷰** 재사용 (확인·취소·삭제·복구 하단 바) | 기본 + A + B + [확장 C](./crud-detail/extension-c-modal-view.md) |
|
|
251
|
+
| 마스터-디테일의 "디테일" 영역 (**control 뷰**) | 기본 + A + B + [확장 D](./crud-detail/extension-d-control-view.md) |
|
|
252
|
+
| page + modal + control **3뷰** 모두 지원 | 기본 + A + B + C + D |
|
|
253
|
+
| 메인 폼과 별개의 **보조 기능** (가져오기/출력 등) | 기본 + A + [확장 E](./crud-detail/extension-e-auxiliary.md) |
|
|
254
|
+
| 상세 폼 내부에 **하위 컬렉션** 편집 (박스 목록 등) | 기본 + A + [확장 F](./crud-detail/extension-f-complex-detail.md) |
|
|
590
255
|
|
|
591
|
-
|
|
592
|
-
this.busyCount.update((v) => v + 1);
|
|
593
|
-
try {
|
|
594
|
-
// 서버 호출로 다른 사용자의 데이터를 조회
|
|
595
|
-
// const src = await this._api.fetchByIdAsync(this.permCopySourceId()!);
|
|
596
|
-
// this.data.set({ ...this.data(), ...src });
|
|
597
|
-
} finally {
|
|
598
|
-
this.busyCount.update((v) => v - 1);
|
|
599
|
-
}
|
|
600
|
-
}, getOrmDataEditToastErrorMessage);
|
|
601
|
-
}
|
|
602
|
-
```
|
|
256
|
+
### 각 확장 요약
|
|
603
257
|
|
|
604
|
-
|
|
258
|
+
- **A. 편집/저장** (선행: 없음) — 읽기 전용을 편집 가능으로 전환. snapshot 기반 변경 감지, `setupCanDeactivate` 이탈 방지, Ctrl+S 일괄 저장. → [상세](./crud-detail/extension-a-edit-save.md)
|
|
259
|
+
- **B. 삭제/복구 토글** (선행: A) — soft-delete 전제. `isDeleted` 필드 + 삭제·복구 버튼. → [상세](./crud-detail/extension-b-delete-restore.md)
|
|
260
|
+
- **C. modal 뷰** (선행: A + B) — `SdModalContentDef` 계약, 하단 액션 바, 모달 우측 상단 액션 슬롯. → [상세](./crud-detail/extension-c-modal-view.md)
|
|
261
|
+
- **D. control 뷰** (선행: A + B) — 마스터-디테일 디테일 영역. 상단 `<sd-dock>` 도구 바. 확장 C와 병행 가능. → [상세](./crud-detail/extension-d-control-view.md)
|
|
262
|
+
- **E. 보조 기능 영역** (선행: A) — 메인 submit과 분리된 보조 `<sd-form>`. 권한 복사·출력 등. → [상세](./crud-detail/extension-e-auxiliary.md)
|
|
263
|
+
- **F. 복합 상세** (선행: A) — 내부 `<sd-sheet>` 중첩, `oneWayDiffs` 기반 일괄 저장. → [상세](./crud-detail/extension-f-complex-detail.md)
|
|
605
264
|
|
|
606
|
-
|
|
607
|
-
- 보조 form의 작업 후에도 `_checkIgnoreChanges()`를 호출하여 메인 폼의 미저장 변경사항을 보호한다.
|
|
608
|
-
- 출력·엑셀 다운로드 같은 read-only 보조 기능은 `formSubmit` 대신 버튼의 `(click)`으로 처리 가능. 이 경우 `<sd-form>` 래핑은 생략한다.
|
|
265
|
+
## 🚫 흔한 실수 (Anti-patterns)
|
|
609
266
|
|
|
610
|
-
|
|
267
|
+
> 공통 규칙(`mark` 오용, `injectViewTypeSignal()` 호출 위치, `_sdSharedData.wait()` 조건, 시트 셀 `[inset]`/`[size]`, soft-delete 선택 기준 등)은 [레시피 공통 규칙](./_common-rules.md)을 참조한다. 이 섹션은 **CRUD 상세폼 진입점 고유 실수**만 다룬다.
|
|
611
268
|
|
|
612
|
-
|
|
269
|
+
### 지원할 뷰를 확인하지 않고 3뷰 분기를 미리 박는다
|
|
613
270
|
|
|
614
271
|
```typescript
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
interface ICustomerBox {
|
|
633
|
-
id: string; // 클라이언트 생성 UUID (서버 저장 시 교체 가능)
|
|
634
|
-
seq: number;
|
|
635
|
-
note: string;
|
|
636
|
-
isDeleted: boolean;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// 3) template — formTpl 본문의 table 아래에 <sd-sheet> 중첩
|
|
640
|
-
<ng-template #formTpl>
|
|
641
|
-
<div class="flex-column fill">
|
|
642
|
-
<div class="flex-fill flex-column">
|
|
643
|
-
<sd-form #formCtrl (formSubmit)="onSubmit()" class="flex-column fill">
|
|
644
|
-
<!-- 상단 단일 필드 -->
|
|
645
|
-
<div class="p-default">
|
|
646
|
-
<table class="form-table">
|
|
647
|
-
<tbody>
|
|
648
|
-
<tr>
|
|
649
|
-
<th>명칭</th>
|
|
650
|
-
<td>
|
|
651
|
-
<sd-textfield
|
|
652
|
-
[type]="'text'"
|
|
653
|
-
[required]="true"
|
|
654
|
-
[disabled]="!canEdit()"
|
|
655
|
-
[(value)]="data().name"
|
|
656
|
-
/>
|
|
657
|
-
</td>
|
|
658
|
-
</tr>
|
|
659
|
-
</tbody>
|
|
660
|
-
</table>
|
|
661
|
-
</div>
|
|
662
|
-
|
|
663
|
-
<!-- 하위 컬렉션 도구 영역 -->
|
|
664
|
-
@if (canEdit()) {
|
|
665
|
-
<div class="flex-row gap-sm p-xs-default">
|
|
666
|
-
<sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddBoxButtonClick()">
|
|
667
|
-
<ng-icon [svg]="icons.tablerCirclePlus" />
|
|
668
|
-
박스 추가
|
|
669
|
-
</sd-button>
|
|
670
|
-
</div>
|
|
272
|
+
// ❌ 실제로는 page 뷰만 쓰는데 modal·control 분기를 선제 조립
|
|
273
|
+
// close output, viewType, <sd-dock [position]="'bottom'">, <ng-template #modalActionTpl>가
|
|
274
|
+
// 모두 죽은 코드로 남고, implements SdModalContentDef 경로는 호출되지 않는다.
|
|
275
|
+
@Component({ /* ... */ })
|
|
276
|
+
export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
|
|
277
|
+
close = output<boolean | undefined>();
|
|
278
|
+
protected readonly viewType = injectViewTypeSignal();
|
|
279
|
+
// ...
|
|
280
|
+
template: `
|
|
281
|
+
<sd-topbar-container>
|
|
282
|
+
@if (viewType() === "page") { <sd-topbar>...</sd-topbar> }
|
|
283
|
+
<sd-dock-container>
|
|
284
|
+
@if (viewType() === "control" && canEdit()) { <sd-dock>...</sd-dock> }
|
|
285
|
+
@if (viewType() === "modal" && canEdit()) {
|
|
286
|
+
<sd-dock [position]="'bottom'">...</sd-dock>
|
|
671
287
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
[items]="data().boxes"
|
|
677
|
-
[trackByFn]="boxTrackByFn"
|
|
678
|
-
[getItemCellStyleFn]="getBoxCellStyleFn"
|
|
679
|
-
>
|
|
680
|
-
@if (canEdit()) {
|
|
681
|
-
<sd-sheet-column [fixed]="true" [key]="'_isDeleted'">
|
|
682
|
-
<ng-template #headerTpl>
|
|
683
|
-
<div class="p-xs-sm tx-center">
|
|
684
|
-
<ng-icon [svg]="icons.tablerEraser" />
|
|
685
|
-
</div>
|
|
686
|
-
</ng-template>
|
|
687
|
-
<ng-template [cell]="data().boxes" let-item="item">
|
|
688
|
-
<div class="p-xs-sm tx-center">
|
|
689
|
-
<sd-anchor
|
|
690
|
-
[theme]="'danger'"
|
|
691
|
-
(click)="onToggleDeleteBoxButtonClick(item)"
|
|
692
|
-
>
|
|
693
|
-
<ng-icon [svg]="item.isDeleted ? icons.tablerRestore : icons.tablerEraser" />
|
|
694
|
-
</sd-anchor>
|
|
695
|
-
</div>
|
|
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>
|
|
725
|
-
</div>
|
|
726
|
-
<!-- 최종수정 표시는 그대로 -->
|
|
727
|
-
</div>
|
|
728
|
-
</ng-template>
|
|
729
|
-
|
|
730
|
-
// 4) 메서드 추가
|
|
731
|
-
protected readonly boxTrackByFn = (item: ICustomerBox): string => item.id;
|
|
732
|
-
|
|
733
|
-
protected readonly getBoxCellStyleFn = (item: ICustomerBox): string | undefined =>
|
|
734
|
-
item.isDeleted ? "text-decoration: line-through;" : undefined;
|
|
735
|
-
|
|
736
|
-
protected onAddBoxButtonClick(): void {
|
|
737
|
-
const newBox: ICustomerBox = {
|
|
738
|
-
id: Uuid.generate().toString(),
|
|
739
|
-
seq: (this.data().boxes.at(-1)?.seq ?? 0) + 1,
|
|
740
|
-
note: "",
|
|
741
|
-
isDeleted: false,
|
|
742
|
-
};
|
|
743
|
-
this.data().boxes.push(newBox);
|
|
744
|
-
mark(this.data);
|
|
288
|
+
</sd-dock-container>
|
|
289
|
+
</sd-topbar-container>
|
|
290
|
+
<ng-template #modalActionTpl>...</ng-template>
|
|
291
|
+
`
|
|
745
292
|
}
|
|
746
293
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
294
|
+
// ✅ 기본 레시피는 page 전용으로 시작. 필요한 뷰가 생기면 확장을 얹는다
|
|
295
|
+
// modal 뷰가 필요해지는 시점에 확장 C를, control 뷰는 확장 D를 추가.
|
|
296
|
+
@Component({ /* ... */ })
|
|
297
|
+
export class CustomerDetail {
|
|
298
|
+
itemId = input<number>();
|
|
299
|
+
// page 전용 — viewType 분기·close output 없음
|
|
750
300
|
}
|
|
301
|
+
```
|
|
751
302
|
|
|
752
|
-
|
|
753
|
-
private async _saveAsync(data: ICustomer): Promise<boolean> {
|
|
754
|
-
// 삭제 플래그가 섞여 있으면 confirm
|
|
755
|
-
if (data.boxes.some((b) => b.isDeleted)) {
|
|
756
|
-
if (!confirm("삭제 표시된 박스가 있습니다. 정말 저장하시겠습니까?")) {
|
|
757
|
-
return false;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
303
|
+
**근거**: 실제 호출되지 않는 뷰의 계약·분기·슬롯은 죽은 코드로 남아 LLM이 모달 사용처 탐색 시 오판 원인이 된다. 지원할 뷰를 먼저 확정하고 필요한 확장만 얹는다.
|
|
760
304
|
|
|
761
|
-
|
|
762
|
-
const snapshotBoxes = this._snapshot?.boxes ?? [];
|
|
763
|
-
const boxDiffs = data.boxes.oneWayDiffs(snapshotBoxes, "id");
|
|
305
|
+
### `effect` 내부 비동기 호출을 `untracked`로 감싸지 않는다
|
|
764
306
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
307
|
+
```typescript
|
|
308
|
+
// ❌ _refresh() 내부에서 읽는 signal이 effect 의존성으로 등록 → 무한 루프
|
|
309
|
+
constructor() {
|
|
310
|
+
effect(() => {
|
|
311
|
+
this.itemId();
|
|
312
|
+
void this._refresh(); // 내부의 this.data()·this.perms() 읽기가 그대로 의존성화
|
|
313
|
+
});
|
|
314
|
+
}
|
|
773
315
|
|
|
774
|
-
|
|
316
|
+
// ✅ 비동기 작업은 untracked로 감싸 의존성 등록을 차단한다
|
|
317
|
+
constructor() {
|
|
318
|
+
effect(() => {
|
|
319
|
+
this.itemId(); // 여기서만 의존성 등록
|
|
320
|
+
void untracked(async () => {
|
|
321
|
+
await this._sdToast.try(() => this._refresh());
|
|
322
|
+
});
|
|
323
|
+
});
|
|
775
324
|
}
|
|
776
325
|
```
|
|
777
326
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
- 하위 컬렉션의 **삭제는 `isDeleted: true` 플래그로 표현**한다. `data().boxes`에서 row를 물리적으로 제거하면 `oneWayDiffs`가 해당 row를 감지하지 못한다 (`oneWayDiffs`는 `type: "create" | "update" | "same"`만 반환, **`"delete"` 없음**). 서버가 soft-delete 처리.
|
|
781
|
-
- 시트 셀 내부 컨트롤은 **`[inset]="true" [size]="'sm'"` 명시 필수** (§9 관용 규칙 1 참조).
|
|
782
|
-
- `data().boxes.push(newBox)` 같은 배열 mutation 후에는 `mark(this.data)`로 signal 참조를 갱신해야 OnPush 템플릿이 재렌더링된다(§9 관용 규칙 2 참조).
|
|
783
|
-
- `id`는 **클라이언트에서 UUID로 생성**하여 `trackByFn` + `oneWayDiffs`의 key로 사용. 서버가 발급한 PK가 별도로 있다면 별도 컬럼으로 관리하고 클라이언트 UUID는 row 식별자로만 사용.
|
|
784
|
-
|
|
785
|
-
## 7. 뷰 타입 분기
|
|
786
|
-
|
|
787
|
-
`@if (viewType() === "page") ... @else if (viewType() === "modal") ... @else { ... }` 분기 구조와 `modalOrPageTitle` computed 계산은 [`page-modal-container.md`](./page-modal-container.md)의 레시피와 동일하다. 본 레시피의 완성 예제도 그 패턴을 그대로 사용한다.
|
|
788
|
-
|
|
789
|
-
상세 폼 특화 사항:
|
|
790
|
-
|
|
791
|
-
- **모달 우측 상단 액션**: `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
|
-
- **모달 하단 확인/삭제 바**: 모달 뷰 분기 내부의 `<div class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest">` 블록으로 직접 구성. `[size]="'sm'"`로 버튼 크기를 모달에 맞춤.
|
|
793
|
-
- **control 뷰 상단 바**: 페이지 뷰의 topbar가 없고 모달 뷰의 하단 바가 없는 대신, 상단에 `저장`/`새로고침`/`삭제` 버튼을 가로로 배치한 `<div class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">` 블록을 사용.
|
|
794
|
-
|
|
795
|
-
## 8. 주의사항 (자주 하는 실수)
|
|
796
|
-
|
|
797
|
-
### 공통 유틸 재도입 금지
|
|
798
|
-
|
|
799
|
-
- `useDataDetail()`, `useCrudDetail()`, `setupDataDetail()` 같은 공통 헬퍼를 도입하지 말 것. 이 레시피가 제거한 추상화를 다시 만드는 행위다. 세 화면이 비슷해 보여도 화면마다 필드·동작 시그니처가 조금씩 다르므로 복사·수정이 낫다.
|
|
800
|
-
|
|
801
|
-
### `_sdSharedData.wait()` 선택적 호출
|
|
802
|
-
|
|
803
|
-
- 과거 `SdDataDetailBase`는 `_refresh()` 직전에 `await this._sdSharedData.wait()`를 호출했다. **공유 데이터를 화면에서 실제로 사용하지 않는다면 이 호출은 불필요**. 사용한다면 `_refresh()` 맨 앞에 `await inject(SdSharedDataProvider).wait();`를 삽입한다.
|
|
804
|
-
|
|
805
|
-
### 커스텀 close 결과 (`R` 제네릭)
|
|
806
|
-
|
|
807
|
-
- 모달이 boolean 대신 사용자 정의 결과를 돌려주어야 할 때, `close = output<FooResult | undefined>()` + `implements SdModalContentDef<FooResult | undefined>`로 선언한다. 호출 측은 `const result: FooResult | undefined = await sdModal.showAsync({ type: CustomerDetail, ... })`로 받는다.
|
|
808
|
-
|
|
809
|
-
### 조용한 저장 (`hideNoChangeMessage` 대응)
|
|
327
|
+
**근거**: Angular `effect`는 콜백 실행 중 읽힌 모든 signal을 의존성으로 등록한다. 비동기 함수 내부에서 signal을 읽으면 effect가 재실행되어 무한 루프가 발생한다.
|
|
810
328
|
|
|
811
|
-
|
|
329
|
+
### `setupCanDeactivate`를 `computed`·`effect`·일반 메서드에서 호출한다
|
|
812
330
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
- `setupCanDeactivate`는 `inject()`를 사용하므로 **생성자(또는 필드 이니셜라이저)**에서만 호출해야 한다. `computed`/`effect` 콜백 또는 일반 메서드에서 호출하면 `NG0203` 런타임 에러가 발생한다.
|
|
820
|
-
|
|
821
|
-
### `injectViewTypeSignal()` 호출 시점
|
|
822
|
-
|
|
823
|
-
- 동일하게 `injectViewTypeSignal()`도 생성자 또는 필드 이니셜라이저에서만 호출한다.
|
|
824
|
-
|
|
825
|
-
### snapshot은 `obj.clone`로 깊은 복제
|
|
826
|
-
|
|
827
|
-
- `this._snapshot = this.data()` 같은 얕은 참조 대입은 `data().field = "x"` mutation을 snapshot까지 오염시켜 변경 감지가 실패한다. 반드시 `obj.clone(data)`로 깊은 복제.
|
|
828
|
-
|
|
829
|
-
## 9. 레시피 작성 관용 규칙
|
|
830
|
-
|
|
831
|
-
향후 데이터 관련 레시피(CRUD 리스트·상세·선택 버튼 등) 전반에서 아래 규칙을 공통으로 따른다.
|
|
832
|
-
|
|
833
|
-
### 규칙 1: 시트 셀 내부 컨트롤은 `[inset]="true" [size]="'sm'"` 명시
|
|
834
|
-
|
|
835
|
-
- `<sd-sheet-column>` `[cell]` 템플릿 내부의 `sd-textfield` / `sd-select` / `sd-checkbox` / `sd-numpad` / `sd-date-range-picker` / `sd-textarea`는 레시피에서 **항상** `[inset]="true" [size]="'sm'"`를 함께 노출한다.
|
|
836
|
-
- 예외: 복합 구조(텍스트+컨트롤) → `[inset]="false"`. 큰 시트 행 → `[size]` 생략.
|
|
837
|
-
- 누락 시 컴파일 에러가 발생하지 않아 LLM이 빠뜨리기 쉽다.
|
|
838
|
-
|
|
839
|
-
### 규칙 2: `mark(sig)`는 "저장 감지"가 아니라 "UI 동기화"
|
|
840
|
-
|
|
841
|
-
- `mark(sig)`는 `WritableSignal`의 값을 shallow copy하여 **참조를 갱신**한다 (배열: `[...v]`, 객체: `{...v}`).
|
|
842
|
-
- 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**.
|
|
843
|
-
- **"저장 감지"가 아니다.** `obj.equal`(`packages/core-common/src/utils/obj.ts:172`)이 deep equal로 snapshot과 값 차이를 감지하므로, `data().field = value` 같은 mutation은 `mark` 없이도 `_checkIgnoreChanges()` / `onSubmit()`의 snapshot 비교에서 감지된다.
|
|
844
|
-
- Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요.
|
|
845
|
-
- ❌ "mark 없으면 저장이 안 된다" 식 서술 금지.
|
|
846
|
-
|
|
847
|
-
### 규칙 3: `setupCanDeactivate`는 뷰 타입에 따라 분기
|
|
331
|
+
```typescript
|
|
332
|
+
// ❌ NG0203 — inject()는 injection context에서만 호출 가능
|
|
333
|
+
protected readonly canEdit = computed(() => {
|
|
334
|
+
setupCanDeactivate(() => !this._dirty()); // 런타임 에러
|
|
335
|
+
return true;
|
|
336
|
+
});
|
|
848
337
|
|
|
849
|
-
|
|
850
|
-
|
|
338
|
+
// ✅ 생성자(또는 필드 이니셜라이저)에서만 호출
|
|
339
|
+
constructor() {
|
|
340
|
+
setupCanDeactivate(() => !this._dirty());
|
|
341
|
+
}
|
|
342
|
+
```
|
|
851
343
|
|
|
852
|
-
|
|
344
|
+
**근거**: `setupCanDeactivate`는 내부에서 `inject(Router)` 등을 사용하므로 injection context가 필수다. computed/effect 콜백이나 일반 메서드 호출 시점엔 context가 없다.
|
|
853
345
|
|
|
854
|
-
##
|
|
346
|
+
## 관련 Entry
|
|
855
347
|
|
|
856
|
-
-
|
|
857
|
-
-
|
|
858
|
-
- `
|
|
859
|
-
- `
|
|
860
|
-
- `
|
|
348
|
+
- [`crud-list.md`](./crud-list.md) — 시트 기반 CRUD 리스트. 차이: 단일 레코드 vs 컬렉션
|
|
349
|
+
- [`page-modal-container.md`](./page-modal-container.md) — page/modal 뷰 분기만 필요한 단순 컨테이너. 차이: CRUD 라이프사이클 없음
|
|
350
|
+
- [`data-select-button.md`](./data-select-button.md) — 데이터 선택 버튼. 차이: 모달 내부 선택 UI
|
|
351
|
+
- [`_common-rules.md`](./_common-rules.md) — 레시피 공통 규칙 (✅ Always / ⚠️ Ask first / 🚫 Never)
|
|
352
|
+
- [`SdModalContentDef`](../provider-types/sd-modal-content-def.md) — 확장 C(modal 뷰)에서 구현하는 계약
|
|
353
|
+
- [`SdModalProvider.showAsync`](../providers/sd-modal-provider.md) — 프로그래밍 방식 모달 호출
|