@simplysm/sd-claude 14.0.91 → 14.0.93

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 (93) hide show
  1. package/claude/references/sd-simplysm14/README.md +7 -6
  2. package/claude/references/sd-simplysm14/apis/angular/README.md +59 -39
  3. package/claude/references/sd-simplysm14/apis/angular/controls.md +119 -186
  4. package/claude/references/sd-simplysm14/apis/angular/crud.md +70 -31
  5. package/claude/references/sd-simplysm14/apis/angular/directives.md +55 -57
  6. package/claude/references/sd-simplysm14/apis/angular/features.md +86 -105
  7. package/claude/references/sd-simplysm14/apis/angular/infra.md +48 -57
  8. package/claude/references/sd-simplysm14/apis/angular/layout.md +37 -47
  9. package/claude/references/sd-simplysm14/apis/angular/overlay.md +82 -74
  10. package/claude/references/sd-simplysm14/apis/angular/routing-appstructure.md +61 -50
  11. package/claude/references/sd-simplysm14/apis/angular/shared-data.md +74 -57
  12. package/claude/references/sd-simplysm14/apis/angular/sheet.md +63 -72
  13. package/claude/references/sd-simplysm14/apis/capacitor-plugin-auto-update/README.md +23 -18
  14. package/claude/references/sd-simplysm14/apis/capacitor-plugin-file-system/README.md +21 -19
  15. package/claude/references/sd-simplysm14/apis/capacitor-plugin-intent/README.md +23 -18
  16. package/claude/references/sd-simplysm14/apis/capacitor-plugin-usb-storage/README.md +72 -32
  17. package/claude/references/sd-simplysm14/apis/core-browser/README.md +18 -18
  18. package/claude/references/sd-simplysm14/apis/core-browser/dom-element.md +29 -29
  19. package/claude/references/sd-simplysm14/apis/core-browser/indexed-db.md +41 -41
  20. package/claude/references/sd-simplysm14/apis/core-common/README.md +97 -90
  21. package/claude/references/sd-simplysm14/apis/core-common/async-runtime.md +75 -51
  22. package/claude/references/sd-simplysm14/apis/core-common/collection-ext.md +81 -0
  23. package/claude/references/sd-simplysm14/apis/core-common/errors.md +27 -29
  24. package/claude/references/sd-simplysm14/apis/core-common/obj.md +44 -45
  25. package/claude/references/sd-simplysm14/apis/core-common/serialization.md +34 -33
  26. package/claude/references/sd-simplysm14/apis/core-common/value-types.md +86 -0
  27. package/claude/references/sd-simplysm14/apis/core-node/README.md +6 -6
  28. package/claude/references/sd-simplysm14/apis/core-node/consola.md +3 -0
  29. package/claude/references/sd-simplysm14/apis/core-node/cpx.md +2 -2
  30. package/claude/references/sd-simplysm14/apis/core-node/fs-watcher.md +1 -1
  31. package/claude/references/sd-simplysm14/apis/core-node/fsx.md +2 -2
  32. package/claude/references/sd-simplysm14/apis/core-node/worker.md +6 -3
  33. package/claude/references/sd-simplysm14/apis/excel/README.md +10 -10
  34. package/claude/references/sd-simplysm14/apis/excel/conditional-format.md +4 -2
  35. package/claude/references/sd-simplysm14/apis/excel/utils.md +1 -1
  36. package/claude/references/sd-simplysm14/apis/excel/workbook-worksheet.md +6 -6
  37. package/claude/references/sd-simplysm14/apis/lint/README.md +6 -32
  38. package/claude/references/sd-simplysm14/apis/lint/recommended.md +60 -0
  39. package/claude/references/sd-simplysm14/apis/lint/rules.md +17 -17
  40. package/claude/references/sd-simplysm14/apis/orm-common/README.md +15 -6
  41. package/claude/references/sd-simplysm14/apis/orm-common/db-context.md +68 -102
  42. package/claude/references/sd-simplysm14/apis/orm-common/expr.md +75 -89
  43. package/claude/references/sd-simplysm14/apis/orm-common/queryable.md +87 -99
  44. package/claude/references/sd-simplysm14/apis/orm-common/schema.md +110 -147
  45. package/claude/references/sd-simplysm14/apis/orm-common/types.md +48 -51
  46. package/claude/references/sd-simplysm14/apis/orm-node/README.md +8 -13
  47. package/claude/references/sd-simplysm14/apis/orm-node/db-conn.md +5 -5
  48. package/claude/references/sd-simplysm14/apis/sd-cli/README.md +9 -6
  49. package/claude/references/sd-simplysm14/apis/sd-cli/SdTsCompiler.md +9 -8
  50. package/claude/references/sd-simplysm14/apis/sd-cli/sd-config-types.md +23 -19
  51. package/claude/references/sd-simplysm14/apis/service-client/README.md +20 -12
  52. package/claude/references/sd-simplysm14/apis/service-client/orm.md +6 -6
  53. package/claude/references/sd-simplysm14/apis/service-client/transport.md +1 -1
  54. package/claude/references/sd-simplysm14/apis/service-common/README.md +35 -32
  55. package/claude/references/sd-simplysm14/apis/service-common/app-structure.md +23 -22
  56. package/claude/references/sd-simplysm14/apis/service-common/protocol.md +23 -23
  57. package/claude/references/sd-simplysm14/apis/service-server/README.md +51 -43
  58. package/claude/references/sd-simplysm14/apis/service-server/service-authoring.md +6 -6
  59. package/claude/references/sd-simplysm14/apis/service-server/transport-internals.md +31 -21
  60. package/claude/references/sd-simplysm14/apis/service-server/v1-legacy.md +8 -8
  61. package/claude/references/sd-simplysm14/apis/storage/README.md +55 -49
  62. package/claude/references/sd-simplysm14/manuals/client-component.md +843 -740
  63. package/claude/references/sd-simplysm14/manuals/client-crud.md +8 -0
  64. package/claude/references/sd-simplysm14/manuals/client-demo.md +6 -16
  65. package/claude/references/sd-simplysm14/manuals/client-shared-data.md +26 -0
  66. package/claude/references/sd-simplysm14/manuals/logging.md +1 -1
  67. package/claude/references/sd-simplysm14/manuals/orm.md +15 -1
  68. package/claude/rules/sd-design-rules.md +7 -0
  69. package/claude/sd-system-prompt.md +5 -8
  70. package/claude/skills/sd-debug/SKILL.md +43 -0
  71. package/claude/skills/sd-debug/workflow.js +390 -0
  72. package/claude/skills/sd-demo/SKILL.md +18 -20
  73. package/claude/skills/sd-dev/SKILL.md +127 -24
  74. package/claude/skills/sd-docs/SKILL.md +5 -3
  75. package/claude/skills/sd-docs/references/subagent-prompt.md +2 -3
  76. package/claude/skills/sd-impl/SKILL.md +18 -18
  77. package/claude/skills/sd-manual/SKILL.md +1 -0
  78. package/claude/skills/sd-review/SKILL.md +24 -18
  79. package/claude/skills/sd-review/workflow.js +324 -0
  80. package/claude/skills/sd-spec/SKILL.md +96 -679
  81. package/claude/skills/sd-spec/references/example-spec.md +28 -50
  82. package/claude/skills/sd-spec/references/format-analyze.md +232 -0
  83. package/claude/skills/sd-spec/references/format-design.md +248 -0
  84. package/claude/skills/sd-spec/workflow-analyze.js +615 -0
  85. package/claude/skills/sd-spec/workflow-design.js +667 -0
  86. package/claude/skills/sd-unpack/scripts/handlers/office_com.py +5 -1
  87. package/package.json +1 -1
  88. package/scripts/postinstall.mjs +157 -18
  89. package/claude/references/sd-simplysm14/apis/angular/selection-managers.md +0 -68
  90. package/claude/references/sd-simplysm14/apis/core-common/array-ext.md +0 -77
  91. package/claude/references/sd-simplysm14/apis/core-common/datetime.md +0 -86
  92. package/claude/skills/sd-skill/SKILL.md +0 -245
  93. package/claude/skills/sd-skill/scripts/run_eval.py +0 -380
@@ -1,740 +1,843 @@
1
- # 클라이언트 화면 작성 매뉴얼
2
-
3
- ## 파일명·역할·위치
4
-
5
- 화면 파일명은 `<domain>.<역할>.ts` 형식. 역할 접미사로 책임을 표시.
6
-
7
- | 파일명 형식 | 역할 |
8
- | ---------------------------- | ------------------------------------------------------------------------- |
9
- | `<domain>.view.ts` | list/detail 합성 화면. list/detail 자식을 두고 상호 트리거를 중계. |
10
- | `<domain>.list.ts` | 목록. `sd-crud-list` 사용. |
11
- | `<domain>.detail.ts` | 단건 보기/편집. `sd-crud-detail` 사용. |
12
- | `<domain>.modal.ts` | 모달 전용 화면. |
13
- | `<domain>.print-template.ts` | 프린트 템플릿. `SdPrintProvider.printAsync` 호출 대상. |
14
- | `<domain>.types.ts` | 도메인 화면들이 공유하는 타입 정의. |
15
- | `<domain>.ts` | 컨트롤(접미사 없음). 여러 화면에서 재사용되는 단위. |
16
-
17
- - 모든 파일명은 dash-case.
18
- - 라이브러리(`@simplysm/angular`) 의 파일은 `sd-` prefix 적용 (`sd-button.ts`, `sd-crud-list.ts`).
19
-
20
- **위치**: 도메인이 있는 파일은 도메인 폴더 안에 둠. 도메인이 없는(범용) 파일은 `src/<역할>s/` 하위에 둠. 예: `src/controls/`, `src/modals/`.
21
-
22
- **변형 파일**: 한 도메인 폴더 안에 같은 역할 파일이 2개 이상이면 `<domain>-<갈래>.<역할>.ts` 형식으로 갈래를 표시. 예 (`outbound-instruction/` 폴더):
23
-
24
- - `outbound-instruction.list.ts` (헤더 목록).
25
- - `outbound-instruction-item.list.ts` (품목 목록).
26
- - `outbound-instruction-box.list.ts` (박스 목록).
27
- - `outbound-instruction.detail.ts`.
28
- - `outbound-instruction-header.detail.ts`.
29
-
30
- ## 컴포넌트 데코레이터 기본값
31
-
32
- Angular 기본과 다른 부분만 명시:
33
-
34
- ```ts
35
- @Component({
36
- selector: "app-<dashed-name>",
37
- changeDetection: ChangeDetectionStrategy.OnPush,
38
- encapsulation: ViewEncapsulation.None,
39
- standalone: true,
40
- template: `...`,
41
- styles: [/* language=SCSS */ `...`], // 선택
42
- })
43
- ```
44
-
45
- - **`changeDetection: OnPush`** — 항상.
46
- - **`encapsulation: ViewEncapsulation.None`** 항상. 글로벌 SCSS 일관된 스타일을 적용하기 위함.
47
- - **`standalone: true`** — 항상. NgModule 신규 도입 금지.
48
- - **`selector` prefix** — 앱 컴포넌트는 `app-`, 라이브러리(`@simplysm/angular`) 컴포넌트는 `sd-`.
49
- - **`template`** 은 인라인 (`*.html` 파일 분리 없음).
50
-
51
- **`styles`** (선택 사용) — 다음 두 가지를 simplysm 약속으로 따름:
52
-
53
- - 첫 줄에 `/* language=SCSS */` 주석 배치 (IDE 가 SCSS 로 인식하게 함).
54
- - 내부 전용 클래스명은 `_` prefix (예: `._content`, `._button`).
55
-
56
- ## 화면 합성 패턴
57
-
58
- 화면은 list / detail / view 단위로 책임을 분리해 합성함.
59
-
60
- - **`*.list.ts`** — 자체 검색·페이지·정렬·재조회를 책임. `selectMode` 같은 입력을 받아 부모가 선택 동작을 제어할 수 있게 노출.
61
- - **`*.detail.ts`** — 식별자(`input.required`) 를 받아 자체 로드·저장. 변경·삭제 후 `submitted` output 으로 부모에게 알림.
62
- - **`*.view.ts`** — list/detail 합성 + 자식 간 트리거 중계. 데이터 페치는 view 에서 수행 금지.
63
-
64
- 화면이 list 또는 detail 하나로 끝나면 view 만들지 않음. 경우 list/detail 자체가 라우팅 진입 단위.
65
-
66
- ### list + detail 합성
67
-
68
- view 의 합성 패턴 (예: `outbound-instruction.view.ts`):
69
-
70
- ```html
71
- <sd-base-container [(ready)]="ready" [initialized]="initialized()" [(busyCount)]="busyCount" ...>
72
- <ng-template #contentTpl>
73
- <div class="flex-row fill">
74
- <app-outbound-instruction-list #headerSheet selectMode="single" class="flex-min" />
75
-
76
- @let _selectedId = headerSheet.selectedKeys().first(); @if (_selectedId == null) {
77
- <div class="flex-fill p-default">선택하세요.</div>
78
- } @else {
79
- <app-outbound-instruction-detail
80
- class="flex-fill"
81
- [instructionId]="_selectedId"
82
- (submitted)="headerSheet.doRefresh()"
83
- />
84
- }
85
- </div>
86
- </ng-template>
87
- </sd-base-container>
88
- ```
89
-
90
- 핵심 약속:
91
-
92
- - view 는 list 컴포넌트를 템플릿 변수(`#headerSheet`) 잡아 `selectedKeys()` 를 읽고 `doRefresh()` 를 호출.
93
- - detail 의 단건 변경·삭제는 list 가 표시하는 동일 데이터에 반영해야 하므로, detail 의 `submitted` → list 의 `doRefresh()` 호출로 동기화.
94
- - view `sd-base-container` 루트로 두고, 내부 콘텐츠는 `#contentTpl` 슬롯에 배치.
95
-
96
- ### list + list 합성 (마스터-라인)
97
-
98
- 좌 list 가 마스터(헤더), 우 list 가 디테일(라인) 역할로 합성:
99
-
100
- ```html
101
- <sd-base-container [(ready)]="ready" [initialized]="initialized()" [(busyCount)]="busyCount" ...>
102
- <ng-template #contentTpl>
103
- <div class="flex-row fill">
104
- <app-master-list #headerSheet selectMode="single" class="flex-min" />
105
-
106
- @let _selectedId = headerSheet.selectedKeys().first();
107
- @if (_selectedId == null) {
108
- <div class="flex-fill p-default">선택하세요.</div>
109
- } @else {
110
- <app-line-list
111
- class="flex-fill"
112
- [headerId]="_selectedId"
113
- (submitted)="headerSheet.doRefresh()"
114
- />
115
- }
116
- </div>
117
- </ng-template>
118
- </sd-base-container>
119
- ```
120
-
121
- 핵심 약속:
122
-
123
- - 우 list 는 좌 list 선택 키를 `input` 으로 받아 자동 재조회. 외부 input → filter 머지 패턴은 아래 "외부 input 을 filter 에 반영" 섹션 참조.
124
- - 우 list 의 저장·삭제 후 좌 헤더 목록까지 갱신해야 하면, 우 list 가 `submitted` output 을 emit 하고, view 가 받아 `#headerSheet.doRefresh()` 호출.
125
- - 우 list 안에 추가 분기(탭 등) 가 필요하면 [client-tab.md](./client-tab.md) 매뉴얼 따름.
126
-
127
- ## 화면 컴포넌트의 표준 시그널
128
-
129
- 화면 컴포넌트(view/list/detail/modal) 공통으로 사용하는 시그널 4종. **필요한 것만 채택**하되, 채택 시 아래 약속된 이름·의미·전파를 그대로 따름.
130
-
131
- | 이름 | 종류 | 의미 |
132
- | ------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
133
- | `ready` | `signal(false)` | 컴포넌트가 데이터 로드를 시작해도 되는 시점. 부모·컨테이너가 true 로 set 하면 자식의 effect 발화. |
134
- | `initialized` | `signal(false)` | 첫 데이터 로드 완료 여부. 자식이 자기 로드 종료 후 true 로 set. |
135
- | `busyCount` | `signal(0)` | 진행 중인 비동기 작업 수. 시작 시 `+1`, 종료 시 `-1`. 값이 0 보다 크면 화면이 busy 표시. |
136
- | `viewType` | `injectViewTypeSignal()` | 화면이 page / control / modal 중 어느 컨텍스트에서 동작 중인지. 라우팅 진입이면 `'page'`, view 자식이면 `'control'`, 모달이면 `'modal'`. |
137
-
138
- **전파**: 부모가 자식에게 위 시그널들을 그대로 전달. `sd-base-container` / `sd-crud-list` / `sd-crud-detail` 는 이 시그널들을 입력으로 받는 표준 컴포넌트.
139
-
140
- **`busyCount` 사용 패턴**:
141
-
142
- ```ts
143
- this.busyCount.update((v) => v + 1);
144
- await this._sdToast.try(async () => {
145
- // ...작업...
146
- });
147
- this.busyCount.update((v) => v - 1);
148
- ```
149
-
150
- ## 권한 (perms)
151
-
152
- 화면 컴포넌트는 권한 정보를 `injectPermsSignal(<paths>, <actions>)` 로 받음.
153
-
154
- ```ts
155
- perms = injectPermsSignal(
156
- ["inventory.outbound-instruction"],
157
- ["use", "edit", "write-and-document.edit"],
158
- );
159
- ```
160
-
161
- - 첫 인자: **권한 path 목록** (도메인 트리 좌표).
162
- - 둘째 인자: **확인할 action 목록**. `perms()` 의 반환값은 사용자가 보유한 action 의 string 배열.
163
-
164
- **사용 약속**:
165
-
166
- - 단순 권한 체크는 `this.perms().includes("use")` 를 템플릿·코드에 인라인으로 작성. 별도 computed 로 묶지 않음.
167
- - `restricted` 입력은 `[restricted]="!perms().includes('use')"` 형태로 인라인 전달. 별도 `canUse` / `restricted` computed 생성 금지.
168
- - 권한 체크 뒤에 추가 조건(데이터 상태 등) 이 결합되어 **같은 결합이 2회 이상 참조되는 경우에만** computed 로 묶음.
169
-
170
- ```ts
171
- canEdit = computed(() => this.perms().includes("edit") && this.data().state === "작성");
172
- ```
173
-
174
- - list/detail 의 effect 진입에서도 인라인으로 가드:
175
-
176
- ```ts
177
- effect(() => {
178
- if (!this.perms().includes("use")) {
179
- this.initialized.set(true);
180
- return;
181
- }
182
- // ...
183
- });
184
- ```
185
-
186
- ## 에러·토스트
187
-
188
- 비동기 작업은 `_sdToast.try(async () => { ... })` 로 감쌈. 콜백 내에서 throw 된 에러는 토스트로 표시되며 외부로 전파되지 않음.
189
-
190
- ```ts
191
- private readonly _sdToast = inject(SdToastProvider);
192
-
193
- // ...
194
-
195
- this.busyCount.update((v) => v + 1);
196
- await this._sdToast.try(async () => {
197
- await this._refresh();
198
- });
199
- this.busyCount.update((v) => v - 1);
200
- ```
201
-
202
- **메시지 직접 표시**:
203
-
204
- - `_sdToast.success("저장되었습니다.")`.
205
- - `_sdToast.warning("...")`.
206
- - `_sdToast.info("...")`.
207
- - `_sdToast.danger("...")`.
208
-
209
- **중복 작업 가드**: 진행 중인 작업이 있으면 새 작업은 건너뛰고 즉시 반환.
210
-
211
- ```ts
212
- if (this.busyCount() > 0) return;
213
- ```
214
-
215
- ## DI 명명
216
-
217
- `inject()` 한 의존은 외부 노출 멤버(시그널·output·공개 메서드 등) 와 구분하기 위해 `_` prefix 적용.
218
-
219
- ```ts
220
- private readonly _sdToast = inject(SdToastProvider);
221
- private readonly _sdModal = inject(SdModalProvider);
222
- private readonly _appOrm = inject(AppOrmProvider);
223
- private readonly _router = inject(Router);
224
- ```
225
-
226
- - 시그널·input·output·공개 메서드는 prefix 없음.
227
- - DI 멤버는 항상 `private readonly`.
228
-
229
- ## 모달 호출
230
-
231
- 화면에서 다른 화면을 모달로 띄울 때:
232
-
233
- ```ts
234
- private readonly _sdModal = inject(SdModalProvider);
235
-
236
- // ...
237
-
238
- const result = await this._sdModal.showAsync({
239
- type: OutboundInstructionHeaderDetail,
240
- title: "출고지시 등록",
241
- inputs: { /* 모달 컴포넌트의 input 들 */ },
242
- });
243
- if (!result) return;
244
-
245
- // result 처리
246
- ```
247
-
248
- - **`type`** — `SdModalContentDef<O>` 를 구현한 컴포넌트 클래스 (`initialized` 시그널 + `close` output 보유. `O` 는 close 페이로드 타입). `SdModal` 은 라이브러리 모달 셸 컴포넌트이므로 상속 대상이 아님.
249
- - **`title`** 모달 헤더 제목.
250
- - **`inputs`** — 모달 컴포넌트가 받을 input 시그널 값. 없으면 `{}`.
251
- - **반환값** — 모달 컴포넌트가 close 시 emit 한 페이로드. 사용자가 닫기(X)·취소로 닫으면 `undefined`.
252
-
253
- ## `mark` 헬퍼
254
-
255
- `@simplysm/angular` 의 `mark(signal)` 은 시그널 값은 그대로 두고 **변경 알림만** 발행. effect 가 의존하는 시그널을 강제 재발화시키거나, 객체 시그널 내부 필드 변경을 알릴 때 사용.
256
-
257
- **1. 외부 트리거로 effect 재발화** — 값은 동일하지만 effect 를 다시 발화시켜야 할 때.
258
-
259
- ```ts
260
- doRefresh(): void {
261
- mark(this.lastFilter);
262
- }
263
- ```
264
-
265
- **2. 객체·배열 시그널 내부 변경 알림** — 시그널이 들고 있는 객체의 _필드만_ 변경된 경우 시그널 자체는 변경 알림을 보내지 않음. 양방향 바인딩 자식의 변경 이벤트에 묶어 호출.
266
-
267
- ```ts
268
- filter = signal<IFilter>({ name: "", state: "" });
269
- ```
270
-
271
- ```html
272
- <sd-textfield [(value)]="filter().name" (valueChange)="mark(filter)" />
273
- ```
274
-
275
- ```ts
276
- data().dueDate = new DateOnly();
277
- mark(this.data);
278
- ```
279
-
280
- ## list 데이터 흐름
281
-
282
- list 컴포넌트는 자체 검색·페이지·정렬·재조회를 책임.
283
-
284
- ### 시그널 구성
285
-
286
- ```ts
287
- items = signal<IItem[]>([]);
288
- selectedKeys = signal<TKey[]>([]);
289
- page = signal(0);
290
- pageLength = signal(0);
291
- sortingDefs = signal<SortingDef[]>([]);
292
-
293
- filter = signal<IFilter>({ ... }); // 폼 입력용
294
- lastFilter = signal<IFilter>({ ... }); // 마지막 조회 시점 — effect 가 이걸 의존
295
-
296
- trackByFn = (item: IItem) => item.id;
297
- ```
298
-
299
- **`filter` vs `lastFilter` 분리**: 폼 입력 도중 매번 재조회되지 않게 하기 위함. 사용자가 조회 버튼을 눌러야 `lastFilter` 가 갱신되고 effect 가 발화.
300
-
301
- ### 자동 재조회 effect
302
-
303
- ```ts
304
- constructor() {
305
- effect(() => {
306
- if (!this.perms().includes("use") || !this.ready()) {
307
- this.initialized.set(true);
308
- return;
309
- }
310
-
311
- this.lastFilter(); // 의존성 등록
312
- this.page();
313
- this.sortingDefs();
314
-
315
- void untracked(async () => {
316
- this.busyCount.update((v) => v + 1);
317
- await this._sdToast.try(async () => {
318
- await this._refresh();
319
- });
320
- this.busyCount.update((v) => v - 1);
321
- this.initialized.set(true);
322
- });
323
- });
324
- }
325
- ```
326
-
327
- - `lastFilter` / `page` / `sortingDefs` 변경 시 자동 재조회.
328
- - 비동기 본체는 `untracked` 안에서 실행해 자기 자신이 의존성에 등록되지 않게 함.
329
-
330
- ### 조회 트리거
331
-
332
- ```ts
333
- onFilterSubmit(): void {
334
- this.page.set(0);
335
- this.lastFilter.set({ ...this.filter() }); // effect 발화
336
- }
337
-
338
- doRefresh(): void { // 부모(view) 가 호출하는 외부 API
339
- if (!this.perms().includes("use")) return;
340
- mark(this.lastFilter); // 값은 동일하나 effect 재발화
341
- }
342
- ```
343
-
344
- ### `_refresh` 구조
345
-
346
- ```ts
347
- private async _refresh(): Promise<void> {
348
- const r = await this._search(true);
349
- this.items.set(r.items);
350
- this.pageLength.set(r.pageLength);
351
- }
352
- ```
353
-
354
- `_search` 는 ORM 쿼리 실행. 자세한 사용법은 [orm.md](./orm.md) 참조.
355
-
356
- ### 페이지네이션
357
-
358
- 두 패턴 중 택일. 데이터 규모와 검색·정렬 책임에 따라 화면 작성자가 판단 (명확한 컷오프 없음).
359
-
360
- **서버 페이징** — 한 페이지 분량만 매번 서버에서 가져옴. 위의 "시그널 구성" / "자동 재조회 effect" / "`_refresh` 구조" 섹션이 가정하는 기본 패턴.
361
-
362
- - `pageLength` 시그널을 두고, `_refresh` 에서 서버 응답의 총 페이지 수로 set.
363
- - `page` / `sortingDefs` / `lastFilter` 모두 effect 의존성. 변경 시 재조회.
364
- - `<sd-crud-list>` 에 `[totalPageCount]="pageLength()"` 전달. `[itemsPerPage]` 는 생략 (= 0).
365
-
366
- **클라이언트 페이징** — 전체 데이터를 한 번에 로드. 시트가 자체적으로 slice·sort 수행.
367
-
368
- - `pageLength` 시그널과 `sortingDefs` effect 의존성 불필요 (정렬은 시트 내부에서 처리).
369
- - `_refresh` 는 전체 아이템을 한 번에 `items.set(all)`.
370
- - `<sd-crud-list>` `[itemsPerPage]="<페이지당 수>"` 전달. `[totalPageCount]` 는 생략 (= 0).
371
- - `[(sorts)]` 는 화면이 정렬 상태를 보유해야 하는 경우에만 바인딩. 그 외에는 생략.
372
-
373
- **`[visiblePageCount]`** (기본 10) — 페이지네이터가 한 번에 표시하는 페이지 번호 개수. 두 패턴 모두 사용자가 명시 지시한 경우에만 설정.
374
-
375
- ### 외부 input 을 filter 에 반영
376
-
377
- list 가 다른 화면 안에 임베드되어 외부에서 filter 의 일부를 input 으로 받는 경우, effect 로 input → filter → lastFilter 흐름을 구성.
378
-
379
- ```ts
380
- constructor() {
381
- effect(() => {
382
- const filterInputs: Partial<IFilter> = {
383
- includeTargetCustomerIds: this.includeTargetCustomerIds() ?? [],
384
- includeGoodsIds: this.includeGoodsIds() ?? [],
385
- excludeIds: this.excludeIds() ?? [],
386
- isIncludeOutOfInventory: this.isIncludeOutOfInventory() ?? false,
387
- // ...
388
- };
389
-
390
- untracked(() => {
391
- this.filter.update((f) => ({ ...f, ...filterInputs }));
392
- this.lastFilter.set({ ...this.filter() });
393
- this.page.set(0);
394
- });
395
- });
396
- }
397
- ```
398
-
399
- - 외부 input 이 effect 의존성. 변경 시 발화.
400
- - input 값을 `Partial<IFilter>` 로 모아 `filter` 에 머지.
401
- - `lastFilter` 갱신 자동 재조회 effect 발화.
402
- - `page.set(0)` 으로 첫 페이지로 리셋.
403
-
404
- ## detail 데이터 흐름
405
-
406
- detail 컴포넌트는 식별자를 받아 자체 로드·저장을 수행하고, 변경·삭제 후 `submitted` 로 부모에게 알림.
407
-
408
- ### 시그널 구성
409
-
410
- ```ts
411
- dataId = input.required<number>(); // 식별자 (부모가 주입)
412
- submitted = output<boolean>(); // 변경·삭제 후 알림
413
-
414
- data = signal<IData>({ ... initialState ... });
415
- private _orgData?: IData; // 변경 추적용 원본 스냅샷
416
- ```
417
-
418
- ### 로드 effect + 페이지 이탈 가드
419
-
420
- ```ts
421
- constructor() {
422
- effect(() => {
423
- this.dataId(); // 식별자 변경 시 재조회
424
-
425
- if (!this.perms().includes("use")) {
426
- this.initialized.set(true);
427
- return;
428
- }
429
-
430
- void untracked(async () => {
431
- this.busyCount.update((v) => v + 1);
432
- await this._sdToast.try(async () => {
433
- await this._refresh();
434
- });
435
- this.busyCount.update((v) => v - 1);
436
- this.initialized.set(true);
437
- });
438
- });
439
-
440
- setupCanDeactivate(() => this._checkIgnoreChanges());
441
- }
442
- ```
443
-
444
- - 식별자(`dataId`) 가 effect 의존성. 부모가 다른 항목으로 전환하면 자동 재로드.
445
- - `setupCanDeactivate(fn)` (`@simplysm/angular`) 는 라우터 이탈 시점에 `fn()` 이 false 를 반환하면 이탈을 차단.
446
-
447
- ### `_refresh` + 원본 스냅샷
448
-
449
- ```ts
450
- private async _refresh(): Promise<void> {
451
- const loaded = await this._appOrm.connectAsync(async (db) => {
452
- // ...쿼리...
453
- return loadedData;
454
- });
455
-
456
- this.data.set(loaded);
457
- this._orgData = obj.clone(loaded); // 변경 비교용 사본
458
- }
459
- ```
460
-
461
- `obj.clone` / `obj.equal` 은 `@simplysm/core-common`.
462
-
463
- ### 변경 가드
464
-
465
- ```ts
466
- private _checkIgnoreChanges(): boolean {
467
- return (
468
- this._orgData == null ||
469
- obj.equal(this.data(), this._orgData) ||
470
- confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?")
471
- );
472
- }
473
- ```
474
-
475
- ### 저장 패턴
476
-
477
- ```ts
478
- async onSubmit(): Promise<void> {
479
- if (this.busyCount() > 0) return;
480
- if (!this.perms().includes("edit")) return;
481
-
482
- const data = this.data();
483
- if (this._orgData && obj.equal(data, this._orgData)) {
484
- this._sdToast.info("변경사항이 없습니다.");
485
- return;
486
- }
487
-
488
- this.busyCount.update((v) => v + 1);
489
- await this._sdToast.try(async () => {
490
- await this._appOrm.connectAsync(async (db) => {
491
- // ... 변경분만 upsert/delete (orm.md 참조) ...
492
- });
493
- this._sdToast.success("저장되었습니다.");
494
- await this._refresh();
495
- this.submitted.emit(true);
496
- });
497
- this.busyCount.update((v) => v - 1);
498
- }
499
- ```
500
-
501
- **핵심 약속**:
502
-
503
- - 식별자는 `input.required<>` 받음.
504
- - 로드 후 `_orgData = obj.clone(loaded)` 원본 보관.
505
- - 페이지 이탈 가드는 `setupCanDeactivate` + `obj.equal` 비교로 처리.
506
- - 저장 완료 후 `_refresh()` 로 다시 로드 → `submitted.emit(true)`.
507
- - 삭제·취소 등 다른 액션도 끝에 `submitted.emit(true)` 를 emit 해 부모(list) 가 새로고침할 수 있게 함.
508
-
509
- ## 시트 컬럼·셀 표준
510
-
511
- ```html
512
- <sd-sheet-column [key]="'name'" [header]="'이름'">
513
- <ng-template [cell]="items()" let-item="item">
514
- <div class="p-xs-sm">{{ item.name }}</div>
515
- </ng-template>
516
- </sd-sheet-column>
517
- ```
518
-
519
- **폭 약속**:
520
-
521
- - `[width]` 는 **미명시가 기본** (자동). px 지정은 사용자가 명시 지시한 경우에만 적용.
522
- - 영역 폭(`flex-min` 의 `style="width: ..."` 등) 도 동일.
523
-
524
- **셀 본문 약속**:
525
-
526
- - 시트 셀에는 패딩이 없으므로 본문 div 에 `p-xs-sm` 클래스 적용이 기본.
527
- - 정렬 클래스(`tx-right` / `tx-center` / `tx-left`) 는 **사용자가 명시 지시한 경우에만** 사용. 기본은 미지정 (브라우저 기본 left). "라벨은 가운데" 같은 자동 휴리스틱 적용 금지.
528
- - 단, **숫자 셀은 `tx-right` 기본 적용** (수량·금액·단가·합계 등 숫자값 컬럼).
529
- - `[cell]="items()"` 는 타입 추론용 더미. 실제 행 데이터는 `<sd-sheet>` 의 `[items]` 가 보유.
530
- - 셀 컨텍스트: `let-item="item"` / `let-index="index"` / `let-depth="depth"` / `let-edit="edit"`.
531
- - 셀 안 div 에 배경색 클래스(`bg-theme-*-lightest` 등) 를 토글할 때는 빈 값 자리에 `&nbsp;` 를 채워 div 가 셀 높이를 유지하게 함 (table cell 자식 div 가 콘텐츠 없을 시 높이 0 → bg 가 셀을 채우지 못함).
532
- - 좋은 예: `{{ item.surveyLocationCode ?? "&nbsp;" }}`.
533
- - 나쁜 예: `{{ item.surveyLocationCode }}`, `{{ item.surveyLocationCode ?? "" }}`, `{{ item.surveyLocationCode ?? " " }}`.
534
-
535
- **list 안에서**: `<sd-crud-list>` 의 직속 자식으로 `<sd-sheet-column>` 을 두면 내부 시트로 자동 투영.
536
-
537
- ```html
538
- <sd-crud-list ...>
539
- <ng-template #filterTpl>...</ng-template>
540
-
541
- <sd-sheet-column [key]="..." [header]="...">
542
- <ng-template [cell]="items()" let-item="item">...</ng-template>
543
- </sd-sheet-column>
544
- </sd-crud-list>
545
- ```
546
-
547
- ### 요약
548
-
549
- 컬럼에 `<ng-template #summaryTpl>` 을 두면 시트의 헤더 영역 하단(`thead` 내부) 요약 행이 렌더됨. 스크롤 시 헤더와 함께 상단 고정되며, 배경은 warning 계열로 자동 강조.
550
-
551
- ```html
552
- <sd-sheet-column [key]="'quantity'" [header]="'수량'">
553
- <ng-template #summaryTpl>
554
- <div class="p-xs-sm tx-right">{{ totalQuantity() }}</div>
555
- </ng-template>
556
- <ng-template [cell]="items()" let-item="item">
557
- <div class="p-xs-sm tx-right">{{ item.quantity }}</div>
558
- </ng-template>
559
- </sd-sheet-column>
560
- ```
561
-
562
- - 컬럼 중 하나라도 `#summaryTpl` 을 가지면 요약 행 전체가 활성화됨. 정의 없는 컬럼은 빈 셀로 표시.
563
- - 본문 약속(`p-xs-sm`, 정렬 클래스 등) 요약 셀에도 동일하게 적용.
564
- - 합계·평균 등 집계 값은 시트가 계산하지 않음. 화면 컴포넌트에서 `computed` 로 직접 만들어 노출.
565
-
566
- ```ts
567
- totalQuantity = computed(() => this.items().sum((i) => i.quantity) ?? 0);
568
- ```
569
-
570
- ## 폼·입력 컨트롤
571
-
572
- ### 항목 레이아웃
573
-
574
- label 과 입력 그룹을 묶는 전용 클래스 3종:
575
-
576
- - `form-box` — 세로 스택. `> div` 안에 `<label>` + 입력. 항목 사이 간격은 `gap-default`.
577
- - `form-box-inline` — 가로 인라인 flex (wrap). 라벨이 입력 옆에 붙음. 검색·필터 폼에 사용. 라벨 없는 `form-box-item` 도 허용 (버튼 등).
578
- - `form-table` — `<table>` 기반. `<th>` 가 우측 정렬 라벨, `<td>` 가 입력. `<th class="form-table-header">` 는 섹션 헤더 (좌측 정렬, 회색, 위쪽 여백 큼). 라벨·입력 폭을 정렬해야 하는 등록·편집 폼에 사용.
579
-
580
- ```html
581
- <div class="form-box-inline">
582
- <div>
583
- <label>기준 일자</label>
584
- <sd-modal-select-button [(value)]="baseDate" ...>{{ baseDate() ?? "선택" }}</sd-modal-select-button>
585
- </div>
586
- <div>
587
- <sd-button [theme]="'primary'" (click)="onCompareButtonClick()">비교</sd-button>
588
- </div>
589
- </div>
590
- ```
591
-
592
- ### 양방향 바인딩 + `mark`
593
-
594
- 객체 시그널 내부 필드를 양방향으로 묶고, 변경 시 위의 "`mark` 헬퍼" 섹션 패턴으로 알림.
595
-
596
- ```html
597
- <sd-textfield [(value)]="data().name" (valueChange)="mark(data)" />
598
- ```
599
-
600
- ### 표준 입력 컨트롤
601
-
602
- | 용도 | 컨트롤 |
603
- | -------------------- | ------------------------------------------------------------ |
604
- | 텍스트 / 숫자 / 날짜 | `<sd-textfield [type]="..." />` |
605
- | 날짜 범위 | `<sd-date-range-picker [(from)] [(to)] />` |
606
- | 정적 선택지 | `<sd-select>` + `<sd-select-item>` |
607
- | 공유 데이터 선택지 | `<sd-shared-data-select [items]>` + `<ng-template [itemOf]>` |
608
- | 체크박스 / 라디오 | `<sd-checkbox [radio]>` |
609
- | 라벨/배지 | `<sd-label [theme]>` |
610
- | 버튼/액션 | `<sd-button>`, `<sd-anchor>` |
611
-
612
- ### 버튼 스타일
613
-
614
- 화면 액션 `<sd-button>` 역할별로 `theme`·`size` 를 구분 적용.
615
-
616
- | 역할 | `[theme]` | `[size]` |
617
- | ------------------------------------------------------------- | --------------------------------------------------------------------------- | -------- |
618
- | 데이터 자체를 통으로 변경하는 최상위 액션 (저장·삭제·생성 등) | 일반 시리즈 (`primary` / `danger` / `success` / `warning` 등 의미에 맞춰) | 기본 |
619
- | 위 액션 옆 유틸리티 버튼 (양식 다운로드·인쇄 등) | link 시리즈 (`link-primary` 등) | 기본 |
620
- | 시트 위(또는 시트 셀 안)에 나열되는 버튼 | link 시리즈 또는 `link` | `sm` |
621
-
622
- ### `<sd-form>` 으로 감싸기
623
-
624
- 폼 안 입력에서 Enter 키로 submit 이 자동 처리되게 하려면 `<sd-form>` 으로 감싸고 `(formSubmit)` 으로 받음. `sd-crud-list` / `sd-crud-detail` 는 내부에 이미 `sd-form` 보유하므로 별도 래핑 불필요.
625
-
626
- ## 서비스 호출 (`AppServiceProvider`)
627
-
628
- ```ts
629
- private readonly _appService = inject(AppServiceProvider);
630
-
631
- await this._appService.user.someMethod(...);
632
-
633
- const listenerKey = await this._appService.authInfoEvent.addListener(info, async (data) => { ... });
634
- ```
635
-
636
- Provider 정의·서비스·이벤트 호출 추가 컨벤션은 [client-service.md](./client-service.md) 참조.
637
-
638
- ## ORM 호출 (`AppOrmProvider`)
639
-
640
- ```ts
641
- private readonly _appOrm = inject(AppOrmProvider);
642
-
643
- await this._appOrm.connectAsync(async (db) => {
644
- // db.someTable()...
645
- });
646
- ```
647
-
648
- - 기본은 `connectAsync` (트랜잭션). `connectWithoutTransAsync` 는 트랜잭션 안에서 동작하지 않는 작업용 헬퍼.
649
- - 쿼리 작성법은 [orm.md](./orm.md), Provider 정의 컨벤션은 [client-orm.md](./client-orm.md) 참조.
650
-
651
- ## 공유 데이터 (`useSharedSignal`)
652
-
653
- 마스터 데이터(고객사·품목 등) 는 `AppSharedDataProvider` 에 등록되어 있고, 화면에서는 `useSharedSignal(name)` 으로 접근.
654
-
655
- ```ts
656
- sharedCustomers = useSharedSignal("고객사");
657
-
658
- // sharedCustomers.items() 시그널, 항목 배열
659
- // sharedCustomers.get(id) — id 단건 조회
660
- ```
661
-
662
- ```html
663
- <sd-shared-data-select [items]="sharedCustomers.items()" [(value)]="data().customerId" ... />
664
- ```
665
-
666
- Provider 정의·새 마스터 데이터 등록 컨벤션은 [client-shared-data.md](./client-shared-data.md) 참조.
667
-
668
- ## 레이아웃·유틸 클래스
669
-
670
- **화면 레이아웃** (영역 분할) 은 flex 유틸 클래스로 구성.
671
-
672
- 상하 분할 (상단 고정 + 본문 fill):
673
-
674
- ```html
675
- <div class="flex-column fill">
676
- <div class="pb-sm">
677
- <!-- 상단 고정 영역 -->
678
- </div>
679
- <div class="flex-fill">
680
- <!-- 본문 (남은 공간 자동) -->
681
- </div>
682
- </div>
683
- ```
684
-
685
- 좌우 분할 (좌측 콘텐츠 폭 + 우측 fill):
686
-
687
- ```html
688
- <div class="flex-row fill">
689
- <div class="flex-min">
690
- <!-- 좌측 -->
691
- </div>
692
- <div class="flex-fill">
693
- <!-- 우측 -->
694
- </div>
695
- </div>
696
- ```
697
-
698
- 자주 사용하는 유틸:
699
-
700
- - **Flex**: `flex-row` / `flex-column` (컨테이너), `flex-fill` (남은 공간), `flex-min` (콘텐츠 크기), `gap-sm` / `gap-default`.
701
- - **부모 가득 채움**: `fill`.
702
- - **패딩**: `p-{vertical}-{horizontal}` (예: `p-default`, `p-xs-sm`, `p-sm-default`). 단일 방향: `pt-` / `pb-` / `pl-` / `pr-`.
703
- - **텍스트**: `tx-left` / `tx-center` / `tx-right`.
704
- - **테마 색**: 텍스트 `tx-theme-{theme}-default`, 배경 `bg-{theme}-lightest`.
705
- - **테두리**: `bd`, `bd-radius-default`, `bd-trans-light`.
706
-
707
- **약속**:
708
-
709
- - 영역 분할·배치 모두 flex 유틸 클래스 우선 적용. 자체 styles 작성은 최후 수단.
710
- - 글로벌 클래스 정의 위치는 `@simplysm/angular/scss/commons/`.
711
-
712
- ## 아이콘
713
-
714
- `@ng-icons/core` `NgIcon` + `@ng-icons/tabler-icons` 의 `tabler*` 셋트를 사용.
715
-
716
- ```ts
717
- import { NgIcon } from "@ng-icons/core";
718
- import { tablerCheck, tablerCirclePlus } from "@ng-icons/tabler-icons";
719
-
720
- @Component({
721
- imports: [NgIcon /* ... */],
722
- template: `
723
- <ng-icon [svg]="tablerCheck" />
724
- <ng-icon [svg]="tablerCirclePlus" />
725
- `,
726
- })
727
- export class SomeComponent {
728
- protected readonly tablerCheck = tablerCheck;
729
- protected readonly tablerCirclePlus = tablerCirclePlus;
730
- }
731
- ```
732
-
733
- **약속**:
734
-
735
- - 아이콘 셋트는 `tabler-icons` 로 통일.
736
- - 사용할 아이콘은 컴포넌트 클래스에 `protected readonly tablerXxx = tablerXxx` 로 노출한 뒤 템플릿에서 `[svg]` 바인딩.
737
-
738
- ## sd-crud-* 컴포넌트
739
-
740
- 목록 화면 표준 골격은 `sd-crud-list`, 단건 편집 화면 표준 골격은 `sd-crud-detail`. 화면 작성 시 채택 여부를 결정. 채택 시 사용법은 [client-crud.md](./client-crud.md) 참조.
1
+ # 클라이언트 화면 작성 매뉴얼
2
+
3
+ ## 파일명·역할·위치
4
+
5
+ 화면 파일명은 `<domain>.<역할>.ts` 형식. 역할 접미사로 책임을 표시.
6
+
7
+ | 파일명 형식 | 역할 |
8
+ | ---------------------------- | ------------------------------------------------------------------ |
9
+ | `<domain>.view.ts` | list/detail 합성 화면. list/detail 자식을 두고 상호 트리거를 중계. |
10
+ | `<domain>.list.ts` | 목록. `sd-crud-list` 사용. |
11
+ | `<domain>.detail.ts` | 단건 보기/편집. `sd-crud-detail` 사용. |
12
+ | `<domain>.modal.ts` | 모달 전용 화면. |
13
+ | `<domain>.print-template.ts` | 프린트 템플릿. `SdPrintProvider.printAsync` 호출 대상. |
14
+ | `<domain>.types.ts` | 도메인 화면들이 공유하는 타입 정의. |
15
+ | `<domain>.ts` | 컨트롤(접미사 없음). 여러 화면에서 재사용되는 단위. |
16
+
17
+ - 모든 파일명은 dash-case.
18
+ - 라이브러리(`@simplysm/angular`) 의 파일은 `sd-` prefix 적용 (`sd-button.ts`, `sd-crud-list.ts`).
19
+
20
+ **위치**: 도메인이 있는 파일은 도메인 폴더 안에 둠. 도메인이 없는(범용) 파일은 `src/<역할>s/` 하위에 둠. 예: `src/controls/`, `src/modals/`.
21
+
22
+ **변형 파일**: 한 도메인 폴더 안에 같은 역할 파일이 2개 이상이면 `<domain>-<갈래>.<역할>.ts` 형식으로 갈래를 표시. 예 (`outbound-instruction/` 폴더):
23
+
24
+ - `outbound-instruction.list.ts` (헤더 목록).
25
+ - `outbound-instruction-item.list.ts` (품목 목록).
26
+ - `outbound-instruction-box.list.ts` (박스 목록).
27
+ - `outbound-instruction.detail.ts`.
28
+ - `outbound-instruction-header.detail.ts`.
29
+
30
+ ## detail modal 구분
31
+
32
+ `.detail.ts` `.modal.ts` **표시 방식이 아니라 화면의 본질**로 정함. detail 도 모달로 띄울 수 있으므로 "모달로 띄움" 은 분류 기준이 아님.
33
+
34
+ - `.detail.ts` — 한 레코드를 로드·저장하는 단건 화면(`sd-crud-detail` 골격, 식별자 input + `submitted`/`close`). 같은 파일이 라우팅 페이지(`viewType='page'`)·view 자식(`viewType='control'`)·모달(`showAsync`) 어디로든 쓰임. 모달로 띄워도 detail.
35
+ - `.modal.ts` — 모달로만 존재하고 단건 CRUD detail 도 라우팅 페이지도 임베드 control 도 아닌 화면(도구·검색·설정 다이얼로그 등 비-CRUD UI). `sd-crud-detail` 을 쓰지 않고 `sd-busy-container` 등으로 자체 구성.
36
+
37
+ 판별:
38
+
39
+ ```
40
+ 레코드를 로드·저장하는 단건 화면인가? (sd-crud-detail)
41
+ ├─ .detail.ts (모달로 띄워도 detail)
42
+ └─ 아니오 → 목록이면 .list.ts / 합성이면 .view.ts /
43
+ 모달로만 뜨는 비-CRUD UI(도구·검색·설정)면 .modal.ts
44
+ ```
45
+
46
+ 단건 편집을 모달로 띄울 때도 `.detail.ts` `_sdModal.showAsync({ type: XxxDetail, ... })` 호출 ([모달 호출](#모달-호출) 참조). 모달 표시용 별도 `.modal.ts` 를 만들지 않음.
47
+
48
+ ## 컴포넌트 데코레이터 기본값
49
+
50
+ Angular 기본과 다른 부분만 명시:
51
+
52
+ ```ts
53
+ @Component({
54
+ selector: "app-<dashed-name>",
55
+ changeDetection: ChangeDetectionStrategy.OnPush,
56
+ encapsulation: ViewEncapsulation.None,
57
+ standalone: true,
58
+ template: `...`,
59
+ styles: [/* language=SCSS */ `...`], // 선택
60
+ })
61
+ ```
62
+
63
+ - **`changeDetection: OnPush`** — 항상.
64
+ - **`encapsulation: ViewEncapsulation.None`** 항상. 글로벌 SCSS 일관된 스타일을 적용하기 위함.
65
+ - **`standalone: true`** — 항상. NgModule 신규 도입 금지.
66
+ - **`selector` prefix** 앱 컴포넌트는 `app-`, 라이브러리(`@simplysm/angular`) 컴포넌트는 `sd-`.
67
+ - **`template`** 은 인라인 (`*.html` 파일 분리 없음).
68
+
69
+ **`styles`** (선택 사용) — 다음 두 가지를 simplysm 약속으로 따름:
70
+
71
+ - 첫 줄에 `/* language=SCSS */` 주석 배치 (IDE 가 SCSS 로 인식하게 함).
72
+ - 내부 전용 클래스명은 `_` prefix (예: `._content`, `._button`).
73
+
74
+ ## 화면 합성 패턴
75
+
76
+ 화면은 list / detail / view 단위로 책임을 분리해 합성함.
77
+
78
+ - **`*.list.ts`** — 자체 검색·페이지·정렬·재조회를 책임. `selectMode` 같은 입력을 받아 부모가 선택 동작을 제어할 수 있게 노출.
79
+ - **`*.detail.ts`** — 식별자(`input.required`) 를 받아 자체 로드·저장. 변경·삭제 후 `submitted` output 으로 부모에게 알림.
80
+ - **`*.view.ts`** — list/detail 합성 + 자식 간 트리거 중계. 데이터 페치는 view 에서 수행 금지.
81
+
82
+ 화면이 list 또는 detail 하나로 끝나면 view 를 만들지 않음. 이 경우 list/detail 자체가 라우팅 진입 단위.
83
+
84
+ ### list + detail 합성
85
+
86
+ view 의 합성 패턴 (예: `outbound-instruction.view.ts`):
87
+
88
+ ```html
89
+ <sd-base-container [(ready)]="ready" [initialized]="initialized()" [(busyCount)]="busyCount" ...>
90
+ <ng-template #contentTpl>
91
+ <div class="flex-row fill">
92
+ <app-outbound-instruction-list #headerSheet selectMode="single" class="flex-min" />
93
+
94
+ @let _selectedId = headerSheet.selectedKeys().first(); @if (_selectedId == null) {
95
+ <div
96
+ class="flex-fill tx-theme-gray-default p-xxl"
97
+ style="font-size: 48px; line-height: 1.5em"
98
+ >
99
+ <ng-icon [svg]="tablerArrowLeft" />
100
+ 선택하세요.
101
+ </div>
102
+ } @else {
103
+ <app-outbound-instruction-detail
104
+ class="flex-fill"
105
+ [instructionId]="_selectedId"
106
+ (submitted)="headerSheet.doRefresh()"
107
+ />
108
+ }
109
+ </div>
110
+ </ng-template>
111
+ </sd-base-container>
112
+ ```
113
+
114
+ 핵심 약속:
115
+
116
+ - view 는 list 컴포넌트를 템플릿 변수(`#headerSheet`) 로 잡아 `selectedKeys()` 를 읽고 `doRefresh()` 를 호출.
117
+ - detail 의 단건 변경·삭제는 list 가 표시하는 동일 데이터에 반영해야 하므로, detail 의 `submitted` → list 의 `doRefresh()` 호출로 동기화.
118
+ - view 는 `sd-base-container` 를 루트로 두고, 내부 콘텐츠는 `#contentTpl` 슬롯에 배치.
119
+ - 미선택 빈 상태(`선택하세요.`) 코드는 위 예시 그대로 사용. `tablerArrowLeft` 아이콘을 쓰므로 화면 컴포넌트에 `NgIcon` 등록 필요 ([아이콘](#아이콘) 참조).
120
+
121
+ ### list + list 합성 (마스터-라인)
122
+
123
+ 좌 list 마스터(헤더), list 디테일(라인) 역할로 합성:
124
+
125
+ ```html
126
+ <sd-base-container [(ready)]="ready" [initialized]="initialized()" [(busyCount)]="busyCount" ...>
127
+ <ng-template #contentTpl>
128
+ <div class="flex-row fill">
129
+ <app-master-list #headerSheet selectMode="single" class="flex-min" />
130
+
131
+ @let _selectedId = headerSheet.selectedKeys().first(); @if (_selectedId == null) {
132
+ <div
133
+ class="flex-fill tx-theme-gray-default p-xxl"
134
+ style="font-size: 48px; line-height: 1.5em"
135
+ >
136
+ <ng-icon [svg]="tablerArrowLeft" />
137
+ 선택하세요.
138
+ </div>
139
+ } @else {
140
+ <app-line-list
141
+ class="flex-fill"
142
+ [headerId]="_selectedId"
143
+ (submitted)="headerSheet.doRefresh()"
144
+ />
145
+ }
146
+ </div>
147
+ </ng-template>
148
+ </sd-base-container>
149
+ ```
150
+
151
+ 핵심 약속:
152
+
153
+ - 우 list 는 좌 list 의 선택 키를 `input` 으로 받아 자동 재조회. 외부 input → filter 머지 패턴은 아래 "외부 input 을 filter 에 반영" 섹션 참조.
154
+ - 우 list 의 저장·삭제 후 좌 헤더 목록까지 갱신해야 하면, 우 list 가 `submitted` output 을 emit 하고, view 가 받아 `#headerSheet.doRefresh()` 호출.
155
+ - list 안에 추가 분기(탭 등) 가 필요하면 [client-tab.md](./client-tab.md) 매뉴얼 따름.
156
+
157
+ ### 공유데이터 목록 + detail 합성
158
+
159
+ 마스터가 자체 `*.list` 가 아니라 공유데이터 목록(`sd-shared-data-select-list`)인 합성. 좌측에서 마스터 항목(역할·창고 등)을 고르고 우측 detail 이 그 항목을 편집:
160
+
161
+ ```html
162
+ <sd-base-container [(ready)]="ready" [initialized]="initialized()" [(busyCount)]="busyCount" ...>
163
+ <ng-template #contentTpl>
164
+ <div class="flex-row fill">
165
+ <sd-shared-data-select-list
166
+ class="flex-min"
167
+ [items]="sharedRoles.items()"
168
+ [(selectedItem)]="selectedRole"
169
+ [header]="'역할'"
170
+ [modal]="{ type: RoleList, title: '역할', inputs: {} }"
171
+ >
172
+ <ng-template [itemOf]="sharedRoles.items()" let-item="item">{{ item.name }}</ng-template>
173
+ </sd-shared-data-select-list>
174
+
175
+ @let _selectedRole = selectedRole(); @if (_selectedRole == null) {
176
+ <div
177
+ class="flex-fill tx-theme-gray-default p-xxl"
178
+ style="font-size: 48px; line-height: 1.5em"
179
+ >
180
+ <ng-icon [svg]="tablerArrowLeft" />
181
+ 선택하세요.
182
+ </div>
183
+ } @else {
184
+ <app-role-permission-detail class="flex-fill" [roleId]="_selectedRole.id" />
185
+ }
186
+ </div>
187
+ </ng-template>
188
+ </sd-base-container>
189
+ ```
190
+
191
+ 핵심 약속:
192
+
193
+ - 마스터를 `[(selectedItem)]` 모델로 받음 — 선택 키가 아니라 **항목 객체**. detail 에는 거기서 꺼낸 식별자(`_selectedRole.id`)를 전달.
194
+ - 마스터 목록 자체의 등록·수정은 `[modal]` 로 위임 ([client-shared-data.md](./client-shared-data.md) 의 '선택 컨트롤에서 관리·선택 모달 띄우기' 참조). view 가 모달을 직접 열지 않음.
195
+ - list+detail 합성과 달리 view 가 재조회를 중계하지 않음 — 공유데이터는 detail 의 저장이 `emitAsync` 로 알리면 자동 갱신되므로 `submitted` → `doRefresh` 배선이 불필요.
196
+
197
+ #### 편집형 detail 임베드 시 — 미저장 변경 가드
198
+
199
+ 임베드한 detail 이 편집 가능(미저장 변경 상태를 가짐)하면, 페이지 이탈뿐 아니라 **마스터 전환**도 막아야 함. 마스터 전환은 라우터가 아니라 `sd-shared-data-select-list` 를 통해 일어나므로, 두 가드를 모두 배선할 수 있는 **view 로 가드를 끌어올림**.
200
+
201
+ ```ts
202
+ detail = viewChild(RolePermissionDetail);
203
+
204
+ // 미저장 변경 보호: 페이지 이탈·마스터 전환 전에 자식 detail 의 변경 가드 확인
205
+ protected readonly checkCanLeave = (): boolean => {
206
+ const detail = this.detail();
207
+ return detail == null || detail.checkIgnoreChanges();
208
+ };
209
+
210
+ constructor() {
211
+ setupCanDeactivate(this.checkCanLeave); // 페이지 이탈 가드
212
+ }
213
+ ```
214
+
215
+ ```html
216
+ <sd-shared-data-select-list ... [canChangeFn]="checkCanLeave">
217
+ ...
218
+ </sd-shared-data-select-list>
219
+ ```
220
+
221
+ 배선 약속:
222
+
223
+ - detail 변경 가드를 **public 메서드**(`checkIgnoreChanges()`, `_` 접두 없음)로 노출하고, **자체 `setupCanDeactivate` 는 두지 않음** — 라우팅 진입 단위가 아니라 view 자식(`viewType='control'`)이기 때문. 모달·페이지 단독으로 동작하는 detail 은 반대로 자체 `setupCanDeactivate` + private `_checkIgnoreChanges` 를 둠 ([detail 데이터 흐름](#detail-데이터-흐름) 참조).
224
+ - view 가 그 public 메서드를 `viewChild` 로 잡아, select-list 의 `[canChangeFn]` 과 자신의 `setupCanDeactivate` **양쪽**에 위임.
225
+ - detail 미렌더(미선택 빈 상태) 시 `viewChild` 가 `undefined` 이므로 `detail == null` 단락으로 통과시킴.
226
+ - `[canChangeFn]` 빠뜨리면 페이지 이탈만 막히고, 다른 마스터를 클릭하면 미저장 편집이 경고 없이 사라짐.
227
+ - detail 읽기 전용(미저장 변경 없음)이면 이 가드 배선 자체가 불필요.
228
+
229
+ ## 화면 컴포넌트의 표준 시그널
230
+
231
+ 화면 컴포넌트(view/list/detail/modal) 공통으로 사용하는 시그널 4종. **필요한 것만 채택**하되, 채택 시 아래 약속된 이름·의미·전파를 그대로 따름.
232
+
233
+ | 이름 | 종류 | 의미 |
234
+ | ------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
235
+ | `ready` | `signal(false)` | 컴포넌트가 데이터 로드를 시작해도 되는 시점. 부모·컨테이너가 true 로 set 하면 자식의 effect 발화. |
236
+ | `initialized` | `signal(false)` | 첫 데이터 로드 완료 여부. 자식이 자기 로드 종료 후 true 로 set. |
237
+ | `busyCount` | `signal(0)` | 진행 중인 비동기 작업 수. 시작 시 `+1`, 종료 시 `-1`. 값이 0 보다 크면 화면이 busy 표시. |
238
+ | `viewType` | `injectViewTypeSignal()` | 화면이 page / control / modal 중 어느 컨텍스트에서 동작 중인지. 라우팅 진입이면 `'page'`, view 자식이면 `'control'`, 모달이면 `'modal'`. |
239
+
240
+ **전파**: 부모가 자식에게 위 시그널들을 그대로 전달. `sd-base-container` / `sd-crud-list` / `sd-crud-detail` 는 이 시그널들을 입력으로 받는 표준 컴포넌트.
241
+
242
+ **`busyCount` 사용 패턴**:
243
+
244
+ ```ts
245
+ this.busyCount.update((v) => v + 1);
246
+ await this._sdToast.try(async () => {
247
+ // ...작업...
248
+ });
249
+ this.busyCount.update((v) => v - 1);
250
+ ```
251
+
252
+ ## 권한 (perms)
253
+
254
+ 화면 컴포넌트는 권한 정보를 `injectPermsSignal(<paths>, <actions>)` 로 받음.
255
+
256
+ ```ts
257
+ perms = injectPermsSignal(
258
+ ["inventory.outbound-instruction"],
259
+ ["use", "edit", "write-and-document.edit"],
260
+ );
261
+ ```
262
+
263
+ - 첫 인자: **권한 path 목록** (도메인 트리 좌표).
264
+ - 둘째 인자: **확인할 action 목록**. `perms()` 의 반환값은 사용자가 보유한 action 의 string 배열.
265
+
266
+ **사용 약속**:
267
+
268
+ - 단순 권한 체크는 `this.perms().includes("use")` 템플릿·코드에 인라인으로 작성. 별도 computed 로 묶지 않음.
269
+ - `restricted` 입력은 `[restricted]="!perms().includes('use')"` 형태로 인라인 전달. 별도 `canUse` / `restricted` computed 생성 금지.
270
+ - 권한 체크 뒤에 추가 조건(데이터 상태 등) 이 결합되어 **같은 결합이 2회 이상 참조되는 경우에만** computed 로 묶음.
271
+
272
+ ```ts
273
+ canEdit = computed(() => this.perms().includes("edit") && this.data().state === "작성");
274
+ ```
275
+
276
+ - list/detail effect 진입에서도 인라인으로 가드:
277
+
278
+ ```ts
279
+ effect(() => {
280
+ if (!this.perms().includes("use")) {
281
+ this.initialized.set(true);
282
+ return;
283
+ }
284
+ // ...
285
+ });
286
+ ```
287
+
288
+ ## 에러·토스트
289
+
290
+ 비동기 작업은 `_sdToast.try(async () => { ... })` 로 감쌈. 콜백 내에서 throw 된 에러는 토스트로 표시되며 외부로 전파되지 않음.
291
+
292
+ ```ts
293
+ private readonly _sdToast = inject(SdToastProvider);
294
+
295
+ // ...
296
+
297
+ this.busyCount.update((v) => v + 1);
298
+ await this._sdToast.try(async () => {
299
+ await this._refresh();
300
+ });
301
+ this.busyCount.update((v) => v - 1);
302
+ ```
303
+
304
+ **메시지 직접 표시**:
305
+
306
+ - `_sdToast.success("저장되었습니다.")`.
307
+ - `_sdToast.warning("...")`.
308
+ - `_sdToast.info("...")`.
309
+ - `_sdToast.danger("...")`.
310
+
311
+ **중복 작업 가드**: 진행 중인 작업이 있으면 새 작업은 건너뛰고 즉시 반환.
312
+
313
+ ```ts
314
+ if (this.busyCount() > 0) return;
315
+ ```
316
+
317
+ ## DI 명명
318
+
319
+ `inject()` 한 의존은 외부 노출 멤버(시그널·output·공개 메서드 등) 와 구분하기 위해 `_` prefix 적용.
320
+
321
+ ```ts
322
+ private readonly _sdToast = inject(SdToastProvider);
323
+ private readonly _sdModal = inject(SdModalProvider);
324
+ private readonly _appOrm = inject(AppOrmProvider);
325
+ private readonly _router = inject(Router);
326
+ ```
327
+
328
+ - 시그널·input·output·공개 메서드는 prefix 없음.
329
+ - DI 멤버는 항상 `private readonly`.
330
+
331
+ ## 모달 호출
332
+
333
+ 화면에서 다른 화면을 모달로 띄울 때:
334
+
335
+ ```ts
336
+ private readonly _sdModal = inject(SdModalProvider);
337
+
338
+ // ...
339
+
340
+ const result = await this._sdModal.showAsync({
341
+ type: OutboundInstructionHeaderDetail,
342
+ title: "출고지시 등록",
343
+ inputs: { /* 모달 컴포넌트의 input 들 */ },
344
+ });
345
+ if (!result) return;
346
+
347
+ // result 처리
348
+ ```
349
+
350
+ - **`type`** — `SdModalContentDef<O>` 를 구현한 컴포넌트 클래스 (`initialized` 시그널 + `close` output 보유. `O` 는 close 페이로드 타입). `SdModal` 은 라이브러리 모달 셸 컴포넌트이므로 상속 대상이 아님.
351
+ - **`title`** — 모달 헤더 제목.
352
+ - **`inputs`** — 모달 컴포넌트가 받을 input 시그널 값. 없으면 `{}`.
353
+ - **반환값** — 모달 컴포넌트가 close 시 emit 한 페이로드. 사용자가 닫기(X)·취소로 닫으면 `undefined`.
354
+
355
+ ## `mark` 헬퍼
356
+
357
+ `@simplysm/angular` 의 `mark(signal)` 은 시그널 값은 그대로 두고 **변경 알림만** 발행. effect 가 의존하는 시그널을 강제 재발화시키거나, 객체 시그널 내부 필드 변경을 알릴 때 사용.
358
+
359
+ **1. 외부 트리거로 effect 재발화** — 값은 동일하지만 effect 를 다시 발화시켜야 할 때.
360
+
361
+ ```ts
362
+ doRefresh(): void {
363
+ mark(this.lastFilter);
364
+ }
365
+ ```
366
+
367
+ **2. 객체·배열 시그널 내부 변경 알림** — 시그널이 들고 있는 객체의 _필드만_ 변경된 경우 시그널 자체는 변경 알림을 보내지 않음. 양방향 바인딩 자식의 변경 이벤트에 묶어 호출.
368
+
369
+ ```ts
370
+ filter = signal<IFilter>({ name: "", state: "" });
371
+ ```
372
+
373
+ ```html
374
+ <sd-textfield [(value)]="filter().name" (valueChange)="mark(filter)" />
375
+ ```
376
+
377
+ ```ts
378
+ data().dueDate = new DateOnly();
379
+ mark(this.data);
380
+ ```
381
+
382
+ ## list 데이터 흐름
383
+
384
+ list 컴포넌트는 자체 검색·페이지·정렬·재조회를 책임.
385
+
386
+ ### 시그널 구성
387
+
388
+ ```ts
389
+ items = signal<IItem[]>([]);
390
+ selectedKeys = signal<TKey[]>([]);
391
+ page = signal(0);
392
+ pageLength = signal(0);
393
+ sortingDefs = signal<SortingDef[]>([]);
394
+
395
+ filter = signal<IFilter>({ ... }); // 폼 입력용
396
+ lastFilter = signal<IFilter>({ ... }); // 마지막 조회 시점 — effect 가 이걸 의존
397
+
398
+ trackByFn = (item: IItem) => item.id;
399
+ ```
400
+
401
+ **`filter` vs `lastFilter` 분리**: 입력 도중 매번 재조회되지 않게 하기 위함. 사용자가 조회 버튼을 눌러야 `lastFilter` 가 갱신되고 effect 발화.
402
+
403
+ ### 자동 재조회 effect
404
+
405
+ ```ts
406
+ constructor() {
407
+ effect(() => {
408
+ if (!this.perms().includes("use") || !this.ready()) {
409
+ this.initialized.set(true);
410
+ return;
411
+ }
412
+
413
+ this.lastFilter(); // 의존성 등록
414
+ this.page();
415
+ this.sortingDefs();
416
+
417
+ void untracked(async () => {
418
+ this.busyCount.update((v) => v + 1);
419
+ await this._sdToast.try(async () => {
420
+ await this._refresh();
421
+ });
422
+ this.busyCount.update((v) => v - 1);
423
+ this.initialized.set(true);
424
+ });
425
+ });
426
+ }
427
+ ```
428
+
429
+ - `lastFilter` / `page` / `sortingDefs` 변경 시 자동 재조회.
430
+ - 비동기 본체는 `untracked` 안에서 실행해 자기 자신이 의존성에 등록되지 않게 함.
431
+
432
+ ### 조회 트리거
433
+
434
+ ```ts
435
+ onFilterSubmit(): void {
436
+ this.page.set(0);
437
+ this.lastFilter.set({ ...this.filter() }); // effect 발화
438
+ }
439
+
440
+ doRefresh(): void { // 부모(view) 가 호출하는 외부 API
441
+ if (!this.perms().includes("use")) return;
442
+ mark(this.lastFilter); // 값은 동일하나 effect 재발화
443
+ }
444
+ ```
445
+
446
+ ### `_refresh` 구조
447
+
448
+ ```ts
449
+ private async _refresh(): Promise<void> {
450
+ const r = await this._search(true);
451
+ this.items.set(r.items);
452
+ this.pageLength.set(r.pageLength);
453
+ }
454
+ ```
455
+
456
+ `_search` 는 ORM 쿼리 실행. 자세한 사용법은 [orm.md](./orm.md) 참조.
457
+
458
+ ### 페이지네이션
459
+
460
+ 두 패턴 중 택일. 데이터 규모와 검색·정렬 책임에 따라 화면 작성자가 판단 (명확한 컷오프 없음).
461
+
462
+ **서버 페이징** — 한 페이지 분량만 매번 서버에서 가져옴. 위의 "시그널 구성" / "자동 재조회 effect" / "`_refresh` 구조" 섹션이 가정하는 기본 패턴.
463
+
464
+ - `pageLength` 시그널을 두고, `_refresh` 에서 서버 응답의 총 페이지 수로 set.
465
+ - `page` / `sortingDefs` / `lastFilter` 모두 effect 의존성. 변경 시 재조회.
466
+ - `<sd-crud-list>` 에 `[totalPageCount]="pageLength()"` 전달. `[itemsPerPage]` 는 생략 (= 0).
467
+
468
+ **클라이언트 페이징** 전체 데이터를 한 번에 로드. 시트가 자체적으로 slice·sort 수행.
469
+
470
+ - `pageLength` 시그널과 `sortingDefs` effect 의존성 불필요 (정렬은 시트 내부에서 처리).
471
+ - `_refresh` 는 전체 아이템을 한 번에 `items.set(all)`.
472
+ - `<sd-crud-list>` 에 `[itemsPerPage]="<페이지당 행 수>"` 전달. `[totalPageCount]` 는 생략 (= 0).
473
+ - `[(sorts)]` 는 화면이 정렬 상태를 보유해야 하는 경우에만 바인딩. 그 외에는 생략.
474
+
475
+ **`[visiblePageCount]`** (기본 10) — 페이지네이터가 한 번에 표시하는 페이지 번호 개수. 두 패턴 모두 사용자가 명시 지시한 경우에만 설정.
476
+
477
+ ### 외부 input 을 filter 에 반영
478
+
479
+ list 다른 화면 안에 임베드되어 외부에서 filter 의 일부를 input 으로 받는 경우, effect 로 input → filter → lastFilter 흐름을 구성.
480
+
481
+ ```ts
482
+ constructor() {
483
+ effect(() => {
484
+ const filterInputs: Partial<IFilter> = {
485
+ includeTargetCustomerIds: this.includeTargetCustomerIds() ?? [],
486
+ includeGoodsIds: this.includeGoodsIds() ?? [],
487
+ excludeIds: this.excludeIds() ?? [],
488
+ isIncludeOutOfInventory: this.isIncludeOutOfInventory() ?? false,
489
+ // ...
490
+ };
491
+
492
+ untracked(() => {
493
+ this.filter.update((f) => ({ ...f, ...filterInputs }));
494
+ this.lastFilter.set({ ...this.filter() });
495
+ this.page.set(0);
496
+ });
497
+ });
498
+ }
499
+ ```
500
+
501
+ - 외부 input 이 effect 의존성. 변경 시 발화.
502
+ - input 값을 `Partial<IFilter>` 로 모아 `filter` 에 머지.
503
+ - `lastFilter` 갱신 → 자동 재조회 effect 발화.
504
+ - `page.set(0)` 으로 페이지로 리셋.
505
+
506
+ ## detail 데이터 흐름
507
+
508
+ detail 컴포넌트는 식별자를 받아 자체 로드·저장을 수행하고, 변경·삭제 후 `submitted` 로 부모에게 알림.
509
+
510
+ ### 시그널 구성
511
+
512
+ ```ts
513
+ dataId = input.required<number>(); // 식별자 (부모가 주입)
514
+ submitted = output<boolean>(); // 변경·삭제 후 알림
515
+
516
+ data = signal<IData>({ ... initialState ... });
517
+ private _orgData?: IData; // 변경 추적용 원본 스냅샷
518
+ ```
519
+
520
+ ### 로드 effect + 페이지 이탈 가드
521
+
522
+ ```ts
523
+ constructor() {
524
+ effect(() => {
525
+ this.dataId(); // 식별자 변경 시 재조회
526
+
527
+ if (!this.perms().includes("use")) {
528
+ this.initialized.set(true);
529
+ return;
530
+ }
531
+
532
+ void untracked(async () => {
533
+ this.busyCount.update((v) => v + 1);
534
+ await this._sdToast.try(async () => {
535
+ await this._refresh();
536
+ });
537
+ this.busyCount.update((v) => v - 1);
538
+ this.initialized.set(true);
539
+ });
540
+ });
541
+
542
+ setupCanDeactivate(() => this._checkIgnoreChanges());
543
+ }
544
+ ```
545
+
546
+ - 식별자(`dataId`) 가 effect 의존성. 부모가 다른 항목으로 전환하면 자동 재로드.
547
+ - `setupCanDeactivate(fn)` (`@simplysm/angular`) 는 라우터 이탈 시점에 `fn()` 이 false 를 반환하면 이탈을 차단.
548
+
549
+ ### `_refresh` + 원본 스냅샷
550
+
551
+ ```ts
552
+ private async _refresh(): Promise<void> {
553
+ const loaded = await this._appOrm.connectAsync(async (db) => {
554
+ // ...쿼리...
555
+ return loadedData;
556
+ });
557
+
558
+ this.data.set(loaded);
559
+ this._orgData = obj.clone(loaded); // 변경 비교용 사본
560
+ }
561
+ ```
562
+
563
+ `obj.clone` / `obj.equal``@simplysm/core-common`.
564
+
565
+ ### 변경 가드
566
+
567
+ ```ts
568
+ private _checkIgnoreChanges(): boolean {
569
+ return (
570
+ this._orgData == null ||
571
+ obj.equal(this.data(), this._orgData) ||
572
+ confirm("변경사항이 있습니다. 무시하고 진행하시겠습니까?")
573
+ );
574
+ }
575
+ ```
576
+
577
+ ### 저장 패턴
578
+
579
+ ```ts
580
+ async onSubmit(): Promise<void> {
581
+ if (this.busyCount() > 0) return;
582
+ if (!this.perms().includes("edit")) return;
583
+
584
+ const data = this.data();
585
+ if (this._orgData && obj.equal(data, this._orgData)) {
586
+ this._sdToast.info("변경사항이 없습니다.");
587
+ return;
588
+ }
589
+
590
+ this.busyCount.update((v) => v + 1);
591
+ await this._sdToast.try(async () => {
592
+ await this._appOrm.connectAsync(async (db) => {
593
+ // ... 변경분만 upsert/delete (orm.md 참조) ...
594
+ });
595
+ this._sdToast.success("저장되었습니다.");
596
+ await this._refresh();
597
+ this.submitted.emit(true);
598
+ });
599
+ this.busyCount.update((v) => v - 1);
600
+ }
601
+ ```
602
+
603
+ **핵심 약속**:
604
+
605
+ - 식별자는 `input.required<>` 받음.
606
+ - 로드 `_orgData = obj.clone(loaded)` 로 원본 보관.
607
+ - 페이지 이탈 가드는 `setupCanDeactivate` + `obj.equal` 비교로 처리.
608
+ - 저장 완료 `_refresh()` 로 다시 로드 → `submitted.emit(true)`.
609
+ - 삭제·취소 다른 액션도 끝에 `submitted.emit(true)` 를 emit 해 부모(list) 가 새로고침할 수 있게 함.
610
+
611
+ ## 시트 컬럼·셀 표준
612
+
613
+ ```html
614
+ <sd-sheet-column [key]="'name'" [header]="'이름'">
615
+ <ng-template [cell]="items()" let-item="item">
616
+ <div class="p-xs-sm">{{ item.name }}</div>
617
+ </ng-template>
618
+ </sd-sheet-column>
619
+ ```
620
+
621
+ **폭 약속**:
622
+
623
+ - `[width]` 는 **미명시가 기본** (자동). px 지정은 사용자가 명시 지시한 경우에만 적용.
624
+ - 영역 (`flex-min` `style="width: ..."` 등) 동일.
625
+
626
+ **셀 본문 약속**:
627
+
628
+ - 시트 셀에는 패딩이 없으므로 본문 div 에 `p-xs-sm` 클래스 적용이 기본.
629
+ - 정렬 클래스(`tx-right` / `tx-center` / `tx-left`) 는 **사용자가 명시 지시한 경우에만** 사용. 기본은 미지정 (브라우저 기본 left). "라벨은 가운데" 같은 자동 휴리스틱 적용 금지.
630
+ - 단, **숫자 셀은 `tx-right` 기본 적용** (수량·금액·단가·합계 등 숫자값 컬럼).
631
+ - `[cell]="items()"` 는 타입 추론용 더미. 실제 행 데이터는 `<sd-sheet>` 의 `[items]` 가 보유.
632
+ - 셀 컨텍스트: `let-item="item"` / `let-index="index"` / `let-depth="depth"` / `let-edit="edit"`.
633
+ - div 배경색 클래스(`bg-theme-*-lightest` 등) 토글할 때는 빈 값 자리에 `&nbsp;` 를 채워 div 가 셀 높이를 유지하게 함 (table cell 자식 div 가 콘텐츠 없을 시 높이 0 → bg 가 셀을 채우지 못함).
634
+ - 좋은 예: `{{ item.surveyLocationCode ?? "&nbsp;" }}`.
635
+ - 나쁜 예: `{{ item.surveyLocationCode }}`, `{{ item.surveyLocationCode ?? "" }}`, `{{ item.surveyLocationCode ?? " " }}`.
636
+
637
+ **list 안에서**: `<sd-crud-list>` 의 직속 자식으로 `<sd-sheet-column>` 을 두면 내부 시트로 자동 투영.
638
+
639
+ ```html
640
+ <sd-crud-list ...>
641
+ <ng-template #filterTpl>...</ng-template>
642
+
643
+ <sd-sheet-column [key]="..." [header]="...">
644
+ <ng-template [cell]="items()" let-item="item">...</ng-template>
645
+ </sd-sheet-column>
646
+ </sd-crud-list>
647
+ ```
648
+
649
+ ### 요약
650
+
651
+ 컬럼에 `<ng-template #summaryTpl>` 을 두면 시트의 헤더 영역 하단(`thead` 내부) 에 요약 행이 렌더됨. 스크롤 시 헤더와 함께 상단 고정되며, 배경은 warning 계열로 자동 강조.
652
+
653
+ ```html
654
+ <sd-sheet-column [key]="'quantity'" [header]="'수량'">
655
+ <ng-template #summaryTpl>
656
+ <div class="p-xs-sm tx-right">{{ totalQuantity() }}</div>
657
+ </ng-template>
658
+ <ng-template [cell]="items()" let-item="item">
659
+ <div class="p-xs-sm tx-right">{{ item.quantity }}</div>
660
+ </ng-template>
661
+ </sd-sheet-column>
662
+ ```
663
+
664
+ - 컬럼 중 하나라도 `#summaryTpl` 을 가지면 요약 행 전체가 활성화됨. 정의 없는 컬럼은 빈 셀로 표시.
665
+ - 셀 본문 약속(`p-xs-sm`, 정렬 클래스 등) 은 요약 셀에도 동일하게 적용.
666
+ - 합계·평균 집계 값은 시트가 계산하지 않음. 화면 컴포넌트에서 `computed` 로 직접 만들어 노출.
667
+
668
+ ```ts
669
+ totalQuantity = computed(() => this.items().sum((i) => i.quantity) ?? 0);
670
+ ```
671
+
672
+ ## 폼·입력 컨트롤
673
+
674
+ ### 폼 항목 레이아웃
675
+
676
+ label 과 입력 그룹을 묶는 전용 클래스 3종:
677
+
678
+ - `form-box` — 세로 스택. `> div` 안에 `<label>` + 입력. 항목 사이 간격은 `gap-default`.
679
+ - `form-box-inline` — 가로 인라인 flex (wrap). 라벨이 입력 옆에 붙음. 검색·필터 폼에 사용. 라벨 없는 `form-box-item` 도 허용 (버튼 등).
680
+ - `form-table` — `<table>` 기반. `<th>` 가 우측 정렬 라벨, `<td>` 가 입력. `<th class="form-table-header">` 는 섹션 헤더 (좌측 정렬, 회색, 위쪽 여백 큼). 라벨·입력 폭을 정렬해야 하는 등록·편집 폼에 사용.
681
+
682
+ ```html
683
+ <div class="form-box-inline">
684
+ <div>
685
+ <label>기준 일자</label>
686
+ <sd-modal-select-button [(value)]="baseDate" ...>
687
+ {{ baseDate() ?? "선택" }}
688
+ </sd-modal-select-button>
689
+ </div>
690
+ <div>
691
+ <sd-button [theme]="'primary'" (click)="onCompareButtonClick()">비교</sd-button>
692
+ </div>
693
+ </div>
694
+ ```
695
+
696
+ ### 양방향 바인딩 + `mark`
697
+
698
+ 객체 시그널 내부 필드를 양방향으로 묶고, 변경 시 위의 "`mark` 헬퍼" 섹션 패턴으로 알림.
699
+
700
+ ```html
701
+ <sd-textfield [(value)]="data().name" (valueChange)="mark(data)" />
702
+ ```
703
+
704
+ ### 표준 입력 컨트롤
705
+
706
+ | 용도 | 컨트롤 |
707
+ | -------------------- | ------------------------------------------------------------ |
708
+ | 텍스트 / 숫자 / 날짜 | `<sd-textfield [type]="..." />` |
709
+ | 날짜 범위 | `<sd-date-range-picker [(from)] [(to)] />` |
710
+ | 정적 선택지 | `<sd-select>` + `<sd-select-item>` |
711
+ | 공유 데이터 선택지 | `<sd-shared-data-select [items]>` + `<ng-template [itemOf]>` |
712
+ | 체크박스 / 라디오 | `<sd-checkbox [radio]>` |
713
+ | 라벨/배지 | `<sd-label [theme]>` |
714
+ | 버튼/액션 | `<sd-button>`, `<sd-anchor>` |
715
+
716
+ ### 버튼 스타일
717
+
718
+ 화면 액션 `<sd-button>` 역할별로 `theme`·`size` 를 구분 적용.
719
+
720
+ | 역할 | `[theme]` | `[size]` |
721
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------- | -------- |
722
+ | 데이터 자체를 통으로 변경하는 최상위 액션 (저장·삭제·생성 등) | 일반 시리즈 (`primary` / `danger` / `success` / `warning` 등 의미에 맞춰) | 기본 |
723
+ | 위 액션 옆 유틸리티 버튼 (양식 다운로드·인쇄 등) | link 시리즈 (`link-primary` 등) | 기본 |
724
+ | 시트 위(또는 시트 셀 안)에 나열되는 버튼 | link 시리즈 또는 `link` | `sm` |
725
+
726
+ ### `<sd-form>` 으로 감싸기
727
+
728
+ 입력에서 Enter 키로 submit 이 자동 처리되게 하려면 `<sd-form>` 으로 감싸고 `(formSubmit)` 으로 받음. `sd-crud-list` / `sd-crud-detail` 는 내부에 이미 `sd-form` 을 보유하므로 별도 래핑 불필요.
729
+
730
+ ## 서비스 호출 (`AppServiceProvider`)
731
+
732
+ ```ts
733
+ private readonly _appService = inject(AppServiceProvider);
734
+
735
+ await this._appService.user.someMethod(...);
736
+
737
+ const listenerKey = await this._appService.authInfoEvent.addListener(info, async (data) => { ... });
738
+ ```
739
+
740
+ Provider 정의·서비스·이벤트 호출 추가 컨벤션은 [client-service.md](./client-service.md) 참조.
741
+
742
+ ## ORM 호출 (`AppOrmProvider`)
743
+
744
+ ```ts
745
+ private readonly _appOrm = inject(AppOrmProvider);
746
+
747
+ await this._appOrm.connectAsync(async (db) => {
748
+ // db.someTable()...
749
+ });
750
+ ```
751
+
752
+ - 쿼리 작성법은 [orm.md](./orm.md), Provider 정의 컨벤션은 [client-orm.md](./client-orm.md) 참조.
753
+
754
+ ## 공유 데이터 (`useSharedSignal`)
755
+
756
+ 마스터 데이터(고객사·품목 등) 는 `AppSharedDataProvider` 에 등록되어 있고, 화면에서는 `useSharedSignal(name)` 으로 접근.
757
+
758
+ ```ts
759
+ sharedCustomers = useSharedSignal("고객사");
760
+
761
+ // sharedCustomers.items() — 시그널, 항목 배열
762
+ // sharedCustomers.get(id) — id 로 단건 조회
763
+ ```
764
+
765
+ ```html
766
+ <sd-shared-data-select [items]="sharedCustomers.items()" [(value)]="data().customerId" ... />
767
+ ```
768
+
769
+ Provider 정의·새 마스터 데이터 등록 컨벤션은 [client-shared-data.md](./client-shared-data.md) 참조.
770
+
771
+ ## 레이아웃·유틸 클래스
772
+
773
+ **화면 레이아웃** (영역 분할) 은 flex 유틸 클래스로 구성.
774
+
775
+ 상하 분할 (상단 고정 + 본문 fill):
776
+
777
+ ```html
778
+ <div class="flex-column fill">
779
+ <div class="pb-sm">
780
+ <!-- 상단 고정 영역 -->
781
+ </div>
782
+ <div class="flex-fill">
783
+ <!-- 본문 (남은 공간 자동) -->
784
+ </div>
785
+ </div>
786
+ ```
787
+
788
+ 좌우 분할 (좌측 콘텐츠 폭 + 우측 fill):
789
+
790
+ ```html
791
+ <div class="flex-row fill">
792
+ <div class="flex-min">
793
+ <!-- 좌측 -->
794
+ </div>
795
+ <div class="flex-fill">
796
+ <!-- 우측 -->
797
+ </div>
798
+ </div>
799
+ ```
800
+
801
+ 자주 사용하는 유틸:
802
+
803
+ - **Flex**: `flex-row` / `flex-column` (컨테이너), `flex-fill` (남은 공간), `flex-min` (콘텐츠 크기), `gap-sm` / `gap-default`.
804
+ - **부모 가득 채움**: `fill`.
805
+ - **패딩**: `p-{vertical}-{horizontal}` (예: `p-default`, `p-xs-sm`, `p-sm-default`). 단일 방향: `pt-` / `pb-` / `pl-` / `pr-`.
806
+ - **텍스트**: `tx-left` / `tx-center` / `tx-right`.
807
+ - **테마 색**: 텍스트 `tx-theme-{theme}-default`, 배경 `bg-{theme}-lightest`.
808
+ - **테두리**: `bd`, `bd-radius-default`, `bd-trans-light`.
809
+
810
+ **약속**:
811
+
812
+ - 영역 분할·배치 모두 flex 유틸 클래스 우선 적용. 자체 styles 작성은 최후 수단.
813
+ - 글로벌 클래스 정의 위치는 `@simplysm/angular/scss/commons/`.
814
+
815
+ ## 아이콘
816
+
817
+ `@ng-icons/core` 의 `NgIcon` + `@ng-icons/tabler-icons` 의 `tabler*` 셋트를 사용.
818
+
819
+ ```ts
820
+ import { NgIcon } from "@ng-icons/core";
821
+ import { tablerCheck, tablerCirclePlus } from "@ng-icons/tabler-icons";
822
+
823
+ @Component({
824
+ imports: [NgIcon /* ... */],
825
+ template: `
826
+ <ng-icon [svg]="tablerCheck" />
827
+ <ng-icon [svg]="tablerCirclePlus" />
828
+ `,
829
+ })
830
+ export class SomeComponent {
831
+ protected readonly tablerCheck = tablerCheck;
832
+ protected readonly tablerCirclePlus = tablerCirclePlus;
833
+ }
834
+ ```
835
+
836
+ **약속**:
837
+
838
+ - 아이콘 셋트는 `tabler-icons` 로 통일.
839
+ - 사용할 아이콘은 컴포넌트 클래스에 `protected readonly tablerXxx = tablerXxx` 로 노출한 뒤 템플릿에서 `[svg]` 바인딩.
840
+
841
+ ## sd-crud-\* 컴포넌트
842
+
843
+ 목록 화면 표준 골격은 `sd-crud-list`, 단건 편집 화면 표준 골격은 `sd-crud-detail`. 화면 작성 시 채택 여부를 결정. 채택 시 사용법은 [client-crud.md](./client-crud.md) 참조.