@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.
Files changed (144) hide show
  1. package/README.md +234 -226
  2. package/dist/controls/select/sd-select.js +3 -3
  3. package/dist/index.d.ts +0 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +0 -2
  6. package/dist/layout/dock/sd-dock-container.js +1 -1
  7. package/dist/styles.css +9 -0
  8. package/docs/bootstrap/provide-sd-angular.md +37 -0
  9. package/docs/bootstrap/sd-angular-config-provider.md +16 -0
  10. package/docs/directives/sd-command-directive.md +30 -0
  11. package/docs/directives/sd-events.md +25 -0
  12. package/docs/directives/sd-intersection-directive.md +36 -0
  13. package/docs/directives/sd-invalid.md +24 -0
  14. package/docs/directives/sd-resize-directive.md +42 -0
  15. package/docs/directives/sd-ripple.md +23 -0
  16. package/docs/directives/sd-router-link.md +38 -0
  17. package/docs/directives/sd-show-effect.md +18 -0
  18. package/docs/directives/sd-typed-template.md +69 -0
  19. package/docs/features/sd-address-search-modal.md +50 -0
  20. package/docs/features/sd-permission-table.md +20 -0
  21. package/docs/features/sd-shared-data-components.md +158 -0
  22. package/docs/features/sd-tiptap-editor.md +26 -0
  23. package/docs/{pipes.md → pipes/format-pipe.md} +14 -5
  24. package/docs/plugins/sd-global-error-handler.md +23 -0
  25. package/docs/{plugins.md → plugins/sd-option-event-plugin.md} +9 -12
  26. package/docs/provider-types/sd-menu.md +65 -0
  27. package/docs/provider-types/sd-modal-content-def.md +148 -0
  28. package/docs/provider-types/sd-toast-content-def.md +73 -0
  29. package/docs/provider-types/shared-data-base.md +59 -0
  30. package/docs/providers/sd-activated-modal-provider.md +34 -0
  31. package/docs/providers/sd-app-structure-provider.md +81 -0
  32. package/docs/providers/sd-busy-provider.md +18 -0
  33. package/docs/providers/sd-file-dialog-provider.md +40 -0
  34. package/docs/providers/sd-local-storage-provider.md +20 -0
  35. package/docs/providers/sd-modal-provider.md +67 -0
  36. package/docs/providers/sd-navigate-window-provider.md +18 -0
  37. package/docs/providers/sd-print-provider.md +25 -0
  38. package/docs/providers/sd-service-client-factory-provider.md +43 -0
  39. package/docs/providers/sd-shared-data-provider.md +64 -0
  40. package/docs/providers/sd-system-config-provider.md +46 -0
  41. package/docs/providers/sd-system-log-provider.md +18 -0
  42. package/docs/providers/sd-theme-provider.md +38 -0
  43. package/docs/providers/sd-toast-provider.md +65 -0
  44. package/docs/recipes/_common-rules.md +244 -0
  45. package/docs/recipes/crud-detail/extension-a-edit-save.md +230 -0
  46. package/docs/recipes/crud-detail/extension-b-delete-restore.md +142 -0
  47. package/docs/recipes/crud-detail/extension-c-modal-view.md +214 -0
  48. package/docs/recipes/crud-detail/extension-d-control-view.md +103 -0
  49. package/docs/recipes/crud-detail/extension-e-auxiliary.md +87 -0
  50. package/docs/recipes/crud-detail/extension-f-complex-detail.md +234 -0
  51. package/docs/recipes/crud-detail.md +215 -722
  52. package/docs/recipes/crud-list/extension-a-inline-edit.md +410 -0
  53. package/docs/recipes/crud-list/extension-b-selection.md +226 -0
  54. package/docs/recipes/crud-list/extension-c-inline-delete.md +87 -0
  55. package/docs/recipes/crud-list/extension-d-select-modal.md +207 -0
  56. package/docs/recipes/crud-list/extension-e-readonly-modal.md +165 -0
  57. package/docs/recipes/crud-list/extension-f-modal-edit.md +177 -0
  58. package/docs/recipes/crud-list/extension-g-excel.md +157 -0
  59. package/docs/recipes/crud-list.md +293 -626
  60. package/docs/recipes/data-select-button.md +194 -101
  61. package/docs/recipes/page-modal-container.md +168 -86
  62. package/docs/styling/classes.md +149 -0
  63. package/docs/styling/mixins.md +100 -0
  64. package/docs/styling/themes.md +35 -0
  65. package/docs/styling/variables.md +147 -0
  66. package/docs/{type-utilities.md → type-utilities/directive-input-signals.md} +17 -35
  67. package/docs/ui-data/sd-list.md +37 -0
  68. package/docs/ui-data/sd-sheet.md +227 -0
  69. package/docs/ui-form/sd-additional-button.md +26 -0
  70. package/docs/ui-form/sd-anchor.md +31 -0
  71. package/docs/ui-form/sd-button.md +105 -0
  72. package/docs/ui-form/sd-checkbox-group.md +39 -0
  73. package/docs/ui-form/sd-checkbox.md +81 -0
  74. package/docs/ui-form/sd-date-range-picker.md +27 -0
  75. package/docs/ui-form/sd-form.md +89 -0
  76. package/docs/ui-form/sd-modal-select-button.md +54 -0
  77. package/docs/ui-form/sd-numpad.md +26 -0
  78. package/docs/ui-form/sd-range.md +26 -0
  79. package/docs/ui-form/sd-select.md +68 -0
  80. package/docs/ui-form/sd-shared-data-select.md +52 -0
  81. package/docs/ui-form/sd-state-preset.md +37 -0
  82. package/docs/ui-form/sd-switch.md +27 -0
  83. package/docs/ui-form/sd-textarea.md +33 -0
  84. package/docs/ui-form/sd-textfield.md +145 -0
  85. package/docs/ui-layout/sd-dock-container.md +64 -0
  86. package/docs/ui-layout/sd-dock.md +37 -0
  87. package/docs/ui-layout/sd-gap.md +26 -0
  88. package/docs/{ui-layout.md → ui-layout/sd-kanban-board.md} +41 -85
  89. package/docs/ui-layout/sd-kanban-lane.md +34 -0
  90. package/docs/ui-layout/sd-kanban.md +29 -0
  91. package/docs/ui-navigation/sd-collapse.md +35 -0
  92. package/docs/ui-navigation/sd-pagination.md +26 -0
  93. package/docs/ui-navigation/sd-sidebar-container.md +49 -0
  94. package/docs/ui-navigation/sd-sidebar-menu.md +22 -0
  95. package/docs/ui-navigation/sd-sidebar-user.md +43 -0
  96. package/docs/ui-navigation/sd-tab.md +51 -0
  97. package/docs/ui-navigation/sd-topbar-container.md +97 -0
  98. package/docs/ui-navigation/sd-topbar-menu.md +23 -0
  99. package/docs/ui-navigation/sd-topbar-user.md +38 -0
  100. package/docs/ui-navigation/sd-topbar.md +30 -0
  101. package/docs/ui-overlay/sd-busy-container.md +69 -0
  102. package/docs/ui-overlay/sd-confirm-modal.md +30 -0
  103. package/docs/ui-overlay/sd-dropdown.md +40 -0
  104. package/docs/ui-overlay/sd-modal.md +34 -0
  105. package/docs/ui-overlay/sd-prompt-modal.md +30 -0
  106. package/docs/ui-overlay/sd-toast.md +35 -0
  107. package/docs/ui-visual/sd-barcode.md +36 -0
  108. package/docs/ui-visual/sd-calendar.md +34 -0
  109. package/docs/ui-visual/sd-echarts.md +32 -0
  110. package/docs/ui-visual/sd-label.md +24 -0
  111. package/docs/ui-visual/sd-note.md +23 -0
  112. package/docs/ui-visual/sd-progress.md +23 -0
  113. package/docs/utils/inject-routing-signals.md +161 -0
  114. package/docs/utils/inject-sd-system-config-resource.md +35 -0
  115. package/docs/utils/mark.md +43 -0
  116. package/docs/utils/selection-managers.md +96 -0
  117. package/docs/utils/set-safe-style.md +19 -0
  118. package/docs/utils/setup-functions.md +93 -0
  119. package/package.json +7 -7
  120. package/scss/commons/_styles.scss +12 -0
  121. package/src/controls/select/sd-select.ts +3 -3
  122. package/src/core/modal/sd-modal.provider.ts +1 -1
  123. package/src/core/modal/sd-modal.ts +1 -1
  124. package/src/core/routing/menu-utils.ts +1 -1
  125. package/src/core/shared-data/sd-shared-data.provider.ts +7 -7
  126. package/src/data/shared-data/sd-shared-data-select.ts +2 -2
  127. package/src/index.ts +0 -3
  128. package/src/layout/dock/sd-dock-container.ts +1 -1
  129. package/dist/data/getOrmDataEditToastErrorMessage.d.ts +0 -2
  130. package/dist/data/getOrmDataEditToastErrorMessage.d.ts.map +0 -1
  131. package/dist/data/getOrmDataEditToastErrorMessage.js +0 -8
  132. package/docs/bootstrap.md +0 -38
  133. package/docs/directives.md +0 -236
  134. package/docs/features.md +0 -169
  135. package/docs/provider-types.md +0 -283
  136. package/docs/providers.md +0 -379
  137. package/docs/styling.md +0 -222
  138. package/docs/ui-data.md +0 -333
  139. package/docs/ui-form.md +0 -502
  140. package/docs/ui-navigation.md +0 -273
  141. package/docs/ui-overlay.md +0 -157
  142. package/docs/ui-visual.md +0 -127
  143. package/docs/utils.md +0 -244
  144. package/src/data/getOrmDataEditToastErrorMessage.ts +0 -10
@@ -0,0 +1,103 @@
1
+ ← [CRUD 상세폼 레시피 진입점](../crud-detail.md)
2
+
3
+ # 확장 D: control 뷰
4
+
5
+ > **선행:** [확장 A: 편집/저장](./extension-a-edit-save.md) + [확장 B: 삭제/복구 토글](./extension-b-delete-restore.md)
6
+
7
+ 확장 A(편집/저장) + 확장 B(삭제/복구)를 전제로, 동일 컴포넌트를 **control 뷰**(마스터-디테일의 디테일 영역)로도 재사용한다. 마스터 화면이 `<app-customer-detail [itemId]="selectedId()" class="flex-fill">`처럼 컴포넌트 selector를 직접 삽입하면 `viewType() === "control"`로 자동 판정되어, 상단 바에 저장·새로고침·삭제·복구 버튼이 가로로 배치된다. page 뷰의 topbar가 없고 modal 뷰의 하단 바가 없는 대신, main 영역 위에 `<sd-dock>` 상단 바가 놓인다.
8
+
9
+ **이 확장이 도입하는 요소:**
10
+
11
+ - **imports:** [확장 C](./extension-c-modal-view.md)에서 이미 도입된 `injectViewTypeSignal`, `SdDockContainer`, `SdDock`, `tablerDeviceFloppy`, `tablerRefresh`, `tablerEraser`, `tablerRestore`를 재사용. 확장 D를 확장 C 없이 단독 적용하는 경우 동일 imports를 신규 도입
12
+ - **파생:** `viewType = injectViewTypeSignal()` (확장 C 없이 단독 적용 시 신규 도입)
13
+ - **템플릿 추가:** [확장 C](./extension-c-modal-view.md)가 도입한 `<sd-dock-container>` 내부에 `@if (viewType() === "control" && canEdit())` 블록으로 `<sd-dock>` 상단 바(저장·새로고침·삭제·복구) 추가 — 확장 C의 modal 하단 바 블록과 나란히 배치
14
+
15
+ > 상세: [`injectViewTypeSignal`](../../utils/inject-routing-signals.md#injectviewtypesignal)
16
+
17
+ > 상세: [`<sd-dock> position 기본 "top"`](../../ui-layout/sd-dock.md)
18
+
19
+ > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A+B) 위에 번호 순서대로 삽입할 지점을 나타낸다. 그대로 컴파일되지 않는다.
20
+
21
+ ```typescript
22
+ // 1) imports 추가 (확장 D를 확장 C 없이 단독 적용하는 경우)
23
+ import { injectViewTypeSignal, SdDock, SdDockContainer } from "@simplysm/angular";
24
+
25
+ // 2) 파생 추가 (확장 C 없이 단독 적용 시)
26
+ protected readonly viewType = injectViewTypeSignal();
27
+
28
+ // 3) template — <sd-dock-container> 내부(확장 C 블록과 나란히)에 control 뷰 상단 바 추가
29
+ template: `
30
+ <sd-dock-container>
31
+ <!-- control 뷰 상단 바: 저장·새로고침·삭제·복구 -->
32
+ @if (viewType() === "control" && canEdit()) {
33
+ <sd-dock class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">
34
+ <sd-button [theme]="'primary'" (click)="onSaveButtonClick()">
35
+ <ng-icon [svg]="tablerDeviceFloppy" /> 저장 <small>(CTRL+S)</small>
36
+ </sd-button>
37
+ <sd-button [theme]="'info'" (click)="onRefreshButtonClick()">
38
+ <ng-icon [svg]="tablerRefresh" /> 새로고침 <small>(CTRL+ALT+L)</small>
39
+ </sd-button>
40
+ @if (!isNew() && canEdit()) {
41
+ @if (data().isDeleted) {
42
+ <sd-button [theme]="'warning'" (click)="onRestoreButtonClick()">
43
+ <ng-icon [svg]="tablerRestore" /> 복구
44
+ </sd-button>
45
+ } @else {
46
+ <sd-button [theme]="'danger'" (click)="onDeleteButtonClick()">
47
+ <ng-icon [svg]="tablerEraser" /> 삭제
48
+ </sd-button>
49
+ }
50
+ }
51
+ </sd-dock>
52
+ }
53
+
54
+ @if (viewType() === "modal" && canEdit()) {
55
+ <!-- modal 하단 바 (확장 C) -->
56
+ }
57
+
58
+ <!-- main: form + 최종수정 (확장 A/B 동일) -->
59
+ </sd-dock-container>
60
+ `
61
+ ```
62
+
63
+ **포인트:**
64
+
65
+ - **control 뷰 = 마스터-디테일의 디테일 영역**. 마스터 화면이 `<app-customer-detail [itemId]="selectedId()" class="flex-fill">`처럼 직접 삽입하여 좌측 리스트 선택에 따라 우측에 상세 폼을 표시한다. `injectViewTypeSignal()`은 `ActivatedRoute.component`의 selector와 호스트 `tagName`이 **다를 때** control로 판정한다(page는 일치, modal은 `SdActivatedModalProvider` 주입 시 우선).
66
+ - **상단 바는 `[position]` 생략** — `<sd-dock>`의 `[position]` 기본값이 `"top"`이므로 명시하지 않는다. modal 하단 바와 달리 기본 동작을 그대로 쓴다.
67
+ - **[확장 C](./extension-c-modal-view.md)(modal 뷰)와 병행 가능** — 두 분기 블록(`@if (viewType() === "control")` / `@if (viewType() === "modal")`)이 상호 배타이므로 같은 `<sd-dock-container>` 내부에 나란히 둬도 안전하다. [변형 확장 A~F 인덱스](../crud-detail.md#변형-확장-a-f-인덱스)가 이 조합을 수용한다.
68
+ - **control 뷰에서는 `setupCanDeactivate`가 아무 동작 하지 않는다** — 라우트 guard도 모달 canDeactivateFn도 연결되지 않는다(`packages/angular/src/core/routing/setupCanDeactivate.ts:10-26`). 마스터 화면이 이동할 때의 이탈 확인은 마스터 화면이 자체적으로 처리한다.
69
+
70
+ ## 🚫 흔한 실수 (Anti-patterns)
71
+
72
+ > 공통 규칙(topbar 소유권, `mark` 오용, `setupCanDeactivate` 호출 위치 등)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 이 섹션은 **control 뷰 확장 고유 실수**만 다룬다.
73
+
74
+ ### control 뷰 분기에 자체 `<sd-topbar>`를 추가한다
75
+
76
+ ```typescript
77
+ // ❌ control 분기용으로 별도 <sd-topbar> 추가 — 마스터 화면이 이 컴포넌트를
78
+ // <app-customer-detail />로 포함할 때, 마스터 화면이 이미 소유한
79
+ // <sd-topbar-container> + <sd-topbar>와 중첩되어 레이아웃이 깨진다.
80
+ @if (viewType() === "control" && canEdit()) {
81
+ <sd-topbar>
82
+ <h4>{{ viewTitle() }}</h4>
83
+ <sd-button [theme]="'link-primary'" (click)="onSaveButtonClick()"> 저장 </sd-button>
84
+ <!-- ... -->
85
+ </sd-topbar>
86
+ }
87
+
88
+ // ✅ control 분기에는 <sd-dock> 상단 바만 둔다. page가 소유한 topbar와
89
+ // 중첩되지 않으며, 저장/새로고침/삭제/복구 버튼을 가로로 배치한다.
90
+ @if (viewType() === "control" && canEdit()) {
91
+ <sd-dock class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">
92
+ <sd-button [theme]="'primary'" (click)="onSaveButtonClick()">
93
+ <ng-icon [svg]="tablerDeviceFloppy" /> 저장 <small>(CTRL+S)</small>
94
+ </sd-button>
95
+ <sd-button [theme]="'info'" (click)="onRefreshButtonClick()">
96
+ <ng-icon [svg]="tablerRefresh" /> 새로고침 <small>(CTRL+ALT+L)</small>
97
+ </sd-button>
98
+ <!-- 삭제/복구는 @if (!isNew() && canEdit()) 블록으로 (확장 B 조건 재사용) -->
99
+ </sd-dock>
100
+ }
101
+ ```
102
+
103
+ **근거**: control 뷰는 마스터 화면이 `<app-customer-detail [itemId]="..." />`처럼 디테일 영역으로 직접 삽입하는 구조다. 마스터 화면은 이미 `<sd-topbar-container>`와 `<sd-topbar>`를 소유하므로, 내부 컴포넌트가 동일 구조를 중첩하면 타이틀·액션 영역이 이중으로 렌더링되어 레이아웃이 깨진다. control 분기의 상단 액션 영역은 `<sd-dock-container>` 내부 `<sd-dock>`으로 구성한다. → [공통 규칙: page 컴포넌트가 `<sd-topbar-container>`와 `<sd-topbar>`를 소유한다](../_common-rules.md#page-컴포넌트가-sd-topbar-container와-sd-topbar를-소유한다)
@@ -0,0 +1,87 @@
1
+ ← [CRUD 상세폼 레시피 진입점](../crud-detail.md)
2
+
3
+ # 확장 E: 보조 기능 영역
4
+
5
+ > **선행:** [확장 A: 편집/저장](./extension-a-edit-save.md)
6
+
7
+ 확장 A(편집/저장)를 전제로, 메인 폼의 submit과 **별개인 보조 기능**(예: 다른 사용자로부터 권한 복사, 출력, 엑셀 다운로드 등)을 추가한다. 과거 `#toolTpl` 슬롯이 담당하던 역할을 소비 화면에 직접 인라인한다. 보조 영역은 control 뷰 상단 `<sd-dock>` 내부 / modal 뷰 하단 바 옆 / main 영역 내 별도 `<sd-form>` 중 하나에 배치한다. 뷰별 UI 배치 본문은 [확장 C](./extension-c-modal-view.md) / [확장 D](./extension-d-control-view.md)에서 처리한다.
8
+
9
+ **이 확장이 도입하는 요소:**
10
+
11
+ - **imports:** `SdSharedDataSelect` (`@simplysm/angular`), 앱 공용 `useSharedSignal` (예: `@adtek/client-common` — `SdSharedDataProvider` 위에 각 앱이 정의하는 공용 훅)
12
+ - **상태:** 예시 — `permCopySourceId = signal<number | undefined>(undefined)`, `sharedUsers = useSharedSignal("사용자")`
13
+ - **메서드:** 예시 — `onImportFormSubmit` (권한·busy 가드 + 메인 폼 변경 보호 호출 + 앱별 ORM 조회·병합)
14
+ - **템플릿 추가:** 보조 `<sd-form (formSubmit)="onImportFormSubmit()">` 블록을 배치(아래 예시는 control 뷰 `<sd-dock>` 내부). 메인 `<sd-form #formCtrl>`과 **별도** 인스턴스
15
+ - **공유 데이터 대기:** 본 확장이 `useSharedSignal`을 도입하므로 `_refresh()` 선두에 `await this._sdSharedData.wait();`가 필요해진다 → [공통 규칙: `_sdSharedData.wait()`](../_common-rules.md#공유-데이터-사용-화면은-_refresh-선두에서-_sdshareddatawait를-호출한다)
16
+
17
+ > 상세: [`<sd-shared-data-select>`](../../ui-form/sd-shared-data-select.md)
18
+
19
+ > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A) 위에 번호 순서대로 삽입할 지점을 나타낸다. 그대로 컴파일되지 않는다.
20
+
21
+ ```typescript
22
+ // 1) imports 추가
23
+ import { SdSharedDataSelect } from "@simplysm/angular";
24
+ // 앱 공용 훅 — @simplysm/angular 소유 아님. 각 앱이 SdSharedDataProvider 위에 정의.
25
+ import { useSharedSignal } from "@adtek/client-common";
26
+
27
+ // 2) 클래스에 상태 추가
28
+ protected readonly permCopySourceId = signal<number | undefined>(undefined);
29
+ protected readonly sharedUsers = useSharedSignal("사용자");
30
+
31
+ // 3) template — 보조 form 블록을 배치한다. 아래는 control 뷰 <sd-dock>(상단 바) 내부 예시.
32
+ // modal 뷰는 하단 바(<sd-dock [position]="'bottom'">) 옆, main 영역은 메인 form 옆이나 아래에 배치할 수 있다.
33
+ @if (viewType() === "control" && canEdit()) {
34
+ <sd-dock class="p-default flex-row gap-default bdb bdb-theme-gray-lightest">
35
+ <!-- 기본 저장/새로고침/삭제 버튼 (확장 D) -->
36
+ <!-- ... -->
37
+
38
+ <!-- 보조 기능: 다른 사용자로부터 가져오기 -->
39
+ <sd-form (formSubmit)="onImportFormSubmit()">
40
+ <div class="form-box-inline">
41
+ <div class="form-box-item">
42
+ <label>가져오기</label>
43
+ <sd-shared-data-select
44
+ [items]="sharedUsers.items()"
45
+ [(value)]="permCopySourceId"
46
+ [inset]="true"
47
+ [size]="'sm'"
48
+ />
49
+ </div>
50
+ <div class="form-box-item">
51
+ <sd-button [type]="'submit'" [disabled]="permCopySourceId() == null">
52
+ 가져오기
53
+ </sd-button>
54
+ </div>
55
+ </div>
56
+ </sd-form>
57
+ </sd-dock>
58
+ }
59
+
60
+ // 4) 메서드 추가
61
+ protected async onImportFormSubmit(): Promise<void> {
62
+ if (this.busyCount() > 0 || !this.perms().includes("edit")) return;
63
+ if (this.permCopySourceId() == null) return;
64
+ // 메인 폼의 미저장 변경사항 보호 — 확장 A가 제공하는 _checkIgnoreChanges() 재호출
65
+ if (!this._checkIgnoreChanges()) return;
66
+
67
+ this.busyCount.update((v) => v + 1);
68
+ await this._sdToast.try(async () => {
69
+ // 앱별 서버 호출로 다른 사용자의 데이터를 조회·병합 — 예:
70
+ // const src = await this._appOrm.connectAsync(async (db) =>
71
+ // (await db.customer.where((it) => [expr.eq(it.id, this.permCopySourceId())]).single())!
72
+ // );
73
+ // this.data.set({ ...this.data(), ...src });
74
+ });
75
+ this.busyCount.update((v) => v - 1);
76
+ }
77
+ ```
78
+
79
+ **포인트:**
80
+
81
+ - **보조 `<sd-form>`은 메인 `<sd-form #formCtrl>`과 별도 인스턴스**다. `SdCommandDirective.sdSaveCommand` → `formCtrl()?.requestSubmit()` 경로는 메인 form 한 곳에만 연결된다(확장 A). 보조 form의 submit 버튼은 Ctrl+S와 연동되지 않고 버튼 클릭(또는 submit 버튼에서 Enter 제출)만 발동한다. 보조 form에는 `#formCtrl` template 변수를 부여하지 않는다.
82
+ - **보조 작업 전에도 `_checkIgnoreChanges()`를 호출**하여 메인 폼의 미저장 변경사항을 보호한다. 보조 form이 메인 `data()`를 덮어쓰는 경우(권한 복사 등) 필수. `_checkIgnoreChanges`는 확장 A가 제공하므로 재정의하지 않는다.
83
+ - **공유 데이터 도입으로 `_refresh()` 선두에 `await this._sdSharedData.wait();`가 필요해진다.** 판정 기준·코드 예시는 [공통 규칙: `_sdSharedData.wait()`](../_common-rules.md#공유-데이터-사용-화면은-_refresh-선두에서-_sdshareddatawait를-호출한다) 참조.
84
+ - **`<sd-shared-data-select>`에 `[inset]="true" [size]="'sm'"`을 명시**한다. `<sd-dock>` 도구 바 내부 치수와 `form-box-inline` 간격이 sm 기준으로 정합하므로 인셋·사이즈를 생략하면 컨트롤 높이가 들쭉날쭉해진다.
85
+ - **배치 위치 선택:** control 뷰는 상단 `<sd-dock>` 안, modal 뷰는 하단 바(`<sd-dock [position]="'bottom'">`) 옆, main 영역에서는 메인 form과 나란한 별도 블록. 뷰 분기 본문 UI는 [확장 C](./extension-c-modal-view.md) / [확장 D](./extension-d-control-view.md)에서 정의한다.
86
+ - **읽기 전용 보조(출력·엑셀 다운로드 등)는 `<sd-form>` 래핑 없이 버튼 `(click)`으로 직접 처리 가능**하다. 입력 값을 수집하지 않는 단일 액션은 form submit 절차가 불필요하다. 이 경우 `_checkIgnoreChanges()` 호출도 상황에 맞게 판단한다(읽기 전용이라 메인 변경 보호가 불필요할 수 있음).
87
+ - **`useSharedSignal`은 `@simplysm/angular` 제공이 아님** — `SdSharedDataProvider`(`packages/angular/src/core/shared-data/sd-shared-data.provider.ts`) 위에서 각 앱이 정의하는 공용 훅이다. 배포 패키지 소속이 다르므로 import 경로 혼동에 주의한다.
@@ -0,0 +1,234 @@
1
+ ← [CRUD 상세폼 레시피 진입점](../crud-detail.md)
2
+
3
+ # 확장 F: 복합 상세 (내부 `<sd-sheet>`)
4
+
5
+ > **선행:** [확장 A: 편집/저장](./extension-a-edit-save.md)
6
+
7
+ 확장 A(편집/저장)를 전제로, 상세 폼 안에 **하위 컬렉션**(박스 목록, 품목 라인 등)을 편집하는 구조를 도입한다. `<sd-form>` 본문 내부에 `<sd-sheet>`를 중첩하고, 하위 컬렉션의 행 추가·수정·삭제는 `item.isDeleted = true` 플래그로 표현하여 `oneWayDiffs` 기반 일괄 저장에 포함시킨다.
8
+
9
+ **이 확장이 도입하는 요소:**
10
+
11
+ - **imports:** `SdSheet`, `SdSheetColumn`, `SdSheetColumnCellTemplate`, `SdAnchor`(하위 행 삭제/복구 아이콘 호스트), `Uuid`, `oneWayDiffs`(side-effect import), 아이콘 `tablerCirclePlus` / `tablerEraser` / `tablerRestore` (`mark`는 확장 A에서 이미 도입)
12
+ - **데이터 타입 확장:** `ICustomer.boxes: ICustomerBox[]` + `interface ICustomerBox { id: string; seq: number; note: string; isDeleted: boolean; }`
13
+ - **data 초기값:** `boxes: []` 필드 추가
14
+ - **시트 함수 (클래스 필드):** `boxTrackByFn`, `getBoxCellStyleFn`
15
+ - **메서드:** `onAddBoxButtonClick`, `onToggleDeleteBoxButtonClick`
16
+ - **템플릿:** main 영역 `<sd-form>` 내부를 [상단 단일 필드 블록 + 하위 컬렉션 도구 dock(`<sd-button>` "박스 추가") + 하위 `<sd-sheet>` 중첩] 구조로 교체
17
+ - **onSubmit 변경:** `_sdToast.try(...)` 블록 내부를 diff 계산(`data().boxes.oneWayDiffs(_dataSnapshot?.boxes, "id")`) + 일괄 제출로 교체
18
+
19
+ > 상세: [`<sd-sheet>`](../../ui-data/sd-sheet.md) · [`<sd-sheet-column>`](../../ui-data/sd-sheet.md#sdsheetcolumn) · [`[cell]`](../../ui-data/sd-sheet.md#sdsheetcolumncelltemplate) · [`<sd-anchor>`](../../ui-form/sd-anchor.md)
20
+
21
+ > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A) 위에 번호 순서대로 삽입·교체할 지점을 나타낸다. 그대로 컴파일되지 않는다.
22
+
23
+ ```typescript
24
+ // 1) imports 추가 — @simplysm/angular에 {SdSheet, SdSheetColumn, SdSheetColumnCellTemplate, SdAnchor} 추가.
25
+ // @simplysm/core-common에서 {Uuid} 추가 + 프로토타입 확장 활성화용 side-effect import.
26
+ // 아이콘에 tablerCirclePlus / tablerEraser / tablerRestore 추가. (mark는 확장 A에서 이미 import됨)
27
+ import { SdSheet, SdSheetColumn, SdSheetColumnCellTemplate, SdAnchor } from "@simplysm/angular";
28
+ import { Uuid } from "@simplysm/core-common";
29
+ import { tablerCirclePlus, tablerEraser, tablerRestore } from "@ng-icons/tabler-icons";
30
+ import "@simplysm/core-common"; // Array.prototype.oneWayDiffs 활성화 (side-effect import)
31
+
32
+ // 2) 데이터 타입 확장 — 진입점 3.1 최소 뼈대의 ICustomer에 boxes 필드만 추가, ICustomerBox 신설
33
+ // (확장 B를 함께 적용하면 ICustomer.isDeleted 필드가 별도로 들어가며, 본 확장 단독 적용에는 포함되지 않는다)
34
+ interface ICustomer {
35
+ id: number | undefined;
36
+ name: string;
37
+ phone: string;
38
+ lastModifiedAt: DateTime | undefined;
39
+ lastModifiedBy: string | undefined;
40
+ boxes: ICustomerBox[]; // 하위 컬렉션 추가
41
+ }
42
+
43
+ interface ICustomerBox {
44
+ id: string; // 클라이언트 생성 UUID (서버 저장 시 교체 가능)
45
+ seq: number;
46
+ note: string;
47
+ isDeleted: boolean;
48
+ }
49
+
50
+ // 3) data 초기값에 boxes: [] 추가 — 진입점 3.1 최소 뼈대의 data = signal<ICustomer>({ ... }) 초기값에 포함
51
+
52
+ // 4) template — main 영역(<sd-dock-container> 안쪽 <sd-form> 내부)을
53
+ // [단일 필드 + 도구 dock + 시트]로 교체. 최종수정 표시 블록은 최소 뼈대와 동일.
54
+ <div class="flex-column fill">
55
+ <sd-form #formCtrl (formSubmit)="onSubmit()" class="flex-fill flex-column">
56
+ <!-- 상단 단일 필드 -->
57
+ <div class="p-default">
58
+ <table class="form-table">
59
+ <tbody>
60
+ <tr>
61
+ <th>명칭</th>
62
+ <td>
63
+ <sd-textfield
64
+ [type]="'text'"
65
+ [required]="true"
66
+ [disabled]="!canEdit()"
67
+ [(value)]="data().name"
68
+ />
69
+ </td>
70
+ </tr>
71
+ </tbody>
72
+ </table>
73
+ </div>
74
+
75
+ <!-- 하위 컬렉션 도구 영역 -->
76
+ @if (canEdit()) {
77
+ <div class="flex-row gap-sm p-xs-default">
78
+ <sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddBoxButtonClick()">
79
+ <ng-icon [svg]="tablerCirclePlus" />
80
+ 박스 추가
81
+ </sd-button>
82
+ </div>
83
+ }
84
+
85
+ <!-- 하위 컬렉션 시트 -->
86
+ <div class="flex-fill">
87
+ <sd-sheet
88
+ [items]="data().boxes"
89
+ [trackByFn]="boxTrackByFn"
90
+ [getItemCellStyleFn]="getBoxCellStyleFn"
91
+ >
92
+ @if (canEdit()) {
93
+ <sd-sheet-column [fixed]="true" [key]="'_isDeleted'">
94
+ <ng-template #headerTpl>
95
+ <div class="p-xs-sm tx-center">
96
+ <ng-icon [svg]="tablerEraser" />
97
+ </div>
98
+ </ng-template>
99
+ <ng-template [cell]="data().boxes" let-item="item">
100
+ <div class="p-xs-sm tx-center">
101
+ <sd-anchor
102
+ [theme]="'danger'"
103
+ (click)="onToggleDeleteBoxButtonClick(item)"
104
+ >
105
+ <ng-icon [svg]="item.isDeleted ? tablerRestore : tablerEraser" />
106
+ </sd-anchor>
107
+ </div>
108
+ </ng-template>
109
+ </sd-sheet-column>
110
+ }
111
+ <sd-sheet-column [key]="'seq'" [header]="'박스#'">
112
+ <ng-template [cell]="data().boxes" let-item="item">
113
+ <sd-textfield
114
+ [type]="'number'"
115
+ [required]="true"
116
+ [disabled]="!canEdit()"
117
+ [(value)]="item.seq"
118
+ [inset]="true"
119
+ [size]="'sm'"
120
+ />
121
+ </ng-template>
122
+ </sd-sheet-column>
123
+ <sd-sheet-column [key]="'note'" [header]="'비고'">
124
+ <ng-template [cell]="data().boxes" let-item="item">
125
+ <sd-textfield
126
+ [type]="'text'"
127
+ [disabled]="!canEdit()"
128
+ [(value)]="item.note"
129
+ [inset]="true"
130
+ [size]="'sm'"
131
+ />
132
+ </ng-template>
133
+ </sd-sheet-column>
134
+ </sd-sheet>
135
+ </div>
136
+ </sd-form>
137
+ <!-- 최종수정 표시는 최소 뼈대와 동일 -->
138
+ </div>
139
+
140
+ // 5) 시트 함수 (클래스 필드) 추가
141
+ protected readonly boxTrackByFn = (item: ICustomerBox): string => item.id;
142
+
143
+ protected readonly getBoxCellStyleFn = (item: ICustomerBox): string | undefined =>
144
+ item.isDeleted ? "text-decoration: line-through;" : undefined;
145
+
146
+ // 6) 메서드 추가
147
+ protected onAddBoxButtonClick(): void {
148
+ const newBox: ICustomerBox = {
149
+ id: Uuid.generate().toString(),
150
+ seq: (this.data().boxes.at(-1)?.seq ?? 0) + 1,
151
+ note: "",
152
+ isDeleted: false,
153
+ };
154
+ this.data().boxes.push(newBox);
155
+ mark(this.data);
156
+ }
157
+
158
+ protected onToggleDeleteBoxButtonClick(item: ICustomerBox): void {
159
+ item.isDeleted = !item.isDeleted;
160
+ mark(this.data);
161
+ }
162
+
163
+ // 7) onSubmit의 _sdToast.try(...) 블록 내부 교체 — diff 계산 + 일괄 제출
164
+ await this._sdToast.try(async () => {
165
+ // 삭제 플래그가 섞여 있으면 사용자 확인
166
+ if (this.data().boxes.some((b) => b.isDeleted)) {
167
+ if (!confirm("삭제 표시된 박스가 있습니다. 정말 저장하시겠습니까?")) return;
168
+ }
169
+
170
+ // 하위 컬렉션 diff 계산 — 반환 type은 "create" | "update" | "same" 3종
171
+ const snapshotBoxes = this._dataSnapshot?.boxes ?? [];
172
+ const boxDiffs = this.data().boxes.oneWayDiffs(snapshotBoxes, "id");
173
+
174
+ // 앱별 ORM 호출:
175
+ // await this._appOrm.connectAsync(async (db) => {
176
+ // await db.customer().where((c) => [expr.eq(c.id, this.data().id)])
177
+ // .upsert(() => ({ name: this.data().name, phone: this.data().phone }));
178
+ // for (const d of boxDiffs) {
179
+ // if (d.type === "create") {
180
+ // await db.customerBox().insert({ ...d.item, customerId: this.data().id! });
181
+ // } else if (d.type === "update") {
182
+ // await db.customerBox().where((c) => [expr.eq(c.id, d.item.id)])
183
+ // .update(() => d.item);
184
+ // }
185
+ // }
186
+ // });
187
+
188
+ this._sdToast.success("저장되었습니다.");
189
+ // 확장 C(modal 뷰)가 함께 적용된 경우에만 존재: this.close.emit(true); — modal 호출 측에 결과 전달
190
+ await this._refresh();
191
+ });
192
+ ```
193
+
194
+ **포인트:**
195
+
196
+ - **하위 컬렉션의 삭제는 `isDeleted: true` 플래그로 표현한다.** 이것은 상위 테이블의 삭제 방식([공통 규칙: 삭제 방식](../_common-rules.md#삭제-방식은-db-스키마에-따라-결정한다))과 **무관**하게, `oneWayDiffs`가 `"delete"` 타입을 지원하지 않기 때문에 하위 컬렉션에서는 항상 이 방식을 사용한다. 반환 `type`은 `"create" | "update" | "same"` 3종뿐이다(`packages/core-common/src/extensions/arr-ext.types.ts:206`). 서버는 `isDeleted: true` row를 soft-delete 또는 물리 삭제로 처리한다.
197
+ - **시트 셀 내부 컨트롤은 `[inset]="true" [size]="'sm'"` 명시 필수** — [공통 규칙: 시트 셀 `[inset]/[size]`](../_common-rules.md#시트-셀-내부-컨트롤에-insettrue-sizesm을-명시한다).
198
+ - **`data().boxes.push(newBox)` 같은 배열 mutation 후에는 `mark(this.data)`로 signal 참조를 갱신한다** — OnPush 템플릿 재렌더링·연계 computed 갱신 용도의 **통지**이며, 값 비교(저장 감지)와는 별개다: [공통 규칙: `mark(sig)`…](../_common-rules.md#marksig를-저장-감지-수단으로-사용하지-않는다).
199
+ - **`id`는 클라이언트에서 UUID로 생성** — `Uuid.generate().toString()`으로 문자열 key를 만들어 `trackByFn` + `oneWayDiffs`의 key로 사용한다. 서버가 발급한 PK가 별도로 있다면 별도 컬럼으로 관리하고 클라이언트 UUID는 row 식별자로만 쓴다.
200
+ - **삭제 플래그 혼재 시 `confirm`** — `this.data().boxes.some((b) => b.isDeleted)`이면 저장 직전 사용자 확인을 요청한다.
201
+
202
+ **🚫 흔한 실수**
203
+
204
+ > 공통 규칙(시트 셀 `[inset]/[size]`, `mark` 오용, 상위 테이블 soft-delete 선택 기준)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 이 섹션은 **복합 상세(하위 컬렉션) 확장 고유 실수**만 다룬다.
205
+
206
+ ### `data().boxes`에서 기존 row를 물리 제거한다
207
+
208
+ ```typescript
209
+ // ❌ 기존 row를 하위 컬렉션 배열에서 제거 — oneWayDiffs는 delete를 반환하지 않으므로
210
+ // 저장 시 서버는 이 row가 사라진 사실을 감지하지 못한다.
211
+ onRemoveBox(box: ICustomerBox): void {
212
+ this.data.update((d) => ({
213
+ ...d,
214
+ boxes: d.boxes.filter((b) => b.id !== box.id),
215
+ }));
216
+ }
217
+
218
+ // ✅ 삭제 의사는 isDeleted 플래그로 표현 → diff의 type: "update"로 전송
219
+ onToggleDeleteBoxButtonClick(box: ICustomerBox): void {
220
+ box.isDeleted = !box.isDeleted;
221
+ mark(this.data); // OnPush 재렌더링 통지
222
+ }
223
+
224
+ // ✅ 예외: 신규 row(snapshot에 없는 클라이언트 UUID)는 저장 포기 시 물리 제거해도 된다
225
+ // — snapshot에 존재하지 않으므로 diff 자체가 발생하지 않는다.
226
+ onCancelNewBox(box: ICustomerBox): void {
227
+ this.data.update((d) => ({
228
+ ...d,
229
+ boxes: d.boxes.filter((b) => b.id !== box.id),
230
+ }));
231
+ }
232
+ ```
233
+
234
+ **근거**: `oneWayDiffs`의 반환 `type`은 `"create" | "update" | "same"` 3종뿐이며 delete를 다루지 않는다(`packages/core-common/src/extensions/arr-ext.types.ts:206`). 기존 row를 화면에서 제거하면 diff가 발생하지 않아 서버 저장 경로를 우회한다. 하위 컬렉션 삭제는 상위 테이블의 soft-delete 여부와 무관하게 항상 `isDeleted` 플래그를 쓴다 — [공통 규칙: 삭제 방식](../_common-rules.md#삭제-방식은-db-스키마에-따라-결정한다)은 상위 테이블 단위 결정이며, 하위 컬렉션은 `oneWayDiffs` delete 미지원이라는 기술적 제약이 원인이다.