@simplysm/sd-cli 14.0.95 → 14.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/commands/init/generators/client.d.ts.map +1 -1
  2. package/dist/commands/init/generators/client.js +12 -0
  3. package/dist/commands/init/generators/client.js.map +1 -1
  4. package/dist/commands/init/generators/server.d.ts.map +1 -1
  5. package/dist/commands/init/generators/server.js +1 -0
  6. package/dist/commands/init/generators/server.js.map +1 -1
  7. package/dist/commands/init/normalize.d.ts.map +1 -1
  8. package/dist/commands/init/normalize.js +1 -0
  9. package/dist/commands/init/normalize.js.map +1 -1
  10. package/dist/commands/init/prompts.d.ts.map +1 -1
  11. package/dist/commands/init/prompts.js +8 -1
  12. package/dist/commands/init/prompts.js.map +1 -1
  13. package/dist/commands/init/types.d.ts +3 -0
  14. package/dist/commands/init/types.d.ts.map +1 -1
  15. package/dist/engines/EsbuildClientEngine.d.ts.map +1 -1
  16. package/dist/engines/EsbuildClientEngine.js +1 -0
  17. package/dist/engines/EsbuildClientEngine.js.map +1 -1
  18. package/dist/esbuild/esbuild-client-config.d.ts.map +1 -1
  19. package/dist/esbuild/esbuild-client-config.js +2 -11
  20. package/dist/esbuild/esbuild-client-config.js.map +1 -1
  21. package/dist/esbuild/esbuild-postcss-plugin.d.ts +4 -0
  22. package/dist/esbuild/esbuild-postcss-plugin.d.ts.map +1 -1
  23. package/dist/esbuild/esbuild-postcss-plugin.js +15 -0
  24. package/dist/esbuild/esbuild-postcss-plugin.js.map +1 -1
  25. package/dist/esbuild/esbuild-ssr-config.d.ts +27 -0
  26. package/dist/esbuild/esbuild-ssr-config.d.ts.map +1 -0
  27. package/dist/esbuild/esbuild-ssr-config.js +113 -0
  28. package/dist/esbuild/esbuild-ssr-config.js.map +1 -0
  29. package/dist/sd-config.types.d.ts +7 -0
  30. package/dist/sd-config.types.d.ts.map +1 -1
  31. package/dist/ssg/prerender.d.ts +19 -0
  32. package/dist/ssg/prerender.d.ts.map +1 -0
  33. package/dist/ssg/prerender.js +43 -0
  34. package/dist/ssg/prerender.js.map +1 -0
  35. package/dist/workers/client.worker.d.ts +2 -0
  36. package/dist/workers/client.worker.d.ts.map +1 -1
  37. package/dist/workers/client.worker.js +21 -0
  38. package/dist/workers/client.worker.js.map +1 -1
  39. package/package.json +6 -6
  40. package/src/commands/init/generators/client.ts +53 -0
  41. package/src/commands/init/generators/server.ts +5 -0
  42. package/src/commands/init/normalize.ts +1 -0
  43. package/src/commands/init/prompts.ts +9 -1
  44. package/src/commands/init/templates/client/package.json.hbs +2 -1
  45. package/src/commands/init/templates/client/src/app/home/home.view.ts.hbs +1 -1
  46. package/src/commands/init/templates/client/src/app/home/master/role-permission/role-permission.detail.ts.hbs +221 -0
  47. package/src/commands/init/templates/client/src/app/home/master/role-permission/role-permission.view.ts.hbs +106 -0
  48. package/src/commands/init/templates/client/src/app/home/master/role-permission/role.detail.ts.hbs +277 -0
  49. package/src/commands/init/templates/client/src/app/home/master/role-permission/role.list.ts.hbs +537 -0
  50. package/src/commands/init/templates/client/src/app/home/master/user.detail.ts.hbs +337 -0
  51. package/src/commands/init/templates/client/src/app/home/master/user.list.ts.hbs +540 -0
  52. package/src/commands/init/templates/client/src/app/home/my-info/my-info.detail.ts.hbs +4 -6
  53. package/src/commands/init/templates/client/src/app/home/system/data-log/data-log.list.ts.hbs +355 -0
  54. package/src/commands/init/templates/client/src/app/home/system/system-log/system-log.list.ts.hbs +382 -0
  55. package/src/commands/init/templates/client/src/app/login/login.view.ts.hbs +3 -4
  56. package/src/commands/init/templates/client/src/app.root.ts.hbs +9 -3
  57. package/src/commands/init/templates/client/src/index.html.hbs +1 -0
  58. package/src/commands/init/templates/client/src/main.server.ts.hbs +24 -0
  59. package/src/commands/init/templates/client/src/main.ts.hbs +36 -18
  60. package/src/commands/init/templates/client/src/modals/text-view.modal.ts.hbs +30 -0
  61. package/src/commands/init/templates/client/src/routes.ts.hbs +22 -0
  62. package/src/commands/init/templates/client-common/src/index.ts.hbs +6 -4
  63. package/src/commands/init/templates/client-common/src/providers/app-auth.provider.ts.hbs +3 -3
  64. package/src/commands/init/templates/client-common/src/providers/app-orm.provider.ts.hbs +0 -11
  65. package/src/commands/init/templates/client-common/src/providers/app-service.provider.ts.hbs +24 -10
  66. package/src/commands/init/templates/client-common/src/providers/app-shared-data.provider.ts.hbs +2 -2
  67. package/src/commands/init/templates/common/package.json.hbs +2 -1
  68. package/src/commands/init/templates/common/src/app-structure.ts.hbs +7 -2
  69. package/src/commands/init/templates/common/src/auth-info-changed.event.ts.hbs +3 -1
  70. package/src/commands/init/templates/common/src/db/db-context.ts.hbs +2 -2
  71. package/src/commands/init/templates/common/src/db/tables/master/user.ts.hbs +2 -2
  72. package/src/commands/init/templates/common/src/db/tables/system/role.ts.hbs +1 -0
  73. package/src/commands/init/templates/common/src/index.ts.hbs +1 -1
  74. package/src/commands/init/templates/server/src/index.ts.hbs +3 -0
  75. package/src/commands/init/templates/server/src/main.ts.hbs +15 -1
  76. package/src/commands/init/templates/server/src/services/auth.service.ts.hbs +28 -22
  77. package/src/commands/init/templates/server/src/services/dev.service.ts.hbs +5 -5
  78. package/src/commands/init/templates/server/src/services/user.service.ts.hbs +191 -0
  79. package/src/commands/init/templates/workspace-root/sd.config.ts.hbs +3 -0
  80. package/src/commands/init/types.ts +3 -0
  81. package/src/engines/EsbuildClientEngine.ts +1 -0
  82. package/src/esbuild/esbuild-client-config.ts +2 -12
  83. package/src/esbuild/esbuild-postcss-plugin.ts +18 -0
  84. package/src/esbuild/esbuild-ssr-config.ts +149 -0
  85. package/src/sd-config.types.ts +7 -0
  86. package/src/ssg/prerender.ts +65 -0
  87. package/src/workers/client.worker.ts +26 -0
  88. package/tests/engines/base-engine.spec.ts +1 -26
  89. package/tests/init/__snapshots__/render.spec.ts.snap +38 -20
  90. package/tests/init/render.spec.ts +113 -33
  91. package/tests/utils/hmr-client-script.acc.spec.ts +0 -21
  92. package/tests/angular/vite-angular-plugin.spec.ts +0 -102
  93. package/tests/engines/engine-adapter-isolation.spec.ts +0 -79
  94. package/tests/runtime/signal-handler.spec.ts +0 -21
  95. package/tests/utils/angular-build.spec.ts +0 -109
  96. package/tests/utils/esbuild-client-config.acc.spec.ts +0 -438
  97. package/tests/utils/esbuild-client-config.spec.ts +0 -659
  98. package/tests/utils/hmr-client-script.spec.ts +0 -44
  99. package/tests/utils/tsconfig-angular.spec.ts +0 -9
@@ -0,0 +1,540 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ effect,
6
+ inject,
7
+ signal,
8
+ untracked,
9
+ ViewEncapsulation,
10
+ } from "@angular/core";
11
+ import {
12
+ FormatPipe,
13
+ injectPermsSignal,
14
+ injectViewTitleSignal,
15
+ injectViewTypeSignal,
16
+ mark,
17
+ SdAnchor,
18
+ SdButton,
19
+ SdCheckbox,
20
+ SdCrudList,
21
+ SdItemOfTemplate,
22
+ SdModalProvider,
23
+ SdSharedDataSelect,
24
+ SdSheetColumn,
25
+ SdSheetColumnCellTemplate,
26
+ SdTextfield,
27
+ SdToastProvider,
28
+ type SortingDef,
29
+ } from "@simplysm/angular";
30
+ import { expr } from "@simplysm/orm-common";
31
+ import { DateTime, obj } from "@simplysm/core-common";
32
+ import { downloadBlob, openFileDialog } from "@simplysm/core-browser";
33
+ import { ExcelWrapper } from "@simplysm/excel";
34
+ import { z } from "zod";
35
+ import {
36
+ AppAuthProvider,
37
+ AppOrmProvider,
38
+ AppServiceProvider,
39
+ AppSharedDataProvider,
40
+ useSharedSignal,
41
+ } from "@{{workspaceName}}/client-common";
42
+ import { tablerDownload, tablerEdit, tablerUpload } from "@ng-icons/tabler-icons";
43
+ import { NgIcon } from "@ng-icons/core";
44
+ import { {{userEntityPascal}}Detail } from "./{{userEntityKebab}}.detail";
45
+
46
+ const ITEMS_PER_PAGE = 50;
47
+
48
+ @Component({
49
+ selector: "app-{{userEntityKebab}}-list",
50
+ changeDetection: ChangeDetectionStrategy.OnPush,
51
+ encapsulation: ViewEncapsulation.None,
52
+ standalone: true,
53
+ imports: [
54
+ SdCrudList,
55
+ SdSheetColumn,
56
+ SdSheetColumnCellTemplate,
57
+ SdTextfield,
58
+ SdCheckbox,
59
+ SdAnchor,
60
+ SdButton,
61
+ SdSharedDataSelect,
62
+ SdItemOfTemplate,
63
+ NgIcon,
64
+ FormatPipe,
65
+ ],
66
+ template: `
67
+ <sd-crud-list
68
+ [key]="'{{userEntityKebab}}-list'"
69
+ [(ready)]="ready"
70
+ [initialized]="initialized()"
71
+ [(busyCount)]="busyCount"
72
+ [viewType]="viewType()"
73
+ [restricted]="!perms().includes('use')"
74
+ [readonly]="!perms().includes('edit')"
75
+ [inlineEdit]="false"
76
+ [items]="items()"
77
+ [currDeletedItems]="deletedItems()"
78
+ [trackByFn]="trackByFn"
79
+ [getItemSelectableFn]="getItemSelectableFn"
80
+ [(selectedKeys)]="selectedKeys"
81
+ [(currentPage)]="page"
82
+ [totalPageCount]="pageLength()"
83
+ [(sorts)]="sortingDefs"
84
+ (filterSubmit)="onFilterSubmit()"
85
+ (create)="onCreate()"
86
+ (delete)="onDelete($event)"
87
+ (restore)="onRestore($event)"
88
+ >
89
+ <ng-template #filterTpl>
90
+ <div class="form-box-inline">
91
+ <div>
92
+ <label>검색어</label>
93
+ <sd-textfield
94
+ [type]="'text'"
95
+ [(value)]="filter().searchText"
96
+ (valueChange)="mark(filter)"
97
+ />
98
+ </div>
99
+ <div>
100
+ <label>역할</label>
101
+ <sd-shared-data-select
102
+ [selectMode]="'multi'"
103
+ [items]="sharedRoles.items()"
104
+ [(value)]="filter().roleIds"
105
+ (valueChange)="mark(filter)"
106
+ >
107
+ <ng-template [itemOf]="sharedRoles.items()" let-item="item">
108
+ \{{ item.name }}
109
+ </ng-template>
110
+ </sd-shared-data-select>
111
+ </div>
112
+ <div class="form-box-item">
113
+ <sd-checkbox [(value)]="filter().isIncludeDeleted" (valueChange)="mark(filter)">
114
+ 삭제항목 포함
115
+ </sd-checkbox>
116
+ </div>
117
+ </div>
118
+ </ng-template>
119
+
120
+ <ng-template #toolTpl>
121
+ @if (perms().includes("edit")) {
122
+ <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onUploadExcelButtonClick()">
123
+ <ng-icon [svg]="tablerUpload" />
124
+ 엑셀 업로드
125
+ </sd-button>
126
+ }
127
+ <sd-button [size]="'sm'" [theme]="'link-success'" (click)="onDownloadExcelButtonClick()">
128
+ <ng-icon [svg]="tablerDownload" />
129
+ 엑셀 다운로드
130
+ </sd-button>
131
+ </ng-template>
132
+
133
+ <sd-sheet-column [key]="'id'" [header]="'#'">
134
+ <ng-template [cell]="items()" let-item="item">
135
+ @if (perms().includes("edit")) {
136
+ <sd-anchor class="flex-row gap-sm p-xs-sm" (click)="onEdit(item, $event)">
137
+ <div><ng-icon [svg]="tablerEdit" /></div>
138
+ <div class="flex-fill tx-right">\{{ item.id }}</div>
139
+ </sd-anchor>
140
+ } @else {
141
+ <div class="p-xs-sm tx-right">\{{ item.id }}</div>
142
+ }
143
+ </ng-template>
144
+ </sd-sheet-column>
145
+
146
+ <sd-sheet-column [key]="'name'" [header]="'이름'">
147
+ <ng-template [cell]="items()" let-item="item">
148
+ <div class="p-xs-sm">\{{ item.name }}</div>
149
+ </ng-template>
150
+ </sd-sheet-column>
151
+
152
+ <sd-sheet-column [key]="'loginId'" [header]="'로그인ID'">
153
+ <ng-template [cell]="items()" let-item="item">
154
+ <div class="p-xs-sm">\{{ item.loginId }}</div>
155
+ </ng-template>
156
+ </sd-sheet-column>
157
+
158
+ <sd-sheet-column [key]="'roleName'" [header]="'역할'">
159
+ <ng-template [cell]="items()" let-item="item">
160
+ <div class="p-xs-sm">\{{ item.roleName ?? "&nbsp;" }}</div>
161
+ </ng-template>
162
+ </sd-sheet-column>
163
+
164
+ <sd-sheet-column [key]="'lastModifiedAt'" [header]="'수정일시'" [hidden]="true">
165
+ <ng-template [cell]="items()" let-item="item">
166
+ <div class="p-xs-sm">\{{ item.lastModifiedAt | format: "yyyy-MM-dd HH:mm" }}</div>
167
+ </ng-template>
168
+ </sd-sheet-column>
169
+
170
+ <sd-sheet-column [key]="'lastModifiedBy'" [header]="'수정자'" [hidden]="true">
171
+ <ng-template [cell]="items()" let-item="item">
172
+ <div class="p-xs-sm">\{{ item.lastModifiedBy ?? "&nbsp;" }}</div>
173
+ </ng-template>
174
+ </sd-sheet-column>
175
+ </sd-crud-list>
176
+ `,
177
+ })
178
+ export class {{userEntityPascal}}List {
179
+ private readonly _sdToast = inject(SdToastProvider);
180
+ private readonly _sdModal = inject(SdModalProvider);
181
+ private readonly _appOrm = inject(AppOrmProvider);
182
+ private readonly _appService = inject(AppServiceProvider);
183
+ private readonly _appSharedData = inject(AppSharedDataProvider);
184
+ private readonly _appAuth = inject(AppAuthProvider);
185
+
186
+ perms = injectPermsSignal(["master.{{userEntityKebab}}"], ["use", "edit"]);
187
+ viewType = injectViewTypeSignal();
188
+ viewTitle = injectViewTitleSignal();
189
+
190
+ ready = signal(false);
191
+ initialized = signal(false);
192
+ busyCount = signal(0);
193
+
194
+ selectedKeys = signal<number[]>([]);
195
+
196
+ sharedRoles = useSharedSignal("역할");
197
+
198
+ items = signal<IItem[]>([]);
199
+ deletedItems = computed(() => this.items().filter((i) => i.isDeleted));
200
+ page = signal(0);
201
+ pageLength = signal(0);
202
+ sortingDefs = signal<SortingDef[]>([]);
203
+
204
+ filter = signal<IFilter>({ isIncludeDeleted: false, roleIds: [] });
205
+ lastFilter = signal<IFilter>({ isIncludeDeleted: false, roleIds: [] });
206
+
207
+ trackByFn = (item: IItem): number => item.id;
208
+
209
+ // 로그인한 본인 계정은 선택(→삭제) 불가 — 해당 행 체크박스 비활성화.
210
+ getItemSelectableFn = (item: IItem): boolean | string =>
211
+ item.id === this._appAuth.authInfo()?.{{userEntityCamel}}Id ? "본인 계정은 삭제할 수 없습니다." : true;
212
+
213
+ private readonly _excelWrapper = new ExcelWrapper(
214
+ z.object({
215
+ id: z.number().optional().describe("ID"),
216
+ name: z.string().describe("이름"),
217
+ loginId: z.string().optional().describe("로그인ID"),
218
+ roleName: z.string().optional().describe("역할"),
219
+ password: z.string().optional().describe("비밀번호"),
220
+ isDeleted: z.boolean().describe("삭제"),
221
+ lastModifiedAt: z.custom<DateTime>().optional().describe("수정일시"),
222
+ lastModifiedBy: z.string().optional().describe("수정자"),
223
+ }),
224
+ );
225
+
226
+ constructor() {
227
+ effect(() => {
228
+ if (!this.perms().includes("use") || !this.ready()) {
229
+ this.initialized.set(true);
230
+ return;
231
+ }
232
+
233
+ this.lastFilter();
234
+ this.page();
235
+ this.sortingDefs();
236
+
237
+ void untracked(async () => {
238
+ this.busyCount.update((v) => v + 1);
239
+ await this._sdToast.try(async () => {
240
+ await this._refresh();
241
+ });
242
+ this.busyCount.update((v) => v - 1);
243
+ this.initialized.set(true);
244
+ });
245
+ });
246
+ }
247
+
248
+ onFilterSubmit(): void {
249
+ this.page.set(0);
250
+ this.lastFilter.set({ ...this.filter() });
251
+ }
252
+
253
+ async onDownloadExcelButtonClick(): Promise<void> {
254
+ if (this.busyCount() > 0) return;
255
+
256
+ this.busyCount.update((v) => v + 1);
257
+ await this._sdToast.try(async () => {
258
+ const r = await this._search(false);
259
+ const wb = await this._excelWrapper.write(this.viewTitle(), r.items);
260
+ try {
261
+ downloadBlob(
262
+ await wb.toBlob(),
263
+ `${this.viewTitle()}_${new DateTime().toFormatString("yyMMdd")}.xlsx`,
264
+ );
265
+ } finally {
266
+ await wb.close();
267
+ }
268
+ });
269
+ this.busyCount.update((v) => v - 1);
270
+ }
271
+
272
+ async onUploadExcelButtonClick(): Promise<void> {
273
+ if (this.busyCount() > 0) return;
274
+ if (!this.perms().includes("edit")) return;
275
+
276
+ const files = await openFileDialog({ accept: ".xlsx" });
277
+ if (files == null) return;
278
+
279
+ this.busyCount.update((v) => v + 1);
280
+ await this._sdToast.try(async () => {
281
+ // 파생 컬럼(수정일시·수정자)은 업로드에서 제외하고 파싱. 삭제 여부는 읽어서 반영.
282
+ const records = await this._excelWrapper.read(files[0], 0, {
283
+ excludes: ["lastModifiedAt", "lastModifiedBy"],
284
+ });
285
+ if (records.length === 0) {
286
+ throw new Error("업로드할 데이터가 없습니다.");
287
+ }
288
+
289
+ // 다건일 때만 에러에 이름을 표기(단건이면 평문).
290
+ const label = ({{userEntityCamel}}Name: string): string =>
291
+ records.length > 1 ? `{{userEntityLabel}} '${ {{~userEntityCamel}}Name}': ` : "";
292
+
293
+ // 역할명 → 역할ID (활성 역할 기준). 없는 역할명은 오류.
294
+ const roleIdByName = new Map(this.sharedRoles.items().map((r) => [r.name, r.id] as const));
295
+ const inputs = records.map((rec) => {
296
+ const roleName = rec.roleName?.trim();
297
+ let roleId: number | undefined;
298
+ if (roleName != null && roleName !== "") {
299
+ roleId = roleIdByName.get(roleName);
300
+ if (roleId == null) {
301
+ throw new Error(`${label(rec.name)}존재하지 않는 역할입니다. (${roleName})`);
302
+ }
303
+ }
304
+ return {
305
+ id: rec.id,
306
+ name: rec.name,
307
+ loginId: rec.loginId,
308
+ roleId,
309
+ password: rec.password,
310
+ isDeleted: rec.isDeleted,
311
+ };
312
+ });
313
+
314
+ // ID↔이름 정합성 — 전체(삭제 포함) {{userEntityLabel}}의 (id, name) 와 대조. ID 없으면 신규.
315
+ const ids = inputs.flatMap((x) => (x.id != null ? [x.id] : []));
316
+ const dbRows =
317
+ ids.length === 0
318
+ ? []
319
+ : await this._appOrm.connectAsync(async (db) =>
320
+ db
321
+ .{{userEntityCamel}}()
322
+ .where((c) => [expr.in(c.id, ids)])
323
+ .select((c) => ({ id: c.id, name: c.name }))
324
+ .execute(),
325
+ );
326
+ const dbNameById = new Map(dbRows.map((r) => [r.id, r.name] as const));
327
+
328
+ let nameChangedCount = 0;
329
+ const changedSamples: string[] = [];
330
+ for (const x of inputs) {
331
+ if (x.id == null) continue;
332
+ const dbName = dbNameById.get(x.id);
333
+ if (dbName == null) {
334
+ throw new Error(`${label(x.name)}ID ${x.id} 에 해당하는 {{userEntityLabel}}이 없습니다.`);
335
+ }
336
+ if (dbName !== x.name.trim()) {
337
+ nameChangedCount++;
338
+ if (changedSamples.length < 2) {
339
+ changedSamples.push(`${dbName} → ${x.name.trim()}`);
340
+ }
341
+ }
342
+ }
343
+
344
+ // 기존 이름(비즈니스키)이 바뀌는 건이 있으면 행 어긋남(정렬 사고)일 수 있어, 건수 입력으로 확인.
345
+ if (nameChangedCount >= 1) {
346
+ const sample =
347
+ changedSamples.join(", ") + (nameChangedCount > changedSamples.length ? " 등" : "");
348
+ let confirmed = false;
349
+ while (!confirmed) {
350
+ const answer = prompt(
351
+ `기존 항목 ${nameChangedCount}건의 이름이 변경됩니다. (예: ${sample})\n` +
352
+ `계속하려면 ${nameChangedCount} 을(를) 입력하세요.`,
353
+ );
354
+ if (answer == null) return;
355
+ confirmed = answer.trim() === String(nameChangedCount);
356
+ }
357
+ }
358
+
359
+ const results = await this._appService.{{userEntityCamel}}.save(inputs);
360
+ await this._appSharedData.emitAsync(
361
+ "{{userEntityLabel}}",
362
+ results.map((r) => r.id),
363
+ );
364
+ this._sdToast.success(`${results.length}건이 반영되었습니다.`);
365
+ await this._refresh();
366
+ });
367
+ this.busyCount.update((v) => v - 1);
368
+ }
369
+
370
+ async onCreate(): Promise<void> {
371
+ await this._openDetail(undefined);
372
+ }
373
+
374
+ async onEdit(item: IItem, event: Event): Promise<void> {
375
+ event.preventDefault();
376
+ event.stopPropagation();
377
+
378
+ await this._openDetail(item.id);
379
+ }
380
+
381
+ private async _openDetail({{userEntityCamel}}Id: number | undefined): Promise<void> {
382
+ if (this.busyCount() > 0) return;
383
+
384
+ const result = await this._sdModal.showAsync({
385
+ type: {{userEntityPascal}}Detail,
386
+ title: {{userEntityCamel}}Id == null ? "{{userEntityLabel}} 등록" : "{{userEntityLabel}} 수정",
387
+ inputs: { {{userEntityCamel}}Id },
388
+ });
389
+ if (result == null) return;
390
+
391
+ this.busyCount.update((v) => v + 1);
392
+ await this._sdToast.try(async () => {
393
+ await this._refresh();
394
+ });
395
+ this.busyCount.update((v) => v - 1);
396
+ }
397
+
398
+ async onDelete(targets: IItem[]): Promise<void> {
399
+ if (this.busyCount() > 0) return;
400
+ if (!this.perms().includes("edit")) return;
401
+ if (targets.length === 0) return;
402
+ if (!confirm("삭제하시겠습니까?")) return;
403
+
404
+ this.busyCount.update((v) => v + 1);
405
+ await this._sdToast.try(async () => {
406
+ const ids = targets.map((t) => t.id);
407
+ const {{userEntityCamel}}Id = this._appAuth.authInfo()?.{{userEntityCamel}}Id;
408
+ await this._appOrm.connectAsync(async (db) => {
409
+ await db
410
+ .{{userEntityCamel}}()
411
+ .where((c) => [expr.in(c.id, ids)])
412
+ .update(() => ({ isDeleted: true }));
413
+ for (const id of ids) {
414
+ await db.{{userEntityCamel}}().insertDataLog({ action: "삭제", itemId: id, {{userEntityCamel}}Id });
415
+ }
416
+ });
417
+ await this._appSharedData.emitAsync("{{userEntityLabel}}", ids);
418
+ this._sdToast.success("삭제되었습니다.");
419
+ await this._refresh();
420
+ });
421
+ this.busyCount.update((v) => v - 1);
422
+ }
423
+
424
+ async onRestore(targets: IItem[]): Promise<void> {
425
+ if (this.busyCount() > 0) return;
426
+ if (!this.perms().includes("edit")) return;
427
+ if (targets.length === 0) return;
428
+
429
+ this.busyCount.update((v) => v + 1);
430
+ await this._sdToast.try(async () => {
431
+ const ids = targets.map((t) => t.id);
432
+ const names = targets.map((t) => t.name);
433
+ const {{userEntityCamel}}Id = this._appAuth.authInfo()?.{{userEntityCamel}}Id;
434
+ await this._appOrm.connectAsync(async (db) => {
435
+ await db
436
+ .{{userEntityCamel}}()
437
+ .where((c) => [expr.in(c.id, ids)])
438
+ .update(() => ({ isDeleted: false }));
439
+
440
+ // 복구 후 이름 비삭제 유니크 재검증 — 복구로 활성 중복이 생겼으면 throw(트랜잭션 롤백).
441
+ // 대상끼리 충돌·기존 활성과의 충돌을 복구한 이름으로 한정해 한 번에 검사.
442
+ const conflicts = await db
443
+ .{{userEntityCamel}}()
444
+ .where((c) => [expr.in(c.name, names), expr.eq(c.isDeleted, false)])
445
+ .groupBy((c) => [c.name])
446
+ .having(() => [expr.gt(expr.count(), 1)])
447
+ .select((c) => ({ name: c.name }))
448
+ .execute();
449
+ if (conflicts.length > 0) {
450
+ const conflictNames = conflicts.map((x) => `'${x.name}'`).join(", ");
451
+ throw new Error(`같은 이름(${conflictNames})의 활성 {{userEntityLabel}}이 있어 복구할 수 없습니다.`);
452
+ }
453
+
454
+ for (const id of ids) {
455
+ await db.{{userEntityCamel}}().insertDataLog({ action: "복구", itemId: id, {{userEntityCamel}}Id });
456
+ }
457
+ });
458
+ await this._appSharedData.emitAsync("{{userEntityLabel}}", ids);
459
+ this._sdToast.success("복구되었습니다.");
460
+ await this._refresh();
461
+ });
462
+ this.busyCount.update((v) => v - 1);
463
+ }
464
+
465
+ private async _refresh(): Promise<void> {
466
+ const r = await this._search(true);
467
+ this.items.set(r.items);
468
+ this.pageLength.set(r.pageLength);
469
+ }
470
+
471
+ private async _search(usePagination: boolean): Promise<{ items: IItem[]; pageLength: number }> {
472
+ return this._appOrm.connectAsync(async (db) => {
473
+ let qr = db.{{userEntityCamel}}();
474
+
475
+ const searchText = this.lastFilter().searchText?.trim();
476
+ if (searchText != null && searchText.length > 0) {
477
+ qr = qr.search((c) => [c.name, c.loginId], searchText);
478
+ }
479
+
480
+ const roleIds = this.lastFilter().roleIds;
481
+ if (roleIds != null && roleIds.length > 0) {
482
+ qr = qr.where((c) => [expr.in(c.roleId, roleIds)]);
483
+ }
484
+
485
+ if (!this.lastFilter().isIncludeDeleted) {
486
+ qr = qr.where((c) => [expr.eq(c.isDeleted, false)]);
487
+ }
488
+
489
+ const pageLength = usePagination ? Math.ceil((await qr.count()) / ITEMS_PER_PAGE) : 0;
490
+
491
+ let qr2 = qr
492
+ .joinLastDataLog()
493
+ .include((c) => c.role)
494
+ .select((c) => ({
495
+ id: c.id,
496
+ name: c.name,
497
+ loginId: c.loginId,
498
+ roleName: c.role?.name,
499
+ isDeleted: c.isDeleted,
500
+ lastModifiedAt: c.lastDataLog?.dateTime,
501
+ lastModifiedBy: c.lastDataLog?.{{userEntityCamel}}Name,
502
+ }));
503
+
504
+ for (const sort of this.sortingDefs()) {
505
+ qr2 = qr2.orderBy((c) => obj.getChainValue(c, sort.key) as any, sort.desc ? "DESC" : "ASC");
506
+ }
507
+ if (!this.sortingDefs().some((s) => s.key === "id")) {
508
+ qr2 = qr2.orderBy((c) => c.id, "DESC");
509
+ }
510
+
511
+ if (usePagination) {
512
+ qr2 = qr2.limit(this.page() * ITEMS_PER_PAGE, ITEMS_PER_PAGE);
513
+ }
514
+
515
+ const items = (await qr2.execute()) as IItem[];
516
+ return { items, pageLength };
517
+ });
518
+ }
519
+
520
+ protected readonly mark = mark;
521
+ protected readonly tablerEdit = tablerEdit;
522
+ protected readonly tablerDownload = tablerDownload;
523
+ protected readonly tablerUpload = tablerUpload;
524
+ }
525
+
526
+ interface IItem {
527
+ id: number;
528
+ name: string;
529
+ loginId?: string;
530
+ roleName?: string;
531
+ isDeleted: boolean;
532
+ lastModifiedAt?: DateTime;
533
+ lastModifiedBy?: string;
534
+ }
535
+
536
+ interface IFilter {
537
+ searchText?: string;
538
+ roleIds?: number[];
539
+ isIncludeDeleted: boolean;
540
+ }
@@ -150,7 +150,7 @@ export class MyInfoDetail {
150
150
 
151
151
  flatMenus = computed(() => this._sdAppStructure.usableFlatMenus());
152
152
 
153
- data = signal<IData>({ name: "", loginId: "", configRecord: {} });
153
+ data = signal<IData>({ configRecord: {} });
154
154
  private _orgData?: IData;
155
155
 
156
156
  constructor() {
@@ -223,9 +223,7 @@ export class MyInfoDetail {
223
223
  .single();
224
224
  });
225
225
 
226
- if (loaded == null) {
227
- throw new Error("{{userEntityLabel}} 정보를 찾을 수 없습니다.");
228
- }
226
+ if (loaded == null) throw new Error("{{userEntityLabel}} 정보를 찾을 수 없습니다.");
229
227
 
230
228
  const configRecord = (loaded.configs ?? []).toObject(
231
229
  (c) => c.code,
@@ -254,8 +252,8 @@ export class MyInfoDetail {
254
252
  }
255
253
 
256
254
  interface IData {
257
- name: string;
258
- loginId: string;
255
+ name?: string;
256
+ loginId?: string;
259
257
  configRecord: I{{userEntityPascal}}ConfigMap;
260
258
  currentPassword?: string;
261
259
  newPassword?: string;