@simplysm/angular 14.0.10 → 14.0.11

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.
@@ -145,14 +145,17 @@ export abstract class AbsSdDataDetail<T extends object, R = boolean>
145
145
  if (!this.toggleDelete) return;
146
146
 
147
147
  this.busyCount.update((v) => v + 1);
148
- await this._sdToast.try(async () => {
149
- const result = await this.toggleDelete!(del);
150
- if (!result) return;
148
+ await this._sdToast.try(
149
+ async () => {
150
+ const result = await this.toggleDelete!(del);
151
+ if (!result) return;
151
152
 
152
- this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
153
+ this._sdToast.success(`${del ? "삭제" : "복구"}되었습니다.`);
153
154
 
154
- this.close.emit(result);
155
- });
155
+ this.close.emit(result);
156
+ },
157
+ (err) => this._getOrmDataEditToastErrorMessage(err),
158
+ );
156
159
  this.busyCount.update((v) => v - 1);
157
160
  }
158
161
 
@@ -172,18 +175,32 @@ export abstract class AbsSdDataDetail<T extends object, R = boolean>
172
175
  }
173
176
 
174
177
  this.busyCount.update((v) => v + 1);
175
- await this._sdToast.try(async () => {
176
- const result = await this.submit!(this.data());
177
- if (!result) return;
178
+ await this._sdToast.try(
179
+ async () => {
180
+ const result = await this.submit!(this.data());
181
+ if (!result) return;
178
182
 
179
- this._sdToast.success("저장되었습니다.");
183
+ this._sdToast.success("저장되었습니다.");
180
184
 
181
- this.close.emit(result);
185
+ this.close.emit(result);
182
186
 
183
- await this.refresh();
184
- });
187
+ await this.refresh();
188
+ },
189
+ (err) => this._getOrmDataEditToastErrorMessage(err),
190
+ );
185
191
  this.busyCount.update((v) => v - 1);
186
192
  }
193
+
194
+ private _getOrmDataEditToastErrorMessage(err: unknown) {
195
+ const message = err instanceof Error ? err.message : String(err);
196
+ if (
197
+ message.includes("a parent row: a foreign key constraint") ||
198
+ message.includes("conflicted with the REFERENCE")
199
+ ) {
200
+ return "경고! 연결된 작업에 의한 처리 거부. 후속작업 확인 요망";
201
+ }
202
+ return message;
203
+ }
187
204
  }
188
205
 
189
206
  //#endregion
@@ -219,13 +236,13 @@ export abstract class AbsSdDataDetail<T extends object, R = boolean>
219
236
  <ng-template #pageTopbarTpl>
220
237
  @if (parent.canEdit() && parent.submit) {
221
238
  <sd-button [theme]="'link-primary'" (click)="onSubmitButtonClick()">
222
- <ng-icon [svg]="tablerDeviceFloppy" />
239
+ <ng-icon [svg]="icons.tablerDeviceFloppy" />
223
240
  저장
224
241
  <small>(CTRL+S)</small>
225
242
  </sd-button>
226
243
  }
227
244
  <sd-button [theme]="'link-info'" (click)="onRefreshButtonClick()">
228
- <ng-icon [svg]="tablerRefresh" />
245
+ <ng-icon [svg]="icons.tablerRefresh" />
229
246
  새로고침
230
247
  <small>(CTRL+ALT+L)</small>
231
248
  </sd-button>
@@ -238,12 +255,12 @@ export abstract class AbsSdDataDetail<T extends object, R = boolean>
238
255
  @if (parent.viewType() === "control" && parent.canEdit()) {
239
256
  @if (parent.submit) {
240
257
  <sd-button [theme]="'primary'" (click)="onSubmitButtonClick()">
241
- <ng-icon [svg]="tablerDeviceFloppy" />
258
+ <ng-icon [svg]="icons.tablerDeviceFloppy" />
242
259
  저장
243
260
  <small>(CTRL+S)</small>
244
261
  </sd-button>
245
262
  <sd-button [theme]="'info'" (click)="onRefreshButtonClick()">
246
- <ng-icon [svg]="tablerRefresh" />
263
+ <ng-icon [svg]="icons.tablerRefresh" />
247
264
  새로고침
248
265
  <small>(CTRL+ALT+L)</small>
249
266
  </sd-button>
@@ -255,12 +272,12 @@ export abstract class AbsSdDataDetail<T extends object, R = boolean>
255
272
  ) {
256
273
  @if (parent.dataInfo()?.isDeleted) {
257
274
  <sd-button [theme]="'warning'" (click)="onRestoreButtonClick()">
258
- <ng-icon [svg]="tablerRestore" />
275
+ <ng-icon [svg]="icons.tablerRestore" />
259
276
  복구
260
277
  </sd-button>
261
278
  } @else {
262
279
  <sd-button [theme]="'danger'" (click)="onDeleteButtonClick()">
263
- <ng-icon [svg]="tablerEraser" />
280
+ <ng-icon [svg]="icons.tablerEraser" />
264
281
  삭제
265
282
  </sd-button>
266
283
  }
@@ -278,7 +295,7 @@ export abstract class AbsSdDataDetail<T extends object, R = boolean>
278
295
  }
279
296
 
280
297
  <div class="flex-fill">
281
- <sd-form #formCtrl (submit)="onSubmit()">
298
+ <sd-form #formCtrl (formSubmit)="onSubmit()">
282
299
  <ng-template [ngTemplateOutlet]="contentTplRef()" />
283
300
  </sd-form>
284
301
  </div>
@@ -332,18 +349,18 @@ export abstract class AbsSdDataDetail<T extends object, R = boolean>
332
349
  </div>
333
350
  </div>
334
351
  </ng-template>
335
-
336
- <ng-template #modalActionTpl>
337
- <sd-anchor
338
- [theme]="'gray'"
339
- class="p-sm-default"
340
- (click)="onRefreshButtonClick()"
341
- title="새로고침(CTRL+ALT+L)"
342
- >
343
- <ng-icon [svg]="tablerRefresh" />
344
- </sd-anchor>
345
- </ng-template>
346
352
  }
353
+
354
+ <ng-template #modalActionTpl>
355
+ <sd-anchor
356
+ [theme]="'gray'"
357
+ class="p-sm-default"
358
+ (click)="onRefreshButtonClick()"
359
+ title="새로고침(CTRL+ALT+L)"
360
+ >
361
+ <ng-icon [svg]="icons.tablerRefresh" />
362
+ </sd-anchor>
363
+ </ng-template>
347
364
  </sd-base-container>
348
365
  `,
349
366
  })
@@ -385,10 +402,12 @@ export class SdDataDetailControl {
385
402
  await this.parent.doSubmit({ permCheck: true });
386
403
  }
387
404
 
388
- protected readonly tablerDeviceFloppy = tablerDeviceFloppy;
389
- protected readonly tablerRefresh = tablerRefresh;
390
- protected readonly tablerRestore = tablerRestore;
391
- protected readonly tablerEraser = tablerEraser;
405
+ protected readonly icons = {
406
+ tablerDeviceFloppy,
407
+ tablerRefresh,
408
+ tablerRestore,
409
+ tablerEraser,
410
+ };
392
411
  }
393
412
 
394
413
  //#endregion
@@ -256,12 +256,11 @@ export abstract class AbsSdDataSheet<
256
256
  this.pageLength.set(result.pageLength ?? 0);
257
257
  this.summaryData.set(result.summary ?? {});
258
258
 
259
+ const selectedKeySet = new Set(
260
+ this.selectedItems().map((sel) => this.getItemInfoFn(sel).key),
261
+ );
259
262
  this.selectedItems.set(
260
- this.items().filter((item) =>
261
- this.selectedItems().some(
262
- (sel) => this.getItemInfoFn(sel).key === this.getItemInfoFn(item).key,
263
- ),
264
- ),
263
+ this.items().filter((item) => selectedKeySet.has(this.getItemInfoFn(item).key)),
265
264
  );
266
265
  }
267
266
 
@@ -300,12 +299,11 @@ export abstract class AbsSdDataSheet<
300
299
 
301
300
  this._sdToast.success("저장되었습니다.");
302
301
  await this.refresh();
302
+ this.submitted.emit(true);
303
303
  },
304
304
  (err) => this._getOrmDataEditToastErrorMessage(err),
305
305
  );
306
306
  this.busyCount.update((v) => v - 1);
307
-
308
- this.submitted.emit(true);
309
307
  }
310
308
 
311
309
  doToggleDeleteItem(item: TItem) {
@@ -758,6 +756,17 @@ export abstract class AbsSdDataSheet<
758
756
  }
759
757
  </div>
760
758
  </ng-template>
759
+
760
+ <ng-template #modalActionTpl>
761
+ <sd-anchor
762
+ [theme]="'gray'"
763
+ class="p-sm-default"
764
+ (click)="onRefreshButtonClick()"
765
+ title="새로고침(CTRL+ALT+L)"
766
+ >
767
+ <ng-icon [svg]="icons.tablerRefresh" />
768
+ </sd-anchor>
769
+ </ng-template>
761
770
  }
762
771
  </sd-base-container>
763
772
  `,
@@ -782,6 +791,14 @@ export class SdDataSheetControl {
782
791
 
783
792
  columnControls = contentChildren(SdDataSheetColumnDirective);
784
793
 
794
+ modalActionTplRef = viewChild("modalActionTpl", { read: TemplateRef });
795
+
796
+ constructor() {
797
+ effect(() => {
798
+ this.parent.actionTplRef = this.modalActionTplRef();
799
+ });
800
+ }
801
+
785
802
  protected readonly icons = {
786
803
  tablerRefresh,
787
804
  tablerDeviceFloppy,
@@ -0,0 +1,385 @@
1
+ import { NgTemplateOutlet } from "@angular/common";
2
+ import {
3
+ booleanAttribute,
4
+ ChangeDetectionStrategy,
5
+ Component,
6
+ computed,
7
+ input,
8
+ model,
9
+ signal,
10
+ ViewEncapsulation,
11
+ } from "@angular/core";
12
+ import { obj } from "@simplysm/core-common";
13
+ import type { ISdPermission } from "../../core/providers/sd-app-structure.provider";
14
+ import { SdCheckboxControl } from "../../ui/form/checkbox/sd-checkbox.control";
15
+ import { SdCollapseIconControl } from "../../ui/navigation/collapse/sd-collapse-icon.control";
16
+ import { SdTypedTemplateDirective } from "../../core/directives/sd-typed-template.directive";
17
+ import { SdAnchorControl } from "../../ui/form/button/sd-anchor.control";
18
+
19
+ @Component({
20
+ selector: "sd-permission-table",
21
+ changeDetection: ChangeDetectionStrategy.OnPush,
22
+ encapsulation: ViewEncapsulation.None,
23
+ standalone: true,
24
+ imports: [
25
+ SdTypedTemplateDirective,
26
+ NgTemplateOutlet,
27
+ SdCollapseIconControl,
28
+ SdCheckboxControl,
29
+ SdAnchorControl,
30
+ ],
31
+ styles: [
32
+ /* language=SCSS */ `
33
+ sd-permission-table {
34
+ table {
35
+ border-collapse: collapse;
36
+
37
+ > * > tr {
38
+ > * {
39
+ padding: var(--gap-sm) var(--gap-lg);
40
+ position: sticky;
41
+ top: 0;
42
+ border-top: 1px solid transparent;
43
+ border-bottom: 1px solid transparent;
44
+
45
+ color: var(--text-trans-default);
46
+
47
+ > * {
48
+ color: var(--text-trans-default) !important;
49
+ }
50
+
51
+ &._title {
52
+ border-top-left-radius: 14px;
53
+ border-bottom-left-radius: 14px;
54
+ padding-left: var(--gap-lg);
55
+ }
56
+ }
57
+
58
+ &[data-sd-collapse="true"] {
59
+ display: none;
60
+ }
61
+
62
+ &[data-sd-theme="first"] {
63
+ > * {
64
+ &._title,
65
+ &._after {
66
+ background: var(--theme-info-default);
67
+
68
+ color: var(--text-trans-rev-default);
69
+
70
+ > * {
71
+ color: var(--text-trans-rev-default) !important;
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ &[data-sd-theme="info"] {
78
+ > * {
79
+ &._title,
80
+ &._after {
81
+ background: var(--theme-info-lightest);
82
+ }
83
+ }
84
+ }
85
+
86
+ &[data-sd-theme="warning"] {
87
+ > * {
88
+ &._title,
89
+ &._after {
90
+ background: var(--theme-warning-lightest);
91
+ }
92
+ }
93
+ }
94
+
95
+ &[data-sd-theme="success"] {
96
+ > * {
97
+ &._title,
98
+ &._after {
99
+ background: var(--theme-success-lightest);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ `,
107
+ ],
108
+ template: `
109
+ <table>
110
+ <tbody>
111
+ @for (item of items(); track item.codeChain.join(".")) {
112
+ <ng-template
113
+ [ngTemplateOutlet]="itemTpl"
114
+ [ngTemplateOutletContext]="{
115
+ item: item,
116
+ parentKey: 'root',
117
+ depth: 0,
118
+ parent: undefined,
119
+ }"
120
+ ></ng-template>
121
+ }
122
+ </tbody>
123
+ </table>
124
+
125
+ <ng-template
126
+ #itemTpl
127
+ [typed]="itemTemplateType"
128
+ let-item="item"
129
+ let-parentKey="parentKey"
130
+ let-depth="depth"
131
+ let-parent="parent"
132
+ >
133
+ @if (
134
+ (item.children && item.children.length !== 0) || (item.perms && item.perms.length > 0)
135
+ ) {
136
+ <tr
137
+ [attr.data-sd-collapse]="!!parent && getIsPermCollapsed(parent)"
138
+ [attr.data-sd-theme]="
139
+ depth === 0
140
+ ? 'first'
141
+ : depth % 3 === 0
142
+ ? 'success'
143
+ : depth % 3 === 1
144
+ ? 'info'
145
+ : 'warning'
146
+ "
147
+ >
148
+ @for (i of arr(depth + 1); track i) {
149
+ <td class="_before">&nbsp;</td>
150
+ }
151
+
152
+ <td class="_title">
153
+ @if (item.children && item.children.length > 0) {
154
+ <sd-anchor (click)="onPermCollapseToggle(item)">
155
+ <sd-collapse-icon [open]="getIsPermCollapsed(item)" />
156
+ {{ item.title }}
157
+ </sd-anchor>
158
+ } @else {
159
+ <div style="padding-left: 14px;">
160
+ {{ item.title }}
161
+ </div>
162
+ }
163
+ </td>
164
+
165
+ @for (i of arr(depthLength() - (depth + 1)); track i) {
166
+ <td class="_after">&nbsp;</td>
167
+ }
168
+
169
+ <td class="_after">
170
+ @if (getIsPermExists(item, "use")) {
171
+ <sd-checkbox
172
+ [inline]="true"
173
+ [value]="getIsPermChecked(item, 'use')"
174
+ (valueChange)="onPermCheckChange(item, 'use', $event)"
175
+ [disabled]="disabled()"
176
+ >
177
+ 사용
178
+ </sd-checkbox>
179
+ }
180
+ </td>
181
+
182
+ <td class="_after">
183
+ @if (getIsPermExists(item, "edit")) {
184
+ <sd-checkbox
185
+ [inline]="true"
186
+ [value]="getIsPermChecked(item, 'edit')"
187
+ (valueChange)="onPermCheckChange(item, 'edit', $event)"
188
+ [disabled]="getEditDisabled(item)"
189
+ >
190
+ 편집
191
+ </sd-checkbox>
192
+ }
193
+ </td>
194
+ </tr>
195
+ }
196
+ @if (item.children && item.children.length > 0) {
197
+ @for (child of item.children; track child.codeChain.join(".")) {
198
+ <ng-template
199
+ [ngTemplateOutlet]="itemTpl"
200
+ [ngTemplateOutletContext]="{
201
+ item: child,
202
+ parentKey: parentKey + '_' + item.codeChain.join('.'),
203
+ depth: depth + 1,
204
+ parent: item,
205
+ }"
206
+ ></ng-template>
207
+ }
208
+ }
209
+ </ng-template>
210
+ `,
211
+ })
212
+ export class SdPermissionTableControl<TModule> {
213
+ value = model<Record<string, boolean>>({});
214
+
215
+ items = input<ISdPermission<TModule>[]>([]);
216
+ disabled = input(false, { transform: booleanAttribute });
217
+
218
+ collapsedItems = signal(new Set<ISdPermission<TModule>>());
219
+
220
+ depthLength = computed(() => {
221
+ return this._getDepthLength(this.items(), 0);
222
+ });
223
+
224
+ arr(len: number): number[] {
225
+ return Array(len)
226
+ .fill(0)
227
+ .map((_, i) => i);
228
+ }
229
+
230
+ getIsPermCollapsed(item: ISdPermission<TModule>): boolean {
231
+ return this.collapsedItems().has(item);
232
+ }
233
+
234
+ getAllChildren(item: ISdPermission<TModule>): ISdPermission<TModule>[] {
235
+ return item.children?.mapMany((child) => [child, ...this.getAllChildren(child)]) ?? [];
236
+ }
237
+
238
+ getEditDisabled(item: ISdPermission<TModule>) {
239
+ if (this.disabled()) {
240
+ return true;
241
+ }
242
+
243
+ if (item.perms) {
244
+ if (this.getIsPermExists(item, "use") && !this.getIsPermChecked(item, "use")) {
245
+ return true;
246
+ }
247
+ } else {
248
+ if (
249
+ item.children?.every(
250
+ (child) => !this.getIsPermExists(child, "edit") || this.getEditDisabled(child),
251
+ )
252
+ ) {
253
+ return true;
254
+ }
255
+ }
256
+
257
+ return false;
258
+ }
259
+
260
+ getIsPermExists(item: ISdPermission<TModule>, type: "use" | "edit"): boolean {
261
+ if (item.perms) {
262
+ return item.perms.includes(type);
263
+ }
264
+
265
+ if (item.children) {
266
+ for (const child of item.children) {
267
+ if (this.getIsPermExists(child, type)) {
268
+ return true;
269
+ }
270
+ }
271
+ }
272
+
273
+ return false;
274
+ }
275
+
276
+ getIsPermChecked(item: ISdPermission<TModule>, type: "use" | "edit"): boolean {
277
+ if (item.perms) {
278
+ const permCode = item.codeChain.join(".");
279
+ return this.value()[permCode + "." + type] ?? false;
280
+ }
281
+
282
+ if (item.children) {
283
+ for (const child of item.children) {
284
+ if (this.getIsPermChecked(child, type)) {
285
+ return true;
286
+ }
287
+ }
288
+ }
289
+
290
+ return false;
291
+ }
292
+
293
+ onPermCollapseToggle(item: ISdPermission<TModule>) {
294
+ this.collapsedItems.update((v) => {
295
+ const r = new Set(v);
296
+ if (r.has(item)) {
297
+ r.delete(item);
298
+ } else {
299
+ r.add(item);
300
+ const allChildren = this.getAllChildren(item);
301
+ for (const allChild of allChildren) {
302
+ r.add(allChild);
303
+ }
304
+ }
305
+ return r;
306
+ });
307
+ }
308
+
309
+ onPermCheckChange(item: ISdPermission<TModule>, type: "use" | "edit", val: boolean) {
310
+ this.value.update((v) => {
311
+ const r = obj.clone(v);
312
+ this._changePermCheck(r, item, type, val);
313
+ return r;
314
+ });
315
+ }
316
+
317
+ private _changePermCheck(
318
+ value: Record<string, boolean>,
319
+ item: ISdPermission<TModule>,
320
+ type: "use" | "edit",
321
+ val: boolean,
322
+ ) {
323
+ let changed = false;
324
+
325
+ if (item.perms) {
326
+ const permCode = item.codeChain.join(".");
327
+
328
+ if (
329
+ type === "edit" &&
330
+ val &&
331
+ this.getIsPermExists(item, "use") &&
332
+ !this.getIsPermChecked(item, "use")
333
+ ) {
334
+ // use가 체크되지 않은 상태에서 edit 체크 시도 → 무시
335
+ } else {
336
+ if (this.getIsPermExists(item, type) && value[permCode + "." + type] !== val) {
337
+ value[permCode + "." + type] = val;
338
+ changed = true;
339
+ }
340
+ }
341
+
342
+ // USE권한 지우면 EDIT권한도 자동으로 지움
343
+ if (
344
+ type === "use" &&
345
+ !val &&
346
+ this.getIsPermExists(item, "edit") &&
347
+ value[permCode + ".edit"]
348
+ ) {
349
+ value[permCode + ".edit"] = false;
350
+ changed = true;
351
+ }
352
+ }
353
+
354
+ // 하위 권한을 함께 변경함
355
+ if (item.children) {
356
+ for (const child of item.children) {
357
+ const childChanged = this._changePermCheck(value, child, type, val);
358
+ if (childChanged) {
359
+ changed = true;
360
+ }
361
+ }
362
+ }
363
+
364
+ return changed;
365
+ }
366
+
367
+ private _getDepthLength(items: ISdPermission<TModule>[], depth: number): number {
368
+ return (
369
+ items.max((item) => {
370
+ if (item.children) {
371
+ return this._getDepthLength(item.children, depth + 1);
372
+ } else {
373
+ return depth + 1;
374
+ }
375
+ }) ?? depth
376
+ );
377
+ }
378
+
379
+ protected readonly itemTemplateType!: {
380
+ item: ISdPermission<TModule>;
381
+ parentKey: string;
382
+ depth: number;
383
+ parent: ISdPermission<TModule> | undefined;
384
+ };
385
+ }
@@ -255,7 +255,7 @@ export class SdSharedDataSelectControl<
255
255
 
256
256
  getItemSelectable(item: TItem, _index: number, depth: number): boolean {
257
257
  if (!this.hasParentKey()) return true;
258
- // depth가 0이면서 자식을 가진 항목(카테고리)은 선택 불가
258
+ // 트리 구조에서 depth가 0이면서 __parentKey가 있는 항목은 선택 불가
259
259
  return depth !== 0 || item.__parentKey == null;
260
260
  }
261
261
 
package/src/index.ts CHANGED
@@ -72,6 +72,9 @@ export {
72
72
  type IAddress,
73
73
  } from "./features/address/sd-address-search.modal";
74
74
 
75
+ // features/permission-table
76
+ export { SdPermissionTableControl } from "./features/permission-table/sd-permission-table.control";
77
+
75
78
  // features
76
79
  export { SdBaseContainerControl } from "./features/base/sd-base-container.control";
77
80
  export {
@@ -139,6 +139,19 @@ export class SdModalProvider {
139
139
  activatedModal.modalComponent.set(modalRef.instance);
140
140
  activatedModal.contentComponent.set(contentRef.instance);
141
141
 
142
+ // 7-1. actionTplRef 브릿지: 컨텐츠 컴포넌트 → 모달 컴포넌트
143
+ if ("actionTplRef" in contentRef.instance) {
144
+ let _actionTplRef = contentRef.instance.actionTplRef;
145
+ Object.defineProperty(contentRef.instance, "actionTplRef", {
146
+ get: () => _actionTplRef,
147
+ set: (value: TemplateRef<any> | undefined) => {
148
+ _actionTplRef = value;
149
+ modalRef.setInput("actionTplRef", value);
150
+ },
151
+ configurable: true,
152
+ });
153
+ }
154
+
142
155
  // 8. appRef에 뷰 등록 + body에 삽입
143
156
  this._appRef.attachView(contentRef.hostView);
144
157
  this._appRef.attachView(modalRef.hostView);