@simplysm/sd-claude 14.0.92 → 14.0.94
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 +4 -4
- package/claude/references/sd-simplysm14/manuals/client-component.md +1 -1
- package/claude/references/sd-simplysm14/manuals/client-crud.md +181 -0
- package/claude/references/sd-simplysm14/manuals/client-demo.md +1 -1
- package/claude/references/sd-simplysm14/manuals/client-shared-data.md +46 -0
- package/claude/references/sd-simplysm14/manuals/data-log.md +32 -0
- package/claude/references/sd-simplysm14/manuals/orm.md +37 -0
- package/claude/sd-system-prompt.md +4 -0
- package/package.json +1 -1
|
@@ -27,17 +27,17 @@ ORM 호출, 파일 변환, 비즈니스 로직 등은 위 두 경우에 해당
|
|
|
27
27
|
| ------------------------------------------------------- | ------------------------------------------------------ |
|
|
28
28
|
| 클라이언트 공통 lint/template 규칙 (예: `$any` 금지) | [client-rules.md](./manuals/client-rules.md) |
|
|
29
29
|
| 화면 컴포넌트(`<domain>.<역할>.ts`) 작성/수정 | [client-component.md](./manuals/client-component.md) |
|
|
30
|
-
| `sd-crud-list` / `sd-crud-detail` 채택한 목록·단건 화면 | [client-crud.md](./manuals/client-crud.md) |
|
|
30
|
+
| `sd-crud-list` / `sd-crud-detail` 채택한 목록·단건 화면 (편집 진입·삭제/복구·엑셀 다운로드·행 선택 제한) | [client-crud.md](./manuals/client-crud.md) |
|
|
31
31
|
| 클라이언트 데모 컴포넌트 작성 | [client-demo.md](./manuals/client-demo.md) |
|
|
32
32
|
| `<sd-tab>` 사용 | [client-tab.md](./manuals/client-tab.md) |
|
|
33
33
|
| 앱에서 서버 서비스·이벤트 호출 (provider 정의·항목 추가) | [client-service.md](./manuals/client-service.md) |
|
|
34
34
|
| 앱에서 ORM(DB) 사용 (AppOrmProvider 정의) | [client-orm.md](./manuals/client-orm.md) |
|
|
35
|
-
| 앱에서 공유 마스터 데이터 사용 (provider 정의·항목 추가, 선택 컨트롤의 관리·선택
|
|
35
|
+
| 앱에서 공유 마스터 데이터 사용 (provider 정의·항목 추가, 선택 컨트롤의 관리·선택 모달, 좌측 선택+우측 상세 레이아웃) | [client-shared-data.md](./manuals/client-shared-data.md) |
|
|
36
36
|
| 클라이언트·서버 간 실시간 이벤트 정의·발생·구독 | [event.md](./manuals/event.md) |
|
|
37
37
|
| 앱 메뉴 구조·권한 정의 추가/수정 | [client-app-structure.md](./manuals/client-app-structure.md) |
|
|
38
|
-
| ORM 쿼리 작성(조회 흐름·안티패턴), 컬럼 nullable/default 정책, 삭제 전략 | [orm.md](./manuals/orm.md) |
|
|
38
|
+
| ORM 쿼리 작성(조회 흐름·안티패턴), 컬럼 nullable/default·유니크 정책, 삭제 전략 | [orm.md](./manuals/orm.md) |
|
|
39
39
|
| 이종 엔티티를 한 목록으로 합쳐 표시 (UNION) | [orm-union.md](./manuals/orm-union.md) |
|
|
40
|
-
| CRUD 처리에 데이터 변경 이력
|
|
40
|
+
| CRUD 처리에 데이터 변경 이력 적재·조회·표시 (누가·언제·무엇을 변경, 목록의 수정일시·수정자 컬럼) | [data-log.md](./manuals/data-log.md) |
|
|
41
41
|
| 콘솔 로깅 코드 작성/수정 (모든 패키지) | [logging.md](./manuals/logging.md) |
|
|
42
42
|
| 클라이언트 시스템 에러·로그를 DB 등 외부에 적재·조회 | [client-system-log.md](./manuals/client-system-log.md) |
|
|
43
43
|
| 패키지 테스트·통합 테스트 작성/추가 | [test.md](./manuals/test.md) |
|
|
@@ -116,7 +116,7 @@ view 의 합성 패턴 (예: `outbound-instruction.view.ts`):
|
|
|
116
116
|
- view 는 list 컴포넌트를 템플릿 변수(`#headerSheet`) 로 잡아 `selectedKeys()` 를 읽고 `doRefresh()` 를 호출.
|
|
117
117
|
- detail 의 단건 변경·삭제는 list 가 표시하는 동일 데이터에 반영해야 하므로, detail 의 `submitted` → list 의 `doRefresh()` 호출로 동기화.
|
|
118
118
|
- view 는 `sd-base-container` 를 루트로 두고, 내부 콘텐츠는 `#contentTpl` 슬롯에 배치.
|
|
119
|
-
- 미선택 빈
|
|
119
|
+
- 미선택 빈 상태는 위 예시의 구조(아이콘 + 안내 문구 `div`)를 그대로 사용하되, 안내 문구는 무엇을 선택하는지 드러내는 맥락 문구로 작성(예: `역할을 선택하세요.`). `tablerArrowLeft` 아이콘을 쓰므로 화면 컴포넌트에 `NgIcon` 등록 필요 ([아이콘](#아이콘) 참조).
|
|
120
120
|
|
|
121
121
|
### list + list 합성 (마스터-라인)
|
|
122
122
|
|
|
@@ -73,6 +73,100 @@
|
|
|
73
73
|
|
|
74
74
|
`selectMode` 는 `readonly` 와 독립 — selectMode 지정만으로는 편집이 막히지 않음. 등록·인라인 편집은 그대로 유지되고, `single` 일 때 "선택 삭제/복구" 버튼만 숨김. 읽기 전용이 필요하면 `readonly=true` 를 별도로 전달. `sd-shared-data-select-list` 가 모달을 띄울 때도 `selectMode="single"` 만 주입하므로 모달 내용은 편집 가능 상태로 유지됨.
|
|
75
75
|
|
|
76
|
+
### 행을 클릭해 상세 편집으로 진입하려면 (inlineEdit=false)
|
|
77
|
+
|
|
78
|
+
`inlineEdit=false` 목록의 편집 진입은 첫 컬럼(`#`)을 권한 분기된 진입점으로 만들어 처리. 편집 가능하면 편집 아이콘이 붙은 앵커를 눌러 상세 모달을 열고, 불가하면 값만 표시.
|
|
79
|
+
|
|
80
|
+
```html
|
|
81
|
+
<sd-sheet-column [key]="'id'" [header]="'#'">
|
|
82
|
+
<ng-template [cell]="items()" let-item="item">
|
|
83
|
+
@if (canEdit()) {
|
|
84
|
+
<sd-anchor class="flex-row gap-sm p-xs-sm" (click)="onEdit(item, $event)">
|
|
85
|
+
<div><ng-icon [svg]="tablerEdit" /></div>
|
|
86
|
+
<div class="flex-fill tx-right">{{ item.id }}</div>
|
|
87
|
+
</sd-anchor>
|
|
88
|
+
} @else {
|
|
89
|
+
<div class="p-xs-sm tx-right">{{ item.id }}</div>
|
|
90
|
+
}
|
|
91
|
+
</ng-template>
|
|
92
|
+
</sd-sheet-column>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
async onEdit(item: IItem, event: Event): Promise<void> {
|
|
97
|
+
event.preventDefault(); // 앵커 기본 동작 차단
|
|
98
|
+
event.stopPropagation(); // 행 선택과 분리
|
|
99
|
+
await this._openDetail(item.id); // 상세 모달 열고, 닫힘 결과 있으면 refresh
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- `canEdit()` 가 false 면 앵커 대신 텍스트만 — 권한 없는 사용자에게 편집 진입을 노출하지 않음.
|
|
104
|
+
- 앵커 레이아웃(`flex-row`·아이콘/값 정렬)은 화면 사정에 맞춤. 정규는 "`#` 컬럼 = 권한 분기된 편집 진입점" 까지.
|
|
105
|
+
|
|
106
|
+
### 현재 검색 결과를 엑셀로 내려받게 하려면
|
|
107
|
+
|
|
108
|
+
`#toolTpl` 에 다운로드 버튼을 두고, 페이징을 무시한 **현재 검색·필터 결과 전체**를 받아 엑셀로 변환. `ExcelWrapper`(zod 스키마) + `downloadBlob` 을 화면에 직접 둠.
|
|
109
|
+
|
|
110
|
+
```html
|
|
111
|
+
<ng-template #toolTpl>
|
|
112
|
+
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
|
|
113
|
+
<ng-icon [svg]="tablerFileExcel" />
|
|
114
|
+
엑셀 다운로드
|
|
115
|
+
</sd-button>
|
|
116
|
+
</ng-template>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
private readonly _excelWrapper = new ExcelWrapper(
|
|
121
|
+
z.object({
|
|
122
|
+
id: z.number().optional().describe("ID"),
|
|
123
|
+
name: z.string().describe("이름"),
|
|
124
|
+
isDeleted: z.boolean().describe("삭제"),
|
|
125
|
+
lastModifiedAt: z.custom<DateTime>().optional().describe("수정일시"),
|
|
126
|
+
lastModifiedBy: z.string().optional().describe("수정자"),
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
async onDownloadExcelButtonClick(): Promise<void> {
|
|
131
|
+
if (this.busyCount() > 0) return;
|
|
132
|
+
this.busyCount.update((v) => v + 1);
|
|
133
|
+
await this._sdToast.try(async () => {
|
|
134
|
+
const r = await this._search(false); // 페이징 무시 — 검색·필터 결과 전체
|
|
135
|
+
const wb = await this._excelWrapper.write(this.viewTitle(), r.items);
|
|
136
|
+
try {
|
|
137
|
+
downloadBlob(
|
|
138
|
+
await wb.toBlob(),
|
|
139
|
+
`${this.viewTitle()}_${new DateTime().toFormatString("yyMMdd")}.xlsx`,
|
|
140
|
+
);
|
|
141
|
+
} finally {
|
|
142
|
+
await wb.close(); // 워크북 자원 해제
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
this.busyCount.update((v) => v - 1);
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- 조회는 목록과 같은 `_search` 를 페이징 인자만 꺼서 재사용 — 보이는 페이지가 아니라 결과 전체를 받음.
|
|
150
|
+
- 파일명은 `<화면제목>_<yyMMdd>.xlsx`. 화면 제목은 `injectViewTitleSignal()`.
|
|
151
|
+
- 양식 컬럼 = 화면 표시 컬럼 + `삭제`(참/거짓) + `수정일시`·`수정자`([data-log.md](./data-log.md) 의 표시 규약). 참조 마스터는 명칭으로 출력.
|
|
152
|
+
- 비밀번호 등 평문으로 못 꺼내는 값은 양식에서 제외.
|
|
153
|
+
- `ExcelWrapper`/`downloadBlob` 자체 사용법은 [apis/excel/README.md](../apis/excel/README.md) · [apis/core-browser/README.md](../apis/core-browser/README.md).
|
|
154
|
+
|
|
155
|
+
### 특정 행의 선택·삭제를 막으려면
|
|
156
|
+
|
|
157
|
+
`[getItemSelectableFn]` 로 행별 선택 가능 여부를 반환. 문자열을 반환하면 그 사유가 안내되고 해당 행은 선택(→삭제) 불가. 개별 선택·전체 선택 모든 경로에 적용됨.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// 로그인한 본인 계정은 선택(→삭제) 불가
|
|
161
|
+
getItemSelectableFn = (item: IItem): boolean | string =>
|
|
162
|
+
item.id === this._appAuth.authInfo()?.employeeId
|
|
163
|
+
? "본인 계정은 삭제할 수 없습니다."
|
|
164
|
+
: true;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `true` = 선택 가능, `string` = 선택 불가 + 사유. 선택 자체가 막히므로 핸들러에 같은 가드를 또 두지 않아도 됨.
|
|
168
|
+
- 단건 상세에는 선택 개념이 없으므로, 같은 제약을 삭제 버튼을 조건부로 숨겨 적용(`@if (!isSelf() && perms().includes("edit")) { ...삭제 버튼... }`).
|
|
169
|
+
|
|
76
170
|
## `sd-crud-detail`
|
|
77
171
|
|
|
78
172
|
단일 레코드 편집 화면의 표준 골격. 다음 기능을 일괄 제공: 폼 래핑, CTRL+S 단축키 저장, 저장 버튼, 모달의 "확인" 버튼 자동 처리.
|
|
@@ -108,3 +202,90 @@
|
|
|
108
202
|
- **`'page'`** — 라우팅 진입 단위. 상단에 저장 버튼.
|
|
109
203
|
- **`'control'`** — view 안에 임베드. 명령 영역에 저장 버튼.
|
|
110
204
|
- **`'modal'`** — 모달. 하단 우측에 "확인" 버튼이 자동으로 추가.
|
|
205
|
+
|
|
206
|
+
## 삭제·복구를 처리하려면 (목록·단건 공통)
|
|
207
|
+
|
|
208
|
+
삭제는 soft delete, 복구는 그 반대. 두 처리 모두 변경 이력 적재([data-log.md](./data-log.md))와 공유 데이터 통지([client-shared-data.md](./client-shared-data.md))를 같은 트랜잭션·동작 안에서 함께 수행.
|
|
209
|
+
|
|
210
|
+
### 삭제 (onDelete)
|
|
211
|
+
|
|
212
|
+
`confirm` → soft delete(`isDeleted=true`) → 이력 적재 → 공유 데이터 통지 → 목록은 refresh / 단건은 close.
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
async onDelete(targets: IItem[]): Promise<void> {
|
|
216
|
+
if (this.busyCount() > 0) return;
|
|
217
|
+
if (!this.canEdit()) return;
|
|
218
|
+
if (targets.length === 0) return;
|
|
219
|
+
if (!confirm("삭제하시겠습니까?")) return;
|
|
220
|
+
|
|
221
|
+
this.busyCount.update((v) => v + 1);
|
|
222
|
+
await this._sdToast.try(async () => {
|
|
223
|
+
const ids = targets.map((t) => t.id);
|
|
224
|
+
const employeeId = this._appAuth.authInfo()?.employeeId;
|
|
225
|
+
await this._appOrm.connectAsync(async (db) => {
|
|
226
|
+
await db.role().where((c) => [expr.in(c.id, ids)]).update(() => ({ isDeleted: true }));
|
|
227
|
+
for (const id of ids) {
|
|
228
|
+
await db.role().insertDataLog({ action: "삭제", itemId: id, employeeId });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
await this._appSharedData.emitAsync("역할", ids);
|
|
232
|
+
this._sdToast.success("삭제되었습니다.");
|
|
233
|
+
await this._refresh();
|
|
234
|
+
});
|
|
235
|
+
this.busyCount.update((v) => v - 1);
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### 복구 (onRestore)
|
|
240
|
+
|
|
241
|
+
복구는 `confirm` 없이 진행하되, **활성 유니크 컬럼(명칭·코드)이 있으면 복구로 활성 중복이 생기지 않는지 재검증**해야 함(삭제된 동안 같은 값의 활성 레코드가 생겼을 수 있음). 검증 위치가 단건/벌크에서 다름. 활성 유니크 정책 자체는 [orm.md](./orm.md) 의 유니크 전략.
|
|
242
|
+
|
|
243
|
+
**단건 복구 (detail) — 선검증**: 복구 전에 `exists` 로 충돌을 막음.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
// 복구 전 활성 유니크 재검증
|
|
247
|
+
const isNameDuplicated = await db
|
|
248
|
+
.role()
|
|
249
|
+
.where((c) => [
|
|
250
|
+
expr.eq(c.name, roleName),
|
|
251
|
+
expr.eq(c.isDeleted, false),
|
|
252
|
+
expr.not(expr.eq(c.id, roleId)), // 자기 자신 제외
|
|
253
|
+
])
|
|
254
|
+
.exists();
|
|
255
|
+
if (isNameDuplicated) throw new Error("같은 이름의 활성 역할이 있어 복구할 수 없습니다.");
|
|
256
|
+
|
|
257
|
+
await db.role().where((c) => [expr.eq(c.id, roleId)]).update(() => ({ isDeleted: false }));
|
|
258
|
+
await db.role().insertDataLog({ action: "복구", itemId: roleId, employeeId });
|
|
259
|
+
// → emitAsync → refresh (단건 복구는 닫지 않고 refresh)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
**벌크 복구 (list) — 후검증**: update 전엔 대상이 모두 삭제 상태라 대상끼리 충돌을 쿼리로 못 봄 → update **후** 한 쿼리로 활성 중복을 검사하고, 충돌 시 throw 해 트랜잭션 전체를 롤백.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
await db.role().where((c) => [expr.in(c.id, ids)]).update(() => ({ isDeleted: false }));
|
|
266
|
+
|
|
267
|
+
// 복구 후 활성 유니크 재검증 — 대상끼리·기존 활성과의 충돌을 복구한 이름으로 한정해 한 번에
|
|
268
|
+
const conflicts = await db
|
|
269
|
+
.role()
|
|
270
|
+
.where((c) => [expr.in(c.name, names), expr.eq(c.isDeleted, false)])
|
|
271
|
+
.groupBy((c) => [c.name])
|
|
272
|
+
.having(() => [expr.gt(expr.count(), 1)])
|
|
273
|
+
.select((c) => ({ name: c.name }))
|
|
274
|
+
.execute();
|
|
275
|
+
if (conflicts.length > 0) {
|
|
276
|
+
const conflictNames = conflicts.map((x) => `'${x.name}'`).join(", ");
|
|
277
|
+
throw new Error(`같은 이름(${conflictNames})의 활성 역할이 있어 복구할 수 없습니다.`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const id of ids) {
|
|
281
|
+
await db.role().insertDataLog({ action: "복구", itemId: id, employeeId });
|
|
282
|
+
}
|
|
283
|
+
// → emitAsync → refresh
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### 지킬 것
|
|
287
|
+
|
|
288
|
+
- 삭제·복구·이력 적재는 한 `connectAsync` 트랜잭션 안에서 수행 — 데이터만 바뀌고 이력이 빠지거나 그 반대가 되지 않게 함.
|
|
289
|
+
- 벌크 복구는 하나라도 충돌하면 전체 롤백(원자성). 충돌분만 빼고 나머지를 복구하지 않음.
|
|
290
|
+
- 활성 유니크 검증은 복구 경로에서 빠뜨리지 않음 — 단건은 선검증, 벌크는 후검증. 활성 유니크가 없는 모델이면 생략 가능.
|
|
291
|
+
- 단건은 삭제 후 닫고(close), 복구 후엔 닫지 않고 refresh — 복구 직후 상세를 계속 보도록.
|
|
@@ -86,7 +86,7 @@ if (!result) return;
|
|
|
86
86
|
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onExcelUploadClick()">
|
|
87
87
|
<ng-icon [svg]="tablerFileExcel" /> 엑셀 업로드
|
|
88
88
|
</sd-button>
|
|
89
|
-
<sd-button [size]="'sm'" [theme]="'link-
|
|
89
|
+
<sd-button [size]="'sm'" [theme]="'link-success'" (click)="onExcelDownloadClick()">
|
|
90
90
|
<ng-icon [svg]="tablerDownload" /> 엑셀 다운로드
|
|
91
91
|
</sd-button>
|
|
92
92
|
</ng-template>
|
|
@@ -165,6 +165,52 @@ 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 p-xxl">역할을 선택하세요.</div>
|
|
188
|
+
} @else {
|
|
189
|
+
<app-role-permission-detail class="flex-fill" [roleId]="_selectedRole.id" />
|
|
190
|
+
}
|
|
191
|
+
</div>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
detail = viewChild(RolePermissionDetail);
|
|
196
|
+
|
|
197
|
+
// 자식 상세의 미저장 변경 가드를 부모가 위임 호출
|
|
198
|
+
protected readonly checkCanLeave = (): boolean => {
|
|
199
|
+
const detail = this.detail();
|
|
200
|
+
return detail == null || detail.checkIgnoreChanges();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
constructor() {
|
|
204
|
+
setupCanDeactivate(this.checkCanLeave); // 라우팅 이탈 보호
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- 자식 상세는 변경 가드를 `public` 메서드(`checkIgnoreChanges()`)로 노출해 부모가 호출. 이 화면에선 자식이 직접 `setupCanDeactivate` 를 두지 않고 부모가 가드를 소유.
|
|
209
|
+
- 두 이탈 경로를 모두 막음:
|
|
210
|
+
- `[canChangeFn]="checkCanLeave"` — 좌측에서 **다른 항목으로 전환**하기 전 확인.
|
|
211
|
+
- `setupCanDeactivate(checkCanLeave)` — **페이지(라우팅) 이탈** 전 확인.
|
|
212
|
+
- 선택 전(`selectedItem == null`)에는 안내 문구를 두고 상세를 띄우지 않음.
|
|
213
|
+
|
|
168
214
|
## 지킬 것
|
|
169
215
|
|
|
170
216
|
- 항목 추가 시 세 곳(`register` · `TAppSharedData` · 인터페이스)을 모두 갱신. 하나라도 빠지면 타입 불일치 또는 미등록 데이터가 됨.
|
|
@@ -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) 의 삭제·복구 처리).
|
|
@@ -296,6 +296,10 @@ X 함수에 캐시 도입 검토 중. 기존 의존성 확인 결과 lru-cache
|
|
|
296
296
|
- 본질 의도가 불명확하면 추측 금지. 사용자에게 질문할 것.
|
|
297
297
|
- 피드백을 받을 때마다 자문: "이게 1회 사례인가, 일반 규칙인가?" → 1회면 규칙으로 확정하지 말 것.
|
|
298
298
|
|
|
299
|
+
**확정 결과만 기재**:
|
|
300
|
+
|
|
301
|
+
- 문서 본문에는 확정된 결과값만 적음. 대화 중 교정으로 폐기된 값이나 그 부정형(`~아님`·`~는 예시일 뿐`)을 본문에 끌어오지 말 것 (상세: [어휘·태도](#어휘태도) 의 교정·치환 규칙).
|
|
302
|
+
|
|
299
303
|
**사전 차단 우선**:
|
|
300
304
|
|
|
301
305
|
- 지침은 "잘못된 행위를 막는다" 가 기본. "행위 후 점검으로 교정" 은 보조.
|