Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/cloud/src/app/@core/services/xpert.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ export class XpertAPIService extends XpertWorkspaceBaseCrudService<IXpert> {
return this.httpClient.delete<void>(this.apiBaseUrl + `/${id}/managers/${userId}`)
}

uploadAndParseManagersCsv(id: string, file: File) {
const formData = new FormData()
formData.append('file', file)
return this.httpClient.post<IUser[]>(`${this.apiBaseUrl}/${id}/managers/bulk/upload`, formData)
}

bulkAddManagers(id: string, userIds: string[]) {
return this.httpClient.put<IUser[]>(this.apiBaseUrl + `/${id}/managers/bulk`, userIds)
}

getMyCopilots(relations?: string[]) {
return this.getMyAll({
relations,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,41 @@
<div class="flex items-center mb-4 p-3 bg-gray-50 rounded-2xl">
<div class="grow mx-2">{{ xpert()?.title || xpert()?.name }}</div>

<div
class="shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200 text-[13px] font-medium text-primary-600 bg-white shadow-xs rounded-lg cursor-pointer"
(click)="openAddUser()"
>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
class="w-4 h-4 mr-2"
<div class="flex items-center gap-2">
<div
class="shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200 text-[13px] font-medium text-gray-600 bg-white shadow-xs rounded-lg cursor-pointer hover:bg-gray-50"
(click)="openBulkImport()"
>
<path
d="M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM18 17V14H20V17H23V19H20V22H18V19H15V17H18Z"
></path></svg
>{{ 'PAC.ACTIONS.Add' | translate: {Default: 'Add'} }}
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
class="w-4 h-4 mr-2"
>
<path
d="M11 4H13V13L17.5 8.5L18.92 9.92L12 16.84L5.08 9.92L6.5 8.5L11 13V4ZM5 18V20H19V18H5Z"
></path></svg
>{{ 'PAC.Xpert.BulkImport' | translate: {Default: 'Bulk Import'} }}
</div>
<div
class="shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200 text-[13px] font-medium text-primary-600 bg-white shadow-xs rounded-lg cursor-pointer"
(click)="openAddUser()"
>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
class="w-4 h-4 mr-2"
>
<path
d="M14 14.252V16.3414C13.3744 16.1203 12.7013 16 12 16C8.68629 16 6 18.6863 6 22H4C4 17.5817 7.58172 14 12 14C12.6906 14 13.3608 14.0875 14 14.252ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11ZM18 17V14H20V17H23V19H20V22H18V19H15V17H18Z"
></path></svg
>{{ 'PAC.ACTIONS.Add' | translate: {Default: 'Add'} }}
</div>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { XpertComponent } from '../xpert.component'
import { derivedAsync } from 'ngxtension/derived-async'
import { UserProfileInlineComponent, UserRoleSelectComponent } from 'apps/cloud/src/app/@shared/user'
import { Dialog } from '@angular/cdk/dialog'
import { XpertManagerBulkImportComponent } from './bulk-import/bulk-import.component'

@Component({
standalone: true,
Expand All @@ -29,7 +30,8 @@ import { Dialog } from '@angular/cdk/dialog'
DragDropModule,
MatTooltipModule,
NgmSpinComponent,
UserProfileInlineComponent
UserProfileInlineComponent,
XpertManagerBulkImportComponent
]
})
export class XpertAuthorizationComponent {
Expand Down Expand Up @@ -73,6 +75,21 @@ export class XpertAuthorizationComponent {
.subscribe()
}

openBulkImport() {
this.#dialog
.open<boolean>(XpertManagerBulkImportComponent, {
data: {
xpertId: this.xpertComponent.xpertId()
}
})
.closed
.subscribe((result) => {
if (result) {
this.refresh$.next()
}
})
}

addManagers(users: IUser[]) {
this.loading.set(true)
const newMembers = users.filter((u) => !this.members().some((_) => _.user.id === u.id))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<div class="system-xl-medium relative pb-1 text-text-primary">
{{'PAC.Xpert.BulkImportManagers' | translate: {Default: 'Bulk Import Managers'} }}
</div>
<div class="absolute right-4 top-4 cursor-pointer p-2" (click)="close()">
<i class="ri-close-line"></i>
</div>
<div class="flex flex-col relative">
<div class="mt-6">
<input id="fileUploader" #fileUploader accept=".csv" type="file" style="display: none" (change)="onFileSelected($event)" />
<div>
<div class="dropzone system-sm-regular flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg"
ngmDnd (fileDropped)="onFileDropped($event)">
<div class="flex w-full items-center justify-center space-x-2 px-2">
<svg
width="32"
height="34"
viewBox="0 0 32 34"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="shrink-0"
data-icon="Csv"
aria-hidden="true"
>
<g id="File Icons/csv">
<g id="sharp" filter="url(#filter0_d_6816_769)">
<path
d="M4 7.73398C4 5.49377 4 4.37367 4.43597 3.51802C4.81947 2.76537 5.43139 2.15345 6.18404 1.76996C7.03969 1.33398 8.15979 1.33398 10.4 1.33398H18.6667L28 10.6673V24.2673C28 26.5075 28 27.6276 27.564 28.4833C27.1805 29.2359 26.5686 29.8478 25.816 30.2313C24.9603 30.6673 23.8402 30.6673 21.6 30.6673H10.4C8.15979 30.6673 7.03969 30.6673 6.18404 30.2313C5.43139 29.8478 4.81947 29.2359 4.43597 28.4833C4 27.6276 4 26.5075 4 24.2673V7.73398Z"
fill="#169951"
></path>
</g>
<g id="CSV" opacity="0.96">
<path
d="M13.0846 21.8908C12.8419 23.3562 11.8246 24.0562 10.5646 24.0562C9.78992 24.0562 9.20192 23.7948 8.71659 23.3095C8.01659 22.6095 8.04459 21.6762 8.04459 20.6775C8.04459 19.6788 8.01659 18.7455 8.71659 18.0455C9.20192 17.5602 9.78992 17.2988 10.5646 17.2988C11.8246 17.2988 12.8419 17.9988 13.0846 19.4642H11.4233C11.3206 19.0908 11.1153 18.7548 10.5739 18.7548C10.2753 18.7548 10.0513 18.8762 9.92992 19.0348C9.78059 19.2308 9.67792 19.4642 9.67792 20.6775C9.67792 21.8908 9.78059 22.1242 9.92992 22.3202C10.0513 22.4788 10.2753 22.6002 10.5739 22.6002C11.1153 22.6002 11.3206 22.2642 11.4233 21.8908H13.0846Z"
fill="white"
></path>
<path
d="M18.4081 21.9655C18.4081 23.3188 17.2414 24.0562 15.8414 24.0562C14.8241 24.0562 13.9934 23.8695 13.3214 23.1788L14.3668 22.1335C14.7121 22.4788 15.3188 22.6002 15.8508 22.6002C16.4948 22.6002 16.8028 22.3855 16.8028 22.0028C16.8028 21.8442 16.7654 21.7135 16.6721 21.6108C16.5881 21.5268 16.4481 21.4615 16.2334 21.4335L15.4308 21.3215C14.8428 21.2375 14.3948 21.0415 14.0961 20.7335C13.7881 20.4162 13.6388 19.9682 13.6388 19.3988C13.6388 18.1855 14.5534 17.2988 16.0654 17.2988C17.1948 17.2988 17.9574 17.6348 18.4948 18.2042L17.4494 19.2495C17.1041 18.9042 16.6374 18.7548 16.0654 18.7548C15.4748 18.7548 15.2441 19.0255 15.2441 19.3615C15.2441 19.5202 15.2908 19.6508 15.3934 19.7535C15.4868 19.8468 15.6454 19.9215 15.8788 19.9495L16.6628 20.0615C17.2601 20.1455 17.6988 20.3322 17.9881 20.6215C18.3068 20.9402 18.4081 21.3788 18.4081 21.9655Z"
fill="white"
></path>
<path
d="M24.4166 17.3548L22.214 24.0002H21.0006L18.8073 17.3548H20.4966L21.6166 21.0695L22.718 17.3548H24.4166Z"
fill="white"
></path>
</g>
<path
id="bevel"
opacity="0.5"
d="M18.6667 1.33398L28.0001 10.6673H21.3334C19.8607 10.6673 18.6667 9.47341 18.6667 8.00065V1.33398Z"
fill="white"
></path>
</g>
<defs>
<filter
id="filter0_d_6816_769"
x="2"
y="0.333984"
width="28"
height="33.334"
filterUnits="useUserSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
></feColorMatrix>
<feOffset dy="1"></feOffset>
<feGaussianBlur stdDeviation="1"></feGaussianBlur>
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"
></feColorMatrix>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_6816_769"></feBlend>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_6816_769" result="shape"></feBlend>
</filter>
</defs>
</svg>
@if (file()) {
<div class="flex justify-between items-center grow">
{{file().name}}
<div class="flex items-center">
<button type="button" class="btn btn-secondary btn-sm" (click)="fileUploader.click()">
{{'PAC.ACTIONS.Change' | translate: {Default: 'Change'} }}
</button>
<div class="w-[1px] h-4 bg-divider-deep mx-2"></div>
<button type="button" class="w-7 h-7 flex justify-center items-center rounded-lg hover:bg-hover-bg"
(click)="file.set(null);users.set([]);fileUploader.value = null;">
<i class="ri-delete-back-2-line"></i>
</button>
</div>
</div>
} @else {
<div class="text-text-tertiary">
{{'PAC.Xpert.DragDropCsv' | translate: {Default: 'Drag and drop your CSV file here, or '} }}
<span class="cursor-pointer text-text-accent" (click)="fileUploader.click()">
{{'PAC.Xpert.Browse' | translate: {Default: 'Browse'} }}
</span>
</div>
}
</div>
</div>
</div>
</div>

<!-- CSV Format Instructions -->
<div class="mt-6">
<div class="system-sm-medium text-text-primary">
{{'PAC.Xpert.ManagersCsvMustContain' | translate: {Default: 'The CSV file must contain an email column. Users will be found by their email address:'} }}
</div>
<div class="mt-2 max-h-[300px] overflow-auto">
<table
class="w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-regular text-sm"
>
<thead class="text-text-tertiary">
<tr>
<td class="h-9 border-b border-divider-regular pl-3 pr-2">email</td>
</tr>
</thead>
<tbody class="text-text-secondary">
<tr>
<td class="h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]">user1@example.com</td>
</tr>
<tr>
<td class="h-9 pl-3 pr-2 text-[13px]">user2@example.com</td>
</tr>
</tbody>
</table>
</div>
<a class="mt-2 block cursor-pointer" (click)="downloadTemplate()">
<div class="system-xs-medium flex h-[18px] items-center space-x-1 text-text-accent">
<i class="ri-download-line mr-1"></i>
{{'PAC.Xpert.DownloadTemplateHere' | translate: {Default: 'Download the template here'} }}
</div>
</a>
</div>

<!-- Preview of found users -->
@if (users().length > 0) {
<div class="mt-6">
<div class="system-sm-medium text-text-primary mb-2">
{{'PAC.Xpert.FoundUsers' | translate: {Default: 'Found users:'} }} {{ users().length }}
</div>
<div class="max-h-[200px] overflow-auto border border-divider-regular rounded-lg">
<table class="w-full text-sm">
<thead class="text-text-tertiary bg-gray-50">
<tr>
<td class="h-9 border-b border-divider-regular pl-3 pr-2">{{ 'PAC.KEY_WORDS.User' | translate: {Default: 'User'} }}</td>
<td class="h-9 border-b border-divider-regular pl-3 pr-2">{{ 'PAC.KEY_WORDS.Email' | translate: {Default: 'Email'} }}</td>
</tr>
</thead>
<tbody class="text-text-secondary">
@for (user of users(); track user.id) {
<tr class="border-b border-divider-subtle last:border-b-0">
<td class="h-9 pl-3 pr-2 text-[13px]">{{ user.fullName || user.firstName + ' ' + user.lastName || user.username }}</td>
<td class="h-9 pl-3 pr-2 text-[13px]">{{ user.email }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}

@if (loading()) {
<ngm-spin class="absolute w-full h-full top-0 left-0"/>
}
</div>


<div class="mt-[28px] flex justify-end pt-6">
<button
type="button"
class="btn disabled:btn-disabled btn-secondary btn-medium system-sm-medium mr-2 text-text-tertiary"
(click)="close()"
>
{{'PAC.ACTIONS.Cancel' | translate: {Default: 'Cancel'} }}</button
>
<button type="button" class="btn disabled:btn-disabled btn-primary btn-medium"
[disabled]="!file() || users().length === 0 || loading()"
(click)="upload()">{{'PAC.ACTIONS.Upload' | translate: {Default: 'Upload'} }}</button>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:host {
display: block;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'
import { CdkMenuModule } from '@angular/cdk/menu'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { getErrorMessage, injectToastr, IUser, XpertAPIService } from '@cloud/app/@core'
import { NgmDndDirective, OverlayAnimation1 } from '@metad/core'
import { NgmSpinComponent } from '@metad/ocap-angular/common'
import { TranslateModule } from '@ngx-translate/core'
import { firstValueFrom } from 'rxjs'

@Component({
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule, CdkMenuModule, NgmDndDirective, NgmSpinComponent],
selector: 'xpert-manager-bulk-import',
templateUrl: './bulk-import.component.html',
styleUrl: './bulk-import.component.scss',
animations: [OverlayAnimation1],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class XpertManagerBulkImportComponent {
readonly #data = inject<{ xpertId: string }>(DIALOG_DATA)
readonly #dialogRef = inject(DialogRef)
readonly #xpertAPI = inject(XpertAPIService)
readonly #toastr = injectToastr()

readonly xpertId = signal(this.#data.xpertId)
readonly file = signal<File>(null)
readonly users = signal<IUser[]>([])
readonly loading = signal(false)

close() {
this.#dialogRef.close()
}

downloadTemplate() {
const csvContent = 'email\nuser1@example.com\nuser2@example.com\n'
const bom = '\uFEFF'
const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'managers-template.csv'
a.click()
URL.revokeObjectURL(url)
}

async onFileDropped(event: FileList) {
if (event.length > 0) {
await this.onFile(event.item(0))
}
}

async onFile(file: File) {
try {
this.loading.set(true)
this.file.set(file)
// Call backend API to parse CSV with proper encoding detection
const parsedUsers = await firstValueFrom(
this.#xpertAPI.uploadAndParseManagersCsv(this.xpertId(), file)
)
this.users.set(parsedUsers)
} catch (err) {
this.#toastr.error(getErrorMessage(err))
} finally {
this.loading.set(false)
}
}

async onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
const file = input.files[0]
this.onFile(file)
}
}

async upload() {
this.loading.set(true)
this.#xpertAPI.bulkAddManagers(this.xpertId(), this.users().map(u => u.id)).subscribe({
next: (response) => {
this.loading.set(false)
this.#toastr.success('PAC.Messages.SavedSuccessfully', { Default: 'Saved Successfully' })
this.#dialogRef.close(true)
},
error: (error) => {
this.loading.set(false)
this.#toastr.error(getErrorMessage(error))
}
})
}
}
Loading