@simplysm/angular 14.0.51 → 14.0.53

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.
Files changed (134) hide show
  1. package/dist/data/sheet/sd-sheet.d.ts +9 -12
  2. package/dist/data/sheet/sd-sheet.d.ts.map +1 -1
  3. package/dist/data/sheet/sd-sheet.js +150 -168
  4. package/dist/data/sheet/types.d.ts +1 -0
  5. package/dist/data/sheet/types.d.ts.map +1 -1
  6. package/dist/data/sheet/useSheetCellStyling.d.ts +2 -2
  7. package/dist/data/sheet/useSheetCellStyling.d.ts.map +1 -1
  8. package/dist/data/sheet/useSheetCellStyling.js +20 -12
  9. package/dist/data/sheet/useSheetColumnFixing.d.ts +3 -8
  10. package/dist/data/sheet/useSheetColumnFixing.d.ts.map +1 -1
  11. package/dist/data/sheet/useSheetColumnFixing.js +19 -27
  12. package/dist/data/sheet/useSheetLayoutEngine.d.ts.map +1 -1
  13. package/dist/data/sheet/useSheetLayoutEngine.js +4 -1
  14. package/dist/layout/dock/sd-dock-container.d.ts.map +1 -1
  15. package/dist/layout/dock/sd-dock-container.js +3 -3
  16. package/package.json +7 -8
  17. package/src/data/sheet/sd-sheet.ts +39 -60
  18. package/src/data/sheet/types.ts +1 -0
  19. package/src/data/sheet/useSheetCellStyling.ts +19 -11
  20. package/src/data/sheet/useSheetColumnFixing.ts +21 -28
  21. package/src/data/sheet/useSheetLayoutEngine.ts +4 -1
  22. package/src/layout/dock/sd-dock-container.ts +1 -0
  23. package/README.md +0 -476
  24. package/docs/bootstrap/provide-sd-angular.md +0 -37
  25. package/docs/bootstrap/sd-angular-config-provider.md +0 -16
  26. package/docs/directives/sd-command-directive.md +0 -30
  27. package/docs/directives/sd-events.md +0 -25
  28. package/docs/directives/sd-intersection-directive.md +0 -36
  29. package/docs/directives/sd-invalid.md +0 -24
  30. package/docs/directives/sd-resize-directive.md +0 -42
  31. package/docs/directives/sd-ripple.md +0 -23
  32. package/docs/directives/sd-router-link.md +0 -38
  33. package/docs/directives/sd-show-effect.md +0 -18
  34. package/docs/directives/sd-typed-template.md +0 -69
  35. package/docs/features/sd-address-search-modal.md +0 -50
  36. package/docs/features/sd-permission-table.md +0 -20
  37. package/docs/features/sd-shared-data-components.md +0 -158
  38. package/docs/features/sd-tiptap-editor.md +0 -26
  39. package/docs/pipes/format-pipe.md +0 -41
  40. package/docs/plugins/sd-global-error-handler.md +0 -23
  41. package/docs/plugins/sd-option-event-plugin.md +0 -34
  42. package/docs/provider-types/sd-menu.md +0 -65
  43. package/docs/provider-types/sd-modal-content-def.md +0 -148
  44. package/docs/provider-types/sd-toast-content-def.md +0 -73
  45. package/docs/provider-types/shared-data-base.md +0 -59
  46. package/docs/providers/sd-activated-modal-provider.md +0 -34
  47. package/docs/providers/sd-app-structure-provider.md +0 -81
  48. package/docs/providers/sd-busy-provider.md +0 -18
  49. package/docs/providers/sd-file-dialog-provider.md +0 -40
  50. package/docs/providers/sd-local-storage-provider.md +0 -20
  51. package/docs/providers/sd-modal-provider.md +0 -67
  52. package/docs/providers/sd-navigate-window-provider.md +0 -18
  53. package/docs/providers/sd-print-provider.md +0 -25
  54. package/docs/providers/sd-service-client-factory-provider.md +0 -43
  55. package/docs/providers/sd-shared-data-provider.md +0 -64
  56. package/docs/providers/sd-system-config-provider.md +0 -46
  57. package/docs/providers/sd-system-log-provider.md +0 -18
  58. package/docs/providers/sd-theme-provider.md +0 -38
  59. package/docs/providers/sd-toast-provider.md +0 -65
  60. package/docs/recipes/_common-rules.md +0 -244
  61. package/docs/recipes/crud-detail/extension-a-edit-save.md +0 -230
  62. package/docs/recipes/crud-detail/extension-b-delete-restore.md +0 -142
  63. package/docs/recipes/crud-detail/extension-c-modal-view.md +0 -214
  64. package/docs/recipes/crud-detail/extension-d-control-view.md +0 -103
  65. package/docs/recipes/crud-detail/extension-e-auxiliary.md +0 -87
  66. package/docs/recipes/crud-detail/extension-f-complex-detail.md +0 -234
  67. package/docs/recipes/crud-detail.md +0 -353
  68. package/docs/recipes/crud-list/extension-a-inline-edit.md +0 -410
  69. package/docs/recipes/crud-list/extension-b-selection.md +0 -226
  70. package/docs/recipes/crud-list/extension-c-inline-delete.md +0 -87
  71. package/docs/recipes/crud-list/extension-d-select-modal.md +0 -207
  72. package/docs/recipes/crud-list/extension-e-readonly-modal.md +0 -165
  73. package/docs/recipes/crud-list/extension-f-modal-edit.md +0 -177
  74. package/docs/recipes/crud-list/extension-g-excel.md +0 -157
  75. package/docs/recipes/crud-list.md +0 -446
  76. package/docs/recipes/data-select-button.md +0 -412
  77. package/docs/recipes/page-modal-container.md +0 -260
  78. package/docs/styling/classes.md +0 -149
  79. package/docs/styling/mixins.md +0 -100
  80. package/docs/styling/themes.md +0 -35
  81. package/docs/styling/variables.md +0 -147
  82. package/docs/type-utilities/directive-input-signals.md +0 -232
  83. package/docs/ui-data/sd-list.md +0 -37
  84. package/docs/ui-data/sd-sheet.md +0 -227
  85. package/docs/ui-form/sd-additional-button.md +0 -26
  86. package/docs/ui-form/sd-anchor.md +0 -31
  87. package/docs/ui-form/sd-button.md +0 -105
  88. package/docs/ui-form/sd-checkbox-group.md +0 -39
  89. package/docs/ui-form/sd-checkbox.md +0 -81
  90. package/docs/ui-form/sd-date-range-picker.md +0 -27
  91. package/docs/ui-form/sd-form.md +0 -89
  92. package/docs/ui-form/sd-modal-select-button.md +0 -54
  93. package/docs/ui-form/sd-numpad.md +0 -26
  94. package/docs/ui-form/sd-range.md +0 -26
  95. package/docs/ui-form/sd-select.md +0 -68
  96. package/docs/ui-form/sd-shared-data-select.md +0 -52
  97. package/docs/ui-form/sd-state-preset.md +0 -37
  98. package/docs/ui-form/sd-switch.md +0 -27
  99. package/docs/ui-form/sd-textarea.md +0 -33
  100. package/docs/ui-form/sd-textfield.md +0 -145
  101. package/docs/ui-layout/sd-dock-container.md +0 -64
  102. package/docs/ui-layout/sd-dock.md +0 -37
  103. package/docs/ui-layout/sd-gap.md +0 -26
  104. package/docs/ui-layout/sd-kanban-board.md +0 -96
  105. package/docs/ui-layout/sd-kanban-lane.md +0 -34
  106. package/docs/ui-layout/sd-kanban.md +0 -29
  107. package/docs/ui-navigation/sd-collapse.md +0 -35
  108. package/docs/ui-navigation/sd-pagination.md +0 -26
  109. package/docs/ui-navigation/sd-sidebar-container.md +0 -49
  110. package/docs/ui-navigation/sd-sidebar-menu.md +0 -22
  111. package/docs/ui-navigation/sd-sidebar-user.md +0 -43
  112. package/docs/ui-navigation/sd-tab.md +0 -51
  113. package/docs/ui-navigation/sd-topbar-container.md +0 -97
  114. package/docs/ui-navigation/sd-topbar-menu.md +0 -23
  115. package/docs/ui-navigation/sd-topbar-user.md +0 -38
  116. package/docs/ui-navigation/sd-topbar.md +0 -30
  117. package/docs/ui-overlay/sd-busy-container.md +0 -69
  118. package/docs/ui-overlay/sd-confirm-modal.md +0 -30
  119. package/docs/ui-overlay/sd-dropdown.md +0 -40
  120. package/docs/ui-overlay/sd-modal.md +0 -34
  121. package/docs/ui-overlay/sd-prompt-modal.md +0 -30
  122. package/docs/ui-overlay/sd-toast.md +0 -35
  123. package/docs/ui-visual/sd-barcode.md +0 -36
  124. package/docs/ui-visual/sd-calendar.md +0 -34
  125. package/docs/ui-visual/sd-echarts.md +0 -32
  126. package/docs/ui-visual/sd-label.md +0 -24
  127. package/docs/ui-visual/sd-note.md +0 -23
  128. package/docs/ui-visual/sd-progress.md +0 -23
  129. package/docs/utils/inject-routing-signals.md +0 -161
  130. package/docs/utils/inject-sd-system-config-resource.md +0 -35
  131. package/docs/utils/mark.md +0 -43
  132. package/docs/utils/selection-managers.md +0 -96
  133. package/docs/utils/set-safe-style.md +0 -19
  134. package/docs/utils/setup-functions.md +0 -93
@@ -1,230 +0,0 @@
1
- ← [CRUD 상세폼 레시피 진입점](../crud-detail.md)
2
-
3
- # 확장 A: 편집/저장
4
-
5
- > **선행:** 없음 (최소 뼈대 §3에 직접 얹음)
6
-
7
- 최소 뼈대의 읽기 전용 필드를 **편집 가능**으로 바꾸고, topbar에 "저장" 버튼(또는 Ctrl+S)을 추가하여 **일괄 저장**을 도입한다. `obj.clone(this.data())` snapshot + `obj.equal(data, _dataSnapshot)` 기반 변경 감지를 부착하고, 라우트 이탈 시 미저장 변경사항을 확인하는 `setupCanDeactivate` 가드를 등록한다. 편집은 page/modal/control 모든 뷰에서 동작하며, 뷰별 UI 배치는 [확장 C](./extension-c-modal-view.md)/[확장 D](./extension-d-control-view.md)에서 처리한다.
8
-
9
- **이 확장이 도입하는 요소:**
10
-
11
- - **imports:** `computed`, `viewChild`, `obj`, `setupCanDeactivate`, `mark`, `SdCommandDirective` outputs에 `sdSaveCommand` 추가, 아이콘 `tablerDeviceFloppy`
12
- - **DI:** 없음 (최소 뼈대와 동일)
13
- - **파생:** `canEdit = computed(() => perms().includes("edit"))` (뷰 분기 없음 — 편집은 3뷰 모두에서 가능, 뷰별 UI는 [확장 C](./extension-c-modal-view.md)/[확장 D](./extension-d-control-view.md)에서 분기)
14
- - **상태:** `_dataSnapshot?: ICustomer` (직전 `_refresh()` 시점 data의 깊은 복제), `isNew = computed(() => data().id == null)` (신규 레코드 여부)
15
- - **권한 키 확장:** `injectPermsSignal` 두 번째 인자 `["use"]` → `["use", "edit"]`
16
- - **hostDirectives·host:** `outputs`에 `sdSaveCommand` 추가, `host`에 `(sdSaveCommand)="onSaveButtonClick()"` 추가
17
- - **viewChild:** `formCtrl = viewChild<SdForm>("formCtrl")` (Ctrl+S 경로 통합용)
18
- - **메서드:** `onSaveButtonClick`, `onSubmit`, `_checkIgnoreChanges`; `onRefreshButtonClick` 선두에 변경 체크 추가
19
- - **for Template:** `protected readonly mark = mark;` (템플릿 `(valueChange)="mark(data)"` 호출용)
20
- - **생성자:** 기존 초기 effect 뒤에 `setupCanDeactivate(() => this._checkIgnoreChanges())`
21
- - **템플릿:** topbar에 "저장" 버튼 추가(`@if (canEdit())`), `<sd-form>` → `<sd-form #formCtrl (formSubmit)="onSubmit()">` 교체, 필드 `[readonly]="true"` → `[disabled]="!canEdit()"` 전환, 모든 입력 필드에 `(valueChange)="mark(data)"` 추가
22
- - **_refresh 변경:** 말미에 `this._dataSnapshot = data.id == null ? undefined : obj.clone(this.data());` 추가
23
-
24
- > 상세: [`<sd-form> #formCtrl / requestSubmit()`](../../ui-form/sd-form.md) · [`setupCanDeactivate`](../../utils/setup-functions.md#setupcandeactivate)
25
-
26
- > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 최소 뼈대 위에 번호 순서대로 삽입·교체할 지점을 나타낸다. 그대로 컴파일되지 않는다.
27
-
28
- ```typescript
29
- // 1) imports 교체 — @angular/core에 {computed, viewChild} 추가, @simplysm/core-common에 {obj} 추가,
30
- // @simplysm/angular에 {setupCanDeactivate, mark} 추가, 아이콘에 tablerDeviceFloppy 추가.
31
- import { tablerAlertTriangle, tablerDeviceFloppy, tablerRefresh } from "@ng-icons/tabler-icons";
32
- import {
33
- ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked,
34
- viewChild, ViewEncapsulation,
35
- } from "@angular/core";
36
- import { type DateTime, obj } from "@simplysm/core-common";
37
- import {
38
- FormatPipe, injectPermsSignal, injectViewTitleSignal, mark,
39
- SdBusyContainer, SdButton, SdCommandDirective,
40
- SdForm, SdTextfield, SdToastProvider, SdTopbar, SdTopbarContainer,
41
- setupCanDeactivate,
42
- } from "@simplysm/angular";
43
-
44
- // 2) @Component — hostDirectives outputs에 "sdSaveCommand" 추가, host에 (sdSaveCommand) 바인딩 추가.
45
- @Component({
46
- // ...selector/changeDetection/encapsulation/standalone/imports 동일
47
- hostDirectives: [
48
- { directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
49
- ],
50
- host: {
51
- "(sdRefreshCommand)": "onRefreshButtonClick()",
52
- "(sdSaveCommand)": "onSaveButtonClick()",
53
- },
54
- // template: 아래 6)에서 상세
55
- })
56
-
57
- // 3) 권한 키 확장 — ["use"] → ["use", "edit"]
58
- perms = injectPermsSignal(["sales.customer"], ["use", "edit"]);
59
-
60
- // 4) 파생·상태·viewChild 추가
61
- protected readonly canEdit = computed(() => this.perms().includes("edit"));
62
- protected readonly isNew = computed(() => this.data().id == null);
63
- private _dataSnapshot?: ICustomer;
64
-
65
- protected readonly formCtrl = viewChild<SdForm>("formCtrl");
66
-
67
- // 5) 생성자에 setupCanDeactivate 추가 (기존 초기 effect 뒤)
68
- constructor() {
69
- // 기존 초기 effect 동일...
70
- setupCanDeactivate(() => this._checkIgnoreChanges());
71
- }
72
-
73
- // 6) template — topbar에 "저장" 버튼 추가, <sd-form>을 <sd-form #formCtrl (formSubmit)>로 교체,
74
- // 필드 [readonly]="true" → [disabled]="!canEdit()"로 전환, 입력 필드에 (valueChange)="mark(data)" 추가.
75
- template: `
76
- <sd-busy-container [busy]="busyCount() > 0">
77
- @if (initialized()) {
78
- @if (!perms().includes("use")) { <!-- 경고 동일 --> }
79
- @else {
80
- <sd-topbar-container>
81
- <sd-topbar>
82
- <h4>{{ viewTitle() }}</h4>
83
- @if (canEdit()) {
84
- <sd-button [theme]="'link-primary'" (click)="onSaveButtonClick()">
85
- <ng-icon [svg]="tablerDeviceFloppy" /> 저장 <small>(CTRL+S)</small>
86
- </sd-button>
87
- }
88
- <sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
89
- <ng-icon [svg]="tablerRefresh" /> 새로고침 <small>(CTRL+ALT+L)</small>
90
- </sd-button>
91
- </sd-topbar>
92
-
93
- <div class="flex-column fill">
94
- <sd-form #formCtrl (formSubmit)="onSubmit()" class="flex-fill">
95
- <div class="p-default">
96
- <table class="form-table">
97
- <tbody>
98
- <tr>
99
- <th>명칭</th>
100
- <td>
101
- <sd-textfield
102
- [type]="'text'" [required]="true"
103
- [disabled]="!canEdit()" [(value)]="data().name"
104
- (valueChange)="mark(data)"
105
- />
106
- </td>
107
- </tr>
108
- <tr>
109
- <th>전화번호</th>
110
- <td>
111
- <sd-textfield
112
- [type]="'text'" [disabled]="!canEdit()"
113
- [(value)]="data().phone"
114
- (valueChange)="mark(data)"
115
- />
116
- </td>
117
- </tr>
118
- </tbody>
119
- </table>
120
- </div>
121
- </sd-form>
122
- <!-- lastModified 블록 동일 (최소 뼈대 §3) -->
123
- </div>
124
- </sd-topbar-container>
125
- }
126
- }
127
- </sd-busy-container>
128
- `
129
-
130
- // 7) 메서드 추가 — onSaveButtonClick / onSubmit / _checkIgnoreChanges
131
- protected onSaveButtonClick(): void {
132
- this.formCtrl()?.requestSubmit();
133
- }
134
-
135
- protected async onSubmit(): Promise<void> {
136
- if (this.busyCount() > 0) return;
137
- if (!this.perms().includes("edit")) return;
138
-
139
- // 신규는 변경 체크 없이 저장. 기존 항목이면 snapshot 대비 변경 여부를 판정.
140
- if (!this.isNew() && this._dataSnapshot != null && obj.equal(this.data(), this._dataSnapshot)) {
141
- this._sdToast.info("변경사항이 없습니다.");
142
- return;
143
- }
144
-
145
- this.busyCount.update((v) => v + 1);
146
- await this._sdToast.try(async () => {
147
- // 앱별 ORM upsert — 예:
148
- // await this._appOrm.connectAsync(async (db) => {
149
- // await db.customer()
150
- // .where((c) => [expr.eq(c.id, this.data().id)])
151
- // .upsert(() => ({ name: this.data().name, phone: this.data().phone }));
152
- // });
153
- // 검증 실패 시 throw 하면 sdToast.try가 포착하여 에러 토스트를 표시한다 (이후 흐름은 생략).
154
-
155
- this._sdToast.success("저장되었습니다.");
156
- await this._refresh();
157
- });
158
- this.busyCount.update((v) => v - 1);
159
- }
160
-
161
- private _checkIgnoreChanges(): boolean {
162
- return (
163
- this._dataSnapshot == null ||
164
- obj.equal(this.data(), this._dataSnapshot) ||
165
- confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?")
166
- );
167
- }
168
-
169
- // 8) onRefreshButtonClick 선두에 변경 체크 추가
170
- protected async onRefreshButtonClick(): Promise<void> {
171
- if (this.busyCount() > 0) return;
172
- if (!this.perms().includes("use")) return;
173
- if (!this._checkIgnoreChanges()) return;
174
-
175
- this.busyCount.update((v) => v + 1);
176
- await this._sdToast.try(async () => {
177
- await this._refresh();
178
- });
179
- this.busyCount.update((v) => v - 1);
180
- }
181
-
182
- // 9) _refresh 말미에 snapshot 갱신
183
- private async _refresh(): Promise<void> {
184
- // ... (최소 뼈대 동일 조회 로직)
185
-
186
- this.data.set(data);
187
- // 신규(id == null)면 snapshot을 비워 변경 체크를 항상 통과시키고 저장을 허용한다.
188
- this._dataSnapshot = data.id == null ? undefined : obj.clone(this.data());
189
- }
190
-
191
- // 10) 아이콘 + mark 템플릿 참조 추가
192
- protected readonly tablerDeviceFloppy = tablerDeviceFloppy;
193
- protected readonly mark = mark;
194
- ```
195
-
196
- **포인트:**
197
-
198
- - **`obj.equal`은 deep equal**(`packages/core-common/src/utils/obj.ts:172`)이므로 `data().field = "x"` 같은 field mutation도 snapshot 대비 **저장 판정에 자동 반영**된다. 입력 필드의 `(valueChange)="mark(data)"`는 OnPush 재렌더링·연계 computed 갱신을 위한 **통지** 용도이며, 값 비교(저장 감지)와는 별개다.
199
- - **snapshot은 반드시 `obj.clone`으로 깊은 복제한다.** `this._dataSnapshot = this.data()` 같은 얕은 참조 대입은 `data().field = "x"` mutation을 snapshot까지 오염시켜 변경 감지가 실패한다. `_refresh()` 말미에서 `obj.clone`(`packages/core-common/src/utils/obj.ts:19`)으로 저장해야 비교가 정확히 동작한다.
200
- - **`isNew = computed(() => data().id == null)`은 PK 형태에 주의한다.** 클라이언트 UUID 선할당·자연키·복합키처럼 신규 상태에서도 `id`가 존재하는 스키마에서는 이 판정이 깨진다. 해당 스키마는 `isNew`를 `signal<boolean>(false)`로 유지하고 `_refresh()` 내부에서 명시 세팅하며, snapshot 분기도 `this.isNew() ? undefined : obj.clone(this.data())`로 치환한다.
201
- - **Ctrl+S 경로 통일:** `hostDirectives` → `(sdSaveCommand)="onSaveButtonClick()"` → `formCtrl()?.requestSubmit()` → `<sd-form (formSubmit)="onSubmit()">`. 버튼 클릭과 단축키가 완전히 동일한 경로로 수렴한다.
202
- - **`setupCanDeactivate(() => this._checkIgnoreChanges())`** — 라우트 이탈 시 snapshot 대비 변경이 있으면 `confirm`으로 사용자 확인을 요청한다. [확장 C](./extension-c-modal-view.md)(modal 뷰)에서는 조건에 `viewType() === "modal" ||`를 추가하여 modal에서는 항상 이탈을 허용한다.
203
- - **조용한 저장(`hideNoChangeMessage` 대응):** 프로그래밍 저장(예: 출력 직전 저장)에서 "변경사항이 없습니다" info 토스트를 억제하려면 `onSubmit(silent: boolean)` 시그니처를 도입하여 `if (!silent) this._sdToast.info(...)`로 감싼다.
204
- - **`_checkIgnoreChanges`는 `_dataSnapshot == null`을 true로 취급** — 신규(snapshot 없음)이거나 아직 로드 전이면 이탈·새로고침을 즉시 허용한다. snapshot이 있어도 `obj.equal`로 값이 동일하면 허용.
205
-
206
- ## 🚫 흔한 실수 (Anti-patterns)
207
-
208
- > 공통 규칙(`mark` 오용 전반, `setupCanDeactivate` 호출 위치, `_sdSharedData.wait()` 조건, 시트 셀 `[inset]`/`[size]`, soft-delete 선택 기준)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 이 섹션은 **편집/저장 확장 고유 실수**만 다룬다.
209
-
210
- ### `mark(data)` 호출 여부로 저장 여부를 판정한다
211
-
212
- ```typescript
213
- // ❌ mark 호출 여부로 저장 가능 여부를 분기 — 의미 없는 전제
214
- protected async onSubmit(): Promise<void> {
215
- if (!this._lastMarkCalled) return; // "mark가 호출되지 않았으니 변경이 없다"는 잘못된 판정
216
- // ...
217
- }
218
-
219
- // ✅ snapshot과 현재값을 obj.equal로 비교하여 변경 판단
220
- protected async onSubmit(): Promise<void> {
221
- if (!this.isNew() && this._dataSnapshot != null
222
- && obj.equal(this.data(), this._dataSnapshot)) {
223
- this._sdToast.info("변경사항이 없습니다.");
224
- return;
225
- }
226
- // ... 저장 로직
227
- }
228
- ```
229
-
230
- **근거**: `mark(sig)`는 `WritableSignal` 값을 shallow copy하여 OnPush 재렌더링·연계 computed 알림을 유발하는 **통지** 수단이다(`packages/angular/src/core/mark.ts`). 값이 실제로 바뀌었는지 "감지"하는 역할은 아니며, 값 비교는 `obj.equal` deep equal이 담당한다. → [공통 규칙: `mark(sig)`를 '저장 감지' 수단으로 사용하지 않는다](../_common-rules.md#marksig를-저장-감지-수단으로-사용하지-않는다)
@@ -1,142 +0,0 @@
1
- ← [CRUD 상세폼 레시피 진입점](../crud-detail.md)
2
-
3
- # 확장 B: 삭제/복구 토글
4
-
5
- > **선행:** [확장 A: 편집/저장](./extension-a-edit-save.md)
6
-
7
- 확장 A(편집/저장)를 전제로, 기존 레코드에 대한 **soft-delete 토글**(삭제/복구 버튼)을 추가한다. 도메인 타입에 `isDeleted: boolean` 필드를 추가하고, 삭제·복구 버튼은 최소 뼈대의 topbar에 `@if (!isNew() && canEdit())` 조건으로 배치한다. 뷰별 UI(modal 하단 바 / control 상단 바의 삭제 버튼)는 [확장 C](./extension-c-modal-view.md)/[확장 D](./extension-d-control-view.md)에서 추가로 처리한다.
8
-
9
- > **적용 조건: DB Table에 `isDeleted` 컬럼이 있는 경우에만.** `isDeleted` 컬럼이 없는 테이블은 물리 삭제(row DELETE)로 처리하며, 이 확장을 사용하지 않는다. → [공통 규칙: 삭제 방식은 DB 스키마에 따라 결정한다](../_common-rules.md#삭제-방식은-db-스키마에-따라-결정한다)
10
-
11
- **이 확장이 도입하는 요소:**
12
-
13
- - **imports:** `tablerEraser`, `tablerRestore`
14
- - **타입 확장:** `ICustomer.isDeleted: boolean` 필드 추가 + `data` 초기값·`_refresh` 빈 객체에 `isDeleted: false` 추가
15
- - **메서드:** `onDeleteButtonClick`, `onRestoreButtonClick`, `_toggleDelete(del: boolean)`
16
- - **템플릿:** 최소 뼈대 topbar 내부(`canEdit()` 저장 버튼과 새로고침 버튼 사이)에 `@if (!isNew() && canEdit())` 블록으로 삭제·복구 버튼 추가
17
-
18
- > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A) 위에 번호 순서대로 삽입할 지점을 나타낸다. 그대로 컴파일되지 않는다.
19
-
20
- ```typescript
21
- // 1) imports 추가
22
- import {
23
- tablerAlertTriangle, tablerDeviceFloppy, tablerEraser, tablerRefresh, tablerRestore,
24
- } from "@ng-icons/tabler-icons";
25
-
26
- // 2) ICustomer 확장 — isDeleted 필드 추가
27
- interface ICustomer {
28
- id: number | undefined;
29
- name: string;
30
- phone: string;
31
- isDeleted: boolean; // ← 추가
32
- lastModifiedAt: DateTime | undefined;
33
- lastModifiedBy: string | undefined;
34
- }
35
-
36
- // 3) data 초기값·_refresh 빈 객체에 isDeleted: false 추가
37
- protected readonly data = signal<ICustomer>({
38
- id: undefined, name: "", phone: "",
39
- isDeleted: false, // ← 추가
40
- lastModifiedAt: undefined, lastModifiedBy: undefined,
41
- });
42
-
43
- // 4) template — topbar에 삭제·복구 버튼 추가 (저장 버튼 뒤, 새로고침 버튼 앞)
44
- template: `
45
- <sd-topbar>
46
- <h4>{{ viewTitle() }}</h4>
47
- @if (canEdit()) {
48
- <sd-button [theme]="'link-primary'" (click)="onSaveButtonClick()"> <!-- 저장 (확장 A) --> </sd-button>
49
- }
50
- @if (!isNew() && canEdit()) {
51
- @if (data().isDeleted) {
52
- <sd-button [theme]="'link-warning'" (click)="onRestoreButtonClick()">
53
- <ng-icon [svg]="tablerRestore" /> 복구
54
- </sd-button>
55
- } @else {
56
- <sd-button [theme]="'link-danger'" (click)="onDeleteButtonClick()">
57
- <ng-icon [svg]="tablerEraser" /> 삭제
58
- </sd-button>
59
- }
60
- }
61
- <sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()"> <!-- 새로고침 --> </sd-button>
62
- </sd-topbar>
63
- `
64
-
65
- // 5) 메서드 추가
66
- protected async onDeleteButtonClick(): Promise<void> {
67
- await this._toggleDelete(true);
68
- }
69
-
70
- protected async onRestoreButtonClick(): Promise<void> {
71
- await this._toggleDelete(false);
72
- }
73
-
74
- private async _toggleDelete(del: boolean): Promise<void> {
75
- if (this.busyCount() > 0) return;
76
- if (!this.perms().includes("edit")) return;
77
- if (del && !confirm("삭제하시겠습니까?")) return;
78
-
79
- this.busyCount.update((v) => v + 1);
80
- await this._sdToast.try(async () => {
81
- // 앱별 ORM delete/restore — 예:
82
- // await this._appOrm.connectAsync(async (db) => {
83
- // await db.customer.where(...).update({ isDeleted: del });
84
- // });
85
-
86
- this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
87
- await this._refresh();
88
- });
89
- this.busyCount.update((v) => v - 1);
90
- }
91
-
92
- // 6) 아이콘 추가
93
- protected readonly tablerEraser = tablerEraser;
94
- protected readonly tablerRestore = tablerRestore;
95
- ```
96
-
97
- **포인트:**
98
-
99
- - **DB Table에 `isDeleted` 컬럼이 있는 경우의 삭제는 `isDeleted: true` 플래그 업데이트 soft-delete**로 구현한다 — 복구 기능·감사 이력·참조 무결성을 유지하기 위함. 서버는 `isDeleted: true` 레코드를 조회에서 기본 제외하고, 상세 폼에서는 복구 버튼으로 토글할 수 있다. `isDeleted` 컬럼이 없는 테이블은 이 확장을 사용하지 않고 물리 삭제(row DELETE)로 처리한다.
100
- - **삭제 confirm은 `_toggleDelete`에서 `del === true`일 때만** 호출한다. 복구는 확인 없이 즉시 수행.
101
- - **`isNew()` 신규 상태에서는 삭제·복구 버튼 노출 안 함** — DB에 저장되지 않은 레코드는 삭제 대상이 없으므로 `@if (!isNew() && canEdit())`로 감싼다.
102
- - **모달/컨트롤 뷰의 삭제 버튼은 [확장 C](./extension-c-modal-view.md)/[확장 D](./extension-d-control-view.md)에서 별도 배치** — topbar가 없는 뷰에서는 이 확장만으로는 삭제 UI가 보이지 않는다. [확장 C](./extension-c-modal-view.md)(modal 하단 바) / [확장 D](./extension-d-control-view.md)(control 상단 바)에서 동일 `_toggleDelete` 메서드를 재사용하여 배치한다.
103
-
104
- ## 🚫 흔한 실수 (Anti-patterns)
105
-
106
- > 공통 규칙(삭제 방식 선택 기준, `mark` 오용, `setupCanDeactivate` 호출 위치 등)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 이 섹션은 **삭제/복구 확장 고유 실수**만 다룬다.
107
-
108
- ### 물리 삭제 경로와 soft-delete 토글을 한 화면에 혼용한다
109
-
110
- ```typescript
111
- // ❌ 같은 화면에서 두 경로를 병행 — 삭제 의미가 불명확해지고,
112
- // 서버 조회 필터(isDeleted: false)와 물리 DELETE가 충돌한다.
113
- protected async onHardDeleteButtonClick(): Promise<void> {
114
- if (!confirm("영구 삭제하시겠습니까?")) return;
115
- await this._appOrm.connectAsync(async (db) => {
116
- await db.customer.where((it) => [expr.eq(it.id, this.data().id)]).delete();
117
- });
118
- // ...
119
- }
120
- private async _toggleDelete(del: boolean): Promise<void> {
121
- // ... isDeleted: del 업데이트 (soft-delete)
122
- }
123
-
124
- // ✅ isDeleted 컬럼 유무로 단일 경로를 선택한다.
125
- // 컬럼이 있으면 `_toggleDelete`(soft-delete)만,
126
- // 컬럼이 없으면 이 확장 자체를 사용하지 않고 물리 삭제 단일 경로로 간다.
127
- private async _toggleDelete(del: boolean): Promise<void> {
128
- if (this.busyCount() > 0) return;
129
- if (!this.perms().includes("edit")) return;
130
- if (del && !confirm("삭제하시겠습니까?")) return;
131
-
132
- this.busyCount.update((v) => v + 1);
133
- await this._sdToast.try(async () => {
134
- // await db.customer.where(...).update({ isDeleted: del });
135
- this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
136
- await this._refresh();
137
- });
138
- this.busyCount.update((v) => v - 1);
139
- }
140
- ```
141
-
142
- **근거**: 동일 테이블에 soft-delete와 물리 DELETE 경로가 공존하면 삭제 이력·복구 가능 여부·참조 무결성이 불일치하게 된다. 서버 리스트 조회가 `isDeleted: false` 필터로 삭제 행을 숨기는 전제에서 물리 DELETE가 섞이면, "복구 불가 삭제"와 "복구 가능 삭제"가 같은 화면에서 구분 없이 발동된다. → [공통 규칙: 삭제 방식은 DB 스키마에 따라 결정한다](../_common-rules.md#삭제-방식은-db-스키마에-따라-결정한다)
@@ -1,214 +0,0 @@
1
- ← [CRUD 상세폼 레시피 진입점](../crud-detail.md)
2
-
3
- # 확장 C: modal 뷰
4
-
5
- > **선행:** [확장 A: 편집/저장](./extension-a-edit-save.md) + [확장 B: 삭제/복구 토글](./extension-b-delete-restore.md)
6
-
7
- 확장 A(편집/저장) + 확장 B(삭제/복구)를 전제로, 동일 컴포넌트를 **modal 뷰**로도 재사용한다. `SdModalProvider.showAsync`로 띄우면 `viewType() === "modal"`로 자동 판정되며, 기존 `<sd-topbar>`를 `@if (viewType() === "page") { ... }`로 래핑하여 page 뷰 전용으로 돌리고, 모달에는 하단 액션 바(확인·취소·삭제·복구)와 우측 상단 새로고침 아이콘을 추가한다. 모달은 `implements SdModalContentDef<boolean | undefined>`로 계약을 부착하여 호출 측이 저장/닫기 결과를 받을 수 있다.
8
-
9
- **이 확장이 도입하는 요소:**
10
-
11
- - **imports:** `output`, `TemplateRef`, `injectViewTypeSignal`, `type SdModalContentDef`, `SdActivatedModalProvider`, `SdAnchor`, `SdDockContainer`, `SdDock`
12
- - **DI:** `_sdActivatedModal = inject(SdActivatedModalProvider, { optional: true })`
13
- - **계약:** `implements SdModalContentDef<boolean | undefined>`, `close = output<boolean | undefined>()`, `actionTplRef?: TemplateRef<any>` (필드 선언만 — `SdModal`이 setter 프록시로 감쌈)
14
- - **viewChild:** `_modalActionTpl = viewChild("modalActionTpl", { read: TemplateRef })`
15
- - **파생:** `viewType = injectViewTypeSignal()` (`viewTitle`은 이미 `injectViewTitleSignal()`로 modal/page 자동 분기됨)
16
- - **생성자 effect:** `effect(() => { this.actionTplRef = this._modalActionTpl(); })` (모달 헤더 우측 액션 슬롯 브릿지)
17
- - **setupCanDeactivate 조건 변경:** `() => this.viewType() === "modal" || this._checkIgnoreChanges()` — modal에서는 항상 true(이탈 허용)
18
- - **onSubmit / `_toggleDelete` 변경:** 성공 경로에 `this.close.emit(true)` 추가
19
- - **템플릿 교체 1:** 기존 `<sd-topbar>...</sd-topbar>`를 `@if (viewType() === "page") { <sd-topbar>...</sd-topbar> }`로 **래핑** (page 뷰 전용으로 돌림)
20
- - **템플릿:** topbar 내부 `<h4>{{ viewTitle() }}</h4>` 및 권한 경고의 viewTitle은 그대로 유지 (`injectViewTitleSignal()`이 modal/page 자동 분기)
21
- - **템플릿 추가:** `<sd-topbar-container>` 내부 main 래퍼(`<div class="flex-column fill">`)를 `<sd-dock-container>`로 감싸고, 그 내부에 `@if (viewType() === "modal" && canEdit())` 블록으로 `<sd-dock [position]="'bottom'">` 하단 액션 바(확인·취소·삭제·복구) 추가
22
- - **템플릿 추가:** `<sd-busy-container>` 바깥에 `<ng-template #modalActionTpl>`로 모달 우측 상단 새로고침 아이콘 정의
23
-
24
- > 상세: [`SdModalContentDef` 구현 패턴](../../provider-types/sd-modal-content-def.md#구현-패턴)
25
-
26
- > 상세: [`SdActivatedModalProvider` 사용법](../../providers/sd-activated-modal-provider.md#usage)
27
-
28
- > 상세: [`injectViewTypeSignal`](../../utils/inject-routing-signals.md#injectviewtypesignal)
29
-
30
- > 상세: [`<sd-dock> position="bottom"`](../../ui-layout/sd-dock.md)
31
-
32
- > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A+B) 위에 번호 순서대로 삽입·교체할 지점을 나타낸다. 그대로 컴파일되지 않는다.
33
-
34
- ```typescript
35
- // 1) imports 추가
36
- import {
37
- ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal,
38
- TemplateRef, untracked, viewChild, ViewEncapsulation,
39
- } from "@angular/core";
40
- import {
41
- FormatPipe, injectPermsSignal,
42
- injectViewTitleSignal, injectViewTypeSignal,
43
- SdActivatedModalProvider, SdAnchor,
44
- SdBusyContainer, SdButton, SdCommandDirective,
45
- SdDock, SdDockContainer,
46
- SdForm, type SdModalContentDef,
47
- SdTextfield, SdToastProvider,
48
- SdTopbar, SdTopbarContainer, setupCanDeactivate,
49
- } from "@simplysm/angular";
50
-
51
- // 2) @Component imports 배열에 SdAnchor / SdDockContainer / SdDock 추가
52
-
53
- // 3) DI 추가
54
- private readonly _sdActivatedModal = inject(SdActivatedModalProvider, { optional: true });
55
-
56
- // 4) 계약 부착 + viewChild + 파생 추가
57
- export class CustomerDetail implements SdModalContentDef<boolean | undefined> {
58
- //== SdModalContentDef 요구 필드 ==
59
- close = output<boolean | undefined>();
60
- // actionTplRef는 SdModal이 setter 프록시로 감싸므로 필드 선언만으로 충분
61
- actionTplRef?: TemplateRef<any>;
62
-
63
- //== viewChild (modal 우측 상단 액션 브릿지) ==
64
- private readonly _modalActionTpl = viewChild("modalActionTpl", { read: TemplateRef });
65
-
66
- //== 파생 ==
67
- protected readonly viewType = injectViewTypeSignal();
68
- // viewTitle은 최소 뼈대에서 이미 injectViewTitleSignal()로 선언됨 — modal/page 자동 분기
69
-
70
- // ... (이하 기존)
71
- }
72
-
73
- // 5) 생성자 — setupCanDeactivate 조건 변경 + actionTplRef effect 추가
74
- constructor() {
75
- // 기존 초기 effect 동일...
76
-
77
- // 변경: setupCanDeactivate 조건에 viewType() === "modal" || 추가
78
- setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
79
-
80
- // 추가: 모달 헤더 우측 액션 슬롯 브릿지 (control/page 뷰에선 _modalActionTpl이 렌더되지 않아 undefined)
81
- effect(() => {
82
- this.actionTplRef = this._modalActionTpl();
83
- });
84
- }
85
-
86
- // 6) onSubmit / _toggleDelete 성공 경로에 close.emit(true) 추가
87
- protected async onSubmit(): Promise<void> {
88
- // ...
89
- await this._sdToast.try(async () => {
90
- // ... (ORM upsert)
91
- this._sdToast.success("저장되었습니다.");
92
- this.close.emit(true); // ← 추가 — modal 호출 측이 결과를 받는다
93
- await this._refresh();
94
- });
95
- // ...
96
- }
97
-
98
- private async _toggleDelete(del: boolean): Promise<void> {
99
- // ...
100
- await this._sdToast.try(async () => {
101
- // ... (ORM delete/restore)
102
- this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
103
- this.close.emit(true); // ← 추가
104
- });
105
- // ...
106
- }
107
-
108
- // 7) template — 기존 <sd-topbar>를 @if (viewType() === "page")로 래핑,
109
- // main 래퍼를 <sd-dock-container>로 감싸고
110
- // modal 하단 바 + <ng-template #modalActionTpl> 추가
111
- template: `
112
- <sd-busy-container [busy]="busyCount() > 0">
113
- @if (initialized()) {
114
- @if (!perms().includes("use")) {
115
- <!-- '{{ viewTitle() }}'에 대한 사용권한이 없습니다. -->
116
- } @else {
117
- <sd-topbar-container>
118
- @if (viewType() === "page") {
119
- <sd-topbar>
120
- <h4>{{ viewTitle() }}</h4>
121
- <!-- 저장/삭제/복구/새로고침 (확장 A/B 동일) -->
122
- </sd-topbar>
123
- }
124
-
125
- <sd-dock-container>
126
- <!-- modal 하단 바: 확인/취소/삭제/복구 -->
127
- @if (viewType() === "modal" && canEdit()) {
128
- <sd-dock
129
- [position]="'bottom'"
130
- class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest"
131
- >
132
- @if (!isNew() && canEdit()) {
133
- @if (data().isDeleted) {
134
- <sd-button [size]="'sm'" [theme]="'warning'" (click)="onRestoreButtonClick()">
135
- 복구
136
- </sd-button>
137
- } @else {
138
- <sd-button [size]="'sm'" [theme]="'danger'" (click)="onDeleteButtonClick()">
139
- 삭제
140
- </sd-button>
141
- }
142
- }
143
- <div class="flex-fill flex-row gap-sm main-align-end">
144
- <sd-button [size]="'sm'" [theme]="'gray'" (click)="close.emit(undefined)">
145
- 취소
146
- </sd-button>
147
- <sd-button [size]="'sm'" [theme]="'primary'" (click)="onSaveButtonClick()">
148
- 확인
149
- </sd-button>
150
- </div>
151
- </sd-dock>
152
- }
153
-
154
- <!-- main: form + 최종수정 (확장 A/B 동일) -->
155
- <div class="flex-column fill"> ... </div>
156
- </sd-dock-container>
157
- </sd-topbar-container>
158
- }
159
- }
160
- </sd-busy-container>
161
-
162
- <!-- 모달 뷰에서만 사용되는 우측 상단 액션 템플릿.
163
- SdModalProvider가 contentComponent.actionTplRef를 setter 프록시로 감싸 SdModal로 브릿지한다.
164
- (packages/angular/src/core/modal/sd-modal.provider.ts:140-150) -->
165
- <ng-template #modalActionTpl>
166
- <sd-anchor
167
- [theme]="'gray'"
168
- class="p-sm-default"
169
- (click)="onRefreshButtonClick()"
170
- title="새로고침(CTRL+ALT+L)"
171
- >
172
- <ng-icon [svg]="tablerRefresh" />
173
- </sd-anchor>
174
- </ng-template>
175
- `
176
- ```
177
-
178
- **포인트:**
179
-
180
- - **modal 하단 바는 `[position]="'bottom'"` 반드시 명시**(`packages/angular/src/layout/dock/sd-dock.ts:97`). 기본값이 `"top"`이라 누락하면 상단에 쌓여 topbar 뒤에 겹친다.
181
- - **`actionTplRef` setter 프록시는 modal 뷰에서만 동작한다.** `SdModalProvider`는 모달 컨텐츠 컴포넌트 생성 시 `if ("actionTplRef" in contentRef.instance)`로 확인한 뒤 setter 프록시를 설치한다(`sd-modal.provider.ts:141`). page/control 뷰에서는 프록시가 설치되지 않으므로 `this.actionTplRef = ...` 할당이 인스턴스 필드에만 저장되고 부작용이 없다. `<ng-template #modalActionTpl>` 선언 자체는 뷰 타입과 무관하게 TemplateRef를 반환하지만, 소비할 `SdModal`이 없으므로 결과적으로 아무 일도 일어나지 않는다.
182
- - **`setupCanDeactivate`는 modal에서 true를 돌려 항상 이탈 허용**한다 — modal 자체에 취소 버튼이 있으므로 이중 confirm을 피한다. 페이지 뷰에서는 `_checkIgnoreChanges()`의 confirm 결과로 제어.
183
- - **modal 취소 버튼은 `close.emit(undefined)`**. 호출 측은 `undefined`를 "취소"로, `true`를 "저장/삭제 성공"으로 해석한다. boolean 대신 사용자 정의 결과 타입이 필요하면 `close = output<FooResult | undefined>()` + `implements SdModalContentDef<FooResult | undefined>`로 변경하고, 호출 측은 `const result = await sdModal.showAsync({ type: CustomerDetail, ... })`로 받는다.
184
- - **`viewTitle` 우선순위**: 모달 컴포넌트의 `title()` input이 세팅되어 있으면 그것을 우선, 없으면 라우트 기반 타이틀을 사용. `_sdSystemLog.writeAsync("warn", ...)`로 실패 시 로그 남김.
185
- - **[확장 D](./extension-d-control-view.md)(control 뷰)와 병행 가능** — 두 분기 블록(`@if (viewType() === "modal")` / `@if (viewType() === "control")`)이 상호 배타이므로 같은 `<sd-dock-container>` 내부에 나란히 둘 수 있다.
186
-
187
- ## 🚫 흔한 실수 (Anti-patterns)
188
-
189
- > 공통 규칙(`mark` 오용, `setupCanDeactivate` / `injectViewTypeSignal()` 호출 위치, page 컴포넌트의 topbar 소유 등)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 이 섹션은 **modal 뷰 확장 고유 실수**만 다룬다.
190
-
191
- ### modal 하단 바의 `<sd-dock>`에 `[position]="'bottom'"`을 명시하지 않는다
192
-
193
- ```typescript
194
- // ❌ [position] 생략 — 기본값이 "top"이라 하단 바가 <sd-dock-container>의 상단부터
195
- // 쌓여 topbar 뒤에 겹치거나, topbar가 숨겨진 modal 뷰에서도 의도한
196
- // "모달 하단 고정"이 깨진다. 컴파일 에러·린트 경고 없이 silent failure.
197
- @if (viewType() === "modal" && canEdit()) {
198
- <sd-dock class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest">
199
- <!-- 확인/취소/삭제/복구 버튼 ... -->
200
- </sd-dock>
201
- }
202
-
203
- // ✅ [position]="'bottom'"을 명시하여 <sd-dock-container> 하단에 고정
204
- @if (viewType() === "modal" && canEdit()) {
205
- <sd-dock
206
- [position]="'bottom'"
207
- class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest"
208
- >
209
- <!-- 확인/취소/삭제/복구 버튼 ... -->
210
- </sd-dock>
211
- }
212
- ```
213
-
214
- **근거**: `SdDock.position`의 기본값이 `"top"`이다(`packages/angular/src/layout/dock/sd-dock.ts:97`). 하단 액션 바 용도로 `<sd-dock>`을 도입할 때 `[position]`을 생략하면 `<sd-dock-container>`가 상단부터 dock을 쌓기 때문에 topbar 영역 뒤에 깔리고, topbar가 없는 modal 뷰에서도 "모달 하단 고정"이 깨진다. 타입 시스템은 기본값으로 통과시키므로 컴파일러·린터가 경고하지 않는다.