@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
@@ -1,109 +1,87 @@
1
- # Recipe: CRUD 리스트 화면 직접 조립
2
-
3
- 소비 화면이 `<sd-busy-container>` · `<sd-topbar-container>` · `<sd-topbar>` · `<sd-form>` · `<sd-sheet>` · `<sd-sheet-column>` 표준 컴포넌트를 **직접 조립**하여 CRUD 리스트 화면을 구성한다. 과거 `SdDataSheet` / `SdDataSheetBase` / `SdDataSheetColumn`이 감추고 있던 필터·페이지네이션·정렬·선택·편집·삭제복구·엑셀 업로드·다운로드·단축키 흐름을 화면 내부에 인라인으로 풀어쓴다.
4
-
5
- ## 1. Overview
6
-
7
- - 제거된 추상화: `SdDataSheet`(컴포넌트) / `SdDataSheetBase`(추상 클래스) / `SdDataSheetColumn`(디렉티브) / `SdDataSheetItemPropInfo`·`SdDataSheetItemInfo`·`SdDataSheetSearchResult`(타입 3종) / `setupCloserWhenSingleSelectionChange`(단일 선택 시 모달 자동 닫기 유틸) / 내부 Manager 5종(`injectDataSheet{Refresh,InlineEdit,ModalEdit,Excel}Manager`, `useDataSheetFilterManager`)
8
- - 대체: 소비 컴포넌트가 표준 조각을 직접 조립
9
- - 조립 요소:
10
- - `<sd-busy-container [busy] [message]>` 전체 busy 오버레이
11
- - `<sd-topbar-container>` + `<sd-topbar>` 페이지 상단 헤더
12
- - `<sd-form (formSubmit)>` 필터 제출 / inline 편집 저장 트리거
13
- - `<sd-sheet>` + `<sd-sheet-column>` + `<ng-template [cell]>` 시트 본체 (items, 페이지네이션, 정렬, 선택, `cumulativeSelection`, 셀 스타일)
14
- - `injectViewTypeSignal()` page / modal / control 판정
15
- - `injectPermsSignal()` — 권한 signal
16
- - `useSortingManager()` 정렬 def 관리 (선택적)
17
- - `setupCanDeactivate()` — 이탈 방지
18
- - `mark(sig)` signal 참조 갱신
19
- - `SdToastProvider.try(fn, messageFn)` 에러 래퍼 + busy 카운트
20
- - `SdModalProvider.showAsync(...)` 편집 모달 호출
21
- - `SdFileDialogProvider.showAsync(...)` 엑셀 업로드 파일 선택
22
- - `getOrmDataEditToastErrorMessage(err)` — ORM 에러 → 사용자 메시지 변환
23
- - `SdCommandDirective`(`sdRefreshCommand` / `sdSaveCommand`) — Ctrl+Alt+L / Ctrl+S 단축키
24
- - 데이터 비교:
25
- - `Array.prototype.oneWayDiffs(orgItems, keyFn)` (`@simplysm/core-common` side-effect import) — `ArrayOneWayDiffResult<T>[]` 반환 (`type: "create" | "update" | "same"`)
26
- - `obj.equal(a, b)` deep equal
27
-
28
- ## 2. 언제 사용하는가
29
-
30
- | 상황 | 적용 여부 |
31
- |---|---|
32
- | 필터 + 페이지네이션 + 정렬 + 선택이 있는 일반 CRUD 리스트 | 본 레시피 전체 적용 |
33
- | 행을 직접 수정하는 인라인 편집 화면 | 레시피 + [변형 1: inline 편집 모드](#5-변형-inline-편집-모드) |
34
- | 다른 화면에서 항목을 고르는 선택 모달 | 레시피 + [변형 2: 선택 모달](#6-변형-선택-모달-뷰) |
35
- | 엑셀 업로드 / 다운로드가 필요 | 레시피 + [변형 3: 엑셀 업로드·다운로드](#7-변형-엑셀-업로드다운로드) |
36
- | 상세 폼(단일 레코드 편집) | 본 레시피 대신 [`crud-detail.md`](./crud-detail.md) 사용 |
37
- | 페이지 / 모달 분기만 필요한 단순 화면 | [`page-modal-container.md`](./page-modal-container.md) 사용 |
38
-
39
- ## 3. 완성 예제
40
-
41
- 아래는 **페이지 뷰 + modal 편집 모드** 기준 완성 컴포넌트다. CRUD 리스트의 가장 일반적 형태(필터 검색 → 페이지네이션 + 정렬 → 모달로 row 편집 → 선택 삭제/복구)를 커버한다. inline / 선택 모달 / 엑셀은 `## 5`~`## 7`의 변형 스니펫으로 교체·추가한다.
1
+ # CRUD 리스트 화면 직접 조립
2
+
3
+ `<sd-busy-container>` · `<sd-topbar-container>` · `<sd-topbar>` · `<sd-form>` · `<sd-sheet>` · `<sd-sheet-column>` 표준 컴포넌트를 소비 화면이 **직접 조립**하여 CRUD 리스트 화면을 구성하는 레시피. 최소 뼈대(조회 전용 page) 위에 편집·선택·모달·엑셀 필요한 확장만 선택적으로 얹는다.
4
+
5
+ > **modal 뷰는 용도(선택 모달 vs 조회 전용)를 먼저 확정한다.** 상세와 회피 방법은 아래 [🚫 흔한 실수](#-흔한-실수-anti-patterns)의 "modal = 선택 모달로 반사적 부착" 참조.
6
+
7
+ ## When to use / When NOT to use
8
+
9
+ - 검색·페이지네이션·정렬을 갖춘 리스트 조회 화면
10
+ - 직접 편집·일괄 저장·선택 삭제·엑셀 업로드/다운로드가 필요한 리스트 CRUD
11
+ - 같은 리스트 화면을 page·select-modal·readonly-modal 등으로 재사용
12
+ - 단일 레코드 상세 [`crud-detail.md`](./crud-detail.md)
13
+ - 모달 선택 버튼(항목을 골라 받는 버튼) 조립 [`data-select-button.md`](./data-select-button.md)
14
+ - 분기(page/modal/control) 공통 껍데기만 필요 → [`page-modal-container.md`](./page-modal-container.md)
15
+
16
+ ### 범위 선택
17
+
18
+ - **page**: 라우트로 진입하는 리스트 화면 → 본 파일의 [기본 레시피](#기본-레시피) 그대로
19
+ - **modal (선택)**: 다른 화면에서 항목을 골라 `close.emit`으로 돌려주는 selector [확장 D](./crud-list/extension-d-select-modal.md)
20
+ - **modal (조회 전용)**: 부모 레코드의 자식 목록·이력을 input으로 받아 읽기 전용으로 표시(닫기는 SdModal 기본 "X") → [확장 E](./crud-list/extension-e-readonly-modal.md)
21
+ - **control**: 마스터-디테일의 리스트 영역으로 임베딩 → [공통 규칙 "page가 topbar 소유"](./_common-rules.md#page-컴포넌트가-sd-topbar-container와-sd-topbar를-소유한다) + 필요 확장 D/E 참조
22
+
23
+ ## 전제조건
24
+
25
+ - Provider: 루트에 `provideSdAngular`가 등록되어 있음
26
+ - peer: Angular 21 standalone, zoneless, `@simplysm/angular`
27
+ - 앱별: ORM provider + `DbContext` (예제의 `AppOrmProvider`·`@adtek/client-common`은 각 앱이 소유하는 placeholder)
28
+ - 횡단 규칙: [`_common-rules.md`](./_common-rules.md)에 정의된 공통 규칙을 모두 준수한다. 본 파일에서 재정의하지 않는다
29
+ - [`injectViewTypeSignal()` 호출 시점](./_common-rules.md#injectviewtypesignal은-생성자-또는-필드-이니셜라이저에서만-호출한다)
30
+ - [page 컴포넌트가 `<sd-topbar>`를 소유](./_common-rules.md#page-컴포넌트가-sd-topbar-container와-sd-topbar를-소유한다)
31
+ - [시트 셀 컨트롤에 `[inset]="true" [size]="'sm'"` 명시](./_common-rules.md#시트-셀-내부-컨트롤에-insettrue-sizesm을-명시한다)
32
+ - [input 변경을 effect에서 filter·lastFilter·page에 반영](./_common-rules.md#input-변경을-effect-내부에서-filterlastfilterpage에-반영한다)
33
+ - [`void this._initAsync()` 금지](./_common-rules.md#input-의존-데이터-로딩에-void-this_initasync를-사용하지-않는다) / [signal 필드 초기값에서 다른 signal 읽기 금지](./_common-rules.md#signal-필드-초기값에서-다른-signal을-읽지-않는다) / [`mark()`를 저장 감지 수단으로 오해 금지](./_common-rules.md#marksig를-저장-감지-수단으로-사용하지-않는다)
34
+
35
+ ## 기본 레시피
36
+
37
+ 조회 전용 page 기준의 최소 뼈대 완성 컴포넌트. 라우트로 진입하면 검색 + 페이지네이션 + 정렬이 동작하는 읽기 전용 리스트로 표시된다. 편집·선택·모달·엑셀은 아래 [변형](#변형-variation) 표에서 필요한 확장을 선택적으로 얹는다.
38
+
39
+ > **이 최소 뼈대가 포함하지 않는 것:** 감사 필드(`lastModifiedAt`/`lastModifiedBy`) · 카테고리 등 FK 표시 컬럼 · 편집 권한(`"edit"`) · 편집용 컨트롤은 모두 확장 A 이후에 도입된다. (참고: `crud-detail.md` 최소 뼈대는 page 전용 읽기 폼이므로 감사 필드 2열을 포함한다 — 도메인 범위는 같지만 뼈대 범위가 다르다.)
42
40
 
43
41
  ```typescript
44
42
  import { NgIcon } from "@ng-icons/core";
45
- import {
46
- tablerAlertTriangle,
47
- tablerCirclePlus,
48
- tablerEdit,
49
- tablerEraser,
50
- tablerRefresh,
51
- tablerRestore,
52
- tablerSearch,
53
- } from "@ng-icons/tabler-icons";
43
+ import { tablerAlertTriangle, tablerRefresh, tablerSearch } from "@ng-icons/tabler-icons";
54
44
  import {
55
45
  ChangeDetectionStrategy,
56
46
  Component,
57
- computed,
58
47
  effect,
59
48
  inject,
60
49
  signal,
61
50
  untracked,
62
51
  ViewEncapsulation,
63
52
  } from "@angular/core";
53
+ import { str } from "@simplysm/core-common";
64
54
  import {
65
- getOrmDataEditToastErrorMessage,
66
- injectCurrentPageCodeSignal,
67
- injectFullPageCodeSignal,
68
55
  injectPermsSignal,
56
+ injectViewTitleSignal,
69
57
  injectViewTypeSignal,
70
58
  mark,
71
- SdActivatedModalProvider,
72
- SdAnchor,
73
- SdAppStructureProvider,
74
59
  SdBusyContainer,
75
60
  SdButton,
76
61
  SdCommandDirective,
62
+ SdDock,
63
+ SdDockContainer,
77
64
  SdForm,
78
- SdModalProvider,
79
65
  SdSheet,
80
66
  SdSheetColumn,
81
67
  SdSheetColumnCellTemplate,
82
- SdSystemLogProvider,
83
68
  SdTextfield,
84
69
  SdToastProvider,
85
70
  SdTopbar,
86
71
  SdTopbarContainer,
87
- setupCanDeactivate,
88
72
  type SortingDef,
89
73
  } from "@simplysm/angular";
90
- import "@simplysm/core-common"; // Array.prototype.oneWayDiffs 프로토타입 확장
74
+ // 앱별 대체: ORM provider + DbContext. simplysm 패키지가 아니라 각 앱이 소유한다.
75
+ import { AppOrmProvider } from "@adtek/client-common";
91
76
 
92
- interface ICustomer {
93
- id: string;
94
- name: string;
95
- phone: string;
96
- isDeleted: boolean;
97
- }
98
-
99
- interface ICustomerFilter {
100
- search: string;
77
+ interface IFilter {
78
+ searchText?: string;
101
79
  }
102
80
 
103
- interface ICustomerSearchResult {
104
- items: ICustomer[];
105
- pageLength: number;
106
- summary: Partial<ICustomer>;
81
+ interface ICustomer {
82
+ id: number;
83
+ name: string;
84
+ phone?: string;
107
85
  }
108
86
 
109
87
  @Component({
@@ -113,8 +91,10 @@ interface ICustomerSearchResult {
113
91
  standalone: true,
114
92
  imports: [
115
93
  SdBusyContainer, SdTopbarContainer, SdTopbar,
94
+ SdDockContainer, SdDock,
116
95
  SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
117
- SdButton, SdAnchor, SdTextfield, NgIcon,
96
+ SdButton, SdTextfield,
97
+ NgIcon,
118
98
  ],
119
99
  hostDirectives: [
120
100
  { directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
@@ -123,657 +103,344 @@ interface ICustomerSearchResult {
123
103
  "(sdRefreshCommand)": "onRefreshButtonClick()",
124
104
  },
125
105
  template: `
126
- <sd-busy-container [busy]="busyCount() > 0" [message]="busyMessage()">
106
+ <sd-busy-container [busy]="busyCount() > 0">
127
107
  @if (initialized()) {
128
- @if (!canUse()) {
108
+ @if (!perms().includes("use")) {
129
109
  <div class="fill tx-theme-gray-light p-xxl tx-center">
130
110
  <br />
131
- <ng-icon [svg]="icons.tablerAlertTriangle" [size]="'5em'" />
132
- <br /><br />
133
- '{{ modalOrPageTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
111
+ <ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
112
+ <br />
113
+ <br />
114
+ '{{ viewTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
134
115
  </div>
135
- } @else if (viewType() === "page") {
116
+ } @else {
136
117
  <sd-topbar-container>
137
- <sd-topbar>
138
- <h4>{{ modalOrPageTitle() }}</h4>
139
- <sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
140
- <ng-icon [svg]="icons.tablerRefresh" />
141
- 새로고침 <small>(CTRL+ALT+L)</small>
142
- </sd-button>
143
- </sd-topbar>
144
-
145
- <div class="flex-column fill">
146
- <!-- 필터 영역 -->
147
- <div class="p-default">
118
+ @if (viewType() === "page") {
119
+ <sd-topbar>
120
+ <h4>{{ viewTitle() }}</h4>
121
+
122
+ <sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
123
+ <ng-icon [svg]="tablerRefresh" />
124
+ 새로고침
125
+ <small>(CTRL+ALT+L)</small>
126
+ </sd-button>
127
+ </sd-topbar>
128
+ }
129
+
130
+ <sd-dock-container>
131
+ <!-- 필터 -->
132
+ <sd-dock class="p-default">
148
133
  <sd-form (formSubmit)="onFilterSubmit()">
149
134
  <div class="form-box-inline">
150
- <sd-button [type]="'submit'" [theme]="'info'">
151
- <ng-icon [svg]="icons.tablerSearch" />
152
- 조회
153
- </sd-button>
154
- <sd-textfield
155
- [type]="'text'"
156
- [placeholder]="'이름/전화번호 검색'"
157
- [(value)]="filterSearch"
158
- [inset]="true"
159
- [size]="'sm'"
160
- />
135
+ <div class="form-box-item">
136
+ <sd-button [type]="'submit'" [theme]="'info'">
137
+ <ng-icon [svg]="tablerSearch" />
138
+ 조회
139
+ </sd-button>
140
+ </div>
141
+ <div class="form-box-item">
142
+ <label>검색어</label>
143
+ <sd-textfield
144
+ [type]="'text'"
145
+ [placeholder]="'이름/전화번호'"
146
+ [(value)]="filter().searchText"
147
+ (valueChange)="mark(filter)"
148
+ />
149
+ </div>
161
150
  </div>
162
151
  </sd-form>
163
- </div>
164
-
165
- <!-- 도구 영역 -->
166
- @if (canEdit()) {
167
- <div class="flex-row gap-sm p-xs-default">
168
- <sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onCreateItemButtonClick()">
169
- <ng-icon [svg]="icons.tablerCirclePlus" />
170
- 등록
171
- </sd-button>
172
- <sd-button
173
- [size]="'sm'"
174
- [theme]="'link-danger'"
175
- (click)="onToggleDeleteItemsButtonClick(true)"
176
- [disabled]="!hasSelectedNotDeleted()"
177
- >
178
- <ng-icon [svg]="icons.tablerEraser" />
179
- 선택 삭제
180
- </sd-button>
181
- @if (hasSelectedDeleted()) {
182
- <sd-button
183
- [size]="'sm'"
184
- [theme]="'link-warning'"
185
- (click)="onToggleDeleteItemsButtonClick(false)"
186
- >
187
- <ng-icon [svg]="icons.tablerRestore" />
188
- 선택 복구
189
- </sd-button>
190
- }
191
- </div>
192
- }
193
-
194
- <!-- 시트 -->
152
+ </sd-dock>
153
+
154
+ <!-- 시트 (main 영역) -->
195
155
  <sd-sheet
196
156
  [key]="'customer-list-sheet'"
197
157
  [items]="items()"
198
158
  [(currentPage)]="page"
199
159
  [totalPageCount]="pageLength()"
200
160
  [(sorts)]="sortingDefs"
201
- [selectMode]="'multi'"
202
- [(selectedItems)]="selectedItems"
203
161
  [trackByFn]="trackByFn"
204
- [getItemCellStyleFn]="getItemCellStyleFn"
205
- class="flex-fill p-default pt-0"
206
162
  >
207
- <sd-sheet-column [key]="'name'" [header]="'이름'" [width]="'200px'">
163
+ <sd-sheet-column [fixed]="true" [header]="'#'" [key]="'id'">
164
+ <ng-template [cell]="items()" let-item="item">
165
+ <div class="p-xs-sm tx-right">{{ item.id }}</div>
166
+ </ng-template>
167
+ </sd-sheet-column>
168
+
169
+ <sd-sheet-column [header]="'이름'" [key]="'name'">
208
170
  <ng-template [cell]="items()" let-item="item">
209
- <sd-anchor (click)="onEditItemButtonClick(item, $event)" class="flex-row">
210
- <div class="p-xs-sm">
211
- <ng-icon [svg]="icons.tablerEdit" />
212
- </div>
213
- <div class="flex-fill p-xs-sm">{{ item.name }}</div>
214
- </sd-anchor>
171
+ <div class="p-xs-sm">{{ item.name }}</div>
215
172
  </ng-template>
216
173
  </sd-sheet-column>
217
- <sd-sheet-column [key]="'phone'" [header]="'전화번호'">
174
+
175
+ <sd-sheet-column [header]="'전화번호'" [key]="'phone'">
218
176
  <ng-template [cell]="items()" let-item="item">
219
177
  <div class="p-xs-sm">{{ item.phone }}</div>
220
178
  </ng-template>
221
179
  </sd-sheet-column>
222
180
  </sd-sheet>
223
- </div>
181
+ </sd-dock-container>
224
182
  </sd-topbar-container>
225
- } @else if (viewType() === "modal") {
226
- <!-- 모달 뷰 분기는 변형 2 "선택 모달" 참조 -->
227
- } @else {
228
- <!-- control 뷰: 다른 화면의 영역으로 삽입될 때 -->
229
183
  }
230
184
  }
231
185
  </sd-busy-container>
232
186
  `,
233
187
  })
234
- export class CustomerListPage {
188
+ export class CustomerList {
235
189
  //== DI ==
190
+ private readonly _appOrm = inject(AppOrmProvider);
236
191
  private readonly _sdToast = inject(SdToastProvider);
237
- private readonly _sdModal = inject(SdModalProvider);
238
- private readonly _sdActivatedModal = inject(SdActivatedModalProvider, { optional: true });
239
- private readonly _sdAppStructure = inject(SdAppStructureProvider);
240
- private readonly _sdSystemLog = inject(SdSystemLogProvider);
241
-
242
- //== 라우팅 / 권한 ==
243
- private readonly _fullPageCode = injectFullPageCodeSignal();
244
- private readonly _currPageCode = injectCurrentPageCodeSignal();
245
- protected readonly viewType = injectViewTypeSignal();
246
- protected readonly canUse = injectPermsSignal(
247
- () => ["sales.customer"],
248
- () => ["use"],
249
- );
250
- protected readonly canEdit = injectPermsSignal(
251
- () => ["sales.customer"],
252
- () => ["edit"],
253
- );
192
+
193
+ //== 식별 / 권한 ==
194
+ perms = injectPermsSignal(["sales.customer"], ["use"]);
195
+
196
+ viewType = injectViewTypeSignal();
197
+ viewTitle = injectViewTitleSignal();
254
198
 
255
199
  //== 상태 ==
256
- protected readonly busyCount = signal(0);
257
- protected readonly busyMessage = signal<string | undefined>(undefined);
258
- protected readonly initialized = signal(false);
259
-
260
- protected readonly items = signal<ICustomer[]>([]);
261
- protected readonly summaryData = signal<Partial<ICustomer>>({});
262
- protected readonly selectedItems = signal<ICustomer[]>([]);
263
- protected readonly page = signal(0);
264
- protected readonly pageLength = signal(0);
265
- protected readonly sortingDefs = signal<SortingDef[]>([{ key: "name", desc: false }]);
266
- protected readonly filterSearch = signal("");
267
- private readonly _lastFilter = signal<ICustomerFilter>({ search: "" });
268
- private _snapshot: ICustomer[] = [];
269
-
270
- //== 파생 ==
271
- protected readonly modalOrPageTitle = computed(() => {
272
- try {
273
- return (
274
- this._sdActivatedModal?.modalComponent()?.title() ??
275
- this._sdAppStructure.getTitleByFullCode(this._currPageCode?.() ?? this._fullPageCode())
276
- );
277
- } catch (err) {
278
- void this._sdSystemLog.writeAsync("warn", `title 계산 실패: ${String(err)}`);
279
- return "";
280
- }
281
- });
282
-
283
- protected readonly hasSelectedDeleted = computed(() =>
284
- this.selectedItems().some((it) => it.isDeleted),
285
- );
286
- protected readonly hasSelectedNotDeleted = computed(() =>
287
- this.selectedItems().some((it) => !it.isDeleted),
288
- );
200
+ initialized = signal(false); // 최초 조회 완료 — @if (initialized()) 가드 해제
201
+ busyCount = signal(0); // 중첩 비동기 작업 카운트 (0 초과 시 busy 표시)
202
+
203
+ items = signal<ICustomer[]>([]);
289
204
 
290
- protected readonly trackByFn = (item: ICustomer): string => item.id;
205
+ page = signal(0);
206
+ pageLength = signal(0);
207
+ sortingDefs = signal<SortingDef[]>([]);
291
208
 
292
- protected readonly getItemCellStyleFn = (item: ICustomer): string | undefined =>
293
- item.isDeleted ? "text-decoration: line-through;" : undefined;
209
+ filter = signal<IFilter>({}); // 입력 버퍼. onFilterSubmit 시 lastFilter로 스냅샷
210
+ lastFilter = signal<IFilter>({}); // 조회 트리거 — effect 의존성은 이 signal
294
211
 
295
- protected readonly icons = {
296
- tablerAlertTriangle, tablerCirclePlus, tablerEdit, tablerEraser,
297
- tablerRefresh, tablerRestore, tablerSearch,
298
- };
212
+ //== 시트 fn ==
213
+ trackByFn = (item: ICustomer) => item.id;
299
214
 
300
- //== 라이프사이클 ==
301
215
  constructor() {
302
- // 최초 로딩 + 필터/페이지/정렬 변경 시 재조회
216
+ // 필터/페이지/정렬/perms 변경 시 재조회
303
217
  effect(() => {
304
- this._lastFilter();
218
+ if (!this.perms().includes("use")) {
219
+ this.initialized.set(true);
220
+ return;
221
+ }
222
+
223
+ this.lastFilter();
305
224
  this.page();
306
225
  this.sortingDefs();
307
- if (!this.canUse()) return;
308
- untracked(() => {
309
- void this._refresh();
226
+
227
+ void untracked(async () => {
228
+ this.busyCount.update((v) => v + 1);
229
+ await this._sdToast.try(async () => {
230
+ await this._refresh();
231
+ });
232
+ this.busyCount.update((v) => v - 1);
233
+ this.initialized.set(true);
310
234
  });
311
235
  });
312
-
313
- setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
314
236
  }
315
237
 
316
- //== 메서드 ==
317
- protected onFilterSubmit(): void {
238
+ //== Handlers ==
239
+ onFilterSubmit(): void {
318
240
  this.page.set(0);
319
- this._lastFilter.set({ search: this.filterSearch() });
241
+ this.lastFilter.set({ ...this.filter() });
320
242
  }
321
243
 
322
- protected onRefreshButtonClick(): void {
244
+ onRefreshButtonClick(): void {
323
245
  if (this.busyCount() > 0) return;
324
- if (!this.canUse()) return;
325
- if (!this._checkIgnoreChanges()) return;
326
- mark(this._lastFilter); // 참조만 갱신 → effect 재실행
327
- }
246
+ if (!this.perms().includes("use")) return;
328
247
 
329
- protected async onCreateItemButtonClick(): Promise<void> {
330
- await this._editItem();
248
+ mark(this.lastFilter); // 변경 없이 참조만 갱신 → effect 재실행
331
249
  }
332
250
 
333
- protected async onEditItemButtonClick(item: ICustomer, event: MouseEvent): Promise<void> {
334
- event.preventDefault();
335
- event.stopPropagation();
336
- await this._editItem(item);
251
+ //== Internals ==
252
+ private async _refresh(): Promise<void> {
253
+ const r = await this._search(true);
254
+ this.items.set(r.items);
255
+ this.pageLength.set(r.pageLength);
337
256
  }
338
257
 
339
- protected async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
340
- await this._sdToast.try(async () => {
341
- this.busyCount.update((v) => v + 1);
342
- try {
343
- // 서버 호출 (앱별 구현):
344
- // await this._api.toggleDeleteAsync(this.selectedItems().map((it) => it.id), del);
345
- await this._refresh();
346
- } finally {
347
- this.busyCount.update((v) => v - 1);
258
+ private async _search(
259
+ usePagination: boolean, // false는 전체 조회 (엑셀 다운로드 등에서 재사용)
260
+ ): Promise<{ items: ICustomer[]; pageLength: number }> {
261
+ const filter = this.lastFilter();
262
+ const sortingDefs = this.sortingDefs();
263
+ const page = this.page();
264
+
265
+ return this._appOrm.connectAsync(async (db) => {
266
+ let qr1 = db.customer();
267
+
268
+ if (!str.isNullOrEmpty(filter.searchText)) {
269
+ qr1 = qr1.search((item) => [item.name, item.phone], filter.searchText);
348
270
  }
349
- }, getOrmDataEditToastErrorMessage);
350
- }
351
271
 
352
- private _checkIgnoreChanges(): boolean {
353
- return this._getDiffs().length === 0
354
- || confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
355
- }
272
+ // 페이지당 50건 — 시트 화면 표준치 (조정 UX 확인 필요)
273
+ const pageLength = usePagination ? Math.ceil((await qr1.count()) / 50) : 0;
356
274
 
357
- private _getDiffs() {
358
- return this.items().oneWayDiffs(this._snapshot, "id");
359
- }
275
+ let qr2 = qr1.select((item) => ({
276
+ id: item.id,
277
+ name: item.name,
278
+ phone: item.phone,
279
+ }));
360
280
 
361
- private async _refresh(): Promise<void> {
362
- await this._sdToast.try(async () => {
363
- this.busyCount.update((v) => v + 1);
364
- try {
365
- const r = await this._fetchList(this._lastFilter(), this.page(), this.sortingDefs());
366
- this.items.set(r.items);
367
- this.pageLength.set(r.pageLength);
368
- this.summaryData.set(r.summary);
369
- // 선택 유지 (현재 items에 없는 item 제외)
370
- const currKeys = new Set(r.items.map((it) => this.trackByFn(it)));
371
- this.selectedItems.update((sel) =>
372
- sel.filter((it) => currKeys.has(this.trackByFn(it))),
373
- );
374
- this._snapshot = r.items.map((it) => ({ ...it }));
375
- this.initialized.set(true);
376
- } finally {
377
- this.busyCount.update((v) => v - 1);
281
+ // orderBy는 string overload 사용 — 람다+obj.getChainValue는 Anti-patterns 참조
282
+ for (const sortingDef of sortingDefs) {
283
+ qr2 = qr2.orderBy(sortingDef.key, sortingDef.desc ? "DESC" : "ASC");
284
+ }
285
+ if (!sortingDefs.some((s) => s.key === "name")) {
286
+ qr2 = qr2.orderBy((item) => item.name);
378
287
  }
379
- }, getOrmDataEditToastErrorMessage);
380
- }
381
288
 
382
- private async _editItem(item?: ICustomer): Promise<void> {
383
- // 편집 모달 (소비 앱이 구현한 CustomerEditModal 사용):
384
- // const r = await this._sdModal.showAsync({
385
- // title: item == null ? "고객 등록" : "고객 수정",
386
- // type: CustomerEditModal,
387
- // inputs: { itemId: item?.id },
388
- // });
389
- // if (r != null) await this._refresh();
390
- }
289
+ if (usePagination) {
290
+ qr2 = qr2.limit(page * 50, 50);
291
+ }
391
292
 
392
- private async _fetchList(
393
- filter: ICustomerFilter,
394
- page: number,
395
- sortingDefs: SortingDef[],
396
- ): Promise<ICustomerSearchResult> {
397
- // 예시 (orm-common):
398
- // let qr = this._dbCtx.customer.where((it) => ... filter.search ... );
399
- // for (const s of sortingDefs) qr = qr.orderBy(s.key, s.desc ? "DESC" : "ASC");
400
- // const items = await qr.limit(page * 50, 50).resultAsync();
401
- // const pageLength = Math.ceil((await qr.countAsync()) / 50);
402
- // return { items, pageLength, summary: {} };
403
- throw new Error("구현 필요");
293
+ const items = await qr2.execute();
294
+ return { items, pageLength };
295
+ });
404
296
  }
297
+
298
+ //== 아이콘 ==
299
+ protected readonly tablerAlertTriangle = tablerAlertTriangle;
300
+ protected readonly tablerRefresh = tablerRefresh;
301
+ protected readonly tablerSearch = tablerSearch;
302
+ protected readonly mark = mark;
405
303
  }
406
304
  ```
407
305
 
408
- ## 4. 분해 설명
409
-
410
- 각 블록의 역할과 원본 `SdDataSheet` 코드 대응 지점:
411
-
412
- | 블록 | 역할 | 원본 대응 |
413
- |---|---|---|
414
- | `<sd-busy-container [busy] [message]>` | 전체 busy 오버레이 | `sd-data-sheet.ts:65-71` + `SdBaseContainer` |
415
- | `@if (initialized())` | 초기 데이터 로딩 전 콘텐츠 숨김 (깜박임 방지) | `sd-data-sheet.base.ts:94`·`_refresh()` 말미 `initialized.set(true)` |
416
- | `@if (!canUse())` | 권한 없음 메시지 | `sd-base-container.ts:44-51` + `page-modal-container.md` |
417
- | `@if (viewType() === "page")` / `"modal"` / else | 뷰 타입 분기 (`page-modal-container.md` 참조) | `sd-data-sheet.ts:65` `viewType` → `SdBaseContainer` 내부 분기 |
418
- | `<sd-topbar-container>` + `<sd-topbar>` | 페이지 뷰 헤더 | `sd-data-sheet.ts:72-87` `pageTopbarTpl` |
419
- | `<sd-form (formSubmit)>` + `form-box-inline` | 필터 제출 폼 | `sd-data-sheet.ts:111-125` 필터 슬롯 |
420
- | 도구 영역 (`<sd-button size=sm theme=link-*>`) | 등록 / 선택 삭제 / 선택 복구 | `sd-data-sheet.ts:127-203` 도구 영역 |
421
- | `<sd-sheet>` + `<sd-sheet-column>` + `<ng-template [cell]>` | 시트 본체, 셀 렌더링 | `sd-data-sheet.ts:205-346` |
422
- | `[cell]` 템플릿의 `<sd-anchor (click)>` | 셀 클릭 → 편집 모달 진입 | `sd-data-sheet.ts:284-315` |
423
- | `getItemCellStyleFn` | `isDeleted` 시 취소선 | `sd-data-sheet.base.ts:137-140` |
424
- | `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-sheet.ts:57-63` |
425
- | `setupCanDeactivate(() => viewType() === "modal" || checkIgnoreChanges())` | 라우트 이탈 시 변경사항 확인 | `sd-data-sheet.base.ts:227` |
426
- | `_refresh()` 내 `busyCount.update + try/finally + sdToast.try` | busy 카운트 증감 + 에러 토스트 | `injectDataSheetRefreshManager.ts:33-46` (삭제됨) |
427
- | `_getDiffs()` = `items.oneWayDiffs(snapshot, "id")` | 변경 감지 | `injectDataSheetRefreshManager.ts`의 `getDiffs()` (삭제됨) |
428
- | `effect(() => { lastFilter(); page(); sortingDefs(); ... })` | 필터/페이지/정렬 변경 시 재조회 | `injectDataSheetRefreshManager.ts` (삭제됨) |
429
- | `mark(this._lastFilter)` | lastFilter 참조 갱신 → effect 재실행 (값 변경 없음) | `sd-data-sheet.base.ts:245` |
430
- | `modalOrPageTitle` computed | `modal title ?? app structure title` | `sd-base-container.ts:102-113` (`header ??` 부분은 필요 시 `input()` 추가) |
431
-
432
- ### 상태 분해
433
-
434
- | signal / computed | 역할 |
435
- |---|---|
436
- | `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
437
- | `busyMessage` | busy 오버레이 문구 |
438
- | `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
439
- | `items` | 현재 페이지 items |
440
- | `summaryData` | 서버가 반환한 합계·집계 (선택적으로 시트 summary row에 표시) |
441
- | `selectedItems` | 선택된 item 배열 (`<sd-sheet [(selectedItems)]>`로 양방향) |
442
- | `page` / `pageLength` | 0-based 현재 페이지 / 전체 페이지 수 |
443
- | `sortingDefs` | `SortingDef[]` — `{ key: string; desc: boolean }[]`, `<sd-sheet [(sorts)]>`로 양방향 |
444
- | `filterSearch` / `_lastFilter` | filter는 입력 버퍼, lastFilter는 "조회" 제출 시점 스냅샷 |
445
- | `_snapshot` | 최근 `_refresh()` 시점의 items 복사본 (변경 감지용) |
446
-
447
- ### 메서드 분해
448
-
449
- | 메서드 | 역할 |
450
- |---|---|
451
- | `onFilterSubmit()` | page=0 리셋 + `_lastFilter.set(filter 스냅샷)` |
452
- | `onRefreshButtonClick()` | busy/권한/변경사항 확인 후 `mark(_lastFilter)` |
453
- | `_refresh()` | search → items.set + pageLength + summary + 선택 유지 + snapshot 갱신 |
454
- | `_editItem(item?)` | `SdModalProvider.showAsync(...)`로 편집 모달 실행 후 refresh |
455
- | `onToggleDeleteItemsButtonClick(del)` | 선택 item ID들을 서버에 전송하여 isDeleted 토글 + refresh |
456
- | `_checkIgnoreChanges()` | snapshot 대비 diff 없으면 true, 있으면 `confirm` 후 true/false |
457
- | `_getDiffs()` | `items.oneWayDiffs(_snapshot, "id")` — `ArrayOneWayDiffResult<T>[]` |
458
-
459
- ## 5. 변형: inline 편집 모드
460
-
461
- 행을 직접 수정하고 `ArrayOneWayDiffResult` 기반 diff로 일괄 저장한다. `CustomerListPage`를 기준으로 아래 변경을 적용한다:
306
+ 섹션에 등장하는 개별 API의 단독 사용법은 각 문서를 참조한다:
462
307
 
463
- ```typescript
464
- // 1) imports에 추가: FormatPipe (선택)
465
- // host에 sdSaveCommand 추가
466
- hostDirectives: [
467
- { directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
468
- ],
469
- host: {
470
- "(sdRefreshCommand)": "onRefreshButtonClick()",
471
- "(sdSaveCommand)": "onSaveButtonClick()",
472
- },
473
-
474
- // 2) template — 도구 영역에 "행 추가" 버튼 추가
475
- <sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddItemButtonClick()">
476
- <ng-icon [svg]="icons.tablerCirclePlus" />
477
- 행 추가
478
- </sd-button>
479
-
480
- // 3) template — <sd-sheet>를 <sd-form>으로 감싸고 (formSubmit)="onSubmit()" 추가
481
- <sd-form #formCtrl (formSubmit)="onSubmit()" class="flex-fill p-default pt-0">
482
- <sd-sheet ...>
483
- <!-- isDeleted 전용 고정 컬럼 -->
484
- @if (canEdit()) {
485
- <sd-sheet-column [fixed]="true" [key]="'isDeleted'">
486
- <ng-template #headerTpl>
487
- <div class="p-xs-sm tx-center"><ng-icon [svg]="icons.tablerEraser" /></div>
488
- </ng-template>
489
- <ng-template [cell]="items()" let-item="item">
490
- <div class="p-xs-sm tx-center">
491
- <sd-anchor
492
- [theme]="'danger'"
493
- (click)="onToggleDeleteItemButtonClick(item)"
494
- >
495
- <ng-icon [svg]="item.isDeleted ? icons.tablerRestore : icons.tablerEraser" />
496
- {{ item.isDeleted ? "복구" : "삭제" }}
497
- </sd-anchor>
498
- </div>
499
- </ng-template>
500
- </sd-sheet-column>
501
- }
502
-
503
- <!-- 편집 가능 셀: sd-textfield 등은 [inset]="true" [size]="'sm'" 명시 -->
504
- <sd-sheet-column [key]="'name'" [header]="'이름'">
505
- <ng-template [cell]="items()" let-item="item">
506
- <sd-textfield [type]="'text'" [(value)]="item.name" [inset]="true" [size]="'sm'" />
507
- </ng-template>
508
- </sd-sheet-column>
509
- </sd-sheet>
510
- </sd-form>
511
-
512
- // 4) 메서드 추가/대체
513
- protected readonly formCtrl = viewChild<SdForm>("formCtrl");
514
-
515
- protected onSaveButtonClick(): void {
516
- this.formCtrl()?.requestSubmit();
517
- }
308
+ - [`<sd-busy-container>`](../ui-overlay/sd-busy-container.md) — busy 오버레이 + busyCount 카운트 패턴
309
+ - [`<sd-topbar-container>` · `<sd-topbar>`](../ui-navigation/sd-topbar-container.md) 탑바
310
+ - [`<sd-dock-container>` · `<sd-dock>`](../ui-layout/sd-dock-container.md) — 도킹 레이아웃
311
+ - [`<sd-form>`](../ui-form/sd-form.md) — 폼 래퍼 + `(formSubmit)` + `requestSubmit()`
312
+ - [`<sd-button>`](../ui-form/sd-button.md) · [`<sd-textfield>`](../ui-form/sd-textfield.md)
313
+ - [`<sd-sheet>` · `<sd-sheet-column>` · `<ng-template [cell]>`](../ui-data/sd-sheet.md)
314
+ - [`injectViewTypeSignal`](../utils/inject-routing-signals.md#injectviewtypesignal) · [`injectViewTitleSignal`](../utils/inject-routing-signals.md#injectviewtitlesignal) · [`mark`](../utils/mark.md) · [`injectPermsSignal`](../utils/inject-routing-signals.md#injectpermssignal) · [`SdToastProvider.try`](../providers/sd-toast-provider.md#try-사용-패턴)
518
315
 
519
- protected onAddItemButtonClick(): void {
520
- const newItem: ICustomer = { id: Uuid.generate().toString(), name: "", phone: "", isDeleted: false };
521
- this.items.update((list) => [newItem, ...list]);
522
- }
316
+ ### 조건부 요소 포함 기준
523
317
 
524
- protected onToggleDeleteItemButtonClick(item: ICustomer): void {
525
- item.isDeleted = !item.isDeleted;
526
- mark(this.items); // OnPush 재렌더 + effect 알림
527
- }
318
+ 최소 뼈대의 인프라·라이프사이클 요소는 화면의 필요에 따라 포함·생략한다. 필요 없는 요소를 기계적으로 포함하지 않는다.
528
319
 
529
- protected async onSubmit(): Promise<void> {
530
- const diffs = this._getDiffs();
531
- if (diffs.length === 0) {
532
- this._sdToast.info("변경사항이 없습니다.");
533
- return;
534
- }
535
- await this._sdToast.try(async () => {
536
- this.busyCount.update((v) => v + 1);
537
- try {
538
- // 서버 호출: await this._api.submitAsync(diffs);
539
- // diffs.forEach((d) => {
540
- // if (d.type === "create") { ... insert ... }
541
- // else if (d.type === "update") { ... update ... }
542
- // // "same"은 includeSame=true 옵션에서만 등장. 여기서는 create/update만.
543
- // });
544
- // 삭제는 `isDeleted: true`로 업데이트 → 서버가 soft-delete 처리
545
- await this._refresh();
546
- } finally {
547
- this.busyCount.update((v) => v - 1);
548
- }
549
- }, getOrmDataEditToastErrorMessage);
550
- }
551
- ```
320
+ | 요소 | 포함 조건 | 생략하는 경우 예시 |
321
+ |------|----------|-------------------|
322
+ | `<sd-topbar-container>` + `<sd-topbar>` | routes로 연결된 페이지에서 헤더를 표시할 때 | route 미연결 컴포넌트(control, 래퍼 ) |
323
+ | `injectViewTitleSignal()` | 타이틀이 필요할 때. topbar에 타이틀을 표시하는 page에는 보통 포함 | topbar가 없거나 타이틀 표시가 불필요한 화면 |
324
+ | `injectViewTypeSignal()` + `@if (viewType() === "page")` 가드 | 해당 컴포넌트가 page 외에 modal 또는 control로도 겸용될 때 | page 전용 리스트, page 전용 대시보드 |
325
+ | `injectPermsSignal()` + 권한 없음 메시지 | 권한 제어가 있는 화면. 권한 제어 자체가 있으면 필수 | 권한 제어가 없는 화면 |
326
+ | `<sd-busy-container>` + `busyCount` | 화면에 비동기 작업(DB 조회, API 호출 등) 있어서 busy 표시가 필요할 때 | 비동기 로딩 없이 동기적으로 렌더되는 래퍼/레이아웃 화면 |
327
+ | `initialized` + `@if (initialized())` 가드 | 초기 데이터 로딩이 완료되기 전에는 화면을 그리면 안 되는 경우 (깜박임 방지) | 초기 로딩이 필요 없거나, 빈 상태로 보여줘도 무방한 화면 |
328
+ | `SHARED_DATA_KEY` + `emitAsync()` 호출 | 해당 화면에서 `SdSharedDataProvider`에 등록된 데이터를 변경(생성/수정/삭제)하는 경우 | 조회만 하는 화면, sharedData에 등록되지 않은 데이터를 다루는 화면 |
552
329
 
553
- > **`oneWayDiffs`는 delete를 다루지 않는다.** `newItems.oneWayDiffs(orgItems, keyFn)`은 `type: "create" | "update" | "same"`만 반환한다. 삭제 의사는 **`item.isDeleted = true` 플래그**로 표현하여 `"update"` diff로 전송된다. 행을 items 배열에서 제거하면 diff에서 누락되므로 절대 삭제하지 않는다.
330
+ ## 변형 (Variation)
554
331
 
555
- ## 6. 변형: 선택 모달
332
+ 아래 확장 필요한 것만 선택적으로 얹는다. 각 확장은 self-contained 문서에서 최소 뼈대 대비 diff를 제공한다.
556
333
 
557
- 화면을 선택 모달로 재사용한다. `<sd-sheet>`가 items 기반이므로 key는 `selectedItems().map((it, i) => trackByFn(it, i))` 수동 변환으로 `SelectModalOutputResult<T>`를 구성한다.
334
+ | 확장 | 언제 쓰나 | 전제 | 문서 |
335
+ |---|---|---|---|
336
+ | A | 셀 직접 편집 + 일괄 저장(inline 편집) | 없음 | [extension-a-inline-edit.md](./crud-list/extension-a-inline-edit.md) |
337
+ | B | 선택 체크박스 + 선택 삭제·복구 | A | [extension-b-selection.md](./crud-list/extension-b-selection.md) |
338
+ | C | 시트 맨 앞 고정 열에 row별 inline 삭제·복구 | A + B | [extension-c-inline-delete.md](./crud-list/extension-c-inline-delete.md) |
339
+ | D | 선택 모달 전환(항목을 골라 `close.emit`으로 돌려줌) | A + B | [extension-d-select-modal.md](./crud-list/extension-d-select-modal.md) |
340
+ | E | 조회 전용 modal(부모 레코드의 자식 목록·이력을 input으로 받아 읽기 전용 표시) | 없음 (최소 뼈대에 직접) | [extension-e-readonly-modal.md](./crud-list/extension-e-readonly-modal.md) |
341
+ | F | 모달 편집 모드(행 클릭 → 편집 모달). A와 상호 배타 | 없음 | [extension-f-modal-edit.md](./crud-list/extension-f-modal-edit.md) |
342
+ | G | 엑셀 업로드/다운로드 (`_upsertItem` 재사용) | A | [extension-g-excel.md](./crud-list/extension-g-excel.md) |
558
343
 
559
- ```typescript
560
- // 1) 컴포넌트 import
561
- import {
562
- type SdSelectModal,
563
- type SelectModalOutputResult,
564
- } from "@simplysm/angular";
565
- import { input, output, model } from "@angular/core";
344
+ ## 🚫 흔한 실수 (Anti-patterns)
566
345
 
567
- // 2) 클래스 선언에 implements 추가
568
- export class CustomerListPage implements SdSelectModal<ICustomer> {
569
- // ...기존 필드 유지
346
+ ### modal = 선택 모달로 반사적 부착
570
347
 
571
- // SdModalContentDef<SelectModalOutputResult<ICustomer>> 요구 필드
572
- close = output<SelectModalOutputResult<ICustomer> | undefined>();
348
+ `viewType() === "modal"`이라는 사실만으로 "선택 모달"이라고 단정하고 `SdSelectModal<T>` 계약을 부착하지 않는다. modal 용도는 최소 두 가지다 — (a) 선택 모달(확장 D): 항목을 골라 `close.emit`으로 돌려줌 / (b) 조회 전용(확장 E): 부모 레코드의 자식 목록·이력을 읽기 전용으로 표시, 닫기는 SdModal 기본 "X".
573
349
 
574
- // SdSelectModal<ICustomer> 요구 필드
350
+ ```typescript
351
+ // ❌ viewType() === "modal"만 보고 선택 모달 계약을 반사적으로 부착
352
+ export class CustomerList implements SdSelectModal<ICustomer> {
575
353
  selectMode = input<"single" | "multi">();
576
- selectedItemKeys = input<string[]>([]);
577
-
578
- // initialized는 WritableSignal이 아닌 Signal을 요구하므로 computed 또는 기존 signal 그대로 노출
579
- // (위 완성 예제의 protected readonly initialized = signal(false) 를 그대로 사용)
580
- }
581
-
582
- // 3) template — 시트의 selectMode를 input 값으로 연결, 누적 선택 활성화
583
- <sd-sheet
584
- ...
585
- [selectMode]="selectMode()"
586
- [cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
587
- [trackByFn]="trackByFn"
588
- [(selectedItems)]="selectedItems"
589
- >
590
- ...
591
- </sd-sheet>
592
-
593
- // 4) template — 모달 뷰 분기 (page-modal-container.md 기반)
594
- } @else if (viewType() === "modal") {
595
- <div class="flex-column fill">
596
- <div class="flex-fill">
597
- <!-- 필터 / 도구 / 시트를 이곳에 동일하게 배치 (page 분기와 같은 구조) -->
598
- </div>
599
- <!-- 하단 액션 바 -->
600
- <div class="p-sm-default flex-row gap-sm bdt bdt-theme-gray-lightest">
601
- <div class="flex-fill"></div>
602
- @if (selectedItems().length > 0) {
603
- <sd-button [size]="'sm'" [theme]="'danger'" (click)="onModalCancelClick()">
604
- {{ selectMode() === "multi" ? "모두" : "선택" }} 해제
605
- </sd-button>
606
- }
607
- @if (selectMode() === "multi") {
608
- <sd-button [size]="'sm'" [theme]="'primary'" (click)="onModalConfirmClick()">
609
- 확인({{ selectedItems().length }})
610
- </sd-button>
611
- }
612
- </div>
613
- </div>
614
- }
615
-
616
- // 5) 메서드 추가 — close.emit 수동 변환
617
- protected onModalConfirmClick(): void {
618
- const items = this.selectedItems();
619
- this.close.emit({
620
- selectedItemKeys: items.map((it, i) => this.trackByFn(it, i) as string),
621
- selectedItems: items,
622
- });
354
+ selectedItemKeys = input<any[]>([]);
355
+ close = output<SelectModalOutputResult<ICustomer>>();
356
+ // ... 조회 전용인데도 하단 "선택 해제 / 확인" 바, cumulativeSelection까지 전부 이식
623
357
  }
624
358
 
625
- protected onModalCancelClick(): void {
626
- this.selectedItems.set([]);
627
- this.close.emit({ selectedItemKeys: [], selectedItems: [] });
628
- }
629
-
630
- // 6) 최초 진입 시 selectedItemKeys input으로 전달된 key들에 해당하는 item을 미리 선택에 반영
631
- // (필요한 경우 refresh 직후 effect 하나 추가)
632
- effect(() => {
633
- const keys = this.selectedItemKeys();
634
- if (keys.length === 0) return;
635
- untracked(() => {
636
- const selected = this.items().filter((it) => keys.includes(this.trackByFn(it)));
637
- if (selected.length > 0) this.selectedItems.set(selected);
638
- });
639
- });
359
+ // modal 용도를 먼저 확정한다
360
+ // (a) 선택 모달이면 확장 D 스켈레톤부터 시작
361
+ // (b) 조회 전용이면 확장 E 스켈레톤부터 시작 — SdSelectModal 계약 부착 안 함
640
362
  ```
641
363
 
642
- ### `cumulativeSelection` 동적 바인딩 원칙
643
-
644
- - `cumulativeSelection` **기본값은 `false`** — `<sd-sheet>`의 items가 교체될 때 `selectedItems`가 빈 배열로 초기화된다
645
- - 선택 모달(`viewType() === "modal"`) + 다중 선택(`selectMode() === "multi"`) 조합에서는 **여러 페이지를 돌며 선택 누적**이 필요하므로 `true`로 바인딩
646
- - 그 외(page 뷰 또는 single selectMode)는 현재 뷰의 **일괄 작업**이 일반적 — `false`(기본값)가 맞다
647
- - 그러므로 정적 `true`가 아니라 **동적 computed**로 바인딩:
648
- ```html
649
- <sd-sheet
650
- [cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
651
- [trackByFn]="trackByFn"
652
- ...
653
- >
654
- ```
655
- - 누적 모드 사용 시 `trackByFn` 명시 필수. 기본 `(item) => item`은 reference 기반이라 서버 페이지네이션에서 새 객체 reference가 내려오면 `obj.equal`로 비교해도 실패할 수 있다. `(item) => item.id` 같은 key 추출 함수를 반드시 지정한다.
364
+ **근거**: 조회 전용 modal에 선택 계약을 부착하면 호출되지 않아 죽은 코드가 된다. LLM이 풀 스택 합본을 복사하면서 "modal 지원"이라는 이유로 반사적으로 이식하는 회귀가 잦다. [확장 E](./crud-list/extension-e-readonly-modal.md) 참조.
656
365
 
657
- ## 7. 변형: 엑셀 업로드/다운로드
366
+ ### viewType 추측으로 3뷰 모두 박기 / 완전 분리 블록 작성
658
367
 
659
- `SdFileDialogProvider`로 파일 선택, 서버 API처리, `SdToastProvider.try`로 래핑한다.
368
+ 화면이 실제 어떤 뷰로 쓰이는지 확정하지 않은 채 page·modal·control 3뷰용 조각을 모두 배치하지 않는다(당장 쓰지 않는 뷰의 계약·분기는 죽은 코드가 된다). 또한 시트 페이지를 page와 modal겸용할 때(확장 D)도 page 블록과 modal 블록을 각각 완성하면서 필터·시트를 중복 작성하지 않는다. 하나의 `<sd-topbar-container>` + `<sd-dock-container>` 공통 껍데기 위에 뷰별로 다른 조각만 `@if`로 얹는다.
660
369
 
661
- ```typescript
662
- // 1) import 추가
663
- import { tablerFileExcel, tablerUpload } from "@ng-icons/tabler-icons";
664
- import { SdFileDialogProvider } from "@simplysm/angular";
665
-
666
- // 2) DI 추가
667
- private readonly _sdFileDialog = inject(SdFileDialogProvider);
668
-
669
- // 3) icons 맵에 추가
670
- protected readonly icons = {
671
- ...existingIcons,
672
- tablerFileExcel, tablerUpload,
673
- };
674
-
675
- // 4) template — 도구 영역 끝에 추가
676
- @if (canEdit()) {
677
- <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
678
- <ng-icon [svg]="icons.tablerUpload" />
679
- 엑셀 업로드
680
- </sd-button>
370
+ ```html
371
+ <!-- page 블록과 modal 블록을 각각 완성 — 필터·시트가 중복되어 수정 시 양쪽을 고쳐야 함 -->
372
+ @if (viewType() === "page") {
373
+ <sd-topbar-container>
374
+ <sd-topbar>...</sd-topbar>
375
+ <sd-dock-container>
376
+ <sd-dock class="p-default"><sd-form>...</sd-form></sd-dock>
377
+ <sd-sheet ...>...</sd-sheet>
378
+ </sd-dock-container>
379
+ </sd-topbar-container>
681
380
  }
682
- <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
683
- <ng-icon [svg]="icons.tablerFileExcel" />
684
- 엑셀 다운로드
685
- </sd-button>
686
-
687
- // 5) 메서드 추가
688
- protected async onDownloadExcelButtonClick(): Promise<void> {
689
- await this._sdToast.try(async () => {
690
- this.busyCount.update((v) => v + 1);
691
- this.busyMessage.set("엑셀 다운로드 중...");
692
- try {
693
- // 전체 조회 (페이지네이션 없이)
694
- const r = await this._fetchList(this._lastFilter(), 0, this.sortingDefs());
695
- // 서버 호출 또는 클라이언트 변환 (앱별):
696
- // await this._api.downloadExcelAsync(r.items);
697
- } finally {
698
- this.busyMessage.set(undefined);
699
- this.busyCount.update((v) => v - 1);
700
- }
701
- }, getOrmDataEditToastErrorMessage);
381
+ @if (viewType() === "modal") {
382
+ <sd-topbar-container>
383
+ <sd-dock-container>
384
+ <sd-dock class="p-default"><sd-form>...</sd-form></sd-dock>
385
+ <sd-sheet ...>...</sd-sheet>
386
+ </sd-dock-container>
387
+ </sd-topbar-container>
702
388
  }
703
389
 
704
- protected async onUploadExcelButtonClick(): Promise<void> {
705
- const file = await this._sdFileDialog.showAsync(false, ".xlsx");
706
- if (file == null) return;
707
- await this._sdToast.try(async () => {
708
- this.busyCount.update((v) => v + 1);
709
- this.busyMessage.set("엑셀 업로드 중...");
710
- try {
711
- // 서버 호출 (앱별):
712
- // await this._api.uploadExcelAsync(file);
713
- await this._refresh();
714
- } finally {
715
- this.busyMessage.set(undefined);
716
- this.busyCount.update((v) => v - 1);
717
- }
718
- }, getOrmDataEditToastErrorMessage);
719
- }
390
+ <!-- 하나의 껍데기 + 차이점만 @if로 얹는다 -->
391
+ <sd-topbar-container>
392
+ @if (viewType() === "page") { <sd-topbar>...</sd-topbar> }
393
+ <sd-dock-container>
394
+ <sd-dock class="p-default"><sd-form>...</sd-form></sd-dock>
395
+ <sd-sheet ...>...</sd-sheet>
396
+ </sd-dock-container>
397
+ </sd-topbar-container>
720
398
  ```
721
399
 
722
- ## 8. 타입 분기
723
-
724
- `@if (viewType() === "page") ... @else if (viewType() === "modal") ... @else { ... }` 분기 구조와 `modalOrPageTitle` computed 계산은 [`page-modal-container.md`](./page-modal-container.md)의 레시피와 동일하다. 본 레시피의 완성 예제도 그 패턴을 그대로 사용한다. control 뷰(다른 화면의 영역으로 삽입)로만 쓰일 경우에는 `@if` 분기를 생략하고 본문만 작성한다.
725
-
726
- ## 9. 주의사항 (자주 하는 실수)
727
-
728
- ### 공통 유틸 재도입 금지
729
-
730
- - `useCrudList()`, `useDataSheet()`, `setupCumulateSelectedKeys2()` 같은 공통 헬퍼를 도입하지 말 것. 이 레시피가 제거한 추상화를 다시 만드는 행위다. 세 화면이 비슷해 보여도 화면마다 필드·동작 시그니처가 조금씩 다르므로 복사·수정이 낫다
731
-
732
- ### 시트 셀 스타일 함정
400
+ **근거**: 필터 줄을 수정할 때 두 블록을 모두 고쳐야 하는 상황이 생기면 구조가 잘못된 것이다. "완전 분리"는 확장 D에서 modal 하단 바 같은 **뷰별 고유 조각**에만 적용한다.
733
401
 
734
- - `<sd-sheet-column>`의 `[cell]` 템플릿 내부에 삽입되는 컨트롤(`sd-textfield` / `sd-select` / `sd-checkbox` / `sd-numpad` / `sd-date-range-picker` / `sd-textarea` 등)은 **`[inset]="true" [size]="'sm'"` 명시 필수**. 누락 시 컴파일 에러 없이 스타일만 깨진다(테두리·여백이 시트 셀에 맞지 않음). 예외: 복합 구조(텍스트+컨트롤)는 `[inset]="false"`, 시트 행 높이가 큰 경우는 `[size]` 생략 가능
402
+ ### `orderBy` 람다 + `obj.getChainValue` 회귀
735
403
 
736
- ### `oneWayDiffs`의 삭제 처리
404
+ `Queryable.orderBy`는 string overload를 지원하므로(`packages/orm-common/src/exec/queryable.ts:420`), 람다 + `obj.getChainValue` 우회 코드를 쓰지 않는다.
737
405
 
738
- - `newItems.oneWayDiffs(orgItems, keyFn)`은 **삭제(delete)를 다루지 않는다**. `type: "create" | "update" | "same"`만 반환
739
- - 삭제 의사 표현은 **`item.isDeleted = true` 플래그**로 하고 `"update"` diff로 전송 (서버가 soft-delete 처리)
740
- - inline 편집 `items` 배열에서 row를 제거하지 말 것. diff에서 해당 row가 누락되어 서버가 변경을 감지할 수 없다
741
-
742
- ### `selectedItemKeys` 수동 변환
743
-
744
- - `<sd-sheet>`는 key 기반이 아니라 item 기반이다. `SelectModalOutputResult<T>.selectedItemKeys`를 구성하려면 `selectedItems().map((it, i) => trackByFn(it, i))`로 수동 변환한다
745
- - `trackByFn`의 signature는 `(item: T, index: number) => unknown`이므로 두 번째 인자(index)를 전달해야 타입이 맞는다
746
-
747
- ### `injectViewTypeSignal()` 호출 시점
406
+ ```typescript
407
+ // overload 도입 우회 코드 LLM 훈련 데이터에 잔존
408
+ for (const s of sortingDefs) {
409
+ qr2 = qr2.orderBy(
410
+ (item) => obj.getChainValue(item, s.key, true) as any,
411
+ s.desc ? "DESC" : "ASC",
412
+ );
413
+ }
748
414
 
749
- - `injectViewTypeSignal()`은 생성자 실행 또는 필드 이니셜라이저에서만 호출한다. `computed`·`effect` 콜백이나 일반 메서드에서 호출하면 `NG0203` 런타임 에러가 발생한다 (Angular `inject()` 제약)
415
+ // string overload 사용. 체인 경로도 string으로 지원: qr.orderBy("user.name")
416
+ for (const s of sortingDefs) {
417
+ qr2 = qr2.orderBy(s.key, s.desc ? "DESC" : "ASC");
418
+ }
419
+ ```
750
420
 
751
- ## 10. 레시피 작성 관용 규칙
421
+ **근거**: 람다 형태는 타입 추론을 깨뜨리고 `as any` 캐스팅을 강제한다. string overload는 체인 경로까지 타입 안전하게 지원한다.
752
422
 
753
- 향후 `crud-detail.md` · `data-select-button.md` 등 데이터 관련 레시피가 추가될 때 아래 3개 규칙을 공통으로 따른다.
423
+ ### 테스트용 public API 노출
754
424
 
755
- ### 규칙 1: 시트 내부 컨트롤은 `[inset]="true" [size]="'sm'"` 명시
425
+ 테스트에서 호출하려고 private 메서드의 얇은 public wrapper를 노출하지 않는다.
756
426
 
757
- - `<sd-sheet-column>` `[cell]` 템플릿 내부의 `sd-textfield` / `sd-select` / `sd-checkbox` / `sd-numpad` / `sd-date-range-picker` / `sd-textarea`는 레시피에서 **항상** `[inset]="true" [size]="'sm'"`를 함께 노출한다
758
- - 예외: 복합 구조(텍스트+컨트롤) → `[inset]="false"`. 시트 → `[size]` 생략
759
- - 누락 컴파일 에러가 발생하지 않아 LLM이 빠뜨리기 쉽다. 자주 하는 실수 섹션에 명시
427
+ ```typescript
428
+ // "테스트에서 호출하려고" private 메서드를 public으로 노출
429
+ async submit(diffs: IDiff[]): Promise<void> {
430
+ await this._submitAsync(diffs);
431
+ }
760
432
 
761
- ### 규칙 2: `mark(sig)`는 "저장 감지"가 아니라 "UI 동기화"
433
+ // TestBed fixture + click/dispatch 이벤트 경로 또는 host의 sdSaveCommand 트리거
434
+ fixture.nativeElement.dispatchEvent(
435
+ new KeyboardEvent("keydown", { key: "s", ctrlKey: true }),
436
+ );
437
+ ```
762
438
 
763
- - `mark(sig)`는 `WritableSignal`의 값을 shallow copy하여 **참조를 갱신**한다 (배열: `[...v]`, 객체: `{...v}`)
764
- - 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**
765
- - **"저장 감지"가 아니다.** `obj.equal`이 deep equal로 값 차이를 감지하므로, `item.name = "new"` 같은 mutation은 `mark` 없이도 `_getDiffs()` / submit에서 감지된다
766
- - Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요
767
- - ❌ "mark 없으면 저장이 안 된다" 식 서술 금지
439
+ **근거**: public wrapper는 캡슐화를 깨고 컴포넌트의 외부 API 인상을 오염시킨다. 실제 사용자가 쓰지 않는 진입점을 공개 API로 만들면 추후 리팩토링 시 제약이 된다.
768
440
 
769
- ### 규칙 3: `sortingDefs` + `orderBy` 체인은 string overload 사용
441
+ ## 관련 Entry
770
442
 
771
- - `Queryable.orderBy`는 string overload를 지원한다 (`packages/orm-common/src/exec/queryable.ts:420`)
772
- - 레시피는 아래 형태로 작성:
773
- ```typescript
774
- for (const s of sortingDefs) {
775
- qr = qr.orderBy(s.key, s.desc ? "DESC" : "ASC");
776
- }
777
- ```
778
- - 체인 경로도 string으로 지원: `qr.orderBy("user.name")` 형태
779
- - 과거 람다 형태 (`qr.orderBy((item) => obj.getChainValue(item, s.key, true) as any, ...)`)는 **쓰지 않는다** — overload 도입 전 우회 코드였다
443
+ - [`_common-rules.md`](./_common-rules.md) 4계열 레시피 횡단 공통 규칙. 본 파일에서 재정의하지 않는 모든 규칙은 여기에 있음
444
+ - [`crud-detail.md`](./crud-detail.md) 차이: 단일 레코드 상세 폼(뷰 범위 선택·편집·저장·삭제/복구)
445
+ - [`data-select-button.md`](./data-select-button.md) — 차이: 모달 기반 선택 버튼 직접 조립(`<sd-modal-select-button>`)
446
+ - [`page-modal-container.md`](./page-modal-container.md) 차이: page/modal/control 3뷰 재사용 공통 껍데기만