@simplysm/sd-claude 14.0.79 → 14.0.81

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 (96) hide show
  1. package/claude/references/sd-requirement-source-handling.md +17 -17
  2. package/claude/references/sd-simplysm14/README.md +58 -58
  3. package/claude/references/sd-simplysm14/manuals/client-component.md +739 -739
  4. package/claude/references/sd-simplysm14/manuals/client-crud.md +1 -1
  5. package/claude/references/sd-simplysm14/manuals/client-demo.md +1 -1
  6. package/claude/references/sd-simplysm14/manuals/client-setup.md +2 -2
  7. package/claude/references/sd-simplysm14/manuals/client-tab.md +2 -2
  8. package/claude/references/sd-simplysm14/manuals/logging.md +3 -3
  9. package/claude/references/sd-simplysm14/manuals/orm-union.md +7 -7
  10. package/claude/references/sd-simplysm14/manuals/orm.md +75 -75
  11. package/claude/references/sd-simplysm14/manuals/test.md +8 -8
  12. package/claude/rules/sd-base-rules.md +261 -354
  13. package/claude/rules/sd-design-rules.md +44 -0
  14. package/claude/{sd-check-forbidden-files.py → sd-check-edit.py} +2 -1
  15. package/claude/{sd-check-bash.py → sd-check-shell.py} +2 -2
  16. package/claude/settings.json +3 -4
  17. package/claude/skills/sd-commit/SKILL.md +17 -17
  18. package/claude/skills/sd-config/SKILL.md +4 -4
  19. package/claude/skills/sd-demo/SKILL.md +41 -43
  20. package/claude/skills/sd-demo/evals/fixtures/inventory-list/.specs/inventory/spec.md +99 -0
  21. package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/package.json +12 -0
  22. package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/src/index.ts +3 -0
  23. package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/src/screens/inbound/inbound.list.ts +150 -0
  24. package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/src/screens/inventory/inventory-master.list.ts +143 -0
  25. package/claude/skills/sd-demo/evals/fixtures/inventory-list/packages/demo-client/src/screens/outbound/outbound.list.ts +150 -0
  26. package/claude/skills/sd-demo/evals/fixtures/inventory-list/pnpm-workspace.yaml +2 -0
  27. package/claude/skills/sd-demo/evals/fixtures/inventory-list/sd.config.ts +12 -0
  28. package/claude/skills/sd-demo/evals/golden.jsonl +1 -5
  29. package/claude/skills/sd-dev/SKILL.md +49 -22
  30. package/claude/skills/sd-dev/evals/fixtures/minimal-ts-pkg/package.json +8 -0
  31. package/claude/skills/sd-dev/evals/fixtures/minimal-ts-pkg/tests/.gitkeep +0 -0
  32. package/claude/skills/sd-dev/evals/fixtures/{case-modify → minimal-ts-pkg}/tsconfig.json +1 -3
  33. package/claude/skills/sd-dev/evals/golden.jsonl +1 -3
  34. package/claude/skills/sd-docs/SKILL.md +8 -8
  35. package/claude/skills/sd-impl/SKILL.md +172 -82
  36. package/claude/skills/sd-impl/evals/fixtures/case-a-new-screen/.specs/260513120000_warehouse/spec.md +101 -0
  37. package/claude/skills/sd-impl/evals/fixtures/case-b-update-with-demo/.specs/260513120000_warehouse/spec.md +101 -0
  38. package/claude/skills/sd-impl/evals/fixtures/case-b-update-with-demo/packages/app/src/screens/box-register/box-register.view.ts +46 -0
  39. package/claude/skills/sd-impl/evals/fixtures/case-c-new-cross/.specs/260513120000_warehouse/spec.md +89 -0
  40. package/claude/skills/sd-impl/evals/fixtures/case-d-spec-modify/.specs/260513120000_warehouse/spec.md +101 -0
  41. package/claude/skills/sd-impl/evals/golden.jsonl +4 -6
  42. package/claude/skills/sd-review/SKILL.md +33 -0
  43. package/claude/skills/sd-review/evals/fixtures/code-review/src/foo.ts +7 -0
  44. package/claude/skills/sd-review/evals/fixtures/doc-review/docs/foo.md +4 -0
  45. package/claude/skills/sd-review/evals/golden.jsonl +2 -0
  46. package/claude/skills/sd-skill/SKILL.md +217 -29
  47. package/claude/skills/sd-skill/evals/fixtures/existing-skill/.claude/skills/todo-format/SKILL.md +14 -0
  48. package/claude/skills/sd-skill/evals/fixtures/new-skill/.gitkeep +0 -0
  49. package/claude/skills/sd-skill/evals/golden.jsonl +2 -5
  50. package/claude/skills/sd-spec/SKILL.md +384 -305
  51. package/claude/skills/sd-spec/references/example-spec.md +41 -64
  52. package/claude/skills/sd-unpack/SKILL.md +83 -83
  53. package/claude/skills/sd-use/SKILL.md +4 -4
  54. package/package.json +1 -1
  55. package/claude/output-styles/sd-tone.md +0 -152
  56. package/claude/skills/sd-demo/evals/fixtures/empty/.specs/260513120000_warehouse/spec.md +0 -45
  57. package/claude/skills/sd-demo/evals/fixtures/with-existing-screen/.specs/260513120000_warehouse/spec.md +0 -42
  58. package/claude/skills/sd-demo/evals/fixtures/with-existing-screen/packages/app/src/screens/dashboard/dashboard.view.ts +0 -33
  59. package/claude/skills/sd-demo/evals/fixtures/with-master-screen/.specs/260513120000_warehouse/spec.md +0 -45
  60. package/claude/skills/sd-demo/evals/fixtures/with-master-screen/packages/app/src/screens/dashboard/dashboard.view.ts +0 -33
  61. package/claude/skills/sd-demo/evals/fixtures/with-modal/.specs/260513120000_warehouse/spec.md +0 -75
  62. package/claude/skills/sd-demo/evals/fixtures/with-modal/packages/app/src/screens/dashboard/dashboard.view.ts +0 -33
  63. package/claude/skills/sd-demo/evals/fixtures/with-screens/.specs/260513120000_warehouse/spec.md +0 -45
  64. package/claude/skills/sd-demo/evals/fixtures/with-screens/packages/app/src/screens/dashboard/dashboard.view.ts +0 -33
  65. package/claude/skills/sd-dev/evals/fixtures/case-add/package.json +0 -13
  66. package/claude/skills/sd-dev/evals/fixtures/case-add/src/index.ts +0 -10
  67. package/claude/skills/sd-dev/evals/fixtures/case-add/tests/index.test.ts +0 -11
  68. package/claude/skills/sd-dev/evals/fixtures/case-add/tsconfig.json +0 -12
  69. package/claude/skills/sd-dev/evals/fixtures/case-bug/package.json +0 -13
  70. package/claude/skills/sd-dev/evals/fixtures/case-bug/src/index.ts +0 -10
  71. package/claude/skills/sd-dev/evals/fixtures/case-bug/tests/index.test.ts +0 -11
  72. package/claude/skills/sd-dev/evals/fixtures/case-bug/tsconfig.json +0 -12
  73. package/claude/skills/sd-dev/evals/fixtures/case-modify/package.json +0 -13
  74. package/claude/skills/sd-dev/evals/fixtures/case-modify/src/index.ts +0 -10
  75. package/claude/skills/sd-dev/evals/fixtures/case-modify/tests/index.test.ts +0 -11
  76. package/claude/skills/sd-impl/evals/fixtures/case-001-new-screen/spec.md +0 -55
  77. package/claude/skills/sd-impl/evals/fixtures/case-002-auto-process/spec.md +0 -55
  78. package/claude/skills/sd-impl/evals/fixtures/case-003-update-screen/packages/client/src/pages/book-list.ts +0 -22
  79. package/claude/skills/sd-impl/evals/fixtures/case-003-update-screen/spec.md +0 -57
  80. package/claude/skills/sd-impl/evals/fixtures/case-004-ambiguous-spec/spec.md +0 -58
  81. package/claude/skills/sd-impl/evals/fixtures/case-005-id-mismatch/spec.md +0 -52
  82. package/claude/skills/sd-impl/evals/fixtures/case-006-with-reference-units/packages/client/src/pages//352/261/260/353/236/230/354/262/230//352/261/260/353/236/230/354/262/230-/353/252/251/353/241/235.test.ts +0 -10
  83. package/claude/skills/sd-impl/evals/fixtures/case-006-with-reference-units/packages/client/src/pages//352/261/260/353/236/230/354/262/230//352/261/260/353/236/230/354/262/230-/353/252/251/353/241/235.ts +0 -11
  84. package/claude/skills/sd-impl/evals/fixtures/case-006-with-reference-units/packages/server/src/data-access//352/261/260/353/236/230/354/262/230-/354/240/221/352/267/274.ts +0 -12
  85. package/claude/skills/sd-impl/evals/fixtures/case-006-with-reference-units/packages/server/src/models//352/261/260/353/236/230/354/262/230.ts +0 -8
  86. package/claude/skills/sd-impl/evals/fixtures/case-006-with-reference-units/spec.md +0 -77
  87. package/claude/skills/sd-impl/evals/fixtures/case-new/.specs/260514120000_/352/261/260/353/236/230/354/262/230/spec.md +0 -101
  88. package/claude/skills/sd-impl/evals/fixtures/case-update/.specs/260514120000_/352/261/260/353/236/230/354/262/230/spec.md +0 -101
  89. package/claude/skills/sd-impl/evals/fixtures/case-update/src//352/261/260/353/236/230/354/262/230//352/261/260/353/236/230/354/262/230-/353/252/250/353/215/270.txt +0 -1
  90. package/claude/skills/sd-impl/evals/fixtures/case-update/src//352/261/260/353/236/230/354/262/230//352/261/260/353/236/230/354/262/230-/353/252/251/353/241/235.txt +0 -1
  91. package/claude/skills/sd-impl/references/spec-cross-check.md +0 -82
  92. package/claude/skills/sd-skill/evals/fixtures/with-existing-review/.claude/skills/review/SKILL.md +0 -14
  93. package/claude/skills/sd-skill/references/eval-authoring.md +0 -81
  94. package/claude/skills/sd-skill/references/eval-run.md +0 -32
  95. package/claude/skills/sd-skill/references/skill-authoring.md +0 -70
  96. /package/claude/skills/{sd-skill/evals/fixtures/empty → sd-dev/evals/fixtures/minimal-ts-pkg/src}/.gitkeep +0 -0
@@ -1,739 +1,739 @@
1
- # 클라이언트 화면 작성 매뉴얼
2
-
3
- ## 파일명·역할·위치
4
-
5
- 화면 파일은 `<domain>.<역할>.ts` 형식, 역할 접미사로 책임을 표시한다.
6
-
7
- | 파일명 형식 | 역할 |
8
- | ---------------------------- | ------------------------------------------------------------------------- |
9
- | `<domain>.view.ts` | list/detail 로 깔끔히 나뉘지 않는 화면. (예시: list/detail 의 orchestrator) |
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` 형식으로 prefix 를 붙여 갈래를 둔다. 예 (`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 는 좌의 선택 키를 `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 로 만들면 자식이 effect 발화. |
134
- | `initialized` | `signal(false)` | 첫 데이터 로드까지 끝났는지. 자식이 자기 로드 끝낸 뒤 set true. |
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
- - 항상 `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`** — `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` 또는 `> 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
- this._appService.authInfoEvent.subscribe(...);
633
- ```
634
-
635
- Provider 정의·서비스 추가 컨벤션은 [client-setup.md#appserviceprovider](./client-setup.md#appserviceprovider) 참조.
636
-
637
- ## ORM 호출 (`AppOrmProvider`)
638
-
639
- ```ts
640
- private readonly _appOrm = inject(AppOrmProvider);
641
-
642
- await this._appOrm.connectAsync(async (db) => {
643
- // db.someTable()...
644
- });
645
- ```
646
-
647
- - 기본은 `connectAsync` (트랜잭션). `connectWithoutTransAsync` 는 트랜잭션 안에서 동작하지 않는 작업용 헬퍼.
648
- - 쿼리 작성은 [orm.md](./orm.md), Provider 정의 컨벤션은 [client-setup.md#appormprovider](./client-setup.md#appormprovider) 참조.
649
-
650
- ## 공유 데이터 (`useSharedSignal`)
651
-
652
- 마스터 데이터(고객사·품목 등)는 `AppSharedDataProvider` 에 등록되어 있고, 화면에서는 `useSharedSignal(name)` 으로 접근한다.
653
-
654
- ```ts
655
- sharedCustomers = useSharedSignal("고객사");
656
-
657
- // sharedCustomers.items() — 시그널, 항목 배열
658
- // sharedCustomers.get(id) — id 로 단건 조회
659
- ```
660
-
661
- ```html
662
- <sd-shared-data-select [items]="sharedCustomers.items()" [(value)]="data().customerId" ... />
663
- ```
664
-
665
- 새 마스터 데이터 등록·Provider 정의 컨벤션은 [client-setup.md#appshareddataprovider](./client-setup.md#appshareddataprovider) 참조.
666
-
667
- ## 레이아웃·유틸 클래스
668
-
669
- **화면 레이아웃** (영역 분할): flex 유틸 클래스.
670
-
671
- 상하 분할 (상단 고정 + 본문 fill):
672
-
673
- ```html
674
- <div class="flex-column fill">
675
- <div class="pb-sm">
676
- <!-- 상단 고정 영역 -->
677
- </div>
678
- <div class="flex-fill">
679
- <!-- 본문 (남은 공간 자동) -->
680
- </div>
681
- </div>
682
- ```
683
-
684
- 좌우 분할 (좌측 콘텐츠 폭 + 우측 fill):
685
-
686
- ```html
687
- <div class="flex-row fill">
688
- <div class="flex-min">
689
- <!-- 좌측 -->
690
- </div>
691
- <div class="flex-fill">
692
- <!-- 우측 -->
693
- </div>
694
- </div>
695
- ```
696
-
697
- 자주 쓰는 유틸:
698
-
699
- - **Flex**: `flex-row` / `flex-column` (컨테이너), `flex-fill` (남은 공간), `flex-min` (콘텐츠 크기), `gap-sm` / `gap-default`.
700
- - **부모 가득**: `fill`.
701
- - **패딩**: `p-{vertical}-{horizontal}` (예: `p-default`, `p-xs-sm`, `p-sm-default`). 단일 방향: `pt-` / `pb-` / `pl-` / `pr-`.
702
- - **텍스트**: `tx-left` / `tx-center` / `tx-right`.
703
- - **테마 색**: 텍스트 `tx-theme-{theme}-default`, 배경 `bg-{theme}-lightest`.
704
- - **테두리**: `bd`, `bd-radius-default`, `bd-trans-light`.
705
-
706
- **약속**:
707
-
708
- - 영역 분할·배치 모두 flex 유틸 클래스 우선. 자체 styles 작성은 마지막 수단.
709
- - 글로벌 클래스 정의는 `@simplysm/angular/scss/commons/`.
710
-
711
- ## 아이콘
712
-
713
- `@ng-icons/core` 의 `NgIcon` + `@ng-icons/tabler-icons` 의 `tabler*` 셋트를 사용한다.
714
-
715
- ```ts
716
- import { NgIcon } from "@ng-icons/core";
717
- import { tablerCheck, tablerCirclePlus } from "@ng-icons/tabler-icons";
718
-
719
- @Component({
720
- imports: [NgIcon /* ... */],
721
- template: `
722
- <ng-icon [svg]="tablerCheck" />
723
- <ng-icon [svg]="tablerCirclePlus" />
724
- `,
725
- })
726
- export class SomeComponent {
727
- protected readonly tablerCheck = tablerCheck;
728
- protected readonly tablerCirclePlus = tablerCirclePlus;
729
- }
730
- ```
731
-
732
- **약속**:
733
-
734
- - 아이콘 셋트는 `tabler-icons` 통일.
735
- - 사용할 아이콘은 컴포넌트 클래스에 `protected readonly tablerXxx = tablerXxx` 로 노출 후 템플릿에서 `[svg]` 바인딩.
736
-
737
- ## sd-crud-* 컴포넌트
738
-
739
- 목록 화면 표준 골격 `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 의 orchestrator) |
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` 형식으로 prefix 를 붙여 갈래를 둠. 예 (`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 는 좌의 선택 키를 `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 로 만들면 자식이 effect 발화. |
134
+ | `initialized` | `signal(false)` | 첫 데이터 로드까지 끝났는지. 자식이 자기 로드 끝낸 뒤 set true. |
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
+ - 항상 `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`** — `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` 또는 `> 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
+ this._appService.authInfoEvent.subscribe(...);
633
+ ```
634
+
635
+ Provider 정의·서비스 추가 컨벤션은 [client-setup.md#appserviceprovider](./client-setup.md#appserviceprovider) 참조.
636
+
637
+ ## ORM 호출 (`AppOrmProvider`)
638
+
639
+ ```ts
640
+ private readonly _appOrm = inject(AppOrmProvider);
641
+
642
+ await this._appOrm.connectAsync(async (db) => {
643
+ // db.someTable()...
644
+ });
645
+ ```
646
+
647
+ - 기본은 `connectAsync` (트랜잭션). `connectWithoutTransAsync` 는 트랜잭션 안에서 동작하지 않는 작업용 헬퍼.
648
+ - 쿼리 작성은 [orm.md](./orm.md), Provider 정의 컨벤션은 [client-setup.md#appormprovider](./client-setup.md#appormprovider) 참조.
649
+
650
+ ## 공유 데이터 (`useSharedSignal`)
651
+
652
+ 마스터 데이터(고객사·품목 등)는 `AppSharedDataProvider` 에 등록되어 있고, 화면에서는 `useSharedSignal(name)` 으로 접근함.
653
+
654
+ ```ts
655
+ sharedCustomers = useSharedSignal("고객사");
656
+
657
+ // sharedCustomers.items() — 시그널, 항목 배열
658
+ // sharedCustomers.get(id) — id 로 단건 조회
659
+ ```
660
+
661
+ ```html
662
+ <sd-shared-data-select [items]="sharedCustomers.items()" [(value)]="data().customerId" ... />
663
+ ```
664
+
665
+ 새 마스터 데이터 등록·Provider 정의 컨벤션은 [client-setup.md#appshareddataprovider](./client-setup.md#appshareddataprovider) 참조.
666
+
667
+ ## 레이아웃·유틸 클래스
668
+
669
+ **화면 레이아웃** (영역 분할): flex 유틸 클래스.
670
+
671
+ 상하 분할 (상단 고정 + 본문 fill):
672
+
673
+ ```html
674
+ <div class="flex-column fill">
675
+ <div class="pb-sm">
676
+ <!-- 상단 고정 영역 -->
677
+ </div>
678
+ <div class="flex-fill">
679
+ <!-- 본문 (남은 공간 자동) -->
680
+ </div>
681
+ </div>
682
+ ```
683
+
684
+ 좌우 분할 (좌측 콘텐츠 폭 + 우측 fill):
685
+
686
+ ```html
687
+ <div class="flex-row fill">
688
+ <div class="flex-min">
689
+ <!-- 좌측 -->
690
+ </div>
691
+ <div class="flex-fill">
692
+ <!-- 우측 -->
693
+ </div>
694
+ </div>
695
+ ```
696
+
697
+ 자주 쓰는 유틸:
698
+
699
+ - **Flex**: `flex-row` / `flex-column` (컨테이너), `flex-fill` (남은 공간), `flex-min` (콘텐츠 크기), `gap-sm` / `gap-default`.
700
+ - **부모 가득**: `fill`.
701
+ - **패딩**: `p-{vertical}-{horizontal}` (예: `p-default`, `p-xs-sm`, `p-sm-default`). 단일 방향: `pt-` / `pb-` / `pl-` / `pr-`.
702
+ - **텍스트**: `tx-left` / `tx-center` / `tx-right`.
703
+ - **테마 색**: 텍스트 `tx-theme-{theme}-default`, 배경 `bg-{theme}-lightest`.
704
+ - **테두리**: `bd`, `bd-radius-default`, `bd-trans-light`.
705
+
706
+ **약속**:
707
+
708
+ - 영역 분할·배치 모두 flex 유틸 클래스 우선. 자체 styles 작성은 마지막 수단.
709
+ - 글로벌 클래스 정의는 `@simplysm/angular/scss/commons/`.
710
+
711
+ ## 아이콘
712
+
713
+ `@ng-icons/core` 의 `NgIcon` + `@ng-icons/tabler-icons` 의 `tabler*` 셋트를 사용함.
714
+
715
+ ```ts
716
+ import { NgIcon } from "@ng-icons/core";
717
+ import { tablerCheck, tablerCirclePlus } from "@ng-icons/tabler-icons";
718
+
719
+ @Component({
720
+ imports: [NgIcon /* ... */],
721
+ template: `
722
+ <ng-icon [svg]="tablerCheck" />
723
+ <ng-icon [svg]="tablerCirclePlus" />
724
+ `,
725
+ })
726
+ export class SomeComponent {
727
+ protected readonly tablerCheck = tablerCheck;
728
+ protected readonly tablerCirclePlus = tablerCirclePlus;
729
+ }
730
+ ```
731
+
732
+ **약속**:
733
+
734
+ - 아이콘 셋트는 `tabler-icons` 통일.
735
+ - 사용할 아이콘은 컴포넌트 클래스에 `protected readonly tablerXxx = tablerXxx` 로 노출 후 템플릿에서 `[svg]` 바인딩.
736
+
737
+ ## sd-crud-* 컴포넌트
738
+
739
+ 목록 화면 표준 골격 `sd-crud-list`, 단건 편집 화면 표준 골격 `sd-crud-detail`. 화면 작성 시 이 둘을 채택할지 결정함. 채택 시 사용법은 [client-crud.md](./client-crud.md).