@simplysm/sd-claude 14.0.93 → 14.0.95
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 +10 -6
- 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 +4 -2
- package/claude/references/sd-simplysm14/manuals/client-component.md +24 -24
- package/claude/references/sd-simplysm14/manuals/client-crud.md +328 -0
- package/claude/references/sd-simplysm14/manuals/client-demo.md +6 -19
- package/claude/references/sd-simplysm14/manuals/client-shared-data.md +49 -0
- package/claude/references/sd-simplysm14/manuals/data-log.md +33 -1
- package/claude/references/sd-simplysm14/manuals/orm.md +37 -0
- package/claude/sd-system-prompt.md +11 -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 +1 -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
|
@@ -50,6 +50,10 @@
|
|
|
50
50
|
|
|
51
51
|
`<sd-sheet-column>` 은 `<sd-crud-list>` 의 직속 자식으로 두면 내부 시트로 자동 투영됨.
|
|
52
52
|
|
|
53
|
+
### 와이어프레임이 표준 버튼 위치와 충돌하면
|
|
54
|
+
|
|
55
|
+
`(create)/(delete)/(restore)` 표준 출력이 와이어프레임에 명시된 버튼 위치를 가린다면, 표준 출력 사용을 포기하고 `#toolTpl` 등 슬롯 안에 `sd-button` 으로 직접 배치. 시각 요소 배치는 와이어프레임이 1순위 ([client-component.md "시각 요소 배치 기준"](./client-component.md)).
|
|
56
|
+
|
|
53
57
|
### viewType 별 동작
|
|
54
58
|
|
|
55
59
|
- **`'page'`** — 라우팅 진입 단위. 상단에 저장 버튼.
|
|
@@ -73,6 +77,217 @@
|
|
|
73
77
|
|
|
74
78
|
`selectMode` 는 `readonly` 와 독립 — selectMode 지정만으로는 편집이 막히지 않음. 등록·인라인 편집은 그대로 유지되고, `single` 일 때 "선택 삭제/복구" 버튼만 숨김. 읽기 전용이 필요하면 `readonly=true` 를 별도로 전달. `sd-shared-data-select-list` 가 모달을 띄울 때도 `selectMode="single"` 만 주입하므로 모달 내용은 편집 가능 상태로 유지됨.
|
|
75
79
|
|
|
80
|
+
### 행을 클릭해 상세 편집으로 진입하려면 (inlineEdit=false)
|
|
81
|
+
|
|
82
|
+
`inlineEdit=false` 목록의 편집 진입은 첫 컬럼(`#`)을 권한 분기된 진입점으로 만들어 처리. 편집 가능하면 편집 아이콘이 붙은 앵커를 눌러 상세 모달을 열고, 불가하면 값만 표시.
|
|
83
|
+
|
|
84
|
+
```html
|
|
85
|
+
<sd-sheet-column [key]="'id'" [header]="'#'">
|
|
86
|
+
<ng-template [cell]="items()" let-item="item">
|
|
87
|
+
@if (canEdit()) {
|
|
88
|
+
<sd-anchor class="flex-row gap-sm p-xs-sm" (click)="onEdit(item, $event)">
|
|
89
|
+
<div><ng-icon [svg]="tablerEdit" /></div>
|
|
90
|
+
<div class="flex-fill tx-right">{{ item.id }}</div>
|
|
91
|
+
</sd-anchor>
|
|
92
|
+
} @else {
|
|
93
|
+
<div class="p-xs-sm tx-right">{{ item.id }}</div>
|
|
94
|
+
}
|
|
95
|
+
</ng-template>
|
|
96
|
+
</sd-sheet-column>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
async onEdit(item: IItem, event: Event): Promise<void> {
|
|
101
|
+
event.preventDefault(); // 앵커 기본 동작 차단
|
|
102
|
+
event.stopPropagation(); // 행 선택과 분리
|
|
103
|
+
await this._openDetail(item.id); // 상세 모달 열고, 닫힘 결과 있으면 refresh
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- `canEdit()` 가 false 면 앵커 대신 텍스트만 — 권한 없는 사용자에게 편집 진입을 노출하지 않음.
|
|
108
|
+
- 앵커 레이아웃(`flex-row`·아이콘/값 정렬)은 화면 사정에 맞춤. 정규는 "`#` 컬럼 = 권한 분기된 편집 진입점" 까지.
|
|
109
|
+
|
|
110
|
+
### 현재 검색 결과를 엑셀로 내려받게 하려면
|
|
111
|
+
|
|
112
|
+
`#toolTpl` 에 다운로드 버튼을 두고, 페이징을 무시한 **현재 검색·필터 결과 전체**를 받아 엑셀로 변환. `ExcelWrapper`(zod 스키마) + `downloadBlob` 을 화면에 직접 둠.
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<ng-template #toolTpl>
|
|
116
|
+
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
|
|
117
|
+
<ng-icon [svg]="tablerUpload" />
|
|
118
|
+
엑셀 다운로드
|
|
119
|
+
</sd-button>
|
|
120
|
+
</ng-template>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
private readonly _excelWrapper = new ExcelWrapper(
|
|
125
|
+
z.object({
|
|
126
|
+
id: z.number().optional().describe("ID"),
|
|
127
|
+
name: z.string().describe("이름"),
|
|
128
|
+
isDeleted: z.boolean().describe("삭제"),
|
|
129
|
+
lastModifiedAt: z.custom<DateTime>().optional().describe("수정일시"),
|
|
130
|
+
lastModifiedBy: z.string().optional().describe("수정자"),
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
async onDownloadExcelButtonClick(): Promise<void> {
|
|
135
|
+
if (this.busyCount() > 0) return;
|
|
136
|
+
this.busyCount.update((v) => v + 1);
|
|
137
|
+
await this._sdToast.try(async () => {
|
|
138
|
+
const r = await this._search(false); // 페이징 무시 — 검색·필터 결과 전체
|
|
139
|
+
const wb = await this._excelWrapper.write(this.viewTitle(), r.items);
|
|
140
|
+
try {
|
|
141
|
+
downloadBlob(
|
|
142
|
+
await wb.toBlob(),
|
|
143
|
+
`${this.viewTitle()}_${new DateTime().toFormatString("yyMMdd")}.xlsx`,
|
|
144
|
+
);
|
|
145
|
+
} finally {
|
|
146
|
+
await wb.close(); // 워크북 자원 해제
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
this.busyCount.update((v) => v - 1);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- 조회는 목록과 같은 `_search` 를 페이징 인자만 꺼서 재사용 — 보이는 페이지가 아니라 결과 전체를 받음.
|
|
154
|
+
- 파일명은 `<화면제목>_<yyMMdd>.xlsx`. 화면 제목은 `injectViewTitleSignal()`.
|
|
155
|
+
- 양식 컬럼 = 화면 표시 컬럼 + `삭제`(참/거짓) + `수정일시`·`수정자`([data-log.md](./data-log.md) 의 표시 규약). 참조 마스터는 명칭으로 출력.
|
|
156
|
+
- 같은 `_excelWrapper`(zod 스키마) 를 아래 업로드 레시피와 **공유**함 — `write` 가 다운로드, `read` 가 업로드.
|
|
157
|
+
- `ExcelWrapper`/`downloadBlob` 자체 사용법은 [apis/excel/README.md](../apis/excel/README.md) · [apis/core-browser/README.md](../apis/core-browser/README.md).
|
|
158
|
+
|
|
159
|
+
### 엑셀 업로드로 일괄 등록·수정하려면
|
|
160
|
+
|
|
161
|
+
다운로드와 **같은 `_excelWrapper`(zod 스키마) 를 공유**해 역방향으로 읽음. `#toolTpl` 의 다운로드 버튼 옆에 업로드 버튼을 `edit` 권한으로 게이팅해 두고, `openFileDialog` → `_excelWrapper.read` → 정합성 검증 → `save` 일괄 저장. 아래 예시는 참조 마스터(예: 역할) 컬럼을 포함한 목록 기준.
|
|
162
|
+
|
|
163
|
+
```html
|
|
164
|
+
<ng-template #toolTpl>
|
|
165
|
+
@if (perms().includes("edit")) {
|
|
166
|
+
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
|
|
167
|
+
<ng-icon [svg]="tablerUpload" />
|
|
168
|
+
엑셀 업로드
|
|
169
|
+
</sd-button>
|
|
170
|
+
}
|
|
171
|
+
<!-- 위 '엑셀 다운로드' 버튼과 같은 #toolTpl 안에 둠 -->
|
|
172
|
+
</ng-template>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
async onUploadExcelButtonClick(): Promise<void> {
|
|
177
|
+
if (this.busyCount() > 0) return;
|
|
178
|
+
if (!this.perms().includes("edit")) return;
|
|
179
|
+
|
|
180
|
+
const files = await openFileDialog({ accept: ".xlsx" });
|
|
181
|
+
if (files == null) return; // 취소
|
|
182
|
+
|
|
183
|
+
this.busyCount.update((v) => v + 1);
|
|
184
|
+
await this._sdToast.try(async () => {
|
|
185
|
+
// 파생 컬럼(수정일시·수정자)은 업로드에서 제외하고 파싱.
|
|
186
|
+
const records = await this._excelWrapper.read(files[0], 0, {
|
|
187
|
+
excludes: ["lastModifiedAt", "lastModifiedBy"],
|
|
188
|
+
});
|
|
189
|
+
if (records.length === 0) throw new Error("업로드할 데이터가 없습니다.");
|
|
190
|
+
|
|
191
|
+
// 다건일 때만 에러 메시지에 항목명을 붙임(단건이면 평문).
|
|
192
|
+
const label = (itemName: string): string =>
|
|
193
|
+
records.length > 1 ? `직원 '${itemName}': ` : "";
|
|
194
|
+
|
|
195
|
+
// 참조 마스터: 명칭 → ID 역변환(활성 기준). 매칭 안 되는 명칭은 throw.
|
|
196
|
+
const roleIdByName = new Map(this.sharedRoles.items().map((r) => [r.name, r.id] as const));
|
|
197
|
+
const inputs = records.map((rec) => {
|
|
198
|
+
let roleId: number | undefined;
|
|
199
|
+
const roleName = rec.roleName?.trim();
|
|
200
|
+
if (roleName != null && roleName !== "") {
|
|
201
|
+
roleId = roleIdByName.get(roleName);
|
|
202
|
+
if (roleId == null) throw new Error(`${label(rec.name)}존재하지 않는 역할입니다. (${roleName})`);
|
|
203
|
+
}
|
|
204
|
+
return { id: rec.id, name: rec.name, roleId, isDeleted: rec.isDeleted };
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ID↔이름 정합성 — id 있는 행은 DB의 (id, name) 과 대조. id 없으면 신규.
|
|
208
|
+
const ids = inputs.flatMap((x) => (x.id != null ? [x.id] : []));
|
|
209
|
+
const dbRows =
|
|
210
|
+
ids.length === 0
|
|
211
|
+
? []
|
|
212
|
+
: await this._appOrm.connectWithoutTransAsync((db) =>
|
|
213
|
+
db
|
|
214
|
+
.employee()
|
|
215
|
+
.where((c) => [expr.in(c.id, ids)])
|
|
216
|
+
.select((c) => ({ id: c.id, name: c.name }))
|
|
217
|
+
.execute(),
|
|
218
|
+
);
|
|
219
|
+
const dbNameById = new Map(dbRows.map((r) => [r.id, r.name] as const));
|
|
220
|
+
|
|
221
|
+
// 기존 이름(비즈니스키)이 바뀌면 엑셀 행 어긋남(정렬 사고) 의심 → 변경 건수를 입력받아 확인.
|
|
222
|
+
let nameChangedCount = 0;
|
|
223
|
+
for (const x of inputs) {
|
|
224
|
+
if (x.id == null) continue;
|
|
225
|
+
const dbName = dbNameById.get(x.id);
|
|
226
|
+
if (dbName == null) throw new Error(`${label(x.name)}ID ${x.id} 에 해당하는 직원이 없습니다.`);
|
|
227
|
+
if (dbName !== x.name.trim()) nameChangedCount++;
|
|
228
|
+
}
|
|
229
|
+
if (nameChangedCount >= 1) {
|
|
230
|
+
const answer = prompt(
|
|
231
|
+
`기존 항목 ${nameChangedCount}건의 이름이 변경됩니다.\n계속하려면 ${nameChangedCount} 을(를) 입력하세요.`,
|
|
232
|
+
);
|
|
233
|
+
if (answer == null || answer.trim() !== String(nameChangedCount)) return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const results = await this._appService.employee.save(inputs); // 일괄 저장(트랜잭션)
|
|
237
|
+
await this._appSharedData.emitAsync(
|
|
238
|
+
"직원",
|
|
239
|
+
results.map((r) => r.id),
|
|
240
|
+
);
|
|
241
|
+
this._sdToast.success(`${results.length}건이 반영되었습니다.`);
|
|
242
|
+
await this._refresh();
|
|
243
|
+
});
|
|
244
|
+
this.busyCount.update((v) => v - 1);
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- 다운로드와 같은 `_excelWrapper` 를 그대로 재사용 — `read(file, sheetIndex, { excludes })` 가 역방향. `excludes` 로 파생 컬럼(수정일시·수정자)을 파싱에서 뺌.
|
|
249
|
+
- 빈 파일(0건)은 throw — 정상 처리하지 않음.
|
|
250
|
+
- 참조 마스터(역할 등)는 **명칭 → ID 역변환**. 매칭 안 되는 명칭은 throw — 일부만 건너뛰지 않음(다중 작업 원자성).
|
|
251
|
+
- `id` 유무로 신규/수정 분기. `id` 있는 행은 DB의 `(id, name)` 과 대조해 존재·정합성 확인(없으면 throw).
|
|
252
|
+
- 기존 이름(비즈니스키) 변경은 엑셀 행이 밀린 사고일 수 있어, 변경 건수를 직접 입력받아 확인 후 진행.
|
|
253
|
+
- 저장은 `save(inputs)` 한 번으로 일괄 — 한 건이라도 실패하면 전체 롤백(트랜잭션 원자성).
|
|
254
|
+
- 업로드 버튼은 `edit` 권한일 때만 노출. import 추가: `openFileDialog`(`@simplysm/core-browser`) · `tablerUpload`(`@ng-icons/tabler-icons`).
|
|
255
|
+
|
|
256
|
+
### 특정 행의 선택·삭제를 막으려면
|
|
257
|
+
|
|
258
|
+
`[getItemSelectableFn]` 로 행별 선택 가능 여부를 반환. 문자열을 반환하면 그 사유가 안내되고 해당 행은 선택(→삭제) 불가. 개별 선택·전체 선택 모든 경로에 적용됨.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// 로그인한 본인 계정은 선택(→삭제) 불가
|
|
262
|
+
getItemSelectableFn = (item: IItem): boolean | string =>
|
|
263
|
+
item.id === this._appAuth.authInfo()?.employeeId
|
|
264
|
+
? "본인 계정은 삭제할 수 없습니다."
|
|
265
|
+
: true;
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
- `true` = 선택 가능, `string` = 선택 불가 + 사유. 선택 자체가 막히므로 핸들러에 같은 가드를 또 두지 않아도 됨.
|
|
269
|
+
- 단건 상세에는 선택 개념이 없으므로, 같은 제약을 삭제 버튼을 조건부로 숨겨 적용(`@if (!isSelf() && perms().includes("edit")) { ...삭제 버튼... }`).
|
|
270
|
+
|
|
271
|
+
### 시트 정렬을 서버 정렬로 반영하려면
|
|
272
|
+
|
|
273
|
+
`[(sorts)]="sortingDefs"` 로 받은 정렬 조건을 `_search` 쿼리에 반영. 시트 컬럼 `key` 가 select 별칭과 일치하므로, 컬럼별 분기 없이 `obj.getChainValue` 로 `key` 를 컬럼으로 풀어 `orderBy` 에 전달.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
// 화면 사용자가 지정한 정렬을 우선순위대로 적용
|
|
277
|
+
for (const sort of this.sortingDefs()) {
|
|
278
|
+
qr2 = qr2.orderBy((c) => obj.getChainValue(c, sort.key) as any, sort.desc ? "DESC" : "ASC");
|
|
279
|
+
}
|
|
280
|
+
// 이 화면의 기본 정렬 — 여기를 고쳐 화면별 기본값을 바꿈
|
|
281
|
+
if (!this.sortingDefs().some((s) => s.key === "id")) {
|
|
282
|
+
qr2 = qr2.orderBy((c) => c.id, "DESC");
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- 아래 `if` 블록이 그 화면의 기본 정렬을 정하는 자리. 예시는 `id DESC` — 다른 기본 정렬이 필요하면 `orderBy` 의 컬럼·방향과 `s.key` 를 같은 키로 함께 바꿈.
|
|
287
|
+
- 화면 사용자가 시트 헤더로 정렬하면 그 정렬이 1순위로 적용되고, 기본 정렬은 맨 뒤에 깔림.
|
|
288
|
+
- 컬럼마다 `if (sort.key === "X") orderBy((c) => c.X, ...)` 식 분기 금지 — `sort.key` 가 select 별칭과 일치하므로 한 줄로 처리.
|
|
289
|
+
- `obj` 는 `@simplysm/core-common`, `SortingDef` 는 `@simplysm/angular`.
|
|
290
|
+
|
|
76
291
|
## `sd-crud-detail`
|
|
77
292
|
|
|
78
293
|
단일 레코드 편집 화면의 표준 골격. 다음 기능을 일괄 제공: 폼 래핑, CTRL+S 단축키 저장, 저장 버튼, 모달의 "확인" 버튼 자동 처리.
|
|
@@ -108,3 +323,116 @@
|
|
|
108
323
|
- **`'page'`** — 라우팅 진입 단위. 상단에 저장 버튼.
|
|
109
324
|
- **`'control'`** — view 안에 임베드. 명령 영역에 저장 버튼.
|
|
110
325
|
- **`'modal'`** — 모달. 하단 우측에 "확인" 버튼이 자동으로 추가.
|
|
326
|
+
|
|
327
|
+
## 삭제·복구를 처리하려면 (목록·단건 공통)
|
|
328
|
+
|
|
329
|
+
삭제는 soft delete, 복구는 그 반대. 두 처리 모두 변경 이력 적재([data-log.md](./data-log.md))와 공유 데이터 통지([client-shared-data.md](./client-shared-data.md))를 같은 트랜잭션·동작 안에서 함께 수행.
|
|
330
|
+
|
|
331
|
+
### 삭제 (onDelete)
|
|
332
|
+
|
|
333
|
+
`confirm` → soft delete(`isDeleted=true`) → 이력 적재 → 공유 데이터 통지 → 목록(list)은 `_refresh()` / 단건(detail)은 `submitted.emit(true)`.
|
|
334
|
+
|
|
335
|
+
**벌크 삭제 (list)**:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
async onDelete(targets: IItem[]): Promise<void> {
|
|
339
|
+
if (this.busyCount() > 0) return;
|
|
340
|
+
if (!this.canEdit()) return;
|
|
341
|
+
if (targets.length === 0) return;
|
|
342
|
+
if (!confirm("삭제하시겠습니까?")) return;
|
|
343
|
+
|
|
344
|
+
this.busyCount.update((v) => v + 1);
|
|
345
|
+
await this._sdToast.try(async () => {
|
|
346
|
+
const ids = targets.map((t) => t.id);
|
|
347
|
+
const employeeId = this._appAuth.authInfo()?.employeeId;
|
|
348
|
+
await this._appOrm.connectAsync(async (db) => {
|
|
349
|
+
await db.role().where((c) => [expr.in(c.id, ids)]).update(() => ({ isDeleted: true }));
|
|
350
|
+
for (const id of ids) {
|
|
351
|
+
await db.role().insertDataLog({ action: "삭제", itemId: id, employeeId });
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
await this._appSharedData.emitAsync("역할", ids);
|
|
355
|
+
this._sdToast.success("삭제되었습니다.");
|
|
356
|
+
await this._refresh();
|
|
357
|
+
});
|
|
358
|
+
this.busyCount.update((v) => v - 1);
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**단건 삭제 (detail)**: `sd-crud-detail` 표준 호출에는 `(delete)` output 이 없으므로, 삭제 버튼을 `#commandTpl` 슬롯에 두고 `(click)="onDelete()"` 로 배선. 목록의 `_refresh()` 대신 `submitted.emit(true)` 로 부모(list) 에 통지.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
async onDelete(): Promise<void> {
|
|
366
|
+
if (this.busyCount() > 0) return;
|
|
367
|
+
if (!this.canEdit()) return;
|
|
368
|
+
if (!confirm("삭제하시겠습니까?")) return;
|
|
369
|
+
|
|
370
|
+
const id = this.dataId();
|
|
371
|
+
const employeeId = this._appAuth.authInfo()?.employeeId;
|
|
372
|
+
this.busyCount.update((v) => v + 1);
|
|
373
|
+
await this._sdToast.try(async () => {
|
|
374
|
+
await this._appOrm.connectAsync(async (db) => {
|
|
375
|
+
await db.role().where((c) => [expr.eq(c.id, id)]).update(() => ({ isDeleted: true }));
|
|
376
|
+
await db.role().insertDataLog({ action: "삭제", itemId: id, employeeId });
|
|
377
|
+
});
|
|
378
|
+
await this._appSharedData.emitAsync("역할", [id]);
|
|
379
|
+
this._sdToast.success("삭제되었습니다.");
|
|
380
|
+
this.submitted.emit(true);
|
|
381
|
+
});
|
|
382
|
+
this.busyCount.update((v) => v - 1);
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### 복구 (onRestore)
|
|
387
|
+
|
|
388
|
+
복구는 `confirm` 없이 진행하되, **활성 유니크 컬럼(명칭·코드)이 있으면 복구로 활성 중복이 생기지 않는지 재검증**해야 함(삭제된 동안 같은 값의 활성 레코드가 생겼을 수 있음). 검증 위치가 단건/벌크에서 다름. 활성 유니크 정책 자체는 [orm.md](./orm.md) 의 유니크 전략.
|
|
389
|
+
|
|
390
|
+
**단건 복구 (detail) — 선검증**: 복구 전에 `exists` 로 충돌을 막음.
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
// 복구 전 활성 유니크 재검증
|
|
394
|
+
const isNameDuplicated = await db
|
|
395
|
+
.role()
|
|
396
|
+
.where((c) => [
|
|
397
|
+
expr.eq(c.name, roleName),
|
|
398
|
+
expr.eq(c.isDeleted, false),
|
|
399
|
+
expr.not(expr.eq(c.id, roleId)), // 자기 자신 제외
|
|
400
|
+
])
|
|
401
|
+
.exists();
|
|
402
|
+
if (isNameDuplicated) throw new Error("같은 이름의 활성 역할이 있어 복구할 수 없습니다.");
|
|
403
|
+
|
|
404
|
+
await db.role().where((c) => [expr.eq(c.id, roleId)]).update(() => ({ isDeleted: false }));
|
|
405
|
+
await db.role().insertDataLog({ action: "복구", itemId: roleId, employeeId });
|
|
406
|
+
// → emitAsync → refresh (단건 복구는 닫지 않고 refresh)
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**벌크 복구 (list) — 후검증**: update 전엔 대상이 모두 삭제 상태라 대상끼리 충돌을 쿼리로 못 봄 → update **후** 한 쿼리로 활성 중복을 검사하고, 충돌 시 throw 해 트랜잭션 전체를 롤백.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
await db.role().where((c) => [expr.in(c.id, ids)]).update(() => ({ isDeleted: false }));
|
|
413
|
+
|
|
414
|
+
// 복구 후 활성 유니크 재검증 — 대상끼리·기존 활성과의 충돌을 복구한 이름으로 한정해 한 번에
|
|
415
|
+
const conflicts = await db
|
|
416
|
+
.role()
|
|
417
|
+
.where((c) => [expr.in(c.name, names), expr.eq(c.isDeleted, false)])
|
|
418
|
+
.groupBy((c) => [c.name])
|
|
419
|
+
.having(() => [expr.gt(expr.count(), 1)])
|
|
420
|
+
.select((c) => ({ name: c.name }))
|
|
421
|
+
.execute();
|
|
422
|
+
if (conflicts.length > 0) {
|
|
423
|
+
const conflictNames = conflicts.map((x) => `'${x.name}'`).join(", ");
|
|
424
|
+
throw new Error(`같은 이름(${conflictNames})의 활성 역할이 있어 복구할 수 없습니다.`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (const id of ids) {
|
|
428
|
+
await db.role().insertDataLog({ action: "복구", itemId: id, employeeId });
|
|
429
|
+
}
|
|
430
|
+
// → emitAsync → refresh
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### 지킬 것
|
|
434
|
+
|
|
435
|
+
- 삭제·복구·이력 적재는 한 `connectAsync` 트랜잭션 안에서 수행 — 데이터만 바뀌고 이력이 빠지거나 그 반대가 되지 않게 함.
|
|
436
|
+
- 벌크 복구는 하나라도 충돌하면 전체 롤백(원자성). 충돌분만 빼고 나머지를 복구하지 않음.
|
|
437
|
+
- 활성 유니크 검증은 복구 경로에서 빠뜨리지 않음 — 단건은 선검증, 벌크는 후검증. 활성 유니크가 없는 모델이면 생략 가능.
|
|
438
|
+
- 단건(detail)은 삭제 후 [client-component.md "detail 데이터 흐름"](./client-component.md) 의 계약대로 `submitted.emit(true)` 로 부모에 통지(modal 컨텍스트에선 모달 호스트가 그 위에 `close` 로 닫음 — `emit` 의 대체 아님). 복구 후엔 닫지 않고 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,9 +71,9 @@ 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
|
-
<sd-button [size]="'sm'" [theme]="'link-
|
|
76
|
+
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onExcelDownloadClick()">
|
|
90
77
|
<ng-icon [svg]="tablerDownload" /> 엑셀 다운로드
|
|
91
78
|
</sd-button>
|
|
92
79
|
</ng-template>
|
|
@@ -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) 를 따름. 데모도 같은 방식으로 등록.
|
|
@@ -165,6 +165,55 @@ sharedProducts = useSharedSignal("품목");
|
|
|
165
165
|
- 이 계약은 `sd-crud-list` 의 모달 선택 모드와 동일 ([client-crud.md](./client-crud.md) 참조). 즉 목록 화면 하나가 일반 페이지·선택 모달 양쪽으로 재사용됨.
|
|
166
166
|
- 선택 컨트롤이 띄울 때는 항상 `selectMode: "single"` 로 주입되므로, 목록은 단건 선택 모드로 동작함.
|
|
167
167
|
|
|
168
|
+
## 좌측 선택 목록 + 우측 상세(master-detail) 레이아웃을 구성하려면
|
|
169
|
+
|
|
170
|
+
`sd-shared-data-select-list` 를 좌측에 두어 마스터를 고르고, 선택된 항목의 상세를 우측에 임베드하는 2-pane 화면. 선택을 바꾸거나 화면을 떠날 때 우측 상세의 미저장 변경을 보호하려면 자식 상세의 변경 가드를 부모가 위임 호출하도록 연결.
|
|
171
|
+
|
|
172
|
+
```html
|
|
173
|
+
<div class="flex-row fill">
|
|
174
|
+
<sd-shared-data-select-list
|
|
175
|
+
class="flex-min"
|
|
176
|
+
[items]="sharedRoles.items()"
|
|
177
|
+
[(selectedItem)]="selectedRole"
|
|
178
|
+
[canChangeFn]="checkCanLeave"
|
|
179
|
+
[header]="'역할'"
|
|
180
|
+
[modal]="{ type: RoleList, title: '역할', inputs: {} }"
|
|
181
|
+
>
|
|
182
|
+
<ng-template [itemOf]="sharedRoles.items()" let-item="item">{{ item.name }}</ng-template>
|
|
183
|
+
</sd-shared-data-select-list>
|
|
184
|
+
|
|
185
|
+
@let _selectedRole = selectedRole();
|
|
186
|
+
@if (_selectedRole == null) {
|
|
187
|
+
<div class="flex-fill tx-theme-gray-default p-xxl" style="font-size: 48px; line-height: 1.5em">
|
|
188
|
+
<ng-icon [svg]="tablerArrowLeft" />
|
|
189
|
+
역할을 선택하세요.
|
|
190
|
+
</div>
|
|
191
|
+
} @else {
|
|
192
|
+
<app-role-permission-detail class="flex-fill" [roleId]="_selectedRole.id" />
|
|
193
|
+
}
|
|
194
|
+
</div>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
detail = viewChild(RolePermissionDetail);
|
|
199
|
+
|
|
200
|
+
// 자식 상세의 미저장 변경 가드를 부모가 위임 호출
|
|
201
|
+
protected readonly checkCanLeave = (): boolean => {
|
|
202
|
+
const detail = this.detail();
|
|
203
|
+
return detail == null || detail.checkIgnoreChanges();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
constructor() {
|
|
207
|
+
setupCanDeactivate(this.checkCanLeave); // 라우팅 이탈 보호
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- 자식 상세는 변경 가드를 `public` 메서드(`checkIgnoreChanges()`)로 노출해 부모가 호출. 이 화면에선 자식이 직접 `setupCanDeactivate` 를 두지 않고 부모가 가드를 소유.
|
|
212
|
+
- 두 이탈 경로를 모두 막음:
|
|
213
|
+
- `[canChangeFn]="checkCanLeave"` — 좌측에서 **다른 항목으로 전환**하기 전 확인.
|
|
214
|
+
- `setupCanDeactivate(checkCanLeave)` — **페이지(라우팅) 이탈** 전 확인.
|
|
215
|
+
- 선택 전(`selectedItem == null`)에는 미선택 빈 상태를 둠 — 아이콘 + 안내 문구 구조와 `NgIcon` 등록은 [client-component.md](./client-component.md) 의 'list + detail 합성' 빈 상태 규약을 따름.
|
|
216
|
+
|
|
168
217
|
## 지킬 것
|
|
169
218
|
|
|
170
219
|
- 항목 추가 시 세 곳(`register` · `TAppSharedData` · 인터페이스)을 모두 갱신. 하나라도 빠지면 타입 불일치 또는 미등록 데이터가 됨.
|
|
@@ -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
|
|
@@ -200,9 +200,41 @@ rows[0].firstDataLog?.action; // "등록"
|
|
|
200
200
|
.joinLastDataLog({ excludeActions: ["삭제"] })
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
+
## 목록 시트에 수정일시·수정자 컬럼을 두려면
|
|
204
|
+
|
|
205
|
+
`joinLastDataLog()` 로 부착한 `lastDataLog` 를 표시용 필드로 평탄화한 뒤 시트 컬럼으로 노출. 컬럼은 기본 숨김으로 두고 사용자가 컬럼 설정에서 켜 보게 함. 엑셀 다운로드에는 숨김과 무관하게 항상 포함.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
.joinLastDataLog()
|
|
209
|
+
.select((c) => ({
|
|
210
|
+
// ...표시 컬럼들...
|
|
211
|
+
lastModifiedAt: c.lastDataLog?.dateTime,
|
|
212
|
+
lastModifiedBy: c.lastDataLog?.employeeName,
|
|
213
|
+
}))
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```html
|
|
217
|
+
<sd-sheet-column [key]="'lastModifiedAt'" [header]="'수정일시'" [hidden]="true">
|
|
218
|
+
<ng-template [cell]="items()" let-item="item">
|
|
219
|
+
<div class="p-xs-sm">{{ item.lastModifiedAt | format: "yyyy-MM-dd HH:mm" }}</div>
|
|
220
|
+
</ng-template>
|
|
221
|
+
</sd-sheet-column>
|
|
222
|
+
|
|
223
|
+
<sd-sheet-column [key]="'lastModifiedBy'" [header]="'수정자'" [hidden]="true">
|
|
224
|
+
<ng-template [cell]="items()" let-item="item">
|
|
225
|
+
<div class="p-xs-sm">{{ item.lastModifiedBy ?? " " }}</div>
|
|
226
|
+
</ng-template>
|
|
227
|
+
</sd-sheet-column>
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- `[hidden]="true"` 로 기본 숨김 — 평소엔 안 보이고, 사용자가 시트 컬럼 설정에서 켜야 노출.
|
|
231
|
+
- 엑셀 다운로드 양식에는 항상 포함([client-crud.md](./client-crud.md) 의 엑셀 다운로드).
|
|
232
|
+
- 모델 단위 변경 로그(`itemId` 없음, 예: 초기화)는 개별 행에 조인되지 않아 그 행은 빈칸 — 정상.
|
|
233
|
+
|
|
203
234
|
## 지킬 것
|
|
204
235
|
|
|
205
236
|
- 모델 변경을 적재할 땐 변경을 수행한 그 모델의 queryable 에서 `insertDataLog` 호출. `db.dataLog().insert(...)` 로 `tableName` 을 손으로 적지 않음(자동 도출이 깨짐).
|
|
206
237
|
- "최종 수정자/일시"·"최초 등록자/일시" 는 대상 테이블에 별도 컬럼을 추가하지 말고 `joinLastDataLog`/`joinFirstDataLog` 조인으로 표시.
|
|
207
238
|
- 적재와 본 데이터 변경은 같은 트랜잭션 안에서 수행 — 이력만 남고 데이터가 롤백되거나 그 반대가 되지 않게 함.
|
|
208
239
|
- `action` 문자열은 프로젝트 단위로 고정된 집합을 쓰고, 조회 측 `includeActions`/`excludeActions` 와 철자를 일치시킴.
|
|
240
|
+
- 목록 시트의 `수정일시`·`수정자` 컬럼은 `[hidden]="true"` 기본(사용자가 켜서 봄)이고, 엑셀 다운로드에는 항상 포함.
|
|
@@ -103,3 +103,40 @@ WHERE 와 SELECT 양쪽에서 동일 도출 산식을 쓰겠다고 `buildDerived
|
|
|
103
103
|
|
|
104
104
|
- **기초정보(마스터)**: soft delete (`isDisabled` 등) 사용. FK 참조 무결성 보존.
|
|
105
105
|
- **프로세스 문서(트랜잭션)**: 물리 delete. 상세 행을 포함해 캐스케이드. 단, 다른 테이블이 FK 로 참조 중이면 삭제를 차단하고 최종 사용자에게 toast 등으로 사유 안내.
|
|
106
|
+
|
|
107
|
+
## 유니크 전략 (활성 유니크 vs 완전 유니크)
|
|
108
|
+
|
|
109
|
+
soft delete 하는 마스터에서 "중복 불가" 컬럼은 두 종류로 나뉨. 대부분 DB 유니크 제약 대신 앱 검증을 기본으로 함.
|
|
110
|
+
|
|
111
|
+
### 명칭·코드 = 활성(비삭제) 유니크
|
|
112
|
+
|
|
113
|
+
활성(`isDeleted=false`) 레코드끼리만 중복 불가. 삭제하면 그 값이 풀려 재사용 가능.
|
|
114
|
+
|
|
115
|
+
- **DB 유니크 제약(부분 유니크 인덱스 포함)을 두지 않음.** 등록·수정 시 앱에서 검증:
|
|
116
|
+
```ts
|
|
117
|
+
const isDuplicated = await db
|
|
118
|
+
.role()
|
|
119
|
+
.where((c) => [
|
|
120
|
+
expr.eq(c.name, data.name),
|
|
121
|
+
expr.eq(c.isDeleted, false),
|
|
122
|
+
...(roleId == null ? [] : [expr.not(expr.eq(c.id, roleId))]), // 수정 시 자기 자신 제외
|
|
123
|
+
])
|
|
124
|
+
.exists();
|
|
125
|
+
if (isDuplicated) throw new Error("이미 존재하는 역할 이름입니다.");
|
|
126
|
+
```
|
|
127
|
+
- **단, `(컬럼, isDeleted)` 복합 비유니크 인덱스는 둠** — 위 검증 쿼리(`컬럼=? AND isDeleted=false`)의 성능용. 유니크 제약이 아님.
|
|
128
|
+
- 채택 이유: 코드·명칭은 엑셀·외부 데이터 업로드 시 id 가 없어 **코드·명칭으로 기존 레코드에 매핑**됨. 완전 유니크로 두면 삭제값이 영구 점유돼 매핑이 깨짐 → 활성만 유니크로 둬 삭제값을 재사용 가능하게 함.
|
|
129
|
+
- 조회·매핑은 항상 `isDeleted=false`(활성) 대상으로.
|
|
130
|
+
|
|
131
|
+
### 자격증명(loginId 등) = 완전 유니크
|
|
132
|
+
|
|
133
|
+
전체 행(삭제 포함) 유니크. 영구 점유 — 퇴사자 ID 재사용 방지(보안).
|
|
134
|
+
|
|
135
|
+
- 업로드 매핑 대상이 아니라 삭제값을 재사용할 이유가 없음 → **DB 유니크 인덱스를 둠**.
|
|
136
|
+
- DB unique 위반은 raw 에러라 사용자 메시지로 못 씀 → 앱 선검증 + 메시지를 병행하고 DB 인덱스는 안전망으로 둠.
|
|
137
|
+
|
|
138
|
+
### 지킬 것
|
|
139
|
+
|
|
140
|
+
- 활성 유니크는 DB 유니크 제약으로 강제하지 말고 앱 검증(`where(컬럼, isDeleted=false, id≠self).exists()`)으로. DB 에는 검증 성능용 `(컬럼, isDeleted)` **비유니크** 인덱스만.
|
|
141
|
+
- 완전 유니크는 DB 유니크 인덱스 + 앱 선검증(메시지용) 병행.
|
|
142
|
+
- 활성 유니크 컬럼은 삭제 후 복구될 때 충돌이 생길 수 있음 → 복구 경로에서 반드시 재검증([client-crud.md](./client-crud.md) 의 삭제·복구 처리).
|
|
@@ -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
|
에이전트가 사용자에게 묻는 모든 행위 (결정·의견·정보 확인 등)에 적용. 사용자에게 묻고 답을 받아 [결정 근거](#결정-근거)로 확정.
|
|
@@ -296,6 +303,10 @@ X 함수에 캐시 도입 검토 중. 기존 의존성 확인 결과 lru-cache
|
|
|
296
303
|
- 본질 의도가 불명확하면 추측 금지. 사용자에게 질문할 것.
|
|
297
304
|
- 피드백을 받을 때마다 자문: "이게 1회 사례인가, 일반 규칙인가?" → 1회면 규칙으로 확정하지 말 것.
|
|
298
305
|
|
|
306
|
+
**확정 결과만 기재**:
|
|
307
|
+
|
|
308
|
+
- 문서 본문에는 확정된 결과값만 적음. 대화 중 교정으로 폐기된 값이나 그 부정형(`~아님`·`~는 예시일 뿐`)을 본문에 끌어오지 말 것 (상세: [어휘·태도](#어휘태도) 의 교정·치환 규칙).
|
|
309
|
+
|
|
299
310
|
**사전 차단 우선**:
|
|
300
311
|
|
|
301
312
|
- 지침은 "잘못된 행위를 막는다" 가 기본. "행위 후 점검으로 교정" 은 보조.
|