@simplysm/angular 14.0.51 → 14.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/data/sheet/sd-sheet.d.ts +9 -12
  2. package/dist/data/sheet/sd-sheet.d.ts.map +1 -1
  3. package/dist/data/sheet/sd-sheet.js +150 -168
  4. package/dist/data/sheet/types.d.ts +1 -0
  5. package/dist/data/sheet/types.d.ts.map +1 -1
  6. package/dist/data/sheet/useSheetCellStyling.d.ts +2 -2
  7. package/dist/data/sheet/useSheetCellStyling.d.ts.map +1 -1
  8. package/dist/data/sheet/useSheetCellStyling.js +20 -12
  9. package/dist/data/sheet/useSheetColumnFixing.d.ts +3 -8
  10. package/dist/data/sheet/useSheetColumnFixing.d.ts.map +1 -1
  11. package/dist/data/sheet/useSheetColumnFixing.js +19 -27
  12. package/dist/data/sheet/useSheetLayoutEngine.d.ts.map +1 -1
  13. package/dist/data/sheet/useSheetLayoutEngine.js +4 -1
  14. package/dist/layout/dock/sd-dock-container.d.ts.map +1 -1
  15. package/dist/layout/dock/sd-dock-container.js +3 -3
  16. package/package.json +7 -8
  17. package/src/data/sheet/sd-sheet.ts +39 -60
  18. package/src/data/sheet/types.ts +1 -0
  19. package/src/data/sheet/useSheetCellStyling.ts +19 -11
  20. package/src/data/sheet/useSheetColumnFixing.ts +21 -28
  21. package/src/data/sheet/useSheetLayoutEngine.ts +4 -1
  22. package/src/layout/dock/sd-dock-container.ts +1 -0
  23. package/README.md +0 -476
  24. package/docs/bootstrap/provide-sd-angular.md +0 -37
  25. package/docs/bootstrap/sd-angular-config-provider.md +0 -16
  26. package/docs/directives/sd-command-directive.md +0 -30
  27. package/docs/directives/sd-events.md +0 -25
  28. package/docs/directives/sd-intersection-directive.md +0 -36
  29. package/docs/directives/sd-invalid.md +0 -24
  30. package/docs/directives/sd-resize-directive.md +0 -42
  31. package/docs/directives/sd-ripple.md +0 -23
  32. package/docs/directives/sd-router-link.md +0 -38
  33. package/docs/directives/sd-show-effect.md +0 -18
  34. package/docs/directives/sd-typed-template.md +0 -69
  35. package/docs/features/sd-address-search-modal.md +0 -50
  36. package/docs/features/sd-permission-table.md +0 -20
  37. package/docs/features/sd-shared-data-components.md +0 -158
  38. package/docs/features/sd-tiptap-editor.md +0 -26
  39. package/docs/pipes/format-pipe.md +0 -41
  40. package/docs/plugins/sd-global-error-handler.md +0 -23
  41. package/docs/plugins/sd-option-event-plugin.md +0 -34
  42. package/docs/provider-types/sd-menu.md +0 -65
  43. package/docs/provider-types/sd-modal-content-def.md +0 -148
  44. package/docs/provider-types/sd-toast-content-def.md +0 -73
  45. package/docs/provider-types/shared-data-base.md +0 -59
  46. package/docs/providers/sd-activated-modal-provider.md +0 -34
  47. package/docs/providers/sd-app-structure-provider.md +0 -81
  48. package/docs/providers/sd-busy-provider.md +0 -18
  49. package/docs/providers/sd-file-dialog-provider.md +0 -40
  50. package/docs/providers/sd-local-storage-provider.md +0 -20
  51. package/docs/providers/sd-modal-provider.md +0 -67
  52. package/docs/providers/sd-navigate-window-provider.md +0 -18
  53. package/docs/providers/sd-print-provider.md +0 -25
  54. package/docs/providers/sd-service-client-factory-provider.md +0 -43
  55. package/docs/providers/sd-shared-data-provider.md +0 -64
  56. package/docs/providers/sd-system-config-provider.md +0 -46
  57. package/docs/providers/sd-system-log-provider.md +0 -18
  58. package/docs/providers/sd-theme-provider.md +0 -38
  59. package/docs/providers/sd-toast-provider.md +0 -65
  60. package/docs/recipes/_common-rules.md +0 -244
  61. package/docs/recipes/crud-detail/extension-a-edit-save.md +0 -230
  62. package/docs/recipes/crud-detail/extension-b-delete-restore.md +0 -142
  63. package/docs/recipes/crud-detail/extension-c-modal-view.md +0 -214
  64. package/docs/recipes/crud-detail/extension-d-control-view.md +0 -103
  65. package/docs/recipes/crud-detail/extension-e-auxiliary.md +0 -87
  66. package/docs/recipes/crud-detail/extension-f-complex-detail.md +0 -234
  67. package/docs/recipes/crud-detail.md +0 -353
  68. package/docs/recipes/crud-list/extension-a-inline-edit.md +0 -410
  69. package/docs/recipes/crud-list/extension-b-selection.md +0 -226
  70. package/docs/recipes/crud-list/extension-c-inline-delete.md +0 -87
  71. package/docs/recipes/crud-list/extension-d-select-modal.md +0 -207
  72. package/docs/recipes/crud-list/extension-e-readonly-modal.md +0 -165
  73. package/docs/recipes/crud-list/extension-f-modal-edit.md +0 -177
  74. package/docs/recipes/crud-list/extension-g-excel.md +0 -157
  75. package/docs/recipes/crud-list.md +0 -446
  76. package/docs/recipes/data-select-button.md +0 -412
  77. package/docs/recipes/page-modal-container.md +0 -260
  78. package/docs/styling/classes.md +0 -149
  79. package/docs/styling/mixins.md +0 -100
  80. package/docs/styling/themes.md +0 -35
  81. package/docs/styling/variables.md +0 -147
  82. package/docs/type-utilities/directive-input-signals.md +0 -232
  83. package/docs/ui-data/sd-list.md +0 -37
  84. package/docs/ui-data/sd-sheet.md +0 -227
  85. package/docs/ui-form/sd-additional-button.md +0 -26
  86. package/docs/ui-form/sd-anchor.md +0 -31
  87. package/docs/ui-form/sd-button.md +0 -105
  88. package/docs/ui-form/sd-checkbox-group.md +0 -39
  89. package/docs/ui-form/sd-checkbox.md +0 -81
  90. package/docs/ui-form/sd-date-range-picker.md +0 -27
  91. package/docs/ui-form/sd-form.md +0 -89
  92. package/docs/ui-form/sd-modal-select-button.md +0 -54
  93. package/docs/ui-form/sd-numpad.md +0 -26
  94. package/docs/ui-form/sd-range.md +0 -26
  95. package/docs/ui-form/sd-select.md +0 -68
  96. package/docs/ui-form/sd-shared-data-select.md +0 -52
  97. package/docs/ui-form/sd-state-preset.md +0 -37
  98. package/docs/ui-form/sd-switch.md +0 -27
  99. package/docs/ui-form/sd-textarea.md +0 -33
  100. package/docs/ui-form/sd-textfield.md +0 -145
  101. package/docs/ui-layout/sd-dock-container.md +0 -64
  102. package/docs/ui-layout/sd-dock.md +0 -37
  103. package/docs/ui-layout/sd-gap.md +0 -26
  104. package/docs/ui-layout/sd-kanban-board.md +0 -96
  105. package/docs/ui-layout/sd-kanban-lane.md +0 -34
  106. package/docs/ui-layout/sd-kanban.md +0 -29
  107. package/docs/ui-navigation/sd-collapse.md +0 -35
  108. package/docs/ui-navigation/sd-pagination.md +0 -26
  109. package/docs/ui-navigation/sd-sidebar-container.md +0 -49
  110. package/docs/ui-navigation/sd-sidebar-menu.md +0 -22
  111. package/docs/ui-navigation/sd-sidebar-user.md +0 -43
  112. package/docs/ui-navigation/sd-tab.md +0 -51
  113. package/docs/ui-navigation/sd-topbar-container.md +0 -97
  114. package/docs/ui-navigation/sd-topbar-menu.md +0 -23
  115. package/docs/ui-navigation/sd-topbar-user.md +0 -38
  116. package/docs/ui-navigation/sd-topbar.md +0 -30
  117. package/docs/ui-overlay/sd-busy-container.md +0 -69
  118. package/docs/ui-overlay/sd-confirm-modal.md +0 -30
  119. package/docs/ui-overlay/sd-dropdown.md +0 -40
  120. package/docs/ui-overlay/sd-modal.md +0 -34
  121. package/docs/ui-overlay/sd-prompt-modal.md +0 -30
  122. package/docs/ui-overlay/sd-toast.md +0 -35
  123. package/docs/ui-visual/sd-barcode.md +0 -36
  124. package/docs/ui-visual/sd-calendar.md +0 -34
  125. package/docs/ui-visual/sd-echarts.md +0 -32
  126. package/docs/ui-visual/sd-label.md +0 -24
  127. package/docs/ui-visual/sd-note.md +0 -23
  128. package/docs/ui-visual/sd-progress.md +0 -23
  129. package/docs/utils/inject-routing-signals.md +0 -161
  130. package/docs/utils/inject-sd-system-config-resource.md +0 -35
  131. package/docs/utils/mark.md +0 -43
  132. package/docs/utils/selection-managers.md +0 -96
  133. package/docs/utils/set-safe-style.md +0 -19
  134. package/docs/utils/setup-functions.md +0 -93
@@ -1,157 +0,0 @@
1
- ← [CRUD 리스트 레시피 진입점](../crud-list.md)
2
-
3
- # 확장 G: 엑셀 업로드/다운로드
4
-
5
- > **선행:** [확장 A: inline 편집/저장](./extension-a-inline-edit.md) (`_upsertItem` / `_search` / 감사 로그 재사용)
6
-
7
- `SdFileDialogProvider`로 파일 선택, `ExcelWrapper`(@simplysm/excel) + `zod` 스키마로 읽기/쓰기를 수행한다. 다운로드는 확장 A의 `_search(false)`로 페이지네이션 없이 전체를 조회한 뒤 `@simplysm/core-browser`의 `downloadBlob`으로 내려받는다. 업로드된 각 행은 확장 A의 `_upsertItem`을 재사용해 중복 검사·감사 로그를 일관된 경로로 적용한다.
8
-
9
- **이 확장이 도입하는 요소:**
10
-
11
- - **imports:** `SdFileDialogProvider`, `DateTime`, `downloadBlob`(@simplysm/core-browser), `ExcelWrapper`(@simplysm/excel), `z`(zod), `tablerFileExcel`, `tablerUpload`
12
- - **DI:** `SdFileDialogProvider`
13
- - **필드:** `tablerFileExcel` / `tablerUpload` 아이콘, `_excelWrapper` (zod 스키마로 컬럼 정의)
14
- - **메서드:** `onUploadExcelButtonClick`, `onDownloadExcelButtonClick`
15
- - **템플릿:** 확장 A가 도입한 inline 도구 dock 뒤쪽에 엑셀 업로드/다운로드 버튼 2개 추가
16
-
17
- > **아래 코드 블록은 diff 조각이다.** 독립 실행 가능한 완성 클래스가 아니며, 선행 확장(A) 위에 번호 순서대로 삽입할 지점을 나타낸다. 그대로 컴파일되지 않는다.
18
-
19
- ```typescript
20
- // 1) imports 추가
21
- import { tablerFileExcel, tablerUpload } from "@ng-icons/tabler-icons";
22
- import { SdFileDialogProvider } from "@simplysm/angular";
23
- import { DateTime } from "@simplysm/core-common";
24
- import { downloadBlob } from "@simplysm/core-browser";
25
- import { ExcelWrapper } from "@simplysm/excel";
26
- import { z } from "zod";
27
-
28
- // 2) DI 추가
29
- private readonly _sdFileDialog = inject(SdFileDialogProvider);
30
-
31
- // 3) 클래스 필드 — 아이콘 + ExcelWrapper (zod 스키마로 컬럼 정의)
32
- protected readonly tablerFileExcel = tablerFileExcel;
33
- protected readonly tablerUpload = tablerUpload;
34
-
35
- private readonly _excelWrapper = new ExcelWrapper(
36
- z.object({
37
- id: z.number().optional().describe("ID"),
38
- name: z.string().describe("이름"),
39
- phone: z.string().optional().describe("전화번호"),
40
- categoryId: z.number().optional().describe("카테고리.ID"),
41
- // isDeleted는 확장 B 적용 시(DB Table에 isDeleted 컬럼이 있는 경우)에만 추가:
42
- // isDeleted: z.boolean().describe("삭제"),
43
- lastModifiedAt: z.custom<DateTime>().optional().describe("최종수정일시"),
44
- lastModifiedBy: z.string().optional().describe("최종수정자"),
45
- }),
46
- );
47
-
48
- // 4) template — 확장 A가 도입한 inline 도구 dock 뒤쪽에 엑셀 버튼 2개 추가.
49
- // 확장 B가 함께 적용되면 동일 dock 안에 "등록 → 선택 삭제 → 선택 복구 → 엑셀 업로드 → 엑셀 다운로드" 순으로 배치한다.
50
- `
51
- <!-- 확장 A가 도입한 도구 dock (canEdit && page 가드) -->
52
- @if (canEdit() && viewType() === "page") {
53
- <sd-dock class="flex-row gap-sm p-xs-default">
54
- <sd-button [size]="'sm'" [theme]="'link-primary'" (click)="onAddItemButtonClick()">
55
- <ng-icon [svg]="tablerCirclePlus" /> 등록
56
- </sd-button>
57
- <!-- 확장 B 적용 시: 선택 삭제 / 선택 복구 버튼이 여기에 위치 -->
58
-
59
- <!-- ↓ 확장 G가 추가 -->
60
- <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
61
- <ng-icon [svg]="tablerUpload" /> 엑셀 업로드
62
- </sd-button>
63
- <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
64
- <ng-icon [svg]="tablerFileExcel" /> 엑셀 다운로드
65
- </sd-button>
66
- </sd-dock>
67
- }
68
- `
69
-
70
- // 5) 메서드 추가
71
- async onUploadExcelButtonClick(): Promise<void> {
72
- const file = await this._sdFileDialog.showAsync(false, ".xlsx");
73
- if (file == null) return;
74
- if (Array.isArray(file)) return;
75
-
76
- this.busyCount.update((v) => v + 1);
77
- await this._sdToast.try(async () => {
78
- const excelItems = await this._excelWrapper.read(file);
79
- const changedIds: number[] = [];
80
- await this._appOrm.connectAsync(async (db) => {
81
- for (const raw of excelItems) {
82
- changedIds.push(await this._upsertItem(db, raw, "엑셀업로드"));
83
- }
84
- });
85
- await this._appSharedData.emitAsync(this.SHARED_DATA_KEY, changedIds);
86
-
87
- this._sdToast.success("업로드되었습니다.");
88
-
89
- await this._refresh();
90
- });
91
- this.busyCount.update((v) => v - 1);
92
- }
93
-
94
- async onDownloadExcelButtonClick(): Promise<void> {
95
- if (this.busyCount() > 0) return;
96
-
97
- this.busyCount.update((v) => v + 1);
98
- await this._sdToast.try(async () => {
99
- // 전체 조회 (페이지네이션 없이) — 확장 A의 _search를 그대로 재사용
100
- const r = await this._search(false);
101
- const wb = await this._excelWrapper.write(this.viewTitle(), r.items);
102
- try {
103
- downloadBlob(
104
- await wb.toBlob(),
105
- `${this.viewTitle()}_${new DateTime().toFormatString("yyMMdd")}.xlsx`,
106
- );
107
- } finally {
108
- await wb.close();
109
- }
110
- });
111
- this.busyCount.update((v) => v - 1);
112
- }
113
- ```
114
-
115
- **포인트:**
116
-
117
- - **다운로드는 `_search(false)`로 전체를 쿼리한다.** 페이지당 50건 제한이 걸리면 현재 페이지만 다운로드되므로 `usePagination: false`를 명시한다.
118
- - **업로드는 `_excelWrapper.read(file)` → `_upsertItem` 루프로 수행한다.** 확장 A의 중복 검사·감사 로그가 동일하게 적용되며, `logType: "엑셀업로드"`로 감사 로그를 구분한다. 상세 Usage는 [`SdFileDialogProvider.showAsync`](../../providers/sd-file-dialog-provider.md#usage) 참조.
119
- - **엑셀의 텍스트 컬럼(고객사명·MPN 등)을 FK id로 변환해야 하면 DB 재조회 대신 `useSharedSignal(...)`로 이미 로드된 공유 데이터를 재사용한다.** 예: `this.sharedCategories.items().toMapValues((it) => it.name, (it) => it.orderBy((v) => (v.__isHidden ? 1 : 0))[0])`. 같은 키에 숨김·비숨김 항목이 섞여 있으면 `orderBy`로 비숨김(`__isHidden: false`)을 우선순위로 정렬한다. 별도 `_buildIdMap` 같은 helper로 분리하지 않고 `toMapValues`를 `onUploadExcelButtonClick` 내부에 직접 인라인한다 (단일 호출처).
120
- - **`busyMessage`는 필요할 때만 추가한다.** 최소 뼈대는 `<sd-busy-container [busy]="busyCount() > 0">`만 사용하고 `busyMessage` signal을 두지 않는다. 짧은 CRUD는 progress 아이콘만으로 충분하다. 오래 걸리는 작업(대량 엑셀 업로드·집계 등)에 진행 문구가 필요한 화면에만 `busyMessage = signal<string | undefined>(undefined)` 추가 + `[message]="busyMessage()"` 바인딩 + 구간별 `busyMessage.set(...)`/`set(undefined)` 제어를 추가한다. 미사용 시 선언·바인딩 모두 생략한다.
121
-
122
- **🚫 흔한 실수**
123
-
124
- > 공통 규칙(`mark` 오용, `setupCanDeactivate` 호출 위치, 시트 셀 `[inset]/[size]`, 공유 데이터 `wait()` 호출 위치 등)은 [레시피 공통 규칙](../_common-rules.md)을 참조한다. 이 섹션은 **엑셀 업로드/다운로드 고유 실수**만 다룬다.
125
-
126
- ### 필수 필드를 `z.string().optional()`로 선언
127
-
128
- ```typescript
129
- // ❌ 모든 zod 필드에 .optional()을 부착 — 빈 셀·누락 행이 그대로 _upsertItem에 전달되어
130
- // DB NOT NULL 위반 또는 name/phone이 공백인 row가 그대로 upsert된다.
131
- new ExcelWrapper(
132
- z.object({
133
- id: z.number().optional().describe("ID"),
134
- name: z.string().optional().describe("이름"), // ← 필수 필드인데 optional
135
- phone: z.string().optional().describe("전화번호"),
136
- categoryId: z.number().optional().describe("카테고리.ID"),
137
- }),
138
- );
139
-
140
- // ✅ 필수 필드는 .optional() 없이 선언 — 빈 셀 행은 ExcelWrapper가 safeParse 단계에서 차단
141
- new ExcelWrapper(
142
- z.object({
143
- id: z.number().optional().describe("ID"),
144
- name: z.string().describe("이름"), // ← 필수
145
- phone: z.string().optional().describe("전화번호"),
146
- categoryId: z.number().optional().describe("카테고리.ID"),
147
- }),
148
- );
149
- ```
150
-
151
- **근거**: `ExcelWrapper.read`는 각 행을 `_schema.safeParse(record)`로 검증하고 실패 시 에러를 던진다(`packages/excel/src/excel-wrapper.ts:77`). zod 스키마가 업로드 유효성의 유일한 방어선이므로, 필수 필드에 `.optional()`을 달면 검증 자체를 통과해 잘못된 값이 `_upsertItem`까지 내려간다. `isDeleted` 컬럼이 있는 테이블에서는 [공통 규칙: 삭제 방식](../_common-rules.md#삭제-방식은-db-스키마에-따라-결정한다)에 따라 `isDeleted: z.boolean().describe("삭제")` 필드를 추가한다(확장 B 병용).
152
-
153
- ## Cross-reference
154
-
155
- - 진입점: [crud-list.md](../crud-list.md)
156
- - 선행: [확장 A: inline 편집/저장](./extension-a-inline-edit.md) (`_upsertItem`/`_search`/감사 로그 재사용)
157
- - 병용 가능: [확장 B: 선택 기능 + 선택 삭제/복구](./extension-b-selection.md) (`isDeleted` 컬럼이 있는 테이블 — zod 스키마에 `isDeleted` 필드 추가)
@@ -1,446 +0,0 @@
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열을 포함한다 — 도메인 범위는 같지만 뼈대 범위가 다르다.)
40
-
41
- ```typescript
42
- import { NgIcon } from "@ng-icons/core";
43
- import { tablerAlertTriangle, tablerRefresh, tablerSearch } from "@ng-icons/tabler-icons";
44
- import {
45
- ChangeDetectionStrategy,
46
- Component,
47
- effect,
48
- inject,
49
- signal,
50
- untracked,
51
- ViewEncapsulation,
52
- } from "@angular/core";
53
- import { str } from "@simplysm/core-common";
54
- import {
55
- injectPermsSignal,
56
- injectViewTitleSignal,
57
- injectViewTypeSignal,
58
- mark,
59
- SdBusyContainer,
60
- SdButton,
61
- SdCommandDirective,
62
- SdDock,
63
- SdDockContainer,
64
- SdForm,
65
- SdSheet,
66
- SdSheetColumn,
67
- SdSheetColumnCellTemplate,
68
- SdTextfield,
69
- SdToastProvider,
70
- SdTopbar,
71
- SdTopbarContainer,
72
- type SortingDef,
73
- } from "@simplysm/angular";
74
- // 앱별 대체: ORM provider + DbContext. simplysm 패키지가 아니라 각 앱이 소유한다.
75
- import { AppOrmProvider } from "@adtek/client-common";
76
-
77
- interface IFilter {
78
- searchText?: string;
79
- }
80
-
81
- interface ICustomer {
82
- id: number;
83
- name: string;
84
- phone?: string;
85
- }
86
-
87
- @Component({
88
- selector: "app-customer-list",
89
- changeDetection: ChangeDetectionStrategy.OnPush,
90
- encapsulation: ViewEncapsulation.None,
91
- standalone: true,
92
- imports: [
93
- SdBusyContainer, SdTopbarContainer, SdTopbar,
94
- SdDockContainer, SdDock,
95
- SdForm, SdSheet, SdSheetColumn, SdSheetColumnCellTemplate,
96
- SdButton, SdTextfield,
97
- NgIcon,
98
- ],
99
- hostDirectives: [
100
- { directive: SdCommandDirective, outputs: ["sdRefreshCommand"] },
101
- ],
102
- host: {
103
- "(sdRefreshCommand)": "onRefreshButtonClick()",
104
- },
105
- template: `
106
- <sd-busy-container [busy]="busyCount() > 0">
107
- @if (initialized()) {
108
- @if (!perms().includes("use")) {
109
- <div class="fill tx-theme-gray-light p-xxl tx-center">
110
- <br />
111
- <ng-icon [svg]="tablerAlertTriangle" [size]="'5em'" />
112
- <br />
113
- <br />
114
- '{{ viewTitle() }}'에 대한 사용권한이 없습니다. 시스템 관리자에게 문의하세요.
115
- </div>
116
- } @else {
117
- <sd-topbar-container>
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">
133
- <sd-form (formSubmit)="onFilterSubmit()">
134
- <div class="form-box-inline">
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>
150
- </div>
151
- </sd-form>
152
- </sd-dock>
153
-
154
- <!-- 시트 (main 영역) -->
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>
181
- </sd-dock-container>
182
- </sd-topbar-container>
183
- }
184
- }
185
- </sd-busy-container>
186
- `,
187
- })
188
- export class CustomerList {
189
- //== DI ==
190
- private readonly _appOrm = inject(AppOrmProvider);
191
- private readonly _sdToast = inject(SdToastProvider);
192
-
193
- //== 식별 / 권한 ==
194
- perms = injectPermsSignal(["sales.customer"], ["use"]);
195
-
196
- viewType = injectViewTypeSignal();
197
- viewTitle = injectViewTitleSignal();
198
-
199
- //== 상태 ==
200
- initialized = signal(false); // 최초 조회 완료 — @if (initialized()) 가드 해제
201
- busyCount = signal(0); // 중첩 비동기 작업 카운트 (0 초과 시 busy 표시)
202
-
203
- items = signal<ICustomer[]>([]);
204
-
205
- page = signal(0);
206
- pageLength = signal(0);
207
- sortingDefs = signal<SortingDef[]>([]);
208
-
209
- filter = signal<IFilter>({}); // 입력 버퍼. onFilterSubmit 시 lastFilter로 스냅샷
210
- lastFilter = signal<IFilter>({}); // 조회 트리거 — effect 의존성은 이 signal
211
-
212
- //== 시트 fn ==
213
- trackByFn = (item: ICustomer) => item.id;
214
-
215
- constructor() {
216
- // 필터/페이지/정렬/perms 변경 시 재조회
217
- effect(() => {
218
- if (!this.perms().includes("use")) {
219
- this.initialized.set(true);
220
- return;
221
- }
222
-
223
- this.lastFilter();
224
- this.page();
225
- this.sortingDefs();
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);
234
- });
235
- });
236
- }
237
-
238
- //== Handlers ==
239
- onFilterSubmit(): void {
240
- this.page.set(0);
241
- this.lastFilter.set({ ...this.filter() });
242
- }
243
-
244
- onRefreshButtonClick(): void {
245
- if (this.busyCount() > 0) return;
246
- if (!this.perms().includes("use")) return;
247
-
248
- mark(this.lastFilter); // 값 변경 없이 참조만 갱신 → effect 재실행
249
- }
250
-
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);
256
- }
257
-
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);
270
- }
271
-
272
- // 페이지당 50건 — 시트 화면 표준치 (조정 시 UX 확인 필요)
273
- const pageLength = usePagination ? Math.ceil((await qr1.count()) / 50) : 0;
274
-
275
- let qr2 = qr1.select((item) => ({
276
- id: item.id,
277
- name: item.name,
278
- phone: item.phone,
279
- }));
280
-
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);
287
- }
288
-
289
- if (usePagination) {
290
- qr2 = qr2.limit(page * 50, 50);
291
- }
292
-
293
- const items = await qr2.execute();
294
- return { items, pageLength };
295
- });
296
- }
297
-
298
- //== 아이콘 ==
299
- protected readonly tablerAlertTriangle = tablerAlertTriangle;
300
- protected readonly tablerRefresh = tablerRefresh;
301
- protected readonly tablerSearch = tablerSearch;
302
- protected readonly mark = mark;
303
- }
304
- ```
305
-
306
- 본 섹션에 등장하는 개별 API의 단독 사용법은 각 문서를 참조한다:
307
-
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-사용-패턴)
315
-
316
- ### 조건부 요소 포함 기준
317
-
318
- 최소 뼈대의 인프라·라이프사이클 요소는 화면의 필요에 따라 포함·생략한다. 필요 없는 요소를 기계적으로 포함하지 않는다.
319
-
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에 등록되지 않은 데이터를 다루는 화면 |
329
-
330
- ## 변형 (Variation)
331
-
332
- 아래 확장 중 필요한 것만 선택적으로 얹는다. 각 확장은 self-contained 문서에서 최소 뼈대 대비 diff를 제공한다.
333
-
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) |
343
-
344
- ## 🚫 흔한 실수 (Anti-patterns)
345
-
346
- ### modal = 선택 모달로 반사적 부착
347
-
348
- `viewType() === "modal"`이라는 사실만으로 "선택 모달"이라고 단정하고 `SdSelectModal<T>` 계약을 부착하지 않는다. modal 용도는 최소 두 가지다 — (a) 선택 모달(확장 D): 항목을 골라 `close.emit`으로 돌려줌 / (b) 조회 전용(확장 E): 부모 레코드의 자식 목록·이력을 읽기 전용으로 표시, 닫기는 SdModal 기본 "X".
349
-
350
- ```typescript
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까지 전부 이식
357
- }
358
-
359
- // ✅ modal 용도를 먼저 확정한다
360
- // (a) 선택 모달이면 확장 D 스켈레톤부터 시작
361
- // (b) 조회 전용이면 확장 E 스켈레톤부터 시작 — SdSelectModal 계약 부착 안 함
362
- ```
363
-
364
- **근거**: 조회 전용 modal에 선택 계약을 부착하면 호출되지 않아 죽은 코드가 된다. LLM이 풀 스택 합본을 복사하면서 "modal 지원"이라는 이유로 반사적으로 이식하는 회귀가 잦다. [확장 E](./crud-list/extension-e-readonly-modal.md) 참조.
365
-
366
- ### viewType 추측으로 3뷰 모두 박기 / 완전 분리 블록 작성
367
-
368
- 화면이 실제 어떤 뷰로 쓰이는지 확정하지 않은 채 page·modal·control 3뷰용 조각을 모두 배치하지 않는다(당장 쓰지 않는 뷰의 계약·분기는 죽은 코드가 된다). 또한 시트 페이지를 page와 modal로 겸용할 때(확장 D)도 page 블록과 modal 블록을 각각 완성하면서 필터·시트를 중복 작성하지 않는다. 하나의 `<sd-topbar-container>` + `<sd-dock-container>` 공통 껍데기 위에 뷰별로 다른 조각만 `@if`로 얹는다.
369
-
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
- }
389
-
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
- ```
399
-
400
- **근거**: 필터 한 줄을 수정할 때 두 블록을 모두 고쳐야 하는 상황이 생기면 구조가 잘못된 것이다. "완전 분리"는 확장 D에서 modal 하단 바 같은 **뷰별 고유 조각**에만 적용한다.
401
-
402
- ### `orderBy` 람다 + `obj.getChainValue` 회귀
403
-
404
- `Queryable.orderBy`는 string overload를 지원하므로(`packages/orm-common/src/exec/queryable.ts:420`), 람다 + `obj.getChainValue` 우회 코드를 쓰지 않는다.
405
-
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
- }
414
-
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
- ```
420
-
421
- **근거**: 람다 형태는 타입 추론을 깨뜨리고 `as any` 캐스팅을 강제한다. string overload는 체인 경로까지 타입 안전하게 지원한다.
422
-
423
- ### 테스트용 public API 노출
424
-
425
- 테스트에서 호출하려고 private 메서드의 얇은 public wrapper를 노출하지 않는다.
426
-
427
- ```typescript
428
- // ❌ "테스트에서 호출하려고" private 메서드를 public으로 노출
429
- async submit(diffs: IDiff[]): Promise<void> {
430
- await this._submitAsync(diffs);
431
- }
432
-
433
- // ✅ TestBed fixture + click/dispatch 이벤트 경로 또는 host의 sdSaveCommand 트리거
434
- fixture.nativeElement.dispatchEvent(
435
- new KeyboardEvent("keydown", { key: "s", ctrlKey: true }),
436
- );
437
- ```
438
-
439
- **근거**: public wrapper는 캡슐화를 깨고 컴포넌트의 외부 API 인상을 오염시킨다. 실제 사용자가 쓰지 않는 진입점을 공개 API로 만들면 추후 리팩토링 시 제약이 된다.
440
-
441
- ## 관련 Entry
442
-
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뷰 재사용 공통 껍데기만