@simplysm/sd-claude 14.0.94 → 14.0.96
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.
- package/claude/references/sd-simplysm14/README.md +6 -2
- package/claude/references/sd-simplysm14/apis/angular/README.md +180 -43
- package/claude/references/sd-simplysm14/apis/angular/controls.md +275 -125
- package/claude/references/sd-simplysm14/apis/angular/crud.md +54 -59
- package/claude/references/sd-simplysm14/apis/angular/directives.md +139 -48
- package/claude/references/sd-simplysm14/apis/angular/features.md +102 -88
- package/claude/references/sd-simplysm14/apis/angular/kanban.md +54 -0
- package/claude/references/sd-simplysm14/apis/angular/layout.md +60 -36
- package/claude/references/sd-simplysm14/apis/angular/overlay.md +127 -75
- package/claude/references/sd-simplysm14/apis/angular/routing-appstructure.md +97 -51
- package/claude/references/sd-simplysm14/apis/angular/shared-data.md +74 -58
- package/claude/references/sd-simplysm14/apis/angular/sheet.md +81 -60
- package/claude/references/sd-simplysm14/apis/excel/README.md +5 -5
- package/claude/references/sd-simplysm14/apis/excel/cell.md +3 -3
- package/claude/references/sd-simplysm14/apis/excel/style.md +2 -2
- package/claude/references/sd-simplysm14/apis/excel/workbook-worksheet.md +5 -4
- package/claude/references/sd-simplysm14/apis/excel/wrapper.md +2 -2
- package/claude/references/sd-simplysm14/manuals/client-app-structure.md +5 -3
- package/claude/references/sd-simplysm14/manuals/client-component.md +31 -26
- package/claude/references/sd-simplysm14/manuals/client-crud.md +154 -4
- package/claude/references/sd-simplysm14/manuals/client-demo.md +5 -18
- package/claude/references/sd-simplysm14/manuals/client-orm.md +3 -12
- package/claude/references/sd-simplysm14/manuals/client-service.md +18 -7
- package/claude/references/sd-simplysm14/manuals/client-shared-data.md +24 -5
- package/claude/references/sd-simplysm14/manuals/data-log.md +1 -1
- package/claude/sd-system-prompt.md +7 -0
- package/claude/skills/sd-debug/SKILL.md +142 -27
- package/claude/skills/sd-review/SKILL.md +158 -20
- package/claude/skills/sd-spec/SKILL.md +53 -61
- package/claude/skills/sd-spec/references/format.md +476 -0
- package/package.json +1 -1
- package/claude/references/sd-simplysm14/apis/angular/infra.md +0 -82
- package/claude/skills/sd-debug/workflow.js +0 -390
- package/claude/skills/sd-review/workflow.js +0 -324
- package/claude/skills/sd-spec/references/format-analyze.md +0 -232
- package/claude/skills/sd-spec/references/format-design.md +0 -248
- package/claude/skills/sd-spec/workflow-analyze.js +0 -615
- package/claude/skills/sd-spec/workflow-design.js +0 -667
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
[selectMode]="selectMode() ?? 'multi'"
|
|
20
20
|
[key]="'<도메인-키>'"
|
|
21
21
|
[items]="items()"
|
|
22
|
+
[currDeletedItems]="deletedItems()"
|
|
22
23
|
[trackByFn]="trackByFn"
|
|
23
24
|
[(selectedKeys)]="selectedKeys"
|
|
24
25
|
[(currentPage)]="page"
|
|
@@ -39,6 +40,8 @@
|
|
|
39
40
|
</sd-crud-list>
|
|
40
41
|
```
|
|
41
42
|
|
|
43
|
+
- **`[currDeletedItems]`** — 삭제(soft delete)된 행을 시트에서 취소선으로 구분하고 "선택 복구" 버튼을 띄우는 입력. `deletedItems = computed(() => this.items().filter((i) => i.isDeleted))` 를 넘김. 삭제항목 포함 검색을 지원하는 목록에는 필수 — 빠뜨리면 삭제 행이 일반 행과 구분되지 않고 복구 버튼이 나오지 않음.
|
|
44
|
+
|
|
42
45
|
### 슬롯 규약
|
|
43
46
|
|
|
44
47
|
| 슬롯 | 용도 |
|
|
@@ -50,6 +53,10 @@
|
|
|
50
53
|
|
|
51
54
|
`<sd-sheet-column>` 은 `<sd-crud-list>` 의 직속 자식으로 두면 내부 시트로 자동 투영됨.
|
|
52
55
|
|
|
56
|
+
### 와이어프레임이 표준 버튼 위치와 충돌하면
|
|
57
|
+
|
|
58
|
+
`(create)/(delete)/(restore)` 표준 출력이 와이어프레임에 명시된 버튼 위치를 가린다면, 표준 출력 사용을 포기하고 `#toolTpl` 등 슬롯 안에 `sd-button` 으로 직접 배치. 시각 요소 배치는 와이어프레임이 1순위 ([client-component.md "시각 요소 배치 기준"](./client-component.md)).
|
|
59
|
+
|
|
53
60
|
### viewType 별 동작
|
|
54
61
|
|
|
55
62
|
- **`'page'`** — 라우팅 진입 단위. 상단에 저장 버튼.
|
|
@@ -110,7 +117,7 @@ async onEdit(item: IItem, event: Event): Promise<void> {
|
|
|
110
117
|
```html
|
|
111
118
|
<ng-template #toolTpl>
|
|
112
119
|
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
|
|
113
|
-
<ng-icon [svg]="
|
|
120
|
+
<ng-icon [svg]="tablerDownload" />
|
|
114
121
|
엑셀 다운로드
|
|
115
122
|
</sd-button>
|
|
116
123
|
</ng-template>
|
|
@@ -149,9 +156,106 @@ async onDownloadExcelButtonClick(): Promise<void> {
|
|
|
149
156
|
- 조회는 목록과 같은 `_search` 를 페이징 인자만 꺼서 재사용 — 보이는 페이지가 아니라 결과 전체를 받음.
|
|
150
157
|
- 파일명은 `<화면제목>_<yyMMdd>.xlsx`. 화면 제목은 `injectViewTitleSignal()`.
|
|
151
158
|
- 양식 컬럼 = 화면 표시 컬럼 + `삭제`(참/거짓) + `수정일시`·`수정자`([data-log.md](./data-log.md) 의 표시 규약). 참조 마스터는 명칭으로 출력.
|
|
152
|
-
-
|
|
159
|
+
- 같은 `_excelWrapper`(zod 스키마) 를 아래 업로드 레시피와 **공유**함 — `write` 가 다운로드, `read` 가 업로드.
|
|
153
160
|
- `ExcelWrapper`/`downloadBlob` 자체 사용법은 [apis/excel/README.md](../apis/excel/README.md) · [apis/core-browser/README.md](../apis/core-browser/README.md).
|
|
154
161
|
|
|
162
|
+
### 엑셀 업로드로 일괄 등록·수정하려면
|
|
163
|
+
|
|
164
|
+
다운로드와 **같은 `_excelWrapper`(zod 스키마) 를 공유**해 역방향으로 읽음. `#toolTpl` 의 다운로드 버튼 옆에 업로드 버튼을 `edit` 권한으로 게이팅해 두고, `openFileDialog` → `_excelWrapper.read` → 정합성 검증 → `save` 일괄 저장. 아래 예시는 참조 마스터(예: 역할) 컬럼을 포함한 목록 기준.
|
|
165
|
+
|
|
166
|
+
```html
|
|
167
|
+
<ng-template #toolTpl>
|
|
168
|
+
@if (perms().includes("edit")) {
|
|
169
|
+
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
|
|
170
|
+
<ng-icon [svg]="tablerUpload" />
|
|
171
|
+
엑셀 업로드
|
|
172
|
+
</sd-button>
|
|
173
|
+
}
|
|
174
|
+
<!-- 위 '엑셀 다운로드' 버튼과 같은 #toolTpl 안에 둠 -->
|
|
175
|
+
</ng-template>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
async onUploadExcelButtonClick(): Promise<void> {
|
|
180
|
+
if (this.busyCount() > 0) return;
|
|
181
|
+
if (!this.perms().includes("edit")) return;
|
|
182
|
+
|
|
183
|
+
const files = await openFileDialog({ accept: ".xlsx" });
|
|
184
|
+
if (files == null) return; // 취소
|
|
185
|
+
|
|
186
|
+
this.busyCount.update((v) => v + 1);
|
|
187
|
+
await this._sdToast.try(async () => {
|
|
188
|
+
// 파생 컬럼(수정일시·수정자)은 업로드에서 제외하고 파싱.
|
|
189
|
+
const records = await this._excelWrapper.read(files[0], 0, {
|
|
190
|
+
excludes: ["lastModifiedAt", "lastModifiedBy"],
|
|
191
|
+
});
|
|
192
|
+
if (records.length === 0) throw new Error("업로드할 데이터가 없습니다.");
|
|
193
|
+
|
|
194
|
+
// 다건일 때만 에러 메시지에 항목명을 붙임(단건이면 평문).
|
|
195
|
+
const label = (itemName: string): string =>
|
|
196
|
+
records.length > 1 ? `직원 '${itemName}': ` : "";
|
|
197
|
+
|
|
198
|
+
// 참조 마스터: 명칭 → ID 역변환(활성 기준). 매칭 안 되는 명칭은 throw.
|
|
199
|
+
const roleIdByName = new Map(this.sharedRoles.items().map((r) => [r.name, r.id] as const));
|
|
200
|
+
const inputs = records.map((rec) => {
|
|
201
|
+
let roleId: number | undefined;
|
|
202
|
+
const roleName = rec.roleName?.trim();
|
|
203
|
+
if (roleName != null && roleName !== "") {
|
|
204
|
+
roleId = roleIdByName.get(roleName);
|
|
205
|
+
if (roleId == null) throw new Error(`${label(rec.name)}존재하지 않는 역할입니다. (${roleName})`);
|
|
206
|
+
}
|
|
207
|
+
return { id: rec.id, name: rec.name, roleId, isDeleted: rec.isDeleted };
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ID↔이름 정합성 — id 있는 행은 DB의 (id, name) 과 대조. id 없으면 신규.
|
|
211
|
+
const ids = inputs.flatMap((x) => (x.id != null ? [x.id] : []));
|
|
212
|
+
const dbRows =
|
|
213
|
+
ids.length === 0
|
|
214
|
+
? []
|
|
215
|
+
: await this._appOrm.connectAsync((db) =>
|
|
216
|
+
db
|
|
217
|
+
.employee()
|
|
218
|
+
.where((c) => [expr.in(c.id, ids)])
|
|
219
|
+
.select((c) => ({ id: c.id, name: c.name }))
|
|
220
|
+
.execute(),
|
|
221
|
+
);
|
|
222
|
+
const dbNameById = new Map(dbRows.map((r) => [r.id, r.name] as const));
|
|
223
|
+
|
|
224
|
+
// 기존 이름(비즈니스키)이 바뀌면 엑셀 행 어긋남(정렬 사고) 의심 → 변경 건수를 입력받아 확인.
|
|
225
|
+
let nameChangedCount = 0;
|
|
226
|
+
for (const x of inputs) {
|
|
227
|
+
if (x.id == null) continue;
|
|
228
|
+
const dbName = dbNameById.get(x.id);
|
|
229
|
+
if (dbName == null) throw new Error(`${label(x.name)}ID ${x.id} 에 해당하는 직원이 없습니다.`);
|
|
230
|
+
if (dbName !== x.name.trim()) nameChangedCount++;
|
|
231
|
+
}
|
|
232
|
+
if (nameChangedCount >= 1) {
|
|
233
|
+
const answer = prompt(
|
|
234
|
+
`기존 항목 ${nameChangedCount}건의 이름이 변경됩니다.\n계속하려면 ${nameChangedCount} 을(를) 입력하세요.`,
|
|
235
|
+
);
|
|
236
|
+
if (answer == null || answer.trim() !== String(nameChangedCount)) return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const results = await this._appService.employee.save(inputs); // 일괄 저장(트랜잭션)
|
|
240
|
+
await this._appSharedData.emitAsync(
|
|
241
|
+
"직원",
|
|
242
|
+
results.map((r) => r.id),
|
|
243
|
+
);
|
|
244
|
+
this._sdToast.success(`${results.length}건이 반영되었습니다.`);
|
|
245
|
+
await this._refresh();
|
|
246
|
+
});
|
|
247
|
+
this.busyCount.update((v) => v - 1);
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
- 다운로드와 같은 `_excelWrapper` 를 그대로 재사용 — `read(file, sheetIndex, { excludes })` 가 역방향. `excludes` 로 파생 컬럼(수정일시·수정자)을 파싱에서 뺌.
|
|
252
|
+
- 빈 파일(0건)은 throw — 정상 처리하지 않음.
|
|
253
|
+
- 참조 마스터(역할 등)는 **명칭 → ID 역변환**. 매칭 안 되는 명칭은 throw — 일부만 건너뛰지 않음(다중 작업 원자성).
|
|
254
|
+
- `id` 유무로 신규/수정 분기. `id` 있는 행은 DB의 `(id, name)` 과 대조해 존재·정합성 확인(없으면 throw).
|
|
255
|
+
- 기존 이름(비즈니스키) 변경은 엑셀 행이 밀린 사고일 수 있어, 변경 건수를 직접 입력받아 확인 후 진행.
|
|
256
|
+
- 저장은 `save(inputs)` 한 번으로 일괄 — 한 건이라도 실패하면 전체 롤백(트랜잭션 원자성).
|
|
257
|
+
- 업로드 버튼은 `edit` 권한일 때만 노출. import 추가: `openFileDialog`(`@simplysm/core-browser`) · `tablerUpload`(`@ng-icons/tabler-icons`).
|
|
258
|
+
|
|
155
259
|
### 특정 행의 선택·삭제를 막으려면
|
|
156
260
|
|
|
157
261
|
`[getItemSelectableFn]` 로 행별 선택 가능 여부를 반환. 문자열을 반환하면 그 사유가 안내되고 해당 행은 선택(→삭제) 불가. 개별 선택·전체 선택 모든 경로에 적용됨.
|
|
@@ -167,6 +271,26 @@ getItemSelectableFn = (item: IItem): boolean | string =>
|
|
|
167
271
|
- `true` = 선택 가능, `string` = 선택 불가 + 사유. 선택 자체가 막히므로 핸들러에 같은 가드를 또 두지 않아도 됨.
|
|
168
272
|
- 단건 상세에는 선택 개념이 없으므로, 같은 제약을 삭제 버튼을 조건부로 숨겨 적용(`@if (!isSelf() && perms().includes("edit")) { ...삭제 버튼... }`).
|
|
169
273
|
|
|
274
|
+
### 시트 정렬을 서버 정렬로 반영하려면
|
|
275
|
+
|
|
276
|
+
`[(sorts)]="sortingDefs"` 로 받은 정렬 조건을 `_search` 쿼리에 반영. 시트 컬럼 `key` 가 select 별칭과 일치하므로, 컬럼별 분기 없이 `obj.getChainValue` 로 `key` 를 컬럼으로 풀어 `orderBy` 에 전달.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
// 화면 사용자가 지정한 정렬을 우선순위대로 적용
|
|
280
|
+
for (const sort of this.sortingDefs()) {
|
|
281
|
+
qr2 = qr2.orderBy((c) => obj.getChainValue(c, sort.key) as any, sort.desc ? "DESC" : "ASC");
|
|
282
|
+
}
|
|
283
|
+
// 이 화면의 기본 정렬 — 여기를 고쳐 화면별 기본값을 바꿈
|
|
284
|
+
if (!this.sortingDefs().some((s) => s.key === "id")) {
|
|
285
|
+
qr2 = qr2.orderBy((c) => c.id, "DESC");
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
- 아래 `if` 블록이 그 화면의 기본 정렬을 정하는 자리. 예시는 `id DESC` — 다른 기본 정렬이 필요하면 `orderBy` 의 컬럼·방향과 `s.key` 를 같은 키로 함께 바꿈.
|
|
290
|
+
- 화면 사용자가 시트 헤더로 정렬하면 그 정렬이 1순위로 적용되고, 기본 정렬은 맨 뒤에 깔림.
|
|
291
|
+
- 컬럼마다 `if (sort.key === "X") orderBy((c) => c.X, ...)` 식 분기 금지 — `sort.key` 가 select 별칭과 일치하므로 한 줄로 처리.
|
|
292
|
+
- `obj` 는 `@simplysm/core-common`, `SortingDef` 는 `@simplysm/angular`.
|
|
293
|
+
|
|
170
294
|
## `sd-crud-detail`
|
|
171
295
|
|
|
172
296
|
단일 레코드 편집 화면의 표준 골격. 다음 기능을 일괄 제공: 폼 래핑, CTRL+S 단축키 저장, 저장 버튼, 모달의 "확인" 버튼 자동 처리.
|
|
@@ -209,7 +333,9 @@ getItemSelectableFn = (item: IItem): boolean | string =>
|
|
|
209
333
|
|
|
210
334
|
### 삭제 (onDelete)
|
|
211
335
|
|
|
212
|
-
`confirm` → soft delete(`isDeleted=true`) → 이력 적재 → 공유 데이터 통지 →
|
|
336
|
+
`confirm` → soft delete(`isDeleted=true`) → 이력 적재 → 공유 데이터 통지 → 목록(list)은 `_refresh()` / 단건(detail)은 `submitted.emit(true)`.
|
|
337
|
+
|
|
338
|
+
**벌크 삭제 (list)**:
|
|
213
339
|
|
|
214
340
|
```ts
|
|
215
341
|
async onDelete(targets: IItem[]): Promise<void> {
|
|
@@ -236,6 +362,30 @@ async onDelete(targets: IItem[]): Promise<void> {
|
|
|
236
362
|
}
|
|
237
363
|
```
|
|
238
364
|
|
|
365
|
+
**단건 삭제 (detail)**: `sd-crud-detail` 표준 호출에는 `(delete)` output 이 없으므로, 삭제 버튼을 슬롯에 직접 둠 — 모달로 띄우는 detail 은 `#bottomCommandTpl`(모달 "확인" 버튼과 같은 하단 줄)에 두고 `(click)="onDelete()"` 로 배선. 목록의 `_refresh()` 대신 detail 통지 output(임베드면 `submitted.emit(true)`, 모달이면 `close.emit(payload)`)으로 부모(list) 에 통지.
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
async onDelete(): Promise<void> {
|
|
369
|
+
if (this.busyCount() > 0) return;
|
|
370
|
+
if (!this.canEdit()) return;
|
|
371
|
+
if (!confirm("삭제하시겠습니까?")) return;
|
|
372
|
+
|
|
373
|
+
const id = this.dataId();
|
|
374
|
+
const employeeId = this._appAuth.authInfo()?.employeeId;
|
|
375
|
+
this.busyCount.update((v) => v + 1);
|
|
376
|
+
await this._sdToast.try(async () => {
|
|
377
|
+
await this._appOrm.connectAsync(async (db) => {
|
|
378
|
+
await db.role().where((c) => [expr.eq(c.id, id)]).update(() => ({ isDeleted: true }));
|
|
379
|
+
await db.role().insertDataLog({ action: "삭제", itemId: id, employeeId });
|
|
380
|
+
});
|
|
381
|
+
await this._appSharedData.emitAsync("역할", [id]);
|
|
382
|
+
this._sdToast.success("삭제되었습니다.");
|
|
383
|
+
this.submitted.emit(true);
|
|
384
|
+
});
|
|
385
|
+
this.busyCount.update((v) => v - 1);
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
239
389
|
### 복구 (onRestore)
|
|
240
390
|
|
|
241
391
|
복구는 `confirm` 없이 진행하되, **활성 유니크 컬럼(명칭·코드)이 있으면 복구로 활성 중복이 생기지 않는지 재검증**해야 함(삭제된 동안 같은 값의 활성 레코드가 생겼을 수 있음). 검증 위치가 단건/벌크에서 다름. 활성 유니크 정책 자체는 [orm.md](./orm.md) 의 유니크 전략.
|
|
@@ -288,4 +438,4 @@ for (const id of ids) {
|
|
|
288
438
|
- 삭제·복구·이력 적재는 한 `connectAsync` 트랜잭션 안에서 수행 — 데이터만 바뀌고 이력이 빠지거나 그 반대가 되지 않게 함.
|
|
289
439
|
- 벌크 복구는 하나라도 충돌하면 전체 롤백(원자성). 충돌분만 빼고 나머지를 복구하지 않음.
|
|
290
440
|
- 활성 유니크 검증은 복구 경로에서 빠뜨리지 않음 — 단건은 선검증, 벌크는 후검증. 활성 유니크가 없는 모델이면 생략 가능.
|
|
291
|
-
-
|
|
441
|
+
- 단건(detail)은 삭제 후 부모에 통지 — 임베드(컨트롤)면 `submitted.emit(true)`, 모달이면 `close.emit(payload)` 로 결과 반환(호출 측이 `showAsync` 반환으로 refresh). 두 output 은 독립이며 사용 맥락에 따라 한쪽 또는 양쪽 ([client-component.md "detail 데이터 흐름"](./client-component.md) 참조). 복구 후엔 닫지 않고 refresh — 복구 직후 상세를 계속 보도록.
|
|
@@ -4,24 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## 화면 정의 섹션(4번 섹션) 의 화면 유형별 파일 역할
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
9
|
-
| 마스터(체크박스·`[E N]`·5버튼바) / 시트 단일 | `<domain>.list.ts` |
|
|
10
|
-
| 단건 입력 폼 | `<domain>.detail.ts` |
|
|
11
|
-
| 좌 목록 + 우 단건 | `<domain>.view.ts` + `.list.ts` + `.detail.ts` |
|
|
12
|
-
| 좌 헤더 목록 + 우(헤더 정보 + 라인 시트) 마스터-라인 | `<domain>.view.ts` + `.list.ts` + `.detail.ts` — 우 라인 영역은 `.detail.ts` (헤더 단건 + 라인) |
|
|
13
|
-
| 모달 전용 비-CRUD 화면 (도구·검색·설정 등) | `<domain>.modal.ts` |
|
|
14
|
-
| 프린트 양식 | `<domain>.print-template.ts` |
|
|
15
|
-
|
|
16
|
-
`<domain>` 은 화면명을 dash-case 영문으로 음역한 슬러그. 같은 도메인 폴더에 같은 역할의 파일이 2개 이상이면 `<domain>-<갈래>.<역할>.ts` 형식 사용.
|
|
17
|
-
|
|
18
|
-
동작 섹션의 `→ [화면.X] 을 모달로 띄움` 표기는 표시 방식일 뿐 파일 역할이 아님. 화면.X 가 단건 편집이면 `.detail.ts` 를 `showAsync` 로 띄우고(= 위 "단건 입력 폼" 행), 모달 전용 비-CRUD UI 일 때만 `.modal.ts`. 판별 기준은 [client-component.md "detail 과 modal 구분"](./client-component.md) 참조.
|
|
7
|
+
화면 유형 → 파일 구성 매핑(`<domain>` 슬러그·모달 표기 해석 포함) 은 [client-component.md "파일명·역할·위치"](./client-component.md) 의 "화면 정의 → 파일 구성" 을 따름. 데모도 같은 매핑으로 파일을 구성.
|
|
19
8
|
|
|
20
9
|
## 와이어프레임 기준
|
|
21
10
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`sd-crud-list` 의 표준 출력(`(create)/(delete)/(restore)`) 이 와이어프레임에 명시된 버튼 위치를 가린다면 표준 출력 사용을 포기하고 슬롯 안에 `sd-button` 으로 직접 배치.
|
|
11
|
+
시각 요소(버튼·필터·시트·탭·검색) 의 존재·영역·순서는 와이어프레임이 1순위 ([client-component.md "시각 요소 배치 기준"](./client-component.md)). `sd-crud-list` 표준 출력과 충돌할 때의 처리는 [client-crud.md "와이어프레임이 표준 버튼 위치와 충돌하면"](./client-crud.md). 데모도 와이어프레임을 따라 구성.
|
|
25
12
|
|
|
26
13
|
## 항목표의 `종류` 컬럼을 입력 컨트롤로 매핑
|
|
27
14
|
|
|
@@ -73,7 +60,7 @@ if (!result) return;
|
|
|
73
60
|
|
|
74
61
|
영역 한정 호출(`→ [화면.Y] 의 <영역> — 선택 전용` 등) 은 모달의 입력 시그널(`selectMode` 등) 로 전달. spec 마커 매핑: "선택 전용"·multiselect 는 `selectMode`(`single`/`multi`) 로, "편집 가능 여부" 는 `readonly` 로 따로 전달. "선택 전용" 은 선택 목적을 뜻할 뿐 편집을 막지 않으므로(readonly 아님), 편집까지 차단하려면 `readonly=true` 를 함께 줄 것.
|
|
75
62
|
|
|
76
|
-
단건 편집을 모달로 띄우는 경우 피호출 화면은 `.detail.ts`(`<sd-crud-detail>` 루트, `viewType='modal'` 자동 주입)이며 모달 표시용 별도 `.modal.ts` 를 만들지 않음. 모달 전용 비-CRUD 화면(`.modal.ts`)은 `sd-crud-detail` 대신 `sd-busy-container` 등으로 자체 구성.
|
|
63
|
+
단건 편집을 모달로 띄우는 경우 피호출 화면은 `.detail.ts`(`<sd-crud-detail>` 루트, `viewType='modal'` 자동 주입)이며 모달 표시용 별도 `.modal.ts` 를 만들지 않음. 모달 전용 비-CRUD 화면(`.modal.ts`)은 `sd-crud-detail` 대신 `sd-busy-container` 등으로 자체 구성.
|
|
77
64
|
|
|
78
65
|
**동반 모달**: 동작 섹션에 `→ [화면.Y] 을 모달로 띄움` 으로 등장하는 모든 모달은 같은 호출에서 함께 생성. 이미 존재하면 재사용.
|
|
79
66
|
|
|
@@ -84,7 +71,7 @@ if (!result) return;
|
|
|
84
71
|
```html
|
|
85
72
|
<ng-template #toolTpl>
|
|
86
73
|
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onExcelUploadClick()">
|
|
87
|
-
<ng-icon [svg]="
|
|
74
|
+
<ng-icon [svg]="tablerUpload" /> 엑셀 업로드
|
|
88
75
|
</sd-button>
|
|
89
76
|
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onExcelDownloadClick()">
|
|
90
77
|
<ng-icon [svg]="tablerDownload" /> 엑셀 다운로드
|
|
@@ -115,4 +102,4 @@ list 와 detail 의 합성 view 에서 항목 미선택 빈 상태는 [client-co
|
|
|
115
102
|
|
|
116
103
|
## 라우팅·메뉴 따라가기
|
|
117
104
|
|
|
118
|
-
|
|
105
|
+
라우팅·메뉴 등록 관례(기존 화면 본뜨기·`title`=화면명·`code`=음역 슬러그) 는 [client-app-structure.md "2. 메뉴 추가"](./client-app-structure.md) 를 따름. 데모도 같은 방식으로 등록.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
## AppOrmProvider 를 정의하려면 (새 앱 1회성)
|
|
9
9
|
|
|
10
|
-
앱의 DbContext·DB명·스키마를 한 곳에 고정하고,
|
|
10
|
+
앱의 DbContext·DB명·스키마를 한 곳에 고정하고, `connectAsync` 한 메서드로 쿼리를 실행.
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
13
|
@Injectable({ providedIn: "root" })
|
|
@@ -24,13 +24,6 @@ export class AppOrmProvider {
|
|
|
24
24
|
callback,
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
connectWithoutTransAsync<R>(callback: (db: MainDbContext) => Promise<R>): Promise<R> {
|
|
29
|
-
return this._appService.orm.connectWithoutTransaction(
|
|
30
|
-
{ /* 같은 옵션 */ },
|
|
31
|
-
callback,
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
27
|
}
|
|
35
28
|
```
|
|
36
29
|
|
|
@@ -38,7 +31,7 @@ export class AppOrmProvider {
|
|
|
38
31
|
|
|
39
32
|
- `@Injectable({ providedIn: "root" })`.
|
|
40
33
|
- DbContext 는 앱별로 정의 (예: `@adtek/db-main` 의 `MainDbContext`). 스키마 정의는 [orm.md](./orm.md).
|
|
41
|
-
-
|
|
34
|
+
- 진입 메서드는 `connectAsync` (트랜잭션 포함).
|
|
42
35
|
- 콜백의 반환값이 그대로 메서드의 반환값이 됨.
|
|
43
36
|
|
|
44
37
|
## 화면·프로바이더에서 쿼리를 실행하려면
|
|
@@ -54,9 +47,7 @@ const rows = await this._appOrm.connectAsync(async (db) => {
|
|
|
54
47
|
```
|
|
55
48
|
|
|
56
49
|
- 콜백 인자 `db` 는 `MainDbContext`. 테이블·뷰 빌더와 쿼리 작성은 [orm.md](./orm.md).
|
|
57
|
-
- 트랜잭션이 곤란한 작업(initialize 등)만 `connectWithoutTransAsync` 사용.
|
|
58
50
|
|
|
59
51
|
## 지킬 것
|
|
60
52
|
|
|
61
|
-
- DB 옵션(`DbClass`·`connOpt`·`dbContextOpt`)은 `AppOrmProvider` 한 곳에만 두고, 화면·프로바이더는 `connectAsync
|
|
62
|
-
- 기본은 `connectAsync`. 트랜잭션 없이 돌려야 하는 명확한 이유가 있을 때만 `connectWithoutTransAsync`.
|
|
53
|
+
- DB 옵션(`DbClass`·`connOpt`·`dbContextOpt`)은 `AppOrmProvider` 한 곳에만 두고, 화면·프로바이더는 `connectAsync` 만 호출. 옵션을 호출부에 흩뿌리지 않음.
|
|
@@ -12,12 +12,14 @@ ORM 사용은 [client-orm.md](./client-orm.md), 이벤트 정의·발생 메커
|
|
|
12
12
|
서버 연결·서비스·이벤트·ORM 진입점을 한 root provider 에 모음. 서비스·이벤트는 `private _xxx?` 캐시 필드 + getter 로 lazy 노출(`??=`).
|
|
13
13
|
|
|
14
14
|
```ts
|
|
15
|
+
export const APP_MAIN_SERVICE_KEY = "MAIN";
|
|
16
|
+
|
|
15
17
|
@Injectable({ providedIn: "root" })
|
|
16
18
|
export class AppServiceProvider {
|
|
17
19
|
private readonly _sdServiceClientFactory = inject(SdServiceClientFactoryProvider);
|
|
18
20
|
|
|
19
21
|
get client() {
|
|
20
|
-
return this._sdServiceClientFactory.get(
|
|
22
|
+
return this._sdServiceClientFactory.get(APP_MAIN_SERVICE_KEY);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
private _orm?: OrmClientConnector;
|
|
@@ -30,13 +32,22 @@ export class AppServiceProvider {
|
|
|
30
32
|
return (this._user ??= this.client.getService<UserServiceMethods>("User"));
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
private
|
|
34
|
-
get
|
|
35
|
-
return (this.
|
|
35
|
+
private _authInfoChangedEvent?: ClientEventProxy<typeof AuthInfoChangedEvent>;
|
|
36
|
+
get authInfoChangedEvent(): ClientEventProxy<typeof AuthInfoChangedEvent> {
|
|
37
|
+
return (this._authInfoChangedEvent ??= this.client.getEvent(AuthInfoChangedEvent));
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
async connectAsync() {
|
|
39
|
-
await this._sdServiceClientFactory.connectAsync(
|
|
41
|
+
await this._sdServiceClientFactory.connectAsync(
|
|
42
|
+
APP_MAIN_SERVICE_KEY,
|
|
43
|
+
Boolean(env("SERVER_HOST"))
|
|
44
|
+
? {
|
|
45
|
+
host: env("SERVER_HOST"),
|
|
46
|
+
port: num.parseInt(env("SERVER_PORT")),
|
|
47
|
+
ssl: parseBoolEnv(env("SERVER_SSL")),
|
|
48
|
+
}
|
|
49
|
+
: {},
|
|
50
|
+
);
|
|
40
51
|
}
|
|
41
52
|
}
|
|
42
53
|
```
|
|
@@ -44,9 +55,9 @@ export class AppServiceProvider {
|
|
|
44
55
|
**약속**:
|
|
45
56
|
|
|
46
57
|
- `@Injectable({ providedIn: "root" })`.
|
|
47
|
-
- `client` getter — `SdServiceClientFactoryProvider.get(
|
|
58
|
+
- `client` getter — `SdServiceClientFactoryProvider.get(APP_MAIN_SERVICE_KEY)` 결과. 서비스·이벤트·ORM 의 공통 진입점. 서비스 키(`"MAIN"`)는 `client.get`·`connectAsync` 등 여러 곳에서 참조하므로 상수로 추출.
|
|
48
59
|
- `orm` getter — `createOrmClientConnector(this.client)` 결과. DB 설정을 얹는 `AppOrmProvider` 가 이 위에 올라감 ([client-orm.md](./client-orm.md)).
|
|
49
|
-
- `connectAsync()` — 앱 부트스트랩 시점에 서버 연결 수행. `addListener` 등 통신은 이 호출 이후에만 가능.
|
|
60
|
+
- `connectAsync()` — 앱 부트스트랩 시점에 서버 연결 수행. 클라이언트·서버를 다른 호스트로 배포할 때를 위해 env(`SERVER_HOST`·`SERVER_PORT`·`SERVER_SSL`)로 연결 옵션을 주입(미설정이면 same-origin). `env`·`num`·`parseBoolEnv` 는 `@simplysm/core-common`. `addListener` 등 통신은 이 호출 이후에만 가능.
|
|
50
61
|
|
|
51
62
|
## 부트스트랩에서 서버에 연결하려면
|
|
52
63
|
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
14
|
export function useSharedSignal<K extends keyof TAppSharedData>(
|
|
15
|
-
|
|
15
|
+
dataKey: K,
|
|
16
16
|
): SharedDataHandle<TAppSharedData[K]> {
|
|
17
17
|
const appSharedData = inject(AppSharedDataProvider);
|
|
18
|
-
return appSharedData.getHandle(
|
|
18
|
+
return appSharedData.getHandle(dataKey);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
@Injectable({ providedIn: "root" })
|
|
@@ -65,7 +65,23 @@ export interface ISharedCustomer extends SharedDataBase<number> {
|
|
|
65
65
|
|
|
66
66
|
- `@Injectable({ providedIn: "root" })` 사용, `SdSharedDataProvider<TAppSharedData>` 를 상속.
|
|
67
67
|
- 등록은 `override initialize()` 안에서 `this.register(name, opts)` 호출로 수행.
|
|
68
|
-
- `useSharedSignal<K>(
|
|
68
|
+
- `useSharedSignal<K>(dataKey)` 헬퍼를 함께 export — 컴포넌트는 inject 없이 이름만으로 접근.
|
|
69
|
+
|
|
70
|
+
## 부트스트랩에 연결하려면 (새 앱 1회성)
|
|
71
|
+
|
|
72
|
+
라이브러리 공유데이터 컨트롤(`sd-shared-data-select` · `sd-shared-data-select-list`)은 base 토큰 `SdSharedDataProvider` 를 inject 하므로, 부트스트랩 providers 에 앱 provider 를 그 토큰의 별칭으로 등록.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// 앱 부트스트랩 (main.ts)
|
|
76
|
+
bootstrapApplication(AppRoot, {
|
|
77
|
+
providers: [
|
|
78
|
+
// ...
|
|
79
|
+
{ provide: SdSharedDataProvider, useExisting: AppSharedDataProvider },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- 이 별칭이 없으면 컨트롤이 데이터가 등록된 `AppSharedDataProvider` 가 아니라 빈 base 인스턴스를 잡아, 공유데이터 select 컨트롤에 항목이 표시되지 않음.
|
|
69
85
|
|
|
70
86
|
## 마스터 데이터 항목을 추가하려면
|
|
71
87
|
|
|
@@ -184,7 +200,10 @@ sharedProducts = useSharedSignal("품목");
|
|
|
184
200
|
|
|
185
201
|
@let _selectedRole = selectedRole();
|
|
186
202
|
@if (_selectedRole == null) {
|
|
187
|
-
<div class="flex-fill p-xxl"
|
|
203
|
+
<div class="flex-fill tx-theme-gray-default p-xxl" style="font-size: 48px; line-height: 1.5em">
|
|
204
|
+
<ng-icon [svg]="tablerArrowLeft" />
|
|
205
|
+
역할을 선택하세요.
|
|
206
|
+
</div>
|
|
188
207
|
} @else {
|
|
189
208
|
<app-role-permission-detail class="flex-fill" [roleId]="_selectedRole.id" />
|
|
190
209
|
}
|
|
@@ -209,7 +228,7 @@ constructor() {
|
|
|
209
228
|
- 두 이탈 경로를 모두 막음:
|
|
210
229
|
- `[canChangeFn]="checkCanLeave"` — 좌측에서 **다른 항목으로 전환**하기 전 확인.
|
|
211
230
|
- `setupCanDeactivate(checkCanLeave)` — **페이지(라우팅) 이탈** 전 확인.
|
|
212
|
-
- 선택 전(`selectedItem == null`)에는 안내
|
|
231
|
+
- 선택 전(`selectedItem == null`)에는 미선택 빈 상태를 둠 — 아이콘 + 안내 문구 구조와 `NgIcon` 등록은 [client-component.md](./client-component.md) 의 'list + detail 합성' 빈 상태 규약을 따름.
|
|
213
232
|
|
|
214
233
|
## 지킬 것
|
|
215
234
|
|
|
@@ -133,7 +133,7 @@ Queryable.prototype.joinLastDataLog = function (this: Queryable<any, any>, opts)
|
|
|
133
133
|
|
|
134
134
|
### 3. db-context 등록
|
|
135
135
|
|
|
136
|
-
확장 메서드는 `*.ext.ts` 가 한 번이라도 로드돼야 prototype 에 붙음. db-context
|
|
136
|
+
확장 메서드는 `*.ext.ts` 가 한 번이라도 로드돼야 prototype 에 붙음. `*.ext.ts` 가 진입점 import 그래프에 포함되면 로드 보장됨 — 표준 구조에선 `index.ts` 의 배럴 재export(`export * from "./db-main/system-data-log.ext"`)가 이를 충족. 배럴을 거치지 않고 db-context 를 직접 import 하는 경로가 따로 있으면 그 db-context 에 side-effect import 추가. 이력 직접 조회용 queryable 도 db-context 에 등록.
|
|
137
137
|
|
|
138
138
|
```ts
|
|
139
139
|
// main.db-context.ts
|
|
@@ -49,6 +49,13 @@ Claude 에이전트가 반드시 지켜야 할 행동 지침.
|
|
|
49
49
|
|
|
50
50
|
**요청 받은 스코프만 처리. 요청되지 않은 개선·확장 금지** (코드·문서·분석·대화 등 모든 작업).
|
|
51
51
|
|
|
52
|
+
**합의·패턴에 없는 구현 선택은 임의로 정하지 않음**: 합의된 범위가 규정하지 않는 모든 선택(색상·테마·아이콘·문구·메시지 포맷·헬퍼 도입·기존 코드나 계약의 변형 등 구현 세부 포함)은 동일 맥락(같은 파일·같은 레이어)의 [기존 코드 패턴](#결정-근거)을 따름. 따를 패턴이 없으면 사용자에게 묻기. 임의 결정 금지.
|
|
53
|
+
|
|
54
|
+
- "사소한 구현 디테일"·"기술적으로 필요"는 면제 사유가 아님 — 합의·패턴에 근거가 없으면 그 선택 자체가 물어야 할 결정임.
|
|
55
|
+
- 합의에서 건드리지 않기로 한 코드·계약(스키마·시그니처 등)을 바꿔야 할 필요가 생기면, 우회·변형으로 조용히 해결하지 말고 그 필요성을 사용자에게 보고 후 결정 수령.
|
|
56
|
+
- 나쁜 예: 같은 화면의 다운로드 버튼이 success 인데 업로드 버튼 색을 근거 없이 primary 로 정함.
|
|
57
|
+
- 좋은 예: 같은 화면 기존 버튼 테마를 따라 맞춤. 맞출 패턴이 없으면 질문.
|
|
58
|
+
|
|
52
59
|
## 사용자 질의 시
|
|
53
60
|
|
|
54
61
|
에이전트가 사용자에게 묻는 모든 행위 (결정·의견·정보 확인 등)에 적용. 사용자에게 묻고 답을 받아 [결정 근거](#결정-근거)로 확정.
|