mirror of
https://github.com/ever-co/ever-gauzy.git
synced 2026-10-02 01:54:50 +08:00
Feat/add employee dialog polish (#10336)
* feat(i18n): add strings for the add-employee dialog and form sections * feat(user-forms): upload the profile photo through the shared avatar uploader * feat(user-forms): group basic info fields into profile, account and employment sections * feat(employees): track queued employees and the current entry in the add dialog * feat(employees): redesign the add-employee dialog with labelled steps, review list and fixed footer * style(employees): style the add-employee dialog header, steps, list and footer * feat(tags): draw the picker checkbox and show selected tags as tinted labels * style(tags): compact rows for the tag picker dropdown panel * fix(employees): vertically centre the Show Deleted toggle in the header * fix(employees): give Status and Screen Capture columns room from Name and Email * fix(employees): address review feedback on the add-employee dialog - mark template-only members protected - translate the step bar's aria-label - clear loading in a finally block so a failed create can be retried - fit the step bar on narrow dialogs by naming only the current step * fix(employees): resolve SonarCloud reliability issues in touched files - mark fire-and-forget promises with void (typescript:S9383) - drop async from delete(), which awaits nothing (typescript:S7503) * fix(user-forms): hide SUPER_ADMIN from the role picker once the permission check resolves excludeRoles() resolves after the role field may already have rendered its options, and it mutated the excludes array in place, so the field kept offering SUPER_ADMIN to users without that role. - basic info form: assign a new excludes array instead of pushing - role field: cache the fetched roles and re-filter them whenever excludes changes, clearing a selection that is no longer allowed * fix(user-forms): keep a preselected role until the role list has loaded applyExcludes() also runs from the excludes setter, which can fire before getAll() returns. With no roles loaded every preselected role looked disallowed and was cleared. Only check the selection once the roles have loaded; renderRoles() re-runs the check at that point.
This commit is contained in:
@@ -25,7 +25,17 @@ nb-card {
|
||||
// stacked onto the row's own gap to give 36px on one side of the toggle and
|
||||
// 20px on the other.
|
||||
.custom-toggle {
|
||||
// A flex box, not a block: `nb-toggle` is inline, so inside a block it sat
|
||||
// on the text baseline with the line's descender space under it, which put
|
||||
// the switch and "Show Deleted" ~5px above the buttons' centre.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.75rem;
|
||||
|
||||
::ng-deep {
|
||||
nb-toggle label {
|
||||
margin: 0;
|
||||
}
|
||||
nb-toggle.status-danger .toggle.checked {
|
||||
background-color: rgba(245, 109, 88, 1);
|
||||
border-color: rgba(245, 109, 88, 1);
|
||||
|
||||
@@ -221,7 +221,7 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
if (customComponentInstance?.constructor === AllowScreenshotCaptureComponent) {
|
||||
this.disableButton = true;
|
||||
const instance: AllowScreenshotCaptureComponent = customComponentInstance;
|
||||
this._updateAllowScreenshotCapture(instance.rowData, !instance.allowed);
|
||||
void this._updateAllowScreenshotCapture(instance.rowData, !instance.allowed);
|
||||
this._grid.clearCustomViewComponent();
|
||||
this.clearItem();
|
||||
}
|
||||
@@ -330,7 +330,7 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
*
|
||||
* @param selectedItem The employee view model to delete.
|
||||
*/
|
||||
async delete(selectedItem?: EmployeeViewModel): Promise<void> {
|
||||
delete(selectedItem?: EmployeeViewModel): void {
|
||||
if (selectedItem) {
|
||||
this.selectEmployee({
|
||||
isSelected: true,
|
||||
@@ -704,7 +704,8 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
title: () => this.getTranslation('SM_TABLE.FULL_NAME'),
|
||||
type: 'custom',
|
||||
class: 'align-row',
|
||||
width: '20%',
|
||||
// Names are short chips; the spare width goes to Status / Screen Capture.
|
||||
width: '15%',
|
||||
isFilterable: true,
|
||||
renderComponent: PictureNameTagsComponent,
|
||||
componentInitFunction: (instance: PictureNameTagsComponent, cell: Cell) => {
|
||||
@@ -724,8 +725,7 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
title: () => this.getTranslation('SM_TABLE.EMAIL'),
|
||||
type: 'text',
|
||||
class: 'align-row',
|
||||
// Two points to the number columns below.
|
||||
width: '18%',
|
||||
width: '15%',
|
||||
isFilterable: true,
|
||||
filter: {
|
||||
type: 'custom',
|
||||
@@ -808,8 +808,7 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
order: 6,
|
||||
title: () => this.getTranslation('SM_TABLE.TAGS'),
|
||||
type: 'custom',
|
||||
// Three points to the number columns above.
|
||||
width: '17%',
|
||||
width: '16%',
|
||||
isFilterable: true,
|
||||
isSortable: false,
|
||||
filter: {
|
||||
@@ -834,7 +833,8 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
title: () => this.getTranslation('SM_TABLE.STATUS'),
|
||||
type: 'custom',
|
||||
class: 'text-center',
|
||||
width: '5%',
|
||||
// Was 5%: "Active" and "Not Started" stacked on two lines.
|
||||
width: '10%',
|
||||
isFilterable: true,
|
||||
isSortable: false,
|
||||
filter: {
|
||||
@@ -923,7 +923,7 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
title: () => this.getTranslation('SM_TABLE.SCREEN_CAPTURE'), // The title of the column
|
||||
type: 'custom', // The type of the column
|
||||
class: 'text-center', // The class of the column
|
||||
width: '5%', // The width of the column
|
||||
width: '10%', // Was 5%: the "Screen Capture" heading wrapped and the toggle + label were squeezed
|
||||
isFilterable: true, // Indicates whether the column is filterable
|
||||
isSortable: false,
|
||||
hide: allowScreenshotCapture === false,
|
||||
@@ -945,7 +945,7 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
next: (isAllow: boolean) => {
|
||||
// Clear selected items and update allowScreenshotCapture
|
||||
this.clearItem();
|
||||
this._updateAllowScreenshotCapture(instance.rowData, isAllow);
|
||||
void this._updateAllowScreenshotCapture(instance.rowData, isAllow);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.warn(err);
|
||||
@@ -1081,7 +1081,7 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements
|
||||
*/
|
||||
onEmployeeFavoriteToggled(_event: { isFavorite: boolean; favorite?: IFavorite }): void {
|
||||
// Reload favorites to keep the list in sync
|
||||
this.loadFavoriteEmployees();
|
||||
void this.loadFavoriteEmployees();
|
||||
}
|
||||
|
||||
getEmployeeDisplayName(employee: IEmployee): string {
|
||||
|
||||
@@ -360,6 +360,14 @@
|
||||
"LOADING_MORE": "Loading more..."
|
||||
},
|
||||
"FORM": {
|
||||
"SECTIONS": {
|
||||
"PROFILE": "Profile",
|
||||
"ACCOUNT": "Account",
|
||||
"EMPLOYMENT": "Employment",
|
||||
"HIRING": "Hiring",
|
||||
"PHOTO": "Profile photo",
|
||||
"PHOTO_HINT": "Optional. Click the photo to upload a square image of at least 256 × 256 px."
|
||||
},
|
||||
"USERNAME": "Username",
|
||||
"PASSWORD": "Password",
|
||||
"CONFIRM": "Confirm",
|
||||
@@ -2461,7 +2469,24 @@
|
||||
"ADD_ANOTHER_EMPLOYEE": "Add Another Employee",
|
||||
"FINISHED_ADDING": "I’ve Added All Current Employees",
|
||||
"NEXT": "Next",
|
||||
"PREVIOUS": "Previous"
|
||||
"PREVIOUS": "Previous",
|
||||
"SUBTITLE": "Create an account and employment record for a new team member.",
|
||||
"PROGRESS": "Progress",
|
||||
"STEP_DETAILS": "Details",
|
||||
"STEP_ADD_MORE": "Add more",
|
||||
"STEP_CONFIRM": "Review",
|
||||
"QUEUED": "{{ count }} queued",
|
||||
"QUEUE_HINT": "Queued employees are created together when you finish.",
|
||||
"READY_TITLE": "Employee ready",
|
||||
"READY_HINT": "Add another employee now, or continue to review everyone before creating them.",
|
||||
"REVIEW_TITLE": "Review employees",
|
||||
"REVIEW_HINT": "These employees will be created in {{ organization }}.",
|
||||
"EMPTY": "No employees to add yet.",
|
||||
"CURRENT_ENTRY": "Current entry",
|
||||
"DISCARD_ENTRY": "Discard entry",
|
||||
"CONTINUE": "Continue",
|
||||
"BACK": "Back",
|
||||
"CREATE_EMPLOYEES": "Create employees ({{ count }})"
|
||||
},
|
||||
"NOT_STARTED": "Not Started",
|
||||
"NOT_STARTED_HELP": "Started work on date is not set for this employee. The employee will not be considered in accounts, split expenses etc."
|
||||
|
||||
+197
-143
@@ -1,144 +1,198 @@
|
||||
<nb-card
|
||||
[nbSpinner]="loading"
|
||||
nbSpinnerStatus="primary"
|
||||
nbSpinnerSize="large"
|
||||
>
|
||||
<nb-card-header class="d-flex flex-column">
|
||||
<span class="cancel"
|
||||
><i class="fas fa-times" (click)="closeDialog()"></i
|
||||
></span>
|
||||
<h5 class="title">
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEE' | translate }}
|
||||
</h5>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<nb-stepper
|
||||
[linear]="linear"
|
||||
orientation="horizontal"
|
||||
disableStepNavigation
|
||||
#stepper
|
||||
>
|
||||
<nb-step [label]="step1">
|
||||
<ng-template #step1>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.STEP_1' | translate }}
|
||||
</ng-template>
|
||||
@if (employees.length > 0) {
|
||||
<div class="row">
|
||||
<div class="col m-2 p-0">
|
||||
<nb-tag-list (tagRemove)="onEmployeeRemove($event)">
|
||||
@for (employee of employees; track employee) {
|
||||
<nb-tag
|
||||
removable
|
||||
[text]="employee?.user?.email"
|
||||
></nb-tag>
|
||||
}
|
||||
</nb-tag-list>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<ga-user-basic-info-form
|
||||
#userBasicInfo
|
||||
[isShowRole]="false"
|
||||
[isEmployee]="true"
|
||||
></ga-user-basic-info-form>
|
||||
<div class="text-left">
|
||||
@if (employees.length) {
|
||||
<button
|
||||
class="mr-2"
|
||||
status="basic"
|
||||
outline
|
||||
(click)="nextStep()"
|
||||
size="small"
|
||||
nbButton
|
||||
>
|
||||
{{ 'BUTTONS.CANCEL' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
status="basic"
|
||||
[disabled]="userBasicInfo.form.invalid"
|
||||
outline
|
||||
class="green"
|
||||
nbButton
|
||||
size="small"
|
||||
nbStepperNext
|
||||
>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.NEXT' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</nb-step>
|
||||
<nb-step [label]="step2">
|
||||
<ng-template #step2>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.STEP_2' | translate }}
|
||||
</ng-template>
|
||||
<div class="text-left">
|
||||
<button
|
||||
status="basic"
|
||||
size="small"
|
||||
class="gray"
|
||||
outline
|
||||
nbButton
|
||||
nbStepperPrevious
|
||||
>
|
||||
{{
|
||||
'EMPLOYEES_PAGE.ADD_EMPLOYEES.PREVIOUS' | translate
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
nbButton
|
||||
status="success"
|
||||
size="small"
|
||||
class="mr-3 ml-3"
|
||||
(click)="addEmployee()"
|
||||
>
|
||||
{{
|
||||
'EMPLOYEES_PAGE.ADD_EMPLOYEES.ADD_ANOTHER_EMPLOYEE'
|
||||
| translate
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
status="basic"
|
||||
class="green"
|
||||
size="small"
|
||||
outline
|
||||
nbButton
|
||||
nbStepperNext
|
||||
>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.NEXT' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</nb-step>
|
||||
<nb-step [label]="step3">
|
||||
<ng-template #step3>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.STEP_3' | translate }}
|
||||
</ng-template>
|
||||
<div class="text-left">
|
||||
<button
|
||||
class="gray"
|
||||
status="basic"
|
||||
outline
|
||||
size="small"
|
||||
nbButton
|
||||
nbStepperPrevious
|
||||
>
|
||||
{{
|
||||
'EMPLOYEES_PAGE.ADD_EMPLOYEES.PREVIOUS' | translate
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
size="small"
|
||||
status="success"
|
||||
class="mr-3 ml-3"
|
||||
(click)="add()"
|
||||
nbButton
|
||||
>
|
||||
{{
|
||||
'EMPLOYEES_PAGE.ADD_EMPLOYEES.FINISHED_ADDING'
|
||||
| translate
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</nb-step>
|
||||
</nb-stepper>
|
||||
</nb-card-body>
|
||||
<nb-card class="employee-dialog" [nbSpinner]="loading" nbSpinnerStatus="primary" nbSpinnerSize="large">
|
||||
<nb-card-header class="dialog-header">
|
||||
<div class="header-row">
|
||||
<div class="heading">
|
||||
<h5 class="title">{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEE' | translate }}</h5>
|
||||
<p class="subtitle">{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
nbButton
|
||||
ghost
|
||||
size="small"
|
||||
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
|
||||
(click)="closeDialog()"
|
||||
>
|
||||
<nb-icon icon="close-outline"></nb-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- nb-card only projects header/body/footer into their slots, so the step bar lives in the header -->
|
||||
<nav class="steps" [attr.aria-label]="'EMPLOYEES_PAGE.ADD_EMPLOYEES.PROGRESS' | translate">
|
||||
@for (step of steps; track step; let i = $index) {
|
||||
<div
|
||||
class="step"
|
||||
[class.active]="stepper.selectedIndex === i"
|
||||
[class.done]="stepper.selectedIndex > i"
|
||||
[attr.aria-current]="stepper.selectedIndex === i ? 'step' : null"
|
||||
>
|
||||
<span class="index">
|
||||
@if (stepper.selectedIndex > i) {
|
||||
<nb-icon icon="checkmark-outline"></nb-icon>
|
||||
} @else {
|
||||
{{ i + 1 }}
|
||||
}
|
||||
</span>
|
||||
<span class="name">{{ step | translate }}</span>
|
||||
</div>
|
||||
@if (i < steps.length - 1) {
|
||||
<span class="connector" [class.done]="stepper.selectedIndex > i"></span>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
</nb-card-header>
|
||||
|
||||
<nb-card-body class="dialog-body">
|
||||
<nb-stepper class="stepper" [linear]="linear" orientation="horizontal" disableStepNavigation #stepper>
|
||||
<!-- Step 1: employee details -->
|
||||
<nb-step>
|
||||
@if (employees.length > 0) {
|
||||
<div class="queue-note">
|
||||
<nb-icon icon="people-outline"></nb-icon>
|
||||
<span>
|
||||
<strong>{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.QUEUED' | translate: { count: employees.length } }}</strong>
|
||||
· {{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.QUEUE_HINT' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<ga-user-basic-info-form #userBasicInfo [isShowRole]="false" [isEmployee]="true"></ga-user-basic-info-form>
|
||||
</nb-step>
|
||||
|
||||
<!-- Step 2: add another or continue -->
|
||||
<nb-step>
|
||||
<div class="panel-heading">
|
||||
<h6>{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.READY_TITLE' | translate }}</h6>
|
||||
<p>{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.READY_HINT' | translate }}</p>
|
||||
</div>
|
||||
<ng-container *ngTemplateOutlet="employeeList"></ng-container>
|
||||
</nb-step>
|
||||
|
||||
<!-- Step 3: review and create -->
|
||||
<nb-step>
|
||||
<div class="panel-heading">
|
||||
<h6>{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.REVIEW_TITLE' | translate }}</h6>
|
||||
<p>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.REVIEW_HINT' | translate: { organization: organization?.name } }}
|
||||
</p>
|
||||
</div>
|
||||
<ng-container *ngTemplateOutlet="employeeList"></ng-container>
|
||||
</nb-step>
|
||||
</nb-stepper>
|
||||
</nb-card-body>
|
||||
|
||||
<nb-card-footer class="dialog-footer">
|
||||
@switch (stepper.selectedIndex) {
|
||||
@case (0) {
|
||||
@if (employees.length) {
|
||||
<button type="button" nbButton ghost status="basic" size="small" (click)="nextStep()">
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.DISCARD_ENTRY' | translate }}
|
||||
</button>
|
||||
}
|
||||
<span class="spacer"></span>
|
||||
<button type="button" nbButton ghost status="basic" size="small" (click)="closeDialog()">
|
||||
{{ 'BUTTONS.CANCEL' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
nbButton
|
||||
status="primary"
|
||||
size="small"
|
||||
[disabled]="userBasicInfo?.form?.invalid"
|
||||
(click)="stepper.next()"
|
||||
>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.CONTINUE' | translate }}
|
||||
<nb-icon icon="arrow-forward-outline"></nb-icon>
|
||||
</button>
|
||||
}
|
||||
@case (1) {
|
||||
<button type="button" nbButton ghost status="basic" size="small" (click)="stepper.previous()">
|
||||
<nb-icon icon="arrow-back-outline"></nb-icon>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.BACK' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" nbButton outline status="basic" size="small" (click)="addEmployee()">
|
||||
<nb-icon icon="person-add-outline"></nb-icon>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.ADD_ANOTHER_EMPLOYEE' | translate }}
|
||||
</button>
|
||||
<button type="button" nbButton status="primary" size="small" (click)="stepper.next()">
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.CONTINUE' | translate }}
|
||||
<nb-icon icon="arrow-forward-outline"></nb-icon>
|
||||
</button>
|
||||
}
|
||||
@case (2) {
|
||||
<button type="button" nbButton ghost status="basic" size="small" (click)="stepper.previous()">
|
||||
<nb-icon icon="arrow-back-outline"></nb-icon>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.BACK' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
nbButton
|
||||
status="primary"
|
||||
size="small"
|
||||
[disabled]="loading || pendingCount === 0"
|
||||
(click)="add()"
|
||||
>
|
||||
<nb-icon icon="checkmark-outline"></nb-icon>
|
||||
{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.CREATE_EMPLOYEES' | translate: { count: pendingCount } }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</nb-card-footer>
|
||||
</nb-card>
|
||||
|
||||
<!-- Employees that will be created: the current entry plus the queue -->
|
||||
<ng-template #employeeList>
|
||||
@if (pendingCount === 0) {
|
||||
<div class="empty">
|
||||
<nb-icon icon="people-outline"></nb-icon>
|
||||
<span>{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.EMPTY' | translate }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="employee-list">
|
||||
@if (draftEmployee; as draft) {
|
||||
<li class="employee-row">
|
||||
<ng-container *ngTemplateOutlet="avatar; context: { $implicit: draft }"></ng-container>
|
||||
<div class="identity">
|
||||
<span class="name">{{ draft.firstName }} {{ draft.lastName }}</span>
|
||||
<span class="email">{{ draft.email }}</span>
|
||||
</div>
|
||||
<span class="badge">{{ 'EMPLOYEES_PAGE.ADD_EMPLOYEES.CURRENT_ENTRY' | translate }}</span>
|
||||
</li>
|
||||
}
|
||||
@for (employee of employees; track $index; let i = $index) {
|
||||
<li class="employee-row">
|
||||
<ng-container *ngTemplateOutlet="avatar; context: { $implicit: employee.user }"></ng-container>
|
||||
<div class="identity">
|
||||
<span class="name">{{ employee.user?.firstName }} {{ employee.user?.lastName }}</span>
|
||||
<span class="email">{{ employee.user?.email }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
nbButton
|
||||
ghost
|
||||
size="tiny"
|
||||
status="basic"
|
||||
class="remove"
|
||||
[title]="'BUTTONS.REMOVE' | translate"
|
||||
[attr.aria-label]="'BUTTONS.REMOVE' | translate"
|
||||
(click)="removeEmployee(i)"
|
||||
>
|
||||
<nb-icon icon="close-outline"></nb-icon>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #avatar let-user>
|
||||
<span class="avatar">
|
||||
@if (user?.imageUrl) {
|
||||
<img [src]="user.imageUrl" alt="" />
|
||||
} @else {
|
||||
{{ initials(user) }}
|
||||
}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
+328
-1
@@ -1 +1,328 @@
|
||||
@forward '@shared/_employee-mutation';
|
||||
@use 'themes' as *;
|
||||
@use 'gauzy/_gauzy-overrides' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
// Same filled inputs, selects (tags) and labels as every other Gauzy dialog
|
||||
@include dialog(var(--gauzy-card-1), var(--gauzy-sidebar-background-4));
|
||||
}
|
||||
|
||||
.employee-dialog {
|
||||
width: 640px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100vh - 4rem);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--gauzy-card-1);
|
||||
border: none;
|
||||
border-radius: nb-theme(border-radius);
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Header
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem 0;
|
||||
border-bottom: 1px solid var(--gauzy-border-default-color);
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5rem;
|
||||
color: nb-theme(text-basic-color);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
}
|
||||
|
||||
.close {
|
||||
margin: -0.25rem -0.5rem 0 0;
|
||||
color: nb-theme(text-hint-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Step indicator
|
||||
.steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid var(--gauzy-border-default-color);
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
}
|
||||
|
||||
.index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color);
|
||||
|
||||
nb-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.step.active {
|
||||
color: nb-theme(text-basic-color);
|
||||
font-weight: 500;
|
||||
|
||||
.index {
|
||||
color: nb-theme(text-control-color);
|
||||
background-color: nb-theme(color-primary-default);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.step.done .index {
|
||||
color: nb-theme(color-primary-default);
|
||||
box-shadow: inset 0 0 0 1px nb-theme(color-primary-default);
|
||||
}
|
||||
|
||||
.connector {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
min-width: 1rem;
|
||||
background-color: var(--gauzy-border-default-color);
|
||||
|
||||
&.done {
|
||||
background-color: nb-theme(color-primary-default);
|
||||
}
|
||||
}
|
||||
|
||||
// Narrow dialogs: the three labels plus connectors no longer fit, and the
|
||||
// card clips overflow. Keep every step's number, but name only the current
|
||||
// step (ellipsized if it still does not fit).
|
||||
@media (max-width: 479.98px) {
|
||||
gap: 0.5rem;
|
||||
|
||||
.step:not(.active) .name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step.active {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.connector {
|
||||
min-width: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body — scrolls on its own so header and footer stay visible
|
||||
.dialog-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// The custom indicator above replaces Nebular's step header
|
||||
.stepper ::ng-deep {
|
||||
.header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
background-color: var(--gauzy-hover-tint);
|
||||
|
||||
nb-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
color: nb-theme(color-primary-default);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
color: nb-theme(text-basic-color);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
color: nb-theme(text-basic-color);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Employee list (steps 2 and 3)
|
||||
.employee-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color);
|
||||
}
|
||||
|
||||
.employee-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--gauzy-border-default-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gauzy-hover-tint);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name,
|
||||
.email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: nb-theme(text-basic-color);
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 0.75rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
color: nb-theme(color-primary-default);
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
box-shadow: inset 0 0 0 1px nb-theme(color-primary-transparent-default);
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex-shrink: 0;
|
||||
color: nb-theme(text-hint-color);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: nb-theme(text-hint-color);
|
||||
background-color: var(--gauzy-hover-tint);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color);
|
||||
|
||||
nb-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-top: 1px solid var(--gauzy-border-default-color);
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[nbButton] {
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
+42
-6
@@ -1,6 +1,6 @@
|
||||
import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core';
|
||||
import { UntypedFormGroup } from '@angular/forms';
|
||||
import { NbDialogRef, NbStepperComponent, NbTagComponent } from '@nebular/theme';
|
||||
import { NbDialogRef, NbStepperComponent } from '@nebular/theme';
|
||||
import { filter, firstValueFrom, tap } from 'rxjs';
|
||||
import { IEmployee, IUser, IEmployeeCreateInput, CrudActionEnum, IOrganization } from '@gauzy/contracts';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
@@ -31,6 +31,13 @@ export class EmployeeMutationComponent implements OnInit, AfterViewInit {
|
||||
public employees: IEmployeeCreateInput[] = [];
|
||||
public organization: IOrganization;
|
||||
|
||||
/** Labels of the custom step indicator, in stepper order. */
|
||||
protected readonly steps: string[] = [
|
||||
'EMPLOYEES_PAGE.ADD_EMPLOYEES.STEP_DETAILS',
|
||||
'EMPLOYEES_PAGE.ADD_EMPLOYEES.STEP_ADD_MORE',
|
||||
'EMPLOYEES_PAGE.ADD_EMPLOYEES.STEP_CONFIRM'
|
||||
];
|
||||
|
||||
constructor(
|
||||
protected readonly dialogRef: NbDialogRef<EmployeeMutationComponent>,
|
||||
protected readonly organizationsService: OrganizationsService,
|
||||
@@ -134,7 +141,6 @@ export class EmployeeMutationComponent implements OnInit, AfterViewInit {
|
||||
this.loading = true;
|
||||
// Create employees in bulk using service
|
||||
const employees: IEmployee[] = await firstValueFrom(this.employeesService.createBulk(this.employees));
|
||||
this.loading = false; // Set loading state to false regardless of success or failure
|
||||
|
||||
// Update employee action in store
|
||||
this._employeeStore.employeeAction = {
|
||||
@@ -147,15 +153,45 @@ export class EmployeeMutationComponent implements OnInit, AfterViewInit {
|
||||
} catch (error) {
|
||||
// Handle errors using error handler service
|
||||
this.errorHandler.handleError(error);
|
||||
} finally {
|
||||
// Reset on failure too, or the spinner stays up and "Create" stays disabled
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removed one employee in the array of employees.
|
||||
* @param tag
|
||||
* Removes one queued employee.
|
||||
*
|
||||
* @param index Position of the employee in the queue.
|
||||
*/
|
||||
onEmployeeRemove(tag: NbTagComponent): void {
|
||||
this.employees = this.employees.filter((t: IEmployeeCreateInput) => t.user.email !== tag.text);
|
||||
protected removeEmployee(index: number): void {
|
||||
this.employees = this.employees.filter((_, i: number) => i !== index);
|
||||
}
|
||||
|
||||
/**
|
||||
* The employee currently entered in the form, or null when the form is not valid.
|
||||
* `add()` includes it in the bulk request, so the review lists it too.
|
||||
*/
|
||||
protected get draftEmployee(): Pick<IUser, 'firstName' | 'lastName' | 'email' | 'imageUrl'> | null {
|
||||
if (!this.form?.valid) return null;
|
||||
const { firstName, lastName, email, imageUrl } = this.form.getRawValue();
|
||||
return { firstName, lastName, email, imageUrl };
|
||||
}
|
||||
|
||||
/** Total employees that "Finish" will create. */
|
||||
protected get pendingCount(): number {
|
||||
return this.employees.length + (this.draftEmployee ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initials for the avatar fallback of an employee row.
|
||||
*/
|
||||
protected initials(user: Pick<IUser, 'firstName' | 'lastName' | 'email'>): string {
|
||||
const letters = [user?.firstName, user?.lastName]
|
||||
.filter(Boolean)
|
||||
.map((part: string) => part.trim().charAt(0))
|
||||
.join('');
|
||||
return (letters || user?.email?.charAt(0) || '?').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-10
@@ -4,8 +4,10 @@
|
||||
{{ 'TAGS_PAGE.HEADER' | translate }}
|
||||
</label>
|
||||
}
|
||||
<!-- `tags-select` is copied onto the body-appended panel; _overrides.scss sizes its rows -->
|
||||
<ng-select
|
||||
id="addTags"
|
||||
class="tags-select"
|
||||
[items]="tags"
|
||||
[multiple]="multiple"
|
||||
bindLabel="name"
|
||||
@@ -18,14 +20,15 @@
|
||||
[placeholder]="'MENU.TAGS' | translate"
|
||||
>
|
||||
<ng-template ng-option-tmp let-tag="item" let-tag$="item$">
|
||||
<div class="tag-option">
|
||||
<div class="tag-option" [style.--tag-color]="background(tag.color)">
|
||||
@if (multiple) {
|
||||
<input type="checkbox" class="tag-option-check" [ngModel]="tag$.selected" />
|
||||
<span class="tag-check" [class.tag-check--on]="tag$.selected" aria-hidden="true">
|
||||
@if (tag$.selected) {
|
||||
<nb-icon icon="checkmark-outline"></nb-icon>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<nb-badge
|
||||
[style.background]="background(tag.color)"
|
||||
class="tag-color"
|
||||
></nb-badge>
|
||||
<span class="tag-dot"></span>
|
||||
<span class="text">{{ tag.name }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -43,8 +46,7 @@
|
||||
; track
|
||||
item) {
|
||||
<div class="ng-value">
|
||||
<nb-badge class="tag-color tag-label" [style.background]="background(item.color)"
|
||||
[style.color]="backgroundContrast(item.color)" [text]="item.name" (click)="clear(item)"></nb-badge>
|
||||
<nb-badge class="tag-color tag-label" [style.--tag-color]="background(item.color)" [text]="item.name" (click)="clear(item)"></nb-badge>
|
||||
</div>
|
||||
}
|
||||
@if (selectedTags && selectedTags.length > noOfTagsFits) {
|
||||
@@ -62,8 +64,7 @@
|
||||
@for (item of selectedTags; track item) {
|
||||
<div class="ng-value">
|
||||
<nb-badge class="tag-color tag-label"
|
||||
[style.background]="background(item.color)"
|
||||
[style.color]="backgroundContrast(item.color)"
|
||||
[style.--tag-color]="background(item.color)"
|
||||
[text]="item.name"
|
||||
(click)="clear(item)"
|
||||
></nb-badge>
|
||||
|
||||
+53
-59
@@ -19,91 +19,85 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// The state box. A bare `<input type="checkbox">` is the one control on the row
|
||||
// the theme never reached — and worse, the app-wide `input` rule in
|
||||
// `text-inputs-overrides` treats it as a text field, so it was handed a text
|
||||
// field's `min-height`, radius, fill and inset ring: a tall soft-cornered box
|
||||
// next to a 1.25rem line of text. Strip the control chrome off it and let it be
|
||||
// a checkbox.
|
||||
//
|
||||
// `accent-color` is the single property that repaints a NATIVE checkbox, and it
|
||||
// resolves the same brand colour `nb-checkbox` is drawn in, so a checked tag row
|
||||
// matches a checked box anywhere else in the app.
|
||||
//
|
||||
// `pointer-events: none` because the row, not the box, is the click target:
|
||||
// ng-select selects on the row's own click, and the box is bound one-way
|
||||
// (`[ngModel]`), so a click that landed on it used to flip it and then have the
|
||||
// change detection flip it back.
|
||||
.tag-option-check {
|
||||
// The state box. Drawn rather than a native `<input type="checkbox">`: the
|
||||
// native box renders as a bright white square on the dark themes, and the
|
||||
// app-wide `input` rules kept treating it as a text field. The row, not the
|
||||
// box, is the click target (ng-select selects on the row's own click).
|
||||
.tag-check {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: unset;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
accent-color: nb-theme(color-primary-default);
|
||||
pointer-events: none;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: inset 0 0 0 1px nb-theme(border-basic-color-5);
|
||||
transition:
|
||||
background-color 0.12s ease-in-out,
|
||||
box-shadow 0.12s ease-in-out;
|
||||
|
||||
nb-icon {
|
||||
font-size: 0.75rem;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: nb-theme(text-control-color);
|
||||
}
|
||||
|
||||
&--on {
|
||||
background-color: nb-theme(color-primary-default);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// The tag's colour in a dropdown row.
|
||||
//
|
||||
// `:not(.tag-label)` because the selected CHIP in the trigger carries this class
|
||||
// too, and that one is a text pill, not a swatch — the two used to share a
|
||||
// `width: 1rem; height: 1rem` box that only the chip's `display: unset` (which
|
||||
// computes to `inline`, where width and height do not apply) kept from taking
|
||||
// effect. The split says which of the two each rule is for.
|
||||
//
|
||||
// A dot rather than the 16px square with 10px of margin on either side it was:
|
||||
// against the row's 1.25rem line box that square read as a second control, and
|
||||
// the margins pushed the tag name a third of the way across the row.
|
||||
.tag-color:not(.tag-label) {
|
||||
position: unset;
|
||||
// The tag's colour: a dot with a soft halo in the same colour, so saturated
|
||||
// and pale tags (white "Verified", grey "Urgent") read with equal weight on
|
||||
// either theme. `--tag-color` is set per row from the tag's own colour.
|
||||
.tag-dot {
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin: 0 0.125rem;
|
||||
border-radius: 50%;
|
||||
// The colours these dots carry are user-chosen, so a pale tag has to stay
|
||||
// visible on a pale panel. Same hairline the overlay surfaces are drawn with.
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
|
||||
background-color: var(--tag-color);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--tag-color) 22%, transparent),
|
||||
inset 0 0 0 1px rgba(126, 126, 143, 0.35);
|
||||
}
|
||||
|
||||
// ── A selected tag, in the trigger ───────────────────────────────────────────
|
||||
// A filled pill in the tag's own colour, with the text colour computed against
|
||||
// it (`backgroundContrast` in the component) so a pale tag is readable.
|
||||
//
|
||||
// It relied on `display: unset` to escape the swatch box above; now that the
|
||||
// swatch is scoped away from it, it can declare its own box. `inline-block` and
|
||||
// not `inline-flex`: the text is a child text node of `nb-badge`, and
|
||||
// `text-overflow` only engages in a block container.
|
||||
// A tinted label rather than a solid fill. Tag colours are user-chosen and
|
||||
// fully saturated (pure red, yellow, black), so solid pills with white/black
|
||||
// text clashed with each other and with the dark surface. Every chip now has
|
||||
// the same weight: a light wash of the tag colour, a hairline ring in it, and
|
||||
// text mixed from the tag colour toward the theme's text colour — lighter on
|
||||
// dark themes, darker on light ones — so yellow and black stay readable on
|
||||
// either.
|
||||
.tag-label {
|
||||
position: unset;
|
||||
// `inline-block`, not flex: the name is a bare text node of `nb-badge`, and
|
||||
// `text-overflow` only engages in a block container.
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
// NO `border-radius` on purpose, where there used to be a `2rem` stadium. This
|
||||
// is an `nb-badge`, and so is the tag chip a smart-table row renders
|
||||
// (`notes-with-tags`, which overrides everything about the chip EXCEPT its
|
||||
// corner). Leaving it alone is what makes a tag look the same in the field you
|
||||
// pick it in as in the table it ends up in — both resolve `badge-border-radius`.
|
||||
// Same `badge-border-radius` as the tag chips in smart-table rows.
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1rem;
|
||||
letter-spacing: 0em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
// The chip removes the tag on click (`(click)="clear(item)"`), which nothing
|
||||
// on it said. It is the only affordance in the trigger that is not the
|
||||
// trigger itself.
|
||||
color: color-mix(in srgb, var(--tag-color) 55%, nb-theme(text-basic-color)) !important;
|
||||
background-color: color-mix(in srgb, var(--tag-color) 14%, transparent) !important;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tag-color) 40%, transparent);
|
||||
// The chip removes the tag on click (`(click)="clear(item)"`).
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--tag-color) 24%, transparent) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// The "+N" chip at the end of the row: the tags that did not fit. Neutral, so it
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TagsColorInputComponent } from './tags-color-input.component';
|
||||
import { NbBadgeModule, NbSelectModule } from '@nebular/theme';
|
||||
import { NbBadgeModule, NbIconModule, NbSelectModule } from '@nebular/theme';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { TagsService } from '@gauzy/ui-core/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, NbSelectModule, NbBadgeModule, FormsModule, NgSelectModule, TranslateModule.forChild()],
|
||||
imports: [CommonModule, NbSelectModule, NbBadgeModule, NbIconModule, FormsModule, NgSelectModule, TranslateModule.forChild()],
|
||||
exports: [TagsColorInputComponent],
|
||||
declarations: [TagsColorInputComponent],
|
||||
providers: [TagsService]
|
||||
|
||||
+263
-305
@@ -1,306 +1,264 @@
|
||||
<form [formGroup]="form" autocomplete-off no-validate>
|
||||
<div class="row">
|
||||
<div class="col-3">
|
||||
<div class="form-group row" [hidden]="form.get('imageUrl').invalid">
|
||||
<div class="col-sm-12">
|
||||
<div class="row preview-img" [hidden]="!showImageMeta">
|
||||
<div class="image">
|
||||
<img
|
||||
#imagePreview
|
||||
[src]="form.get('imageUrl').value"
|
||||
alt="Invalid image"
|
||||
class="img-rounded"
|
||||
/>
|
||||
<button class="trash-icon" nbButton status="basic" size="small" (click)="deleteImageUrl()">
|
||||
<nb-icon
|
||||
status="danger"
|
||||
[title]="'FORM.PLACEHOLDERS.REMOVE_IMAGE' | translate"
|
||||
icon="trash-2-outline"
|
||||
[title]="'FORM.PLACEHOLDERS.REMOVE_IMAGE' | translate"
|
||||
></nb-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row preview-img" [hidden]="showImageMeta">
|
||||
<div class="image">
|
||||
<i class="far fa-image"></i>
|
||||
<button class="trash-icon" nbButton status="basic" size="small">
|
||||
<nb-icon
|
||||
status="danger"
|
||||
[title]="'FORM.PLACEHOLDERS.REMOVE_IMAGE' | translate"
|
||||
icon="trash-2-outline"
|
||||
></nb-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="form-group">
|
||||
<label for="inputImageUrl" class="label">
|
||||
{{ 'FORM.LABELS.IMAGE_URL' | translate }}
|
||||
</label>
|
||||
<ngx-file-uploader-input
|
||||
id="inputImageUrl"
|
||||
[placeholder]="'FORM.PLACEHOLDERS.UPLOADER_PLACEHOLDER' | translate"
|
||||
[fileUrl]="form.get('imageUrl').value"
|
||||
(uploadedImageAsset)="updateImageAsset($event)"
|
||||
(uploadedImgUrl)="updateImageUrl($event)"
|
||||
></ngx-file-uploader-input>
|
||||
</div>
|
||||
@if (form.get('imageUrl')?.value !== null && FormHelpers.isInvalidControl(form, 'imageUrl')) {
|
||||
<p class="caption status-danger mt-1">
|
||||
{{ 'FORM.ERROR.INVALID_IMAGE_URL' | translate }}
|
||||
</p>
|
||||
}
|
||||
<form [formGroup]="form" class="basic-info" autocomplete-off no-validate>
|
||||
<!-- Profile -->
|
||||
<section class="form-section">
|
||||
<h6 class="section-title">{{ 'FORM.SECTIONS.PROFILE' | translate }}</h6>
|
||||
<div class="photo">
|
||||
<div class="avatar" [class.avatar--hover]="avatarHover">
|
||||
<ngx-image-uploader
|
||||
(changeHoverState)="avatarHover = $event"
|
||||
(uploadedImageAsset)="updateImageAsset($event)"
|
||||
(uploadImageAssetError)="handleImageUploadError($event)"
|
||||
></ngx-image-uploader>
|
||||
<!-- Always rendered: the load/error hooks on #imagePreview validate imageUrl -->
|
||||
<img
|
||||
#imagePreview
|
||||
class="avatar__image"
|
||||
[hidden]="!hasAvatar"
|
||||
[src]="form.get('imageUrl').value"
|
||||
alt="Profile photo"
|
||||
/>
|
||||
@if (!hasAvatar) {
|
||||
<div class="avatar__image avatar__image--empty">
|
||||
<nb-icon icon="person"></nb-icon>
|
||||
</div>
|
||||
}
|
||||
<span class="avatar__scrim" aria-hidden="true"></span>
|
||||
<span class="avatar__badge" aria-hidden="true">
|
||||
<nb-icon icon="camera-outline"></nb-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div class="photo__info">
|
||||
<span class="photo__title">{{ 'FORM.SECTIONS.PHOTO' | translate }}</span>
|
||||
@if (form.get('imageUrl')?.value !== null && FormHelpers.isInvalidControl(form, 'imageUrl')) {
|
||||
<span class="photo__hint status-danger">{{ 'FORM.ERROR.INVALID_IMAGE_URL' | translate }}</span>
|
||||
} @else {
|
||||
<span class="photo__hint">{{ 'FORM.SECTIONS.PHOTO_HINT' | translate }}</span>
|
||||
}
|
||||
@if (showImageMeta) {
|
||||
<div class="photo__actions">
|
||||
<button type="button" nbButton ghost status="danger" size="tiny" (click)="deleteImageUrl()">
|
||||
{{ 'FORM.PLACEHOLDERS.REMOVE_IMAGE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label for="firstName" class="label">
|
||||
{{ 'FORM.LABELS.FIRST_NAME' | translate }}
|
||||
</label>
|
||||
<input
|
||||
nbInput
|
||||
type="text"
|
||||
id="firstName"
|
||||
fullWidth
|
||||
[placeholder]="'FORM.PLACEHOLDERS.FIRST_NAME' | translate"
|
||||
formControlName="firstName"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'firstName') ? 'danger' : 'basic'"
|
||||
/>
|
||||
@if (FormHelpers.isInvalidControl(form, 'firstName')) {
|
||||
<p class="caption status-danger">
|
||||
{{ 'TOASTR.MESSAGE.NAME_REQUIRED' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lastName" class="label">{{ 'FORM.LABELS.LAST_NAME' | translate }}</label>
|
||||
<input
|
||||
nbInput
|
||||
type="text"
|
||||
id="lastName"
|
||||
fullWidth
|
||||
[placeholder]="'FORM.PLACEHOLDERS.LAST_NAME' | translate"
|
||||
formControlName="lastName"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'lastName') ? 'danger' : 'basic'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="firstName" class="label">
|
||||
{{ 'FORM.LABELS.FIRST_NAME' | translate }}
|
||||
</label>
|
||||
<input
|
||||
nbInput
|
||||
type="text"
|
||||
id="firstName"
|
||||
fullWidth
|
||||
[placeholder]="'FORM.PLACEHOLDERS.FIRST_NAME' | translate"
|
||||
formControlName="firstName"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'firstName') ? 'danger' : 'basic'"
|
||||
/>
|
||||
@if (FormHelpers.isInvalidControl(form, 'firstName')) {
|
||||
<p class="caption status-danger mt-1">
|
||||
{{ 'TOASTR.MESSAGE.NAME_REQUIRED' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="lastName" class="label">{{ 'FORM.LABELS.LAST_NAME' | translate }}</label>
|
||||
<input
|
||||
nbInput
|
||||
type="text"
|
||||
id="lastName"
|
||||
fullWidth
|
||||
[placeholder]="'FORM.PLACEHOLDERS.LAST_NAME' | translate"
|
||||
formControlName="lastName"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'lastName') ? 'danger' : 'basic'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">{{ 'FORM.USERNAME' | translate }}</label>
|
||||
<input
|
||||
nbInput
|
||||
type="text"
|
||||
id="username"
|
||||
fullWidth
|
||||
[placeholder]="'FORM.USERNAME' | translate"
|
||||
formControlName="username"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'username') ? 'danger' : 'basic'"
|
||||
/>
|
||||
</div>
|
||||
@if (FormHelpers.isInvalidControl(form, 'username')) {
|
||||
<p class="caption status-danger mt-1">
|
||||
{{ 'TOASTR.MESSAGE.NAME_REQUIRED' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<div class="form-group">
|
||||
<label for="email" class="label">{{ 'FORM.EMAIL' | translate }}</label>
|
||||
<input
|
||||
autofocus
|
||||
pattern=".+@.+\..+"
|
||||
type="email"
|
||||
#email
|
||||
nbInput
|
||||
type="email"
|
||||
id="email"
|
||||
fullWidth
|
||||
[placeholder]="'FORM.EMAIL' | translate"
|
||||
formControlName="email"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'email') ? 'danger' : 'basic'"
|
||||
/>
|
||||
@if (FormHelpers.isInvalidControl(form, 'email')) {
|
||||
@if (form.controls.email.errors?.pattern) {
|
||||
<p class="caption status-danger mt-1">
|
||||
{{ 'TOASTR.MESSAGE.EMAIL_SHOULD_BE_REAL' | translate }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<ngx-password-form-field
|
||||
id="password"
|
||||
[placeholder]="'FORM.PASSWORD' | translate"
|
||||
[label]="'FORM.PASSWORD' | translate"
|
||||
[ctrl]="form.controls.password"
|
||||
formControlName="password"
|
||||
></ngx-password-form-field>
|
||||
@if (FormHelpers.isInvalidControl(form, 'password')) {
|
||||
<p class="caption status-danger mt-1">
|
||||
{{ 'TOASTR.MESSAGE.PASSWORD_REQUIRED' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@if (isShowRole) {
|
||||
<div class="col">
|
||||
<ngx-role-form-field
|
||||
id="role"
|
||||
formControlName="role"
|
||||
[placeholder]="'FORM.PLACEHOLDERS.ROLE' | translate"
|
||||
[label]="'FORM.LABELS.ROLE' | translate"
|
||||
[excludes]="excludes"
|
||||
(selectedChange)="onSelectionChange($event)"
|
||||
></ngx-role-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (isEmployee) {
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="startedWork" class="label">{{ 'FORM.LABELS.START_DATE' | translate }}</label>
|
||||
<input
|
||||
[nbDatepicker]="startWorkOnDatepicker"
|
||||
nbInput
|
||||
fullWidth
|
||||
[placeholder]="'FORM.PLACEHOLDERS.START_DATE' | translate"
|
||||
formControlName="startedWorkOn"
|
||||
/>
|
||||
<nb-datepicker #startWorkOnDatepicker></nb-datepicker>
|
||||
@if (form.get('startedWorkOn').touched && form.get('startedWorkOn').value == null) {
|
||||
<div class="notes">
|
||||
<p>
|
||||
{{ 'FORM.NOTIFICATIONS.STARTED_WORK_ON' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (isCandidate) {
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="appliedDate" class="label">{{ 'FORM.LABELS.APPLIED_DATE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="appliedDate"
|
||||
formControlName="appliedDate"
|
||||
nbInput
|
||||
[nbDatepicker]="appliedDatePicker"
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
/>
|
||||
<nb-datepicker #appliedDatePicker></nb-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (isEmployee || isCandidate) {
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="rejectDate" class="label">{{ 'FORM.LABELS.REJECT_DATE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="rejectDate"
|
||||
formControlName="rejectDate"
|
||||
nbInput
|
||||
[nbDatepicker]="rejectDatePicker"
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'rejectDate') ? 'danger' : 'basic'"
|
||||
/>
|
||||
<nb-datepicker #rejectDatePicker></nb-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isEmployee) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="offerDate" class="label">{{ 'FORM.LABELS.OFFER_DATE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="offerDate"
|
||||
formControlName="offerDate"
|
||||
nbInput
|
||||
[nbDatepicker]="offerDatePicker"
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
/>
|
||||
<nb-datepicker #offerDatePicker></nb-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="acceptDate" class="label">{{ 'FORM.LABELS.ACCEPT_DATE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="acceptDate"
|
||||
formControlName="acceptDate"
|
||||
nbInput
|
||||
[nbDatepicker]="acceptDatePicker"
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'acceptDate') ? 'danger' : 'basic'"
|
||||
/>
|
||||
<nb-datepicker #acceptDatePicker></nb-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<ga-tags-color-input
|
||||
[selectedTags]="form.get('tags').value"
|
||||
(selectedTagsEvent)="selectedTagsHandler($event)"
|
||||
[isOrgLevel]="true"
|
||||
>
|
||||
</ga-tags-color-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (isCandidate) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<label for="source" class="label">{{ 'FORM.LABELS.SOURCE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="source"
|
||||
formControlName="source"
|
||||
nbInput
|
||||
[placeholder]="'POP_UPS.SOURCE' | translate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (isShowRole) {
|
||||
@if (enableEmployee()) {
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="form-group">
|
||||
<nb-checkbox formControlName="featureAsEmployee">
|
||||
{{ 'FORM.LABELS.ENABLE_EMPLOYEE_FEATURES' | translate }}
|
||||
</nb-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
<!-- Account -->
|
||||
<section class="form-section">
|
||||
<h6 class="section-title">{{ 'FORM.SECTIONS.ACCOUNT' | translate }}</h6>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label for="email" class="label">{{ 'FORM.EMAIL' | translate }}</label>
|
||||
<input
|
||||
autofocus
|
||||
pattern=".+@.+\..+"
|
||||
#email
|
||||
nbInput
|
||||
type="email"
|
||||
id="email"
|
||||
fullWidth
|
||||
[placeholder]="'FORM.EMAIL' | translate"
|
||||
formControlName="email"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'email') ? 'danger' : 'basic'"
|
||||
/>
|
||||
@if (FormHelpers.isInvalidControl(form, 'email') && form.controls.email.errors?.pattern) {
|
||||
<p class="caption status-danger">
|
||||
{{ 'TOASTR.MESSAGE.EMAIL_SHOULD_BE_REAL' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">{{ 'FORM.USERNAME' | translate }}</label>
|
||||
<input
|
||||
nbInput
|
||||
type="text"
|
||||
id="username"
|
||||
fullWidth
|
||||
[placeholder]="'FORM.USERNAME' | translate"
|
||||
formControlName="username"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'username') ? 'danger' : 'basic'"
|
||||
/>
|
||||
@if (FormHelpers.isInvalidControl(form, 'username')) {
|
||||
<p class="caption status-danger">
|
||||
{{ 'TOASTR.MESSAGE.NAME_REQUIRED' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="password">
|
||||
<ngx-password-form-field
|
||||
id="password"
|
||||
[placeholder]="'FORM.PASSWORD' | translate"
|
||||
[label]="'FORM.PASSWORD' | translate"
|
||||
[ctrl]="form.controls.password"
|
||||
formControlName="password"
|
||||
></ngx-password-form-field>
|
||||
@if (FormHelpers.isInvalidControl(form, 'password')) {
|
||||
<p class="caption status-danger">
|
||||
{{ 'TOASTR.MESSAGE.PASSWORD_REQUIRED' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@if (isShowRole) {
|
||||
<div>
|
||||
<ngx-role-form-field
|
||||
id="role"
|
||||
formControlName="role"
|
||||
[placeholder]="'FORM.PLACEHOLDERS.ROLE' | translate"
|
||||
[label]="'FORM.LABELS.ROLE' | translate"
|
||||
[excludes]="excludes"
|
||||
(selectedChange)="onSelectionChange($event)"
|
||||
></ngx-role-form-field>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isShowRole && enableEmployee()) {
|
||||
<div class="form-group">
|
||||
<nb-checkbox formControlName="featureAsEmployee">
|
||||
{{ 'FORM.LABELS.ENABLE_EMPLOYEE_FEATURES' | translate }}
|
||||
</nb-checkbox>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Employment / hiring dates -->
|
||||
@if (isEmployee || isCandidate) {
|
||||
<section class="form-section">
|
||||
<h6 class="section-title">
|
||||
{{ (isCandidate ? 'FORM.SECTIONS.HIRING' : 'FORM.SECTIONS.EMPLOYMENT') | translate }}
|
||||
</h6>
|
||||
<div class="grid">
|
||||
@if (isEmployee) {
|
||||
<div class="form-group">
|
||||
<label for="startedWork" class="label">{{ 'FORM.LABELS.START_DATE' | translate }}</label>
|
||||
<input
|
||||
id="startedWork"
|
||||
[nbDatepicker]="startWorkOnDatepicker"
|
||||
nbInput
|
||||
fullWidth
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
formControlName="startedWorkOn"
|
||||
/>
|
||||
<nb-datepicker #startWorkOnDatepicker></nb-datepicker>
|
||||
@if (form.get('startedWorkOn').touched && form.get('startedWorkOn').value == null) {
|
||||
<p class="caption hint">
|
||||
<nb-icon icon="info-outline"></nb-icon>
|
||||
{{ 'FORM.NOTIFICATIONS.STARTED_WORK_ON' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="offerDate" class="label">{{ 'FORM.LABELS.OFFER_DATE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="offerDate"
|
||||
formControlName="offerDate"
|
||||
nbInput
|
||||
[nbDatepicker]="offerDatePicker"
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
/>
|
||||
<nb-datepicker #offerDatePicker></nb-datepicker>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="acceptDate" class="label">{{ 'FORM.LABELS.ACCEPT_DATE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="acceptDate"
|
||||
formControlName="acceptDate"
|
||||
nbInput
|
||||
[nbDatepicker]="acceptDatePicker"
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'acceptDate') ? 'danger' : 'basic'"
|
||||
/>
|
||||
<nb-datepicker #acceptDatePicker></nb-datepicker>
|
||||
</div>
|
||||
}
|
||||
@if (isCandidate) {
|
||||
<div class="form-group">
|
||||
<label for="appliedDate" class="label">{{ 'FORM.LABELS.APPLIED_DATE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="appliedDate"
|
||||
formControlName="appliedDate"
|
||||
nbInput
|
||||
[nbDatepicker]="appliedDatePicker"
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
/>
|
||||
<nb-datepicker #appliedDatePicker></nb-datepicker>
|
||||
</div>
|
||||
}
|
||||
<div class="form-group">
|
||||
<label for="rejectDate" class="label">{{ 'FORM.LABELS.REJECT_DATE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="rejectDate"
|
||||
formControlName="rejectDate"
|
||||
nbInput
|
||||
[nbDatepicker]="rejectDatePicker"
|
||||
[placeholder]="'POP_UPS.PICK_DATE' | translate"
|
||||
[status]="FormHelpers.isInvalidControl(form, 'rejectDate') ? 'danger' : 'basic'"
|
||||
/>
|
||||
<nb-datepicker #rejectDatePicker></nb-datepicker>
|
||||
</div>
|
||||
@if (isCandidate) {
|
||||
<div class="form-group">
|
||||
<label for="source" class="label">{{ 'FORM.LABELS.SOURCE' | translate }}</label>
|
||||
<input
|
||||
fullWidth
|
||||
id="source"
|
||||
formControlName="source"
|
||||
nbInput
|
||||
[placeholder]="'POP_UPS.SOURCE' | translate"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="form-group">
|
||||
<ga-tags-color-input
|
||||
[selectedTags]="form.get('tags').value"
|
||||
(selectedTagsEvent)="selectedTagsHandler($event)"
|
||||
[isOrgLevel]="true"
|
||||
></ga-tags-color-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+168
-42
@@ -1,49 +1,175 @@
|
||||
@use 'themes' as *;
|
||||
|
||||
.preview-img {
|
||||
padding-left: 14px;
|
||||
padding-right: 16px;
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.remove-icon {
|
||||
div {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
padding-left: 7px;
|
||||
padding-right: 7px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.notes {
|
||||
text-indent: 1em;
|
||||
max-width: 360px;
|
||||
p {
|
||||
margin: 0;
|
||||
color: #eac72d;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 300;
|
||||
line-height: initial;
|
||||
}
|
||||
.basic-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
height: 98px;
|
||||
width: 130px;
|
||||
border-radius: nb-theme(border-radius) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(126, 126, 143, 0.1);
|
||||
.img-rounded {
|
||||
object-fit: cover;
|
||||
max-height: 98px;
|
||||
max-width: 130px;
|
||||
border-radius: nb-theme(border-radius) !important;
|
||||
}
|
||||
.trash-icon {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
& + & {
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--gauzy-border-default-color);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
// Field wrappers own their spacing through the grid gap.
|
||||
.grid .form-group,
|
||||
.grid ::ng-deep .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.caption.hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
|
||||
nb-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.875rem;
|
||||
color: nb-theme(color-warning-default);
|
||||
}
|
||||
}
|
||||
|
||||
// Profile photo — same avatar uploader as the Edit Employee page
|
||||
.photo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.photo__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.photo__title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
color: nb-theme(text-basic-color);
|
||||
}
|
||||
|
||||
.photo__hint {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: nb-theme(text-hint-color);
|
||||
}
|
||||
|
||||
.photo__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
border-radius: nb-theme(border-radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gauzy-border-default-color);
|
||||
background-color: var(--gauzy-sidebar-background-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar__image--empty {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
color: nb-theme(text-hint-color);
|
||||
|
||||
nb-icon {
|
||||
font-size: 3.75rem;
|
||||
width: 3.75rem;
|
||||
height: 3.75rem;
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: #000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.avatar__badge {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
inset-inline-end: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: nb-theme(color-primary-500);
|
||||
color: nb-theme(text-control-color);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
nb-icon {
|
||||
font-size: 0.875rem;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar--hover {
|
||||
.avatar__scrim {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.avatar__badge {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export class BasicInfoFormComponent extends TranslationBaseComponent implements
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.excludeRoles();
|
||||
void this.excludeRoles();
|
||||
this._store.selectedOrganization$
|
||||
.pipe(
|
||||
distinctUntilChange(),
|
||||
@@ -147,7 +147,8 @@ export class BasicInfoFormComponent extends TranslationBaseComponent implements
|
||||
async excludeRoles(): Promise<void> {
|
||||
const hasSuperAdminRole = await firstValueFrom(this._authService.hasRole([RolesEnum.SUPER_ADMIN]));
|
||||
if (!hasSuperAdminRole) {
|
||||
this.excludes.push(RolesEnum.SUPER_ADMIN);
|
||||
// A new array, not push(): the role field only sees a changed reference
|
||||
this.excludes = [...this.excludes, RolesEnum.SUPER_ADMIN];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +166,21 @@ export class BasicInfoFormComponent extends TranslationBaseComponent implements
|
||||
return this.form.get('imageUrl') && this.form.get('imageUrl').value;
|
||||
}
|
||||
|
||||
/** True while the pointer is over the avatar uploader. */
|
||||
protected avatarHover = false;
|
||||
|
||||
/** An image is set and loaded without error. */
|
||||
protected get hasAvatar(): boolean {
|
||||
return !!this.showImageMeta && !this.form.get('imageUrl').invalid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a failed avatar upload.
|
||||
*/
|
||||
protected handleImageUploadError(error: any): void {
|
||||
this._errorHandlingService.handleError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a user with different roles
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, OnInit, OnDestroy, Input, forwardRef, EventEmitter, Output } from '@angular/core';
|
||||
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { filter, map, Observable, of as observableOf } from 'rxjs';
|
||||
import { filter, Observable, of as observableOf } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { NbComponentSize } from '@nebular/theme';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
@@ -36,9 +36,17 @@ export class RoleFormFieldComponent implements OnInit, OnDestroy {
|
||||
return this._excludes;
|
||||
}
|
||||
@Input() set excludes(value: RolesEnum[]) {
|
||||
this._excludes = value;
|
||||
this._excludes = value || [];
|
||||
// The parent can resolve its excludes after the roles have loaded
|
||||
// (e.g. an async permission check), so re-filter what is already shown.
|
||||
this.applyExcludes();
|
||||
}
|
||||
|
||||
/** Every tenant role as fetched, before `excludes` is applied. */
|
||||
private _allRoles: IRole[] = [];
|
||||
/** Set once `getAll()` has returned; until then no selection can be judged. */
|
||||
private _rolesLoaded = false;
|
||||
|
||||
// ID attribute for the field and for attribute for the label
|
||||
private _id: string;
|
||||
get id(): string {
|
||||
@@ -122,7 +130,7 @@ export class RoleFormFieldComponent implements OnInit, OnDestroy {
|
||||
this.store.user$
|
||||
.pipe(
|
||||
filter((user: IUser) => !!user),
|
||||
tap(() => this.renderRoles()),
|
||||
tap(() => void this.renderRoles()),
|
||||
untilDestroyed(this)
|
||||
)
|
||||
.subscribe();
|
||||
@@ -133,10 +141,27 @@ export class RoleFormFieldComponent implements OnInit, OnDestroy {
|
||||
* Excludes role if needed
|
||||
*/
|
||||
async renderRoles() {
|
||||
this.roles$ = observableOf((await this.rolesService.getAll()).items).pipe(
|
||||
map((roles: IRole[]) => roles.filter((role: IRole) => !this.excludes.includes(role.name as RolesEnum))),
|
||||
tap((roles: IRole[]) => (this.roles = roles))
|
||||
);
|
||||
this._allRoles = (await this.rolesService.getAll()).items;
|
||||
this._rolesLoaded = true;
|
||||
this.applyExcludes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the fetched roles by `excludes`, and clears the selection if it
|
||||
* points at a role that is no longer allowed. The selection is only checked
|
||||
* once the roles have loaded: before that the list is empty, and every
|
||||
* preselected role would look disallowed. `renderRoles()` re-runs this after
|
||||
* loading, so the check still happens then.
|
||||
*/
|
||||
private applyExcludes(): void {
|
||||
this.roles = this._allRoles.filter((role: IRole) => !this.excludes.includes(role.name as RolesEnum));
|
||||
this.roles$ = observableOf(this.roles);
|
||||
|
||||
if (this._rolesLoaded && this.roleId && !this.roles.some((role: IRole) => role.id === this.roleId)) {
|
||||
this.roleId = null;
|
||||
this.ctrl.setValue(null);
|
||||
this.role = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AuthService, CandidatesService, IncomeService, RoleService, TagsService } from '@gauzy/ui-core/core';
|
||||
import { FileUploaderModule } from '../../file-uploader-input/file-uploader-input.module';
|
||||
import { ImageUploaderModule } from '../../image-uploader/image-uploader.module';
|
||||
import { TagsColorInputModule } from '../../tags/tags-color-input/tags-color-input.module';
|
||||
import { SharedModule } from '../../shared.module';
|
||||
import { PasswordFormFieldModule, RoleFormFieldModule } from './fields';
|
||||
@@ -51,6 +52,7 @@ const COMPONENTS = [
|
||||
TranslateModule.forChild(),
|
||||
SharedModule,
|
||||
FileUploaderModule,
|
||||
ImageUploaderModule,
|
||||
TagsColorInputModule,
|
||||
PasswordFormFieldModule,
|
||||
RoleFormFieldModule
|
||||
|
||||
@@ -781,6 +781,32 @@ $card-hairline: 0 0 0 1px var(--gauzy-overlay-border-color, rgba(126, 126, 143,
|
||||
}
|
||||
}
|
||||
|
||||
// The tag picker (`ga-tags-color-input`, class `tags-select`). Same compact
|
||||
// row as the entity selects; the checkbox and colour dot inside each row are
|
||||
// drawn by the component itself. Selection is shown by the checkbox, so the
|
||||
// selected row keeps only a light tint and the regular weight — a bold,
|
||||
// tinted block per ticked tag made a multi-select read as a wall.
|
||||
.ng-dropdown-panel.tags-select {
|
||||
.ng-dropdown-panel-items {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.ng-dropdown-panel-items .ng-option {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
padding: 0.3125rem 0.5rem !important;
|
||||
min-height: 1.75rem;
|
||||
font-size: nb-theme(text-paragraph-2-font-size);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ng-dropdown-panel-items .ng-option.ng-option-selected,
|
||||
.ng-dropdown-panel-items .ng-option.ng-option-selected:hover {
|
||||
font-weight: 400;
|
||||
background-color: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-input > input {
|
||||
color: var(--gauzy-text-color-1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user