@simplysm/angular 14.0.49 → 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 (134) hide show
  1. package/README.md +234 -225
  2. package/dist/controls/select/sd-select.js +3 -3
  3. package/dist/layout/dock/sd-dock-container.js +1 -1
  4. package/docs/bootstrap/provide-sd-angular.md +37 -0
  5. package/docs/bootstrap/sd-angular-config-provider.md +16 -0
  6. package/docs/directives/sd-command-directive.md +30 -0
  7. package/docs/directives/sd-events.md +25 -0
  8. package/docs/directives/sd-intersection-directive.md +36 -0
  9. package/docs/directives/sd-invalid.md +24 -0
  10. package/docs/directives/sd-resize-directive.md +42 -0
  11. package/docs/directives/sd-ripple.md +23 -0
  12. package/docs/directives/sd-router-link.md +38 -0
  13. package/docs/directives/sd-show-effect.md +18 -0
  14. package/docs/directives/sd-typed-template.md +69 -0
  15. package/docs/features/sd-address-search-modal.md +50 -0
  16. package/docs/features/sd-permission-table.md +20 -0
  17. package/docs/features/sd-shared-data-components.md +158 -0
  18. package/docs/features/sd-tiptap-editor.md +26 -0
  19. package/docs/{pipes.md → pipes/format-pipe.md} +14 -5
  20. package/docs/plugins/sd-global-error-handler.md +23 -0
  21. package/docs/{plugins.md → plugins/sd-option-event-plugin.md} +9 -12
  22. package/docs/provider-types/sd-menu.md +65 -0
  23. package/docs/provider-types/sd-modal-content-def.md +148 -0
  24. package/docs/provider-types/sd-toast-content-def.md +73 -0
  25. package/docs/provider-types/shared-data-base.md +59 -0
  26. package/docs/providers/sd-activated-modal-provider.md +34 -0
  27. package/docs/providers/sd-app-structure-provider.md +81 -0
  28. package/docs/providers/sd-busy-provider.md +18 -0
  29. package/docs/providers/sd-file-dialog-provider.md +40 -0
  30. package/docs/providers/sd-local-storage-provider.md +20 -0
  31. package/docs/providers/sd-modal-provider.md +67 -0
  32. package/docs/providers/sd-navigate-window-provider.md +18 -0
  33. package/docs/providers/sd-print-provider.md +25 -0
  34. package/docs/providers/sd-service-client-factory-provider.md +43 -0
  35. package/docs/providers/sd-shared-data-provider.md +64 -0
  36. package/docs/providers/sd-system-config-provider.md +46 -0
  37. package/docs/providers/sd-system-log-provider.md +18 -0
  38. package/docs/providers/sd-theme-provider.md +38 -0
  39. package/docs/providers/sd-toast-provider.md +65 -0
  40. package/docs/recipes/_common-rules.md +244 -0
  41. package/docs/recipes/crud-detail/extension-a-edit-save.md +230 -0
  42. package/docs/recipes/crud-detail/extension-b-delete-restore.md +142 -0
  43. package/docs/recipes/crud-detail/extension-c-modal-view.md +214 -0
  44. package/docs/recipes/crud-detail/extension-d-control-view.md +103 -0
  45. package/docs/recipes/crud-detail/extension-e-auxiliary.md +87 -0
  46. package/docs/recipes/crud-detail/extension-f-complex-detail.md +234 -0
  47. package/docs/recipes/crud-detail.md +184 -706
  48. package/docs/recipes/crud-list/extension-a-inline-edit.md +410 -0
  49. package/docs/recipes/crud-list/extension-b-selection.md +226 -0
  50. package/docs/recipes/crud-list/extension-c-inline-delete.md +87 -0
  51. package/docs/recipes/crud-list/extension-d-select-modal.md +207 -0
  52. package/docs/recipes/crud-list/extension-e-readonly-modal.md +165 -0
  53. package/docs/recipes/crud-list/extension-f-modal-edit.md +177 -0
  54. package/docs/recipes/crud-list/extension-g-excel.md +157 -0
  55. package/docs/recipes/crud-list.md +196 -787
  56. package/docs/recipes/data-select-button.md +185 -91
  57. package/docs/recipes/page-modal-container.md +168 -86
  58. package/docs/styling/classes.md +149 -0
  59. package/docs/styling/mixins.md +100 -0
  60. package/docs/styling/themes.md +35 -0
  61. package/docs/styling/variables.md +147 -0
  62. package/docs/{type-utilities.md → type-utilities/directive-input-signals.md} +17 -35
  63. package/docs/ui-data/sd-list.md +37 -0
  64. package/docs/ui-data/sd-sheet.md +227 -0
  65. package/docs/ui-form/sd-additional-button.md +26 -0
  66. package/docs/ui-form/sd-anchor.md +31 -0
  67. package/docs/ui-form/sd-button.md +105 -0
  68. package/docs/ui-form/sd-checkbox-group.md +39 -0
  69. package/docs/ui-form/sd-checkbox.md +81 -0
  70. package/docs/ui-form/sd-date-range-picker.md +27 -0
  71. package/docs/ui-form/sd-form.md +89 -0
  72. package/docs/ui-form/sd-modal-select-button.md +54 -0
  73. package/docs/ui-form/sd-numpad.md +26 -0
  74. package/docs/ui-form/sd-range.md +26 -0
  75. package/docs/ui-form/sd-select.md +68 -0
  76. package/docs/ui-form/sd-shared-data-select.md +52 -0
  77. package/docs/ui-form/sd-state-preset.md +37 -0
  78. package/docs/ui-form/sd-switch.md +27 -0
  79. package/docs/ui-form/sd-textarea.md +33 -0
  80. package/docs/ui-form/sd-textfield.md +145 -0
  81. package/docs/ui-layout/sd-dock-container.md +64 -0
  82. package/docs/ui-layout/sd-dock.md +37 -0
  83. package/docs/ui-layout/sd-gap.md +26 -0
  84. package/docs/{ui-layout.md → ui-layout/sd-kanban-board.md} +41 -85
  85. package/docs/ui-layout/sd-kanban-lane.md +34 -0
  86. package/docs/ui-layout/sd-kanban.md +29 -0
  87. package/docs/ui-navigation/sd-collapse.md +35 -0
  88. package/docs/ui-navigation/sd-pagination.md +26 -0
  89. package/docs/ui-navigation/sd-sidebar-container.md +49 -0
  90. package/docs/ui-navigation/sd-sidebar-menu.md +22 -0
  91. package/docs/ui-navigation/sd-sidebar-user.md +43 -0
  92. package/docs/ui-navigation/sd-tab.md +51 -0
  93. package/docs/ui-navigation/sd-topbar-container.md +97 -0
  94. package/docs/ui-navigation/sd-topbar-menu.md +23 -0
  95. package/docs/ui-navigation/sd-topbar-user.md +38 -0
  96. package/docs/ui-navigation/sd-topbar.md +30 -0
  97. package/docs/ui-overlay/sd-busy-container.md +69 -0
  98. package/docs/ui-overlay/sd-confirm-modal.md +30 -0
  99. package/docs/ui-overlay/sd-dropdown.md +40 -0
  100. package/docs/ui-overlay/sd-modal.md +34 -0
  101. package/docs/ui-overlay/sd-prompt-modal.md +30 -0
  102. package/docs/ui-overlay/sd-toast.md +35 -0
  103. package/docs/ui-visual/sd-barcode.md +36 -0
  104. package/docs/ui-visual/sd-calendar.md +34 -0
  105. package/docs/ui-visual/sd-echarts.md +32 -0
  106. package/docs/ui-visual/sd-label.md +24 -0
  107. package/docs/ui-visual/sd-note.md +23 -0
  108. package/docs/ui-visual/sd-progress.md +23 -0
  109. package/docs/utils/inject-routing-signals.md +161 -0
  110. package/docs/utils/inject-sd-system-config-resource.md +35 -0
  111. package/docs/utils/mark.md +43 -0
  112. package/docs/utils/selection-managers.md +96 -0
  113. package/docs/utils/set-safe-style.md +19 -0
  114. package/docs/utils/setup-functions.md +93 -0
  115. package/package.json +5 -5
  116. package/src/controls/select/sd-select.ts +3 -3
  117. package/src/core/modal/sd-modal.provider.ts +1 -1
  118. package/src/core/modal/sd-modal.ts +1 -1
  119. package/src/core/routing/menu-utils.ts +1 -1
  120. package/src/core/shared-data/sd-shared-data.provider.ts +7 -7
  121. package/src/data/shared-data/sd-shared-data-select.ts +2 -2
  122. package/src/layout/dock/sd-dock-container.ts +1 -1
  123. package/docs/bootstrap.md +0 -38
  124. package/docs/directives.md +0 -236
  125. package/docs/features.md +0 -154
  126. package/docs/provider-types.md +0 -283
  127. package/docs/providers.md +0 -379
  128. package/docs/styling.md +0 -222
  129. package/docs/ui-data.md +0 -333
  130. package/docs/ui-form.md +0 -502
  131. package/docs/ui-navigation.md +0 -303
  132. package/docs/ui-overlay.md +0 -157
  133. package/docs/ui-visual.md +0 -127
  134. package/docs/utils.md +0 -244
@@ -1,90 +1,67 @@
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]>` 전체 busy 오버레이
11
- - `<sd-topbar-container>` + `<sd-topbar>` 공통 컨테이너, `<sd-topbar>`는 page 뷰에서만 조건부 렌더
12
- - `<sd-dock-container>` + `<sd-dock>` 필터 / 도구 / 모달 하단 바를 dock로 부착, 본문(시트)은 main 영역
13
- - `<sd-form (formSubmit)>` 필터 제출 / inline 편집 일괄 저장 트리거
14
- - `<sd-sheet>` + `<sd-sheet-column>` + `<ng-template [cell]>` — 시트 본체 (items, 페이지네이션, 정렬, 선택, `cumulativeSelection`, 셀 스타일)
15
- - `injectViewTypeSignal()` — page / modal / control 뷰 판정
16
- - `injectPermsSignal()` 권한 signal
17
- - `setupCanDeactivate()` — 이탈 방지
18
- - `mark(sig)` signal 참조 갱신
19
- - `SdToastProvider.try(fn)` 에러 래퍼 (busy 카운트는 호출부에서 `busyCount.update`로 직접 제어)
20
- - `SdSelectModal<T>` 선택 모달 계약. 소비 화면이 직접 `implements`하여 `selectMode` / `selectedItemKeys` input + `close` output 노출
21
- - `SdCommandDirective`(`sdRefreshCommand` / `sdSaveCommand`) — Ctrl+Alt+L / Ctrl+S 단축키
22
- - 선택적:
23
- - `SdFileDialogProvider.showAsync(...)` — 엑셀 업로드 파일 선택 (`## 7` 변형)
24
- - 데이터 비교:
25
- - `Array.prototype.oneWayDiffs(orgItems, keyFn)` (`@simplysm/core-common` side-effect import) — `ArrayOneWayDiffResult<T>[]` 반환 (`type: "create" | "update" | "same"`)
26
- - `obj.clone(items)` snapshot 깊은 복제 (`@simplysm/core-common`)
27
-
28
- ## 2. 언제 사용하는가
29
-
30
- | 상황 | 적용 여부 |
31
- |---|---|
32
- | 필터 + 페이지네이션 + 정렬 + 선택 + inline 편집이 있는 일반 CRUD 리스트 | 본 레시피 전체 적용 (기본 예제가 page 뷰) |
33
- | 다른 화면에서 항목을 고르는 선택 모달로도 재사용 | 기본 예제가 page + modal 2뷰를 함께 지원 별도 variant 불필요 |
34
- | 시트 첫 열에 row별 inline 삭제/복구 버튼이 필요 | 레시피 + [변형 1: inline 삭제 열](#5-변형-inline-삭제-열) |
35
- | 클릭 시 편집 모달을 띄우는 모달 편집 모드 | 레시피 + [변형 2: 모달 편집 모드](#6-변형-모달-편집-모드) |
36
- | 엑셀 업로드 / 다운로드가 필요 | 레시피 + [변형 3: 엑셀 업로드·다운로드](#7-변형-엑셀-업로드다운로드) |
37
- | 상세 폼(단일 레코드 편집) | 레시피 대신 [`crud-detail.md`](./crud-detail.md) 사용 |
38
- | 페이지 / 모달 뷰 분기만 필요한 단순 화면 | [`page-modal-container.md`](./page-modal-container.md) 사용 |
39
-
40
- ## 3. 완성 예제
41
-
42
- 아래는 **page + modal 2뷰 동시 지원 + inline 편집** 기준 완성 컴포넌트다. 하나의 화면이 라우트로 진입하면 CRUD 리스트(조회·등록·inline 수정·선택 삭제/복구)로 동작하고, `SdModalProvider.showAsync()`로 열리면 동일 화면이 "선택 모달"로 전환된다(selectMode에 따라 single/multi, 선택 결과를 `close.emit`). inline 삭제 열 / 엑셀 업로드·다운로드 / 모달로 row 편집은 `## 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열을 포함한다 — 도메인 범위는 같지만 뼈대 범위가 다르다.)
43
40
 
44
41
  ```typescript
45
42
  import { NgIcon } from "@ng-icons/core";
46
- import {
47
- tablerAlertTriangle,
48
- tablerCirclePlus,
49
- tablerEraser,
50
- tablerDeviceFloppy,
51
- tablerRefresh,
52
- tablerRestore,
53
- tablerSearch,
54
- tablerX,
55
- } from "@ng-icons/tabler-icons";
43
+ import { tablerAlertTriangle, tablerRefresh, tablerSearch } from "@ng-icons/tabler-icons";
56
44
  import {
57
45
  ChangeDetectionStrategy,
58
46
  Component,
59
- computed,
60
47
  effect,
61
48
  inject,
62
- input,
63
- output,
64
49
  signal,
65
50
  untracked,
66
- viewChild,
67
51
  ViewEncapsulation,
68
52
  } from "@angular/core";
69
- import { ArgumentError, type DateTime, obj, str } from "@simplysm/core-common";
70
- import { expr } from "@simplysm/orm-common";
53
+ import { str } from "@simplysm/core-common";
71
54
  import {
72
- FormatPipe,
73
55
  injectPermsSignal,
74
56
  injectViewTitleSignal,
75
57
  injectViewTypeSignal,
76
58
  mark,
77
- SdAnchor,
78
59
  SdBusyContainer,
79
60
  SdButton,
80
- SdCheckbox,
81
61
  SdCommandDirective,
82
62
  SdDock,
83
63
  SdDockContainer,
84
64
  SdForm,
85
- SdItemOfTemplate,
86
- type SdSelectModal,
87
- SdSharedDataSelect,
88
65
  SdSheet,
89
66
  SdSheetColumn,
90
67
  SdSheetColumnCellTemplate,
@@ -92,28 +69,19 @@ import {
92
69
  SdToastProvider,
93
70
  SdTopbar,
94
71
  SdTopbarContainer,
95
- type SelectModalOutputResult,
96
- setupCanDeactivate,
97
72
  type SortingDef,
98
73
  } from "@simplysm/angular";
99
- // 앱별 대체: ORM/공유 데이터/인증 provider + DbContext. simplysm 패키지가 아니라 각 앱이 소유한다.
100
- import { AppOrmProvider, AppSharedDataProvider, useSharedSignal } from "@adtek/client-common";
101
- import type { MainDbContext } from "@adtek/db-main";
102
- import { AppAuthProvider } from "../../../providers/AppAuthProvider";
74
+ // 앱별 대체: ORM provider + DbContext. simplysm 패키지가 아니라 각 앱이 소유한다.
75
+ import { AppOrmProvider } from "@adtek/client-common";
103
76
 
104
77
  interface IFilter {
105
78
  searchText?: string;
106
- isIncludeDeleted: boolean;
107
79
  }
108
80
 
109
81
  interface ICustomer {
110
- id?: number;
111
- name?: string;
82
+ id: number;
83
+ name: string;
112
84
  phone?: string;
113
- categoryId?: number;
114
- isDeleted: boolean;
115
- lastModifiedAt?: DateTime;
116
- lastModifiedBy?: string;
117
85
  }
118
86
 
119
87
  @Component({
@@ -125,21 +93,19 @@ interface ICustomer {
125
93
  SdBusyContainer, SdTopbarContainer, SdTopbar,
126
94
  SdDockContainer, SdDock,
127
95
  SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
128
- SdButton, SdAnchor, SdCheckbox, SdTextfield,
129
- SdSharedDataSelect, SdItemOfTemplate,
130
- NgIcon, FormatPipe,
96
+ SdButton, SdTextfield,
97
+ NgIcon,
131
98
  ],
132
99
  hostDirectives: [
133
- { directive: SdCommandDirective, outputs: ["sdRefreshCommand", "sdSaveCommand"] },
100
+ { directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
134
101
  ],
135
102
  host: {
136
103
  "(sdRefreshCommand)": "onRefreshButtonClick()",
137
- "(sdSaveCommand)": "onSaveButtonClick()",
138
104
  },
139
105
  template: `
140
106
  <sd-busy-container [busy]="busyCount() > 0">
141
107
  @if (initialized()) {
142
- @if (!canUse()) {
108
+ @if (!perms().includes("use")) {
143
109
  <div class="fill tx-theme-gray-light p-xxl tx-center">
144
110
  <br />
145
111
  <ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
@@ -158,13 +124,6 @@ interface ICustomer {
158
124
  새로고침
159
125
  <small>(CTRL+ALT+L)</small>
160
126
  </sd-button>
161
- @if (canEdit()) {
162
- <sd-button [theme]="'link-primary'" (click)="onSaveButtonClick()">
163
- <ng-icon [svg]="tablerDeviceFloppy" />
164
- 저장
165
- <small>(CTRL+S)</small>
166
- </sd-button>
167
- }
168
127
  </sd-topbar>
169
128
  }
170
129
 
@@ -188,166 +147,37 @@ interface ICustomer {
188
147
  (valueChange)="mark(filter)"
189
148
  />
190
149
  </div>
191
- <div class="form-box-item">
192
- <sd-checkbox
193
- [(value)]="filter().isIncludeDeleted"
194
- (valueChange)="mark(filter)"
195
- >
196
- 삭제항목 포함
197
- </sd-checkbox>
198
- </div>
199
150
  </div>
200
151
  </sd-form>
201
152
  </sd-dock>
202
153
 
203
- <!-- 도구 (inline 편집용, page 뷰에서만) -->
204
- @if (canEdit() && viewType() === "page") {
205
- <sd-dock class="flex-row gap-sm p-xs-default">
206
- <sd-button
207
- [size]="'sm'"
208
- [theme]="'link-primary'"
209
- (click)="onAddItemButtonClick()"
210
- >
211
- <ng-icon [svg]="tablerCirclePlus" />
212
- 등록
213
- </sd-button>
214
- <sd-button
215
- [size]="'sm'"
216
- [theme]="'link-danger'"
217
- (click)="onToggleDeleteItemsButtonClick(true)"
218
- [disabled]="!hasSelectedNotDeleted()"
219
- >
220
- <ng-icon [svg]="tablerEraser" />
221
- 선택 삭제
222
- </sd-button>
223
- @if (hasSelectedDeleted()) {
224
- <sd-button
225
- [size]="'sm'"
226
- [theme]="'link-warning'"
227
- (click)="onToggleDeleteItemsButtonClick(false)"
228
- >
229
- <ng-icon [svg]="tablerRestore" />
230
- 선택 복구
231
- </sd-button>
232
- }
233
- </sd-dock>
234
- }
235
-
236
154
  <!-- 시트 (main 영역) -->
237
- <sd-form #formCtrl (formSubmit)="onSubmit()" class="block fill p-default pt-0">
238
- <sd-sheet
239
- [key]="'customer-list-sheet'"
240
- [items]="items()"
241
- [(currentPage)]="page"
242
- [totalPageCount]="pageLength()"
243
- [(sorts)]="sortingDefs"
244
- [selectMode]="selectMode() ?? 'multi'"
245
- [(selectedItems)]="selectedItems"
246
- [trackByFn]="trackByFn"
247
- [getItemCellStyleFn]="getItemCellStyleFn"
248
- [cumulativeSelection]="viewType() === 'modal' && selectMode() === 'multi'"
249
- >
250
- <sd-sheet-column [fixed]="true" [key]="'id'" [header]="'#'">
251
- <ng-template [cell]="items()" let-item="item">
252
- <div
253
- class="p-xs-sm"
254
- [class.tx-right]="item.id"
255
- [style.background]="getIsItemChanged(item) ? 'yellow' : ''"
256
- >
257
- @if (item.id) {
258
- {{ item.id }}
259
- } @else if (canEdit()) {
260
- <sd-anchor (click)="onRemoveNewItemButtonClick(item)">
261
- <ng-icon [svg]="tablerX" />
262
- </sd-anchor>
263
- }
264
- </div>
265
- </ng-template>
266
- </sd-sheet-column>
267
-
268
- <sd-sheet-column [key]="'name'" [header]="'이름'">
269
- <ng-template [cell]="items()" let-item="item" let-edit="edit">
270
- <sd-textfield
271
- [type]="'text'"
272
- [inset]="true"
273
- [size]="'sm'"
274
- [required]="true"
275
- [disabled]="!canEdit()"
276
- [readonly]="!edit"
277
- [(value)]="item.name"
278
- (valueChange)="mark(items)"
279
- />
280
- </ng-template>
281
- </sd-sheet-column>
282
-
283
- <sd-sheet-column [key]="'phone'" [header]="'전화번호'">
284
- <ng-template [cell]="items()" let-item="item" let-edit="edit">
285
- <sd-textfield
286
- [type]="'text'"
287
- [inset]="true"
288
- [size]="'sm'"
289
- [disabled]="!canEdit()"
290
- [readonly]="!edit"
291
- [(value)]="item.phone"
292
- (valueChange)="mark(items)"
293
- />
294
- </ng-template>
295
- </sd-sheet-column>
296
-
297
- <sd-sheet-column [key]="'categoryId'" [header]="'카테고리'">
298
- <ng-template [cell]="items()" let-item="item">
299
- <sd-shared-data-select
300
- [inset]="true"
301
- [size]="'sm'"
302
- [disabled]="!canEdit()"
303
- [items]="sharedCategories.items()"
304
- [(value)]="item.categoryId"
305
- (valueChange)="mark(items)"
306
- >
307
- <ng-template [itemOf]="sharedCategories.items()" let-cat>
308
- {{ cat.name }}
309
- </ng-template>
310
- </sd-shared-data-select>
311
- </ng-template>
312
- </sd-sheet-column>
313
-
314
- <sd-sheet-column [key]="'lastModifiedAt'" [header]="'수정일시'" [hidden]="true">
315
- <ng-template [cell]="items()" let-item="item">
316
- <div class="p-xs-sm tx-center">
317
- {{ item.lastModifiedAt | format: "yyyy-MM-dd HH:mm" }}
318
- </div>
319
- </ng-template>
320
- </sd-sheet-column>
321
-
322
- <sd-sheet-column [key]="'lastModifiedBy'" [header]="'수정자'" [hidden]="true">
323
- <ng-template [cell]="items()" let-item="item">
324
- <div class="p-xs-sm tx-center">{{ item.lastModifiedBy }}</div>
325
- </ng-template>
326
- </sd-sheet-column>
327
- </sd-sheet>
328
- </sd-form>
329
-
330
- <!-- modal 하단 확인 바 -->
331
- @if (viewType() === "modal") {
332
- <sd-dock
333
- [position]="'bottom'"
334
- class="p-sm-default flex-row main-align-end gap-sm bdt bdt-theme-gray-lightest"
335
- >
336
- <sd-button
337
- [size]="'sm'"
338
- [theme]="'danger'"
339
- (click)="onModalCancelClick()"
340
- [disabled]="selectedItems().length < 1"
341
- >
342
- 선택 해제
343
- </sd-button>
344
- @if (selectMode() === "multi") {
345
- <sd-button [size]="'sm'" [theme]="'primary'" (click)="onModalConfirmClick()">
346
- 확인({{ selectedItems().length }})
347
- </sd-button>
348
- }
349
- </sd-dock>
350
- }
155
+ <sd-sheet
156
+ [key]="'customer-list-sheet'"
157
+ [items]="items()"
158
+ [(currentPage)]="page"
159
+ [totalPageCount]="pageLength()"
160
+ [(sorts)]="sortingDefs"
161
+ [trackByFn]="trackByFn"
162
+ >
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'">
170
+ <ng-template [cell]="items()" let-item="item">
171
+ <div class="p-xs-sm">{{ item.name }}</div>
172
+ </ng-template>
173
+ </sd-sheet-column>
174
+
175
+ <sd-sheet-column [header]="'전화번호'" [key]="'phone'">
176
+ <ng-template [cell]="items()" let-item="item">
177
+ <div class="p-xs-sm">{{ item.phone }}</div>
178
+ </ng-template>
179
+ </sd-sheet-column>
180
+ </sd-sheet>
351
181
  </sd-dock-container>
352
182
  </sd-topbar-container>
353
183
  }
@@ -355,63 +185,37 @@ interface ICustomer {
355
185
  </sd-busy-container>
356
186
  `,
357
187
  })
358
- export class CustomerListPage implements SdSelectModal<ICustomer> {
188
+ export class CustomerList {
359
189
  //== DI ==
360
190
  private readonly _appOrm = inject(AppOrmProvider);
361
- private readonly _appAuth = inject(AppAuthProvider);
362
- private readonly _appSharedData = inject(AppSharedDataProvider);
363
191
  private readonly _sdToast = inject(SdToastProvider);
364
192
 
365
- //== SdSelectModal<ICustomer> 계약 ==
366
- selectMode = input<"single" | "multi" | undefined>();
367
- selectedItemKeys = input<(number | undefined)[]>([]);
368
- close = output<SelectModalOutputResult<ICustomer> | undefined>();
369
-
370
- //== viewChild ==
371
- formCtrl = viewChild<SdForm>("formCtrl");
372
-
373
193
  //== 식별 / 권한 ==
374
- SHARED_DATA_KEY = "고객" as const;
375
-
376
- perms = injectPermsSignal(["sales.customer"], ["use", "edit"]);
377
- canUse = computed(() => this.perms().includes("use"));
378
- canEdit = computed(() => this.perms().includes("edit") && this.viewType() === "page");
194
+ perms = injectPermsSignal(["sales.customer"], ["use"]);
379
195
 
380
196
  viewType = injectViewTypeSignal();
381
197
  viewTitle = injectViewTitleSignal();
382
198
 
383
- //== 공유 데이터 ==
384
- sharedCategories = useSharedSignal("카테고리");
385
-
386
199
  //== 상태 ==
387
- initialized = signal(false);
388
- busyCount = signal(0);
200
+ initialized = signal(false); // 최초 조회 완료 — @if (initialized()) 가드 해제
201
+ busyCount = signal(0); // 중첩 비동기 작업 카운트 (0 초과 시 busy 표시)
389
202
 
390
- private _itemsSnapshot: ICustomer[] = [];
391
203
  items = signal<ICustomer[]>([]);
392
- selectedItems = signal<ICustomer[]>([]);
393
- diffs = computed(() => this.items().oneWayDiffs(this._itemsSnapshot, "id"));
394
204
 
395
205
  page = signal(0);
396
206
  pageLength = signal(0);
397
207
  sortingDefs = signal<SortingDef[]>([]);
398
208
 
399
- filter = signal<IFilter>({ isIncludeDeleted: false });
400
- lastFilter = signal<IFilter>({ isIncludeDeleted: false });
401
-
402
- //== 파생 ==
403
- hasSelectedDeleted = computed(() => this.selectedItems().some((it) => it.isDeleted));
404
- hasSelectedNotDeleted = computed(() => this.selectedItems().some((it) => !it.isDeleted));
209
+ filter = signal<IFilter>({}); // 입력 버퍼. onFilterSubmit 시 lastFilter로 스냅샷
210
+ lastFilter = signal<IFilter>({}); // 조회 트리거 — effect 의존성은 이 signal
405
211
 
406
212
  //== 시트 fn ==
407
213
  trackByFn = (item: ICustomer) => item.id;
408
- getItemCellStyleFn = (item: ICustomer): string | undefined =>
409
- item.isDeleted ? "text-decoration: line-through;" : undefined;
410
214
 
411
215
  constructor() {
412
216
  // 필터/페이지/정렬/perms 변경 시 재조회
413
217
  effect(() => {
414
- if (!this.canUse()) {
218
+ if (!this.perms().includes("use")) {
415
219
  this.initialized.set(true);
416
220
  return;
417
221
  }
@@ -429,29 +233,6 @@ export class CustomerListPage implements SdSelectModal<ICustomer> {
429
233
  this.initialized.set(true);
430
234
  });
431
235
  });
432
-
433
- // 모달 뷰: selectedItemKeys → selectedItems 복원
434
- effect(() => {
435
- if (this.viewType() !== "modal") return;
436
-
437
- const keys = this.selectedItemKeys();
438
- if (keys.length === 0) return;
439
-
440
- const currItems = this.items();
441
- if (currItems.length === 0) return;
442
-
443
- untracked(() => {
444
- const sel = currItems.filter((it) => keys.includes(this.trackByFn(it)));
445
- if (sel.length > 0) this.selectedItems.set(sel);
446
- });
447
- });
448
-
449
- setupCanDeactivate(() => this.viewType() === "modal" || this._checkIgnoreChanges());
450
- }
451
-
452
- getIsItemChanged(item: ICustomer): boolean {
453
- if (item.id == null) return true;
454
- return this.diffs().some((diff) => diff.item.id === item.id);
455
236
  }
456
237
 
457
238
  //== Handlers ==
@@ -462,96 +243,20 @@ export class CustomerListPage implements SdSelectModal<ICustomer> {
462
243
 
463
244
  onRefreshButtonClick(): void {
464
245
  if (this.busyCount() > 0) return;
465
- if (!this.canUse()) return;
466
- if (!this._checkIgnoreChanges()) return;
467
-
468
- mark(this.lastFilter);
469
- }
246
+ if (!this.perms().includes("use")) return;
470
247
 
471
- onSaveButtonClick(): void {
472
- this.formCtrl()?.requestSubmit();
473
- }
474
-
475
- async onSubmit(): Promise<void> {
476
- if (this.busyCount() > 0) return;
477
-
478
- const diffs = this.diffs();
479
- if (diffs.length === 0) {
480
- this._sdToast.info("변경사항이 없습니다.");
481
- return;
482
- }
483
-
484
- this.busyCount.update((v) => v + 1);
485
- await this._sdToast.try(async () => {
486
- const changedIds: number[] = [];
487
- await this._appOrm.connectAsync(async (db) => {
488
- for (const diff of diffs) {
489
- const changedId = await this._upsertItem(
490
- db,
491
- diff.item,
492
- diff.type === "create" ? "등록" : "수정",
493
- );
494
- changedIds.push(changedId);
495
- }
496
- });
497
- await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, changedIds);
498
-
499
- this._sdToast.success("저장되었습니다.");
500
-
501
- await this._refresh();
502
- });
503
-
504
- this.busyCount.update((v) => v - 1);
505
- }
506
-
507
- onAddItemButtonClick(): void {
508
- this.items.update((list) => [{ isDeleted: false }, ...list]);
509
- }
510
-
511
- onRemoveNewItemButtonClick(item: ICustomer): void {
512
- this.items.update((list) => list.filter((it) => it !== item));
513
- }
514
-
515
- onToggleDeleteItemsButtonClick(del: boolean): void {
516
- for (const it of this.selectedItems()) it.isDeleted = del;
517
- mark(this.items);
518
- }
519
-
520
- onModalConfirmClick(): void {
521
- const sel = this.selectedItems();
522
- this.close.emit({
523
- selectedItemKeys: sel.map((it) => this.trackByFn(it)).filterExists(),
524
- selectedItems: sel,
525
- });
526
- }
527
-
528
- onModalCancelClick(): void {
529
- this.selectedItems.set([]);
530
-
531
- if (this.selectMode() === "single") {
532
- this.close.emit({ selectedItemKeys: [], selectedItems: [] });
533
- }
248
+ mark(this.lastFilter); // 변경 없이 참조만 갱신 → effect 재실행
534
249
  }
535
250
 
536
251
  //== Internals ==
537
- private _checkIgnoreChanges(): boolean {
538
- return this.diffs().length === 0 || confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?");
539
- }
540
-
541
- // 로드+snapshot만 담당. busy/try는 호출부에서 처리.
542
252
  private async _refresh(): Promise<void> {
543
253
  const r = await this._search(true);
544
254
  this.items.set(r.items);
545
255
  this.pageLength.set(r.pageLength);
546
-
547
- const currKeys = new Set(r.items.map((it) => this.trackByFn(it)));
548
- this.selectedItems.update((sel) => sel.filter((it) => currKeys.has(this.trackByFn(it))));
549
-
550
- this._itemsSnapshot = obj.clone(r.items);
551
256
  }
552
257
 
553
258
  private async _search(
554
- usePagination: boolean,
259
+ usePagination: boolean, // false는 전체 조회 (엑셀 다운로드 등에서 재사용)
555
260
  ): Promise<{ items: ICustomer[]; pageLength: number }> {
556
261
  const filter = this.lastFilter();
557
262
  const sortingDefs = this.sortingDefs();
@@ -563,22 +268,17 @@ export class CustomerListPage implements SdSelectModal<ICustomer> {
563
268
  if (!str.isNullOrEmpty(filter.searchText)) {
564
269
  qr1 = qr1.search((item) => [item.name, item.phone], filter.searchText);
565
270
  }
566
- if (!filter.isIncludeDeleted) {
567
- qr1 = qr1.where((item) => [expr.eq(item.isDeleted, false)]);
568
- }
569
271
 
272
+ // 페이지당 50건 — 시트 화면 표준치 (조정 시 UX 확인 필요)
570
273
  const pageLength = usePagination ? Math.ceil((await qr1.count()) / 50) : 0;
571
274
 
572
- let qr2 = qr1.joinLastDataLog().select((item) => ({
275
+ let qr2 = qr1.select((item) => ({
573
276
  id: item.id,
574
277
  name: item.name,
575
278
  phone: item.phone,
576
- categoryId: item.categoryId,
577
- isDeleted: item.isDeleted,
578
- lastModifiedAt: item.lastDataLog?.dateTime,
579
- lastModifiedBy: item.lastDataLog?.userName,
580
279
  }));
581
280
 
281
+ // orderBy는 string overload 사용 — 람다+obj.getChainValue는 Anti-patterns 참조
582
282
  for (const sortingDef of sortingDefs) {
583
283
  qr2 = qr2.orderBy(sortingDef.key, sortingDef.desc ? "DESC" : "ASC");
584
284
  }
@@ -595,443 +295,152 @@ export class CustomerListPage implements SdSelectModal<ICustomer> {
595
295
  });
596
296
  }
597
297
 
598
- private async _upsertItem(
599
- db: MainDbContext,
600
- item: ICustomer,
601
- logType: string,
602
- ): Promise<number> {
603
- if (
604
- !item.isDeleted &&
605
- (await db
606
- .customer()
607
- .where((c) => [
608
- expr.eq(c.name, item.name),
609
- expr.not(expr.eq(c.id, item.id)),
610
- expr.eq(c.isDeleted, false),
611
- ])
612
- .exists())
613
- ) {
614
- throw new ArgumentError("동일한 명칭이 이미 등록되어 있습니다.", { 명칭: item.name });
615
- }
616
-
617
- const upsertResult = await db
618
- .customer()
619
- .where((c) => [expr.eq(c.id, item.id)])
620
- .upsert(
621
- () => ({
622
- name: item.name!,
623
- phone: item.phone,
624
- categoryId: item.categoryId,
625
- isDeleted: item.isDeleted,
626
- }),
627
- ["id"],
628
- );
629
- const upsertId = upsertResult[0].id;
630
-
631
- await db.customer().insertDataLogAsync({
632
- type: logType,
633
- itemId: upsertId,
634
- valueJson: undefined,
635
- userId: this._appAuth.authInfo()!.user.id,
636
- });
637
-
638
- return upsertId;
639
- }
640
-
641
298
  //== 아이콘 ==
642
299
  protected readonly tablerAlertTriangle = tablerAlertTriangle;
643
- protected readonly tablerCirclePlus = tablerCirclePlus;
644
- protected readonly tablerDeviceFloppy = tablerDeviceFloppy;
645
- protected readonly tablerEraser = tablerEraser;
646
300
  protected readonly tablerRefresh = tablerRefresh;
647
- protected readonly tablerRestore = tablerRestore;
648
301
  protected readonly tablerSearch = tablerSearch;
649
- protected readonly tablerX = tablerX;
650
302
  protected readonly mark = mark;
651
303
  }
652
304
  ```
653
305
 
654
- ## 4. 분해 설명
655
-
656
- 각 블록의 역할과 원본 `SdDataSheet` 코드 대응 지점:
657
-
658
- | 블록 | 역할 | 원본 대응 |
659
- |---|---|---|
660
- | `<sd-busy-container [busy]>` | 전체 busy 오버레이 | `sd-data-sheet.ts:65-71` + `SdBaseContainer` |
661
- | `@if (initialized())` | 초기 데이터 로딩 전 콘텐츠 숨김 (깜박임 방지) | `sd-data-sheet.base.ts:94`·initial effect 말미 `initialized.set(true)` |
662
- | `@if (!canUse())` | 권한 없음 메시지 | `sd-base-container.ts:44-51` + `page-modal-container.md` |
663
- | `<sd-topbar-container>` 공통 껍데기 + `@if (viewType() === "page")` 내부 `<sd-topbar>` | page 뷰만 topbar 표시, modal/control은 topbar 없는 컨테이너로 사용 | `sd-data-sheet.ts:65-87` `pageTopbarTpl` |
664
- | `<sd-dock-container>` + `<sd-dock>` (필터 / inline 도구 / modal 하단 바) | 필터·도구·modal 하단 바를 dock로 부착, 본문(`<sd-form>` + `<sd-sheet>`)은 main 영역 | `sd-data-sheet.ts:89-203` |
665
- | `<sd-form (formSubmit)>` + `form-box-inline` + `form-box-item` | 필터 제출 폼 — 각 입력을 `<div class="form-box-item">`로 감싸 label/버튼 배치 | `sd-data-sheet.ts:111-125` 필터 슬롯 |
666
- | inline 도구 `<sd-dock>` (page 뷰 + canEdit에만) | 등록 / 선택 삭제 / 선택 복구 | `sd-data-sheet.ts:127-203` 도구 영역 |
667
- | `<sd-form #formCtrl (formSubmit)="onSubmit()">` + `<sd-sheet>` | main 영역의 일괄 저장 form + 시트 본체 | `sd-data-sheet.ts:205-346` + `inline-edit` 매니저 |
668
- | `[cell]` 템플릿의 `let-edit="edit"` + `[readonly]="!edit"` | inline 편집 가능 셀 (`[inset]="true" [size]="'sm'"` 필수) | — |
669
- | `getItemCellStyleFn` | `isDeleted` 시 취소선 | `sd-data-sheet.base.ts:137-140` |
670
- | modal 하단 `<sd-dock [position]="'bottom'">` | 모달 뷰에서만 "선택 해제 / 확인" 바 노출 | `sd-data-sheet.ts:287-315` 모달 하단 바 |
671
- | `hostDirectives` + `SdCommandDirective` | Ctrl+Alt+L / Ctrl+S 단축키 | `sd-data-sheet.ts:57-63` |
672
- | `setupCanDeactivate(() => viewType() === "modal" || checkIgnoreChanges())` | 라우트 이탈 시 변경사항 확인 | `sd-data-sheet.base.ts:227` |
673
- | 호출부(`onRefresh`/`onSubmit`/초기 effect) 내 `busyCount.update` + `sdToast.try(...)` | busy 카운트 증감 + 에러 토스트 래핑 | `injectDataSheetRefreshManager.ts:33-46` (삭제됨) |
674
- | `diffs = computed(() => items.oneWayDiffs(_itemsSnapshot, "id"))` | 변경 감지 signal — 템플릿·호출부 모두에서 `this.diffs()`로 참조 | `injectDataSheetRefreshManager.ts`의 `getDiffs()` (삭제됨) |
675
- | `effect(() => { if (!canUse()) ...; lastFilter(); page(); sortingDefs(); untracked(async ...); })` | 필터/페이지/정렬/perms 변경 시 재조회 + 초기 로드 | `injectDataSheetRefreshManager.ts` (삭제됨) |
676
- | `effect(() => { if (viewType() !== "modal") return; selectedItemKeys() → selectedItems })` | 모달 뷰 초기 selection 복원 | `sd-data-sheet.base.ts:165-183` |
677
- | `mark(this.lastFilter)` | lastFilter 참조 갱신 → effect 재실행 (값 변경 없음) | `sd-data-sheet.base.ts:245` |
678
-
679
- ### 상태 분해
680
-
681
- | signal / computed | 역할 |
682
- |---|---|
683
- | `busyCount` | 중첩 비동기 작업 카운트 (0 초과 시 busy 표시) |
684
- | `initialized` | 최초 조회 완료 여부 (완료 전 본문 숨김) |
685
- | `items` | 현재 페이지 items |
686
- | `selectedItems` | 선택된 item 배열 (`<sd-sheet [(selectedItems)]>`로 양방향) |
687
- | `diffs` | `computed(() => items().oneWayDiffs(_itemsSnapshot, "id"))` — 변경 감지 signal. 템플릿(`getIsItemChanged`)·호출부(`onSubmit`·`_checkIgnoreChanges`) 모두에서 `diffs()`로 참조 |
688
- | `page` / `pageLength` | 0-based 현재 페이지 / 전체 페이지 수 |
689
- | `sortingDefs` | `SortingDef[]` — `{ key: string; desc: boolean }[]`, `<sd-sheet [(sorts)]>`로 양방향 |
690
- | `filter` / `lastFilter` | `filter`는 입력 버퍼, `lastFilter`는 "조회" 제출 시점 스냅샷 (effect 의존성) |
691
- | `_itemsSnapshot` | 최근 `_refresh()` 시점의 items 깊은 복제 (변경 감지용) |
692
- | `hasSelectedDeleted` / `hasSelectedNotDeleted` | 선택 항목의 삭제 상태 — 선택 삭제/복구 버튼 조건 |
693
- | `perms` / `canUse` / `canEdit` | 권한. `canEdit`은 page 뷰 + edit 권한일 때만 true (modal에선 항상 false) |
694
- | `close` (output) | `SdSelectModal<T>` 요구 — 모달 결과 전달 |
695
-
696
- ### 메서드 분해
697
-
698
- | 메서드 | 역할 |
699
- |---|---|
700
- | `onFilterSubmit()` | page=0 리셋 + `lastFilter.set({ ...filter() })` |
701
- | `onRefreshButtonClick()` | busy/권한/변경사항 확인 후 `mark(lastFilter)` — 참조 갱신으로 effect 재실행 |
702
- | `onSaveButtonClick()` | `formCtrl()?.requestSubmit()` — Ctrl+S와 동일 경로. `host`의 `sdSaveCommand`와 어휘 일치 |
703
- | `onSubmit()` | diff 0건이면 정보 토스트 → `busyCount` 증가 → `_sdToast.try(diff 일괄 upsert + emit + _refresh)` → `busyCount` 감소 |
704
- | `onAddItemButtonClick()` | `items.update((list) => [{ isDeleted: false }, ...list])` — 신규 행을 맨 앞에 삽입 |
705
- | `onToggleDeleteItemsButtonClick(del)` | 선택 항목의 `isDeleted = del` 토글 + `mark(items)`. 실제 DB 반영은 저장 버튼 클릭 시 `onSubmit`에서 일괄 처리 |
706
- | `onModalConfirmClick()` / `onModalCancelClick()` | 모달 결과 emit. 취소는 `selectMode === "single"`일 때만 즉시 close |
707
- | `_checkIgnoreChanges()` | `diffs()` 길이 0이면 true, 아니면 `confirm` 후 true/false |
708
- | `getIsItemChanged(item)` | row 하이라이트 판정 — `item.id == null`(신규) 또는 `diffs()`에 해당 id가 포함되면 true |
709
- | `onRemoveNewItemButtonClick(item)` | 저장 전 신규 row(`id == null`) 제거 — reference 기반 `filter((it) => it !== item)` |
710
- | `_refresh()` | `_search(true)` → `items.set` + `pageLength.set` + 선택 유지 + `_itemsSnapshot = obj.clone(r.items)`. busy/try는 호출부 책임 |
711
- | `_search(usePagination)` | ORM 쿼리 (filter/sort/limit) — `_refresh`와 엑셀 다운로드 등에서 재사용 |
712
- | `_upsertItem(db, item, logType)` | 중복 검사 → `upsert(() => record, ["id"])` → `insertDataLogAsync` |
713
-
714
- ## 5. 변형: inline 삭제 열
715
-
716
- 기본 예제는 상단 "선택 삭제 / 선택 복구" 버튼만 사용하지만, row별 inline 삭제/복구 버튼을 함께 제공하고 싶으면 시트 맨 앞 고정 컬럼에 `<sd-anchor>`를 추가한다. `CustomerListPage`를 기준으로 아래 변경을 적용한다.
306
+ 섹션에 등장하는 개별 API의 단독 사용법은 각 문서를 참조한다:
717
307
 
718
- ```typescript
719
- // 1) imports 추가
720
- import { SdAnchor } from "@simplysm/angular";
721
- // @Component imports 배열에도 SdAnchor 추가
722
-
723
- // 2) template — <sd-sheet> 가장 앞에 isDeleted 고정 컬럼 삽입
724
- <sd-sheet ...>
725
- @if (canEdit() && viewType() === "page") {
726
- <sd-sheet-column [fixed]="true" [key]="'_isDeleted'">
727
- <ng-template #headerTpl>
728
- <div class="p-xs-sm tx-center">
729
- <ng-icon [svg]="tablerEraser" />
730
- </div>
731
- </ng-template>
732
- <ng-template [cell]="items()" let-item="item">
733
- <div class="p-xs-sm tx-center">
734
- <sd-anchor
735
- [theme]="'danger'"
736
- (click)="onToggleDeleteItemButtonClick(item)"
737
- >
738
- <ng-icon [svg]="item.isDeleted ? tablerRestore : tablerEraser" />
739
- {{ item.isDeleted ? "복구" : "삭제" }}
740
- </sd-anchor>
741
- </div>
742
- </ng-template>
743
- </sd-sheet-column>
744
- }
745
- <!-- 나머지 컬럼(id, name, phone, categoryId, ...)은 기본 예제 그대로 -->
746
- </sd-sheet>
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-사용-패턴)
747
315
 
748
- // 3) 메서드 추가
749
- protected onToggleDeleteItemButtonClick(item: ICustomer): void {
750
- item.isDeleted = !item.isDeleted;
751
- mark(this.items); // OnPush 재렌더 + effect 알림
752
- }
753
- ```
316
+ ### 조건부 요소 포함 기준
754
317
 
755
- **포인트:**
318
+ 최소 뼈대의 인프라·라이프사이클 요소는 화면의 필요에 따라 포함·생략한다. 필요 없는 요소를 기계적으로 포함하지 않는다.
756
319
 
757
- - inline 삭제도 **`item.isDeleted` 플래그 토글**로 표현. 행을 `items`에서 제거하면 `oneWayDiffs`가 누락한다. DB 반영은 저장 버튼 클릭 시 `onSubmit`에서 일괄 처리(soft-delete).
758
- - **`canEdit() && viewType() === "page"` 조건**: modal 뷰 / 권한 없음이면 열 자체 숨김 (`canEdit`은 이미 page 한정이지만 명시적으로 쓰면 의도가 분명).
759
- - 컬럼 key는 **`"_isDeleted"`** (언더스코어 prefix) 서버 정렬·컬럼 지속성 설정과 충돌하지 않는 임의 키.
760
- - 기본 예제의 상단 "선택 삭제/복구" 버튼과 **공존** 가능. row별 빠른 처리 + 다건 일괄 처리.
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에 등록되지 않은 데이터를 다루는 화면 |
761
329
 
762
- > **`oneWayDiffs`는 delete를 다루지 않는다.** `newItems.oneWayDiffs(orgItems, keyFn)`은 `type: "create" | "update" | "same"`만 반환한다. 삭제 의사는 **`item.isDeleted = true` 플래그**로 표현하여 `"update"` diff로 전송된다. 행을 `items` 배열에서 물리적으로 제거하면 diff에서 누락되므로 절대 삭제하지 않는다.
330
+ ## 변형 (Variation)
763
331
 
764
- ## 6. 변형: 모달 편집 모드
332
+ 아래 확장 필요한 것만 선택적으로 얹는다. 각 확장은 self-contained 문서에서 최소 뼈대 대비 diff를 제공한다.
765
333
 
766
- 기본 예제는 시트 직접 수정(inline 편집) + 일괄 저장 방식이지만, 행 클릭 시 **편집 모달을 띄워 한 행씩 편집**하는 모드가 필요하면 아래 변경을 적용한다. 일괄 저장(diff) / snapshot / `_checkIgnoreChanges` / `setupCanDeactivate`는 불필요해져 모두 제거된다.
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) |
767
343
 
768
- ```typescript
769
- // 1) imports 추가
770
- import { SdAnchor, SdModalProvider } from "@simplysm/angular";
771
- import { tablerEdit } from "@ng-icons/tabler-icons";
772
- // 앱별 편집 모달 컴포넌트 (crud-detail.md 레시피로 작성):
773
- import { CustomerEditModal } from "./CustomerEditModal";
774
-
775
- // 2) DI 추가
776
- private readonly _sdModal = inject(SdModalProvider);
777
-
778
- // 3) 클래스 필드 추가 — 편집 버튼 아이콘
779
- protected readonly tablerEdit = tablerEdit;
780
-
781
- // 4) template — 이름 컬럼 셀을 <sd-anchor> + 편집 아이콘으로 교체.
782
- // inline 편집용 <sd-textfield>/let-edit 제거.
783
- <sd-sheet-column [key]="'name'" [header]="'이름'">
784
- <ng-template [cell]="items()" let-item="item">
785
- <sd-anchor (click)="onEditItemButtonClick(item, $event)" class="flex-row">
786
- <div class="p-xs-sm">
787
- <ng-icon [svg]="tablerEdit" />
788
- </div>
789
- <div class="flex-fill p-xs-sm">{{ item.name }}</div>
790
- </sd-anchor>
791
- </ng-template>
792
- </sd-sheet-column>
793
-
794
- // 5) template — inline 편집용 도구 <sd-dock>의 "등록" 버튼은 _editItem() 호출로 바꿈.
795
- // "선택 삭제/복구"는 bulk API로 별도 처리(개별 _refresh 필요).
796
- <sd-button ... (click)="onCreateItemButtonClick()">등록</sd-button>
797
-
798
- // 6) 메서드 교체
799
- protected async onCreateItemButtonClick(): Promise<void> {
800
- await this._editItem();
801
- }
344
+ ## 🚫 흔한 실수 (Anti-patterns)
802
345
 
803
- protected async onEditItemButtonClick(item: ICustomer, event: MouseEvent): Promise<void> {
804
- event.preventDefault();
805
- event.stopPropagation();
806
- await this._editItem(item);
807
- }
346
+ ### modal = 선택 모달로 반사적 부착
808
347
 
809
- private async _editItem(item?: ICustomer): Promise<void> {
810
- const r = await this._sdModal.showAsync({
811
- title: item == null ? "고객 등록" : "고객 수정",
812
- type: CustomerEditModal,
813
- inputs: { itemId: item?.id },
814
- });
815
- if (r != null) await this._refresh();
816
- }
817
-
818
- // 7) 제거:
819
- // - hostDirectives의 sdSaveCommand / host의 (sdSaveCommand)
820
- // - onSaveButtonClick / onSubmit / onAddItemButtonClick
821
- // - diffs computed / _itemsSnapshot / _checkIgnoreChanges / _upsertItem / getIsItemChanged / onRemoveNewItemButtonClick
822
- // - setupCanDeactivate(...) 호출 (다른 화면의 이탈 방지는 편집 모달이 책임짐)
823
- // - <sd-form #formCtrl (formSubmit)="onSubmit()"> 래퍼 → <sd-sheet>를 main 영역에 직접 배치
824
- ```
825
-
826
- **포인트:**
827
-
828
- - 모달 편집 모드에서는 **inline diff 개념이 없다.** 개별 item 변경은 `CustomerEditModal`(상세 폼) 내부에서 즉시 upsert하고 결과를 `close.emit(true)`로 전달. 리스트는 모달 close 후 `_refresh()`로 재조회.
829
- - `CustomerEditModal`은 [`crud-detail.md`](./crud-detail.md) 레시피로 별도 작성. modal 뷰 분기를 그대로 활용.
830
- - 시트 `[cell]` 템플릿에 **`let-edit="edit"` / `[readonly]="!edit"`는 불필요** (inline 편집 아님). 읽기 전용 표시만.
831
- - "선택 삭제/복구"를 남길 경우 `onToggleDeleteItemsButtonClick(del)` 내부를 diff 방식 대신 **bulk API 호출 + `_refresh()`** 로 구현한다:
832
- ```typescript
833
- protected async onToggleDeleteItemsButtonClick(del: boolean): Promise<void> {
834
- if (this.busyCount() > 0) return;
835
- const ids = this.selectedItems().map((it) => this.trackByFn(it)).filterExists();
836
- if (ids.length === 0) return;
837
-
838
- this.busyCount.update((v) => v + 1);
839
- await this._sdToast.try(async () => {
840
- await this._appOrm.connectAsync((db) => db.customer().where((c) => [expr.in(c.id, ids)]).updateAsync(() => ({ isDeleted: del })));
841
- await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, ids);
842
- this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
843
- await this._refresh();
844
- });
845
- this.busyCount.update((v) => v - 1);
846
- }
847
- ```
848
-
849
- ## 7. 변형: 엑셀 업로드/다운로드
850
-
851
- `SdFileDialogProvider`로 파일 선택, `ExcelWrapper`(@simplysm/excel) + `zod` 스키마로 읽기/쓰기. 다운로드는 `_search(false)`로 전체 페이지 조회 후 `@simplysm/core-browser`의 `downloadBlob`으로 내려받는다.
348
+ `viewType() === "modal"`이라는 사실만으로 "선택 모달"이라고 단정하고 `SdSelectModal<T>` 계약을 부착하지 않는다. modal 용도는 최소 두 가지다 — (a) 선택 모달(확장 D): 항목을 골라 `close.emit`으로 돌려줌 / (b) 조회 전용(확장 E): 부모 레코드의 자식 목록·이력을 읽기 전용으로 표시, 닫기는 SdModal 기본 "X".
852
349
 
853
350
  ```typescript
854
- // 1) import 추가
855
- import { tablerFileExcel, tablerUpload } from "@ng-icons/tabler-icons";
856
- import { SdFileDialogProvider } from "@simplysm/angular";
857
- import { DateTime } from "@simplysm/core-common";
858
- import { downloadBlob } from "@simplysm/core-browser";
859
- import { ExcelWrapper } from "@simplysm/excel";
860
- import { z } from "zod";
861
-
862
- // 2) DI 추가
863
- private readonly _sdFileDialog = inject(SdFileDialogProvider);
864
-
865
- // 3) 클래스 필드 추가 — 아이콘 + ExcelWrapper (zod 스키마로 컬럼 정의)
866
- protected readonly tablerFileExcel = tablerFileExcel;
867
- protected readonly tablerUpload = tablerUpload;
868
-
869
- private readonly _excelWrapper = new ExcelWrapper(
870
- z.object({
871
- id: z.number().optional().describe("ID"),
872
- name: z.string().describe("이름"),
873
- phone: z.string().optional().describe("전화번호"),
874
- categoryId: z.number().optional().describe("카테고리.ID"),
875
- isDeleted: z.boolean().describe("삭제"),
876
- lastModifiedAt: z.custom<DateTime>().optional().describe("최종수정일시"),
877
- lastModifiedBy: z.string().optional().describe("최종수정자"),
878
- }),
879
- );
880
-
881
- // 4) template — page 뷰 topbar에 엑셀 버튼 2개 추가
882
- <sd-topbar>
883
- <!-- 기존 "새로고침" / "저장" 버튼 옆 -->
884
- <sd-button [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
885
- <ng-icon [svg]="tablerFileExcel" />
886
- 엑셀 다운로드
887
- </sd-button>
888
- @if (canEdit()) {
889
- <sd-button [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
890
- <ng-icon [svg]="tablerUpload" />
891
- 엑셀 업로드
892
- </sd-button>
893
- }
894
- </sd-topbar>
895
-
896
- // 5) 메서드 추가
897
- async onDownloadExcelButtonClick(): Promise<void> {
898
- if (this.busyCount() > 0) return;
899
-
900
- this.busyCount.update((v) => v + 1);
901
- await this._sdToast.try(async () => {
902
- // 전체 조회 (페이지네이션 없이) — 기본 예제의 _search를 그대로 재사용
903
- const r = await this._search(false);
904
- const wb = await this._excelWrapper.write(this.viewTitle(), r.items);
905
- try {
906
- downloadBlob(
907
- await wb.toBlob(),
908
- `${this.viewTitle()}_${new DateTime().toFormatString("yyMMdd")}.xlsx`,
909
- );
910
- } finally {
911
- await wb.close();
912
- }
913
- });
914
- this.busyCount.update((v) => v - 1);
351
+ // ❌ viewType() === "modal"만 보고 선택 모달 계약을 반사적으로 부착
352
+ export class CustomerList implements SdSelectModal<ICustomer> {
353
+ selectMode = input<"single" | "multi">();
354
+ selectedItemKeys = input<any[]>([]);
355
+ close = output<SelectModalOutputResult<ICustomer>>();
356
+ // ... 조회 전용인데도 하단 "선택 해제 / 확인" 바, cumulativeSelection까지 전부 이식
915
357
  }
916
358
 
917
- async onUploadExcelButtonClick(): Promise<void> {
918
- const file = await this._sdFileDialog.showAsync(false, ".xlsx");
919
- if (file == null) return;
920
- if (Array.isArray(file)) return;
921
-
922
- this.busyCount.update((v) => v + 1);
923
- await this._sdToast.try(async () => {
924
- const excelItems = await this._excelWrapper.read(file);
925
- const changedIds: number[] = [];
926
- await this._appOrm.connectAsync(async (db) => {
927
- for (const raw of excelItems) {
928
- changedIds.push(await this._upsertItem(db, raw, "엑셀업로드"));
929
- }
930
- });
931
- await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, changedIds);
932
-
933
- this._sdToast.success("업로드되었습니다.");
934
-
935
- await this._refresh();
936
- });
937
- this.busyCount.update((v) => v - 1);
938
- }
359
+ // modal 용도를 먼저 확정한다
360
+ // (a) 선택 모달이면 확장 D 스켈레톤부터 시작
361
+ // (b) 조회 전용이면 확장 E 스켈레톤부터 시작 — SdSelectModal 계약 부착 안 함
939
362
  ```
940
363
 
941
- **포인트:**
942
-
943
- - 다운로드는 **`_search(false)`**(페이지네이션 없이 전체)로 쿼리. 페이지당 50건 제한이 걸리면 현재 페이지만 다운로드되는 실수가 생기므로 `usePagination: false` 명시 필수.
944
- - 업로드는 `_excelWrapper.read(file)` → `_upsertItem`(기본 예제의 메서드) **재사용**. 중복 검사·DataLog 기록 로직이 동일하게 적용됨.
945
- - 엑셀의 텍스트 컬럼(고객사명·MPN 등)을 FK id로 변환해야 하면 **DB 재조회 대신 `useSharedSignal(...)`로 이미 로드된 공유 데이터를 재사용**한다. 예: `this.sharedCustomers.items().toMapValues((it) => it.name, (it) => it.orderBy((v) => (v.__isHidden ? 1 : 0))[0])`. 같은 키에 숨김·비숨김 항목이 섞여 있으면 `orderBy`로 비숨김(`__isHidden: false`)을 우선순위로 정렬한다. 별도 `_buildIdMap` 같은 helper로 분리하지 말고 `toMapValues`를 `onUploadExcelButtonClick` 내부에 직접 인라인한다 (단일 호출처).
946
- - 오래 걸리는 대량 업로드에는 §9 "`busyMessage`는 필요할 때만 추가"를 참조하여 `busyMessage.set("엑셀 업로드 중...")`을 선택적으로 부착.
947
-
948
- ## 8. 뷰 타입 분기
949
-
950
- page·modal·control 세 뷰는 **하나의 `<sd-topbar-container>` + `<sd-dock-container>` 공통 껍데기** 위에 뷰별로 다른 조각만 `@if`로 얹어 구성한다. 세 뷰별로 별도 블록을 전체 복제하지 않는다.
951
-
952
- | 뷰 | topbar | dock (도구 바) | main (시트) | 하단 바 |
953
- |---|---|---|---|---|
954
- | page | `<sd-topbar>` (새로고침/저장/...) | inline 도구 `<sd-dock>` (canEdit) | `<sd-form>` + `<sd-sheet>` | 없음 |
955
- | modal | 없음 | inline 도구 숨김 (`canEdit` = false) | 동일 | `<sd-dock [position]="'bottom'">` (선택 해제 / 확인) |
956
- | control | 없음 | 필요 시 주석만 | 동일 | 없음 |
957
-
958
- - **`<sd-dock>` position 명시**: 필터·inline 도구는 기본 `"top"`. **modal 하단 바는 반드시 `[position]="'bottom'"`를 명시**한다 — 누락하면 상단에 쌓여 필터·도구와 겹친다(`packages/angular/src/layout/dock/sd-dock.ts:97`).
959
- - **`canEdit = perms().includes("edit") && viewType() === "page"`**: edit 권한이 있어도 modal 뷰에서는 항상 false. 시트 셀의 `[readonly]="!edit"`·inline 도구 바 모두 자동으로 비활성화.
960
- - **`selectMode ?? 'multi'`**: modal로 호출할 때 input으로 `"single"` / `"multi"` 전달. 기본은 page 뷰용 `'multi'`로 fallback.
961
- - **`cumulativeSelection="viewType() === 'modal' && selectMode() === 'multi'"`**: 모달 + 다중 선택 조합에서만 페이지를 넘어 선택 누적. 기본값(`false`)이면 페이지 이동 시 선택이 초기화되는데, 일괄 작업 대상 리스트에서 이는 의도적 동작.
962
-
963
- ## 9. 주의사항 (자주 하는 실수)
964
-
965
- ### 공통 유틸 재도입 금지
966
-
967
- - `useCrudList()`, `useDataSheet()`, `setupCumulateSelectedKeys2()` 같은 공통 헬퍼를 도입하지 말 것. 이 레시피가 제거한 추상화를 다시 만드는 행위다. 세 화면이 비슷해 보여도 화면마다 필드·동작 시그니처가 조금씩 다르므로 복사·수정이 낫다
968
-
969
- ### 뷰 분기를 "완전 분리 블록"으로 쓰지 않는다
970
-
971
- - 시트 페이지를 modal로도 쓰는 경우, LLM이 page 블록과 modal 블록을 각각 완성하면서 **필터·시트를 중복 작성**하기 쉽다. 본 레시피의 기본 예제처럼 **하나의 껍데기 + 차이점만 `@if`** 로 얹어야 한다. 필터 하나를 수정할 때 두 블록을 모두 고치는 상황이 나오면 구조가 잘못된 것.
972
-
973
- ### `<sd-dock>` position 누락
974
-
975
- - `<sd-dock>`의 `position` input 기본값은 `"top"`이다(`sd-dock.ts:97`). 모달 하단 "확인 바"에 `[position]="'bottom'"`을 빠뜨리면 필터 위에 쌓여 레이아웃이 깨진다.
976
-
977
- ### 시트 셀 스타일 함정
978
-
979
- - `<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]` 생략 가능
364
+ **근거**: 조회 전용 modal에 선택 계약을 부착하면 호출되지 않아 죽은 코드가 된다. LLM이 풀 스택 합본을 복사하면서 "modal 지원"이라는 이유로 반사적으로 이식하는 회귀가 잦다. [확장 E](./crud-list/extension-e-readonly-modal.md) 참조.
980
365
 
981
- ### `oneWayDiffs`의 삭제 처리
366
+ ### viewType 추측으로 3뷰 모두 박기 / 완전 분리 블록 작성
982
367
 
983
- - `newItems.oneWayDiffs(orgItems, keyFn)`은 **삭제(delete) 다루지 않는다**. `type: "create" | "update" | "same"`만 반환
984
- - 삭제 의사 표현은 **`item.isDeleted = true` 플래그**로 하고 `"update"` diff로 전송 (서버가 soft-delete 처리)
985
- - inline 편집 시 `items` 배열에서 row를 제거하지 말 것. diff에서 해당 row가 누락되어 서버가 변경을 감지할 수 없다
368
+ 화면이 실제 어떤 뷰로 쓰이는지 확정하지 않은 채 page·modal·control 3뷰용 조각을 모두 배치하지 않는다(당장 쓰지 않는 뷰의 계약·분기는 죽은 코드가 된다). 또한 시트 페이지를 page와 modal로 겸용할 때(확장 D) page 블록과 modal 블록을 각각 완성하면서 필터·시트를 중복 작성하지 않는다. 하나의 `<sd-topbar-container>` + `<sd-dock-container>` 공통 껍데기 위에 뷰별로 다른 조각만 `@if`로 얹는다.
986
369
 
987
- ### `selectedItemKeys`는 `filterExists()`로 undefined 제거
988
-
989
- - `<sd-sheet>`는 key 기반이 아니라 item 기반이므로 `SelectModalOutputResult<T>.selectedItemKeys`는 수동 변환한다: `selectedItems().map((it) => trackByFn(it)).filterExists()`.
990
- - **index fallback(`trackByFn(it, i) ?? i`) 금지.** id가 `undefined`인 신규 row가 있을 때 0, 1, 2 같은 index 값이 가짜 key로 들어가 호출 측이 잘못된 selection을 돌려받는다. `filterExists()`로 `undefined`를 제거하는 게 안전하다.
991
-
992
- ### 모달 "선택 해제"는 single 모드에서만 close
993
-
994
- - `onModalCancelClick`에서 `this.close.emit`을 무조건 호출하면 multi 모드에서 "선택 해제 = 취소 + 닫기"가 되어 다시 선택하려면 모달을 재오픈해야 한다. multi에서는 `selectedItems.set([])`만 하고 close는 호출하지 않는다(사용자가 "확인" 버튼으로 최종 emit).
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>
380
+ }
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>
388
+ }
995
389
 
996
- ### `injectViewTypeSignal()` 호출 시점
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>
398
+ ```
997
399
 
998
- - `injectViewTypeSignal()`은 생성자 실행 또는 필드 이니셜라이저에서만 호출한다. `computed`·`effect` 콜백이나 일반 메서드에서 호출하면 `NG0203` 런타임 에러가 발생한다 (Angular `inject()` 제약)
400
+ **근거**: 필터 줄을 수정할 블록을 모두 고쳐야 하는 상황이 생기면 구조가 잘못된 것이다. "완전 분리"는 확장 D에서 modal 하단 바 같은 **뷰별 고유 조각**에만 적용한다.
999
401
 
1000
- ### `busyMessage`는 필요할 때만 추가
402
+ ### `orderBy` 람다 + `obj.getChainValue` 회귀
1001
403
 
1002
- - 기본 예제는 `<sd-busy-container [busy]="busyCount() > 0">`만 사용하고 `busyMessage` signal을 두지 않는다. 짧은 CRUD는 progress 아이콘만으로 충분.
1003
- - 오래 걸리는 작업(대량 엑셀 업로드·집계 등)에 진행 문구가 필요하면 **필요한 화면에만** `busyMessage = signal<string | undefined>(undefined)` 추가 + `[message]="busyMessage()"` 바인딩 + 구간별 `busyMessage.set(...)`/`set(undefined)` 제어. 미사용 시 선언·바인딩 모두 생략.
404
+ `Queryable.orderBy`는 string overload를 지원하므로(`packages/orm-common/src/exec/queryable.ts:420`), 람다 + `obj.getChainValue` 우회 코드를 쓰지 않는다.
1004
405
 
1005
- ### 테스트만을 위한 public API 금지
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
+ }
1006
414
 
1007
- - `async submit(diffs) { await this._submitAsync(diffs); }` 같이 "테스트에서 호출하려고" private 메서드의 얇은 public wrapper를 노출하지 않는다. 캡슐화를 깨고 컴포넌트의 외부 API 인상을 오염시킨다. 테스트는 TestBed fixture + click/dispatch 이벤트 경로 또는 host의 `sdSaveCommand` 트리거로 수행.
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
+ ```
1008
420
 
1009
- ## 10. 레시피 작성 관용 규칙
421
+ **근거**: 람다 형태는 타입 추론을 깨뜨리고 `as any` 캐스팅을 강제한다. string overload는 체인 경로까지 타입 안전하게 지원한다.
1010
422
 
1011
- 향후 `crud-detail.md` · `data-select-button.md` 등 데이터 관련 레시피가 추가될 때 아래 3개 규칙을 공통으로 따른다.
423
+ ### 테스트용 public API 노출
1012
424
 
1013
- ### 규칙 1: 시트 내부 컨트롤은 `[inset]="true" [size]="'sm'"` 명시
425
+ 테스트에서 호출하려고 private 메서드의 얇은 public wrapper를 노출하지 않는다.
1014
426
 
1015
- - `<sd-sheet-column>` `[cell]` 템플릿 내부의 `sd-textfield` / `sd-select` / `sd-checkbox` / `sd-numpad` / `sd-date-range-picker` / `sd-textarea`는 레시피에서 **항상** `[inset]="true" [size]="'sm'"`를 함께 노출한다
1016
- - 예외: 복합 구조(텍스트+컨트롤) → `[inset]="false"`. 시트 → `[size]` 생략
1017
- - 누락 컴파일 에러가 발생하지 않아 LLM이 빠뜨리기 쉽다. 자주 하는 실수 섹션에 명시
427
+ ```typescript
428
+ // "테스트에서 호출하려고" private 메서드를 public으로 노출
429
+ async submit(diffs: IDiff[]): Promise<void> {
430
+ await this._submitAsync(diffs);
431
+ }
1018
432
 
1019
- ### 규칙 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
+ ```
1020
438
 
1021
- - `mark(sig)`는 `WritableSignal`의 값을 shallow copy하여 **참조를 갱신**한다 (배열: `[...v]`, 객체: `{...v}`)
1022
- - 역할: **OnPush 템플릿 재렌더링** + **다른 computed / effect의 의존성 갱신**
1023
- - **"저장 감지"가 아니다.** `obj.equal`이 deep equal로 값 차이를 감지하므로, `item.name = "new"` 같은 mutation은 `mark` 없이도 `diffs()` / submit에서 감지된다
1024
- - Chrome 61 호환성(Proxy 폴리필 불가)으로 signal 자동 notify가 불가하여 명시적 호출이 필요
1025
- - ❌ "mark 없으면 저장이 안 된다" 식 서술 금지
439
+ **근거**: public wrapper는 캡슐화를 깨고 컴포넌트의 외부 API 인상을 오염시킨다. 실제 사용자가 쓰지 않는 진입점을 공개 API로 만들면 추후 리팩토링 시 제약이 된다.
1026
440
 
1027
- ### 규칙 3: `sortingDefs` + `orderBy` 체인은 string overload 사용
441
+ ## 관련 Entry
1028
442
 
1029
- - `Queryable.orderBy`는 string overload를 지원한다 (`packages/orm-common/src/exec/queryable.ts:420`)
1030
- - 레시피는 아래 형태로 작성:
1031
- ```typescript
1032
- for (const s of sortingDefs) {
1033
- qr = qr.orderBy(s.key, s.desc ? "DESC" : "ASC");
1034
- }
1035
- ```
1036
- - 체인 경로도 string으로 지원: `qr.orderBy("user.name")` 형태
1037
- - 과거 람다 형태 (`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뷰 재사용 공통 껍데기만