Organization Onboarding Workflow
This commit is contained in:
@@ -12,7 +12,7 @@ export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
: req;
|
||||
|
||||
if (!shouldSkipLoader) {
|
||||
loadingService.show();
|
||||
// loadingService.show();
|
||||
}
|
||||
|
||||
return next(request).pipe(
|
||||
|
||||
@@ -24,67 +24,47 @@ export const SAAS_MENU_DATA: MenuContext = {
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{
|
||||
title: 'Global Master',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/states', title: 'State', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tenant Master',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
|
||||
{ path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false }
|
||||
],
|
||||
},
|
||||
{ path: '/users', title: 'Users', type: 'link', dirchange: false },
|
||||
{
|
||||
title: 'Configuration',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/global-masters/currencies', title: 'Currency', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/languages', title: 'Language', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/timezones', title: 'Timezone', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/countries', title: 'Country', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/states', title: 'State', type: 'link', dirchange: false },
|
||||
{ path: '/global-masters/cities', title: 'City', type: 'link', dirchange: false },
|
||||
|
||||
{ path: '/localization', title: 'Localization', type: 'link', dirchange: false },
|
||||
{
|
||||
title: 'Branding',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/theming', title: 'Theming', type: 'link', dirchange: false },
|
||||
{ path: '/platform', title: 'Platform', type: 'link', dirchange: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: 'Tenant Master',
|
||||
// type: 'sub',
|
||||
// active: false,
|
||||
// selected: false,
|
||||
// dirchange: false,
|
||||
// children: [
|
||||
// { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false },
|
||||
// { path: '/tenants/tenant-currencies', title: 'Tenant Currencies', type: 'link', dirchange: false }
|
||||
// ],
|
||||
// }
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operations',
|
||||
icon: '<i class="bx bx-line-chart side-menu__icon"></i>',
|
||||
title: 'Organizations',
|
||||
icon: '<i class="bx bx-buildings side-menu__icon"></i>',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/billing', title: 'Billing', type: 'link', dirchange: false },
|
||||
{ path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false },
|
||||
{ path: '/organizations/list', title: 'Organization List', type: 'link', dirchange: false },
|
||||
{ path: '/organizations/onboarding', title: 'Organization Onboarding', type: 'link', dirchange: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
icon: '<i class="bx bx-cog side-menu__icon"></i>',
|
||||
type: 'sub',
|
||||
active: false,
|
||||
selected: false,
|
||||
dirchange: false,
|
||||
children: [
|
||||
{ path: '/settings/branding', title: 'Branding', type: 'link', dirchange: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const billingRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./pages/billing-list/billing-list').then((m) => m.BillingList),
|
||||
data: { childTitle: 'Billing', parentTitle: 'Platform', subParentTitle: 'Subscriptions' },
|
||||
},
|
||||
];
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Billing</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Plans, subscriptions, and tenant subscription upgrades will
|
||||
be implemented here.</p>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-billing-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './billing-list.html',
|
||||
styleUrl: './billing-list.scss',
|
||||
})
|
||||
export class BillingList {}
|
||||
@@ -50,10 +50,16 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-2 lg:col-span-1">
|
||||
<button type="button" class="ti-btn ti-btn-sm ti-btn-primary-full !mb-0 flex min-h-8 w-full items-center justify-center gap-1.5 !px-3 md:!w-auto" (click)="applyCityFilters()">
|
||||
<i class="ti ti-filter" aria-hidden="true"></i>
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
<app-button
|
||||
action="custom"
|
||||
label="Filter"
|
||||
icon="ti ti-filter"
|
||||
variant="primary-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
||||
(buttonClicked)="applyCityFilters()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</app-filter-card>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||
import { Button } from '../../../../../shared/components/button/button';
|
||||
|
||||
interface CityTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
@@ -66,7 +67,7 @@ interface CityTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'city-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard],
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, FilterCard, Button],
|
||||
templateUrl: './city-list.html',
|
||||
styleUrl: './city-list.scss'
|
||||
})
|
||||
|
||||
@@ -24,10 +24,16 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
||||
<button type="button" class="ti-btn ti-btn-sm ti-btn-primary-full !mb-0 flex min-h-8 w-full items-center justify-center gap-1.5 !px-3 md:!w-auto" (click)="applyCountryFilter()">
|
||||
<i class="ti ti-filter" aria-hidden="true"></i>
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
<app-button
|
||||
action="custom"
|
||||
label="Filter"
|
||||
icon="ti ti-filter"
|
||||
variant="primary-full"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="!rounded-full shadow-sm !mb-0 min-h-8 w-full md:!w-auto"
|
||||
(buttonClicked)="applyCountryFilter()"
|
||||
></app-button>
|
||||
</div>
|
||||
</form>
|
||||
</app-filter-card>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { FormInput } from '../../../../../shared/components/form/form-input/form
|
||||
import { Modal } from '../../../../../shared/components/modal/modal';
|
||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
||||
import { Button as AppButton } from '../../../../../shared/components/button/button';
|
||||
|
||||
interface StateTableRow extends DataTableRecord {
|
||||
id: string;
|
||||
@@ -56,7 +57,7 @@ interface StateTableRow extends DataTableRecord {
|
||||
@Component({
|
||||
selector: 'state-list',
|
||||
standalone: true,
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard],
|
||||
imports: [DataTable, Modal, ReactiveFormsModule, FormInput, Autocomplete, ConfirmDialog, FilterCard, AppButton],
|
||||
templateUrl: './state-list.html',
|
||||
styleUrl: './state-list.scss',
|
||||
})
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const localizationRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./pages/localization-list/localization-list').then((m) => m.LocalizationList),
|
||||
data: { childTitle: 'Localization', parentTitle: 'Platform', subParentTitle: 'Translations' },
|
||||
},
|
||||
];
|
||||
@@ -1,4 +0,0 @@
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Localization</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Translation browser, editor, import/export, and missing translation reports will be added here.</p>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-localization-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './localization-list.html',
|
||||
styleUrl: './localization-list.scss',
|
||||
})
|
||||
export class LocalizationList {}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const monitoringRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./pages/monitoring-dashboard/monitoring-dashboard').then((m) => m.MonitoringDashboard),
|
||||
data: { childTitle: 'Monitoring', parentTitle: 'Platform', subParentTitle: 'Observability' },
|
||||
},
|
||||
];
|
||||
@@ -1,4 +0,0 @@
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Tenant counts, subscription status, login audits, and recent failed logins will be added here.</p>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-monitoring-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './monitoring-dashboard.html',
|
||||
styleUrl: './monitoring-dashboard.scss',
|
||||
})
|
||||
export class MonitoringDashboard {}
|
||||
@@ -0,0 +1,63 @@
|
||||
<div class="md:flex block items-center justify-between my-[1.5rem] page-header-breadcrumb">
|
||||
<div>
|
||||
<p class="font-semibold text-[1.125rem] text-defaulttextcolor dark:text-defaulttextcolor/70 !mb-0 ">Welcome
|
||||
back,
|
||||
Json Taylor !</p>
|
||||
<p class="font-normal text-[#8c9097] dark:text-white/50 text-[0.813rem]">Track your sales activity, leads and
|
||||
deals
|
||||
here.</p>
|
||||
</div>
|
||||
<div class="btn-list md:mt-0 mt-2">
|
||||
<button type="button"
|
||||
class="ti-btn bg-primary text-white btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
|
||||
<i class="ri-filter-3-fill inline-block"></i>Filters
|
||||
</button>
|
||||
<button type="button"
|
||||
class="ti-btn ti-btn-outline-secondary btn-wave !font-medium !me-[0.375rem] !ms-0 !text-[0.85rem] !rounded-[0.35rem] !py-[0.51rem] !px-[0.86rem] shadow-none">
|
||||
<i class="ri-upload-cloud-line inline-block"></i>Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
|
||||
@for (card of statCards(); track card.title) {
|
||||
<div class="xxl:col-span-2 xl:col-span-2 md:col-span-6 col-span-12">
|
||||
<div [class]="'box overflow-hidden border-t-[3px] ' + card.accentClass">
|
||||
<div class="box-body">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="text-center">
|
||||
<p class="mb-2 text-[0.813rem] text-[#8c9097] dark:text-white/50">{{ card.title }}</p>
|
||||
<h4 class="mb-2 text-[1.75rem] font-semibold text-defaulttextcolor dark:text-white">
|
||||
{{ card.value }}
|
||||
</h4>
|
||||
<span
|
||||
[class]="'inline-flex rounded-md px-2 py-1 text-[0.6875rem] font-medium ' + card.helperClass">
|
||||
{{ card.helper }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
[class]="'inline-flex !h-[2.75rem] !w-[2.75rem] items-center justify-center rounded-full text-white ' + card.iconBackgroundClass">
|
||||
<i [class]="card.iconClass + ' text-[1.1rem]'"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
|
||||
|
||||
<app-data-table [columns]="columns()" [rows]="recentOrganizations()" [actions]="actions()"
|
||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()"
|
||||
tableTitle="Organizations" buttonTitle="Add" [showSearch]="true" [showAddButton]="true"
|
||||
searchPlaceholder="Search..." [searchDebounceTime]="300" [emptyMessage]="emptyMessage()"
|
||||
[emptyDescription]="emptyDescription()" (addClicked)="onAddOrganization()"
|
||||
(searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)" (sortChanged)="onSortChange($event)"
|
||||
(actionClicked)="onActionClick($event)" toolTip="Add Organization" rowColorMode="none" />
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Component, signal, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { DataTable } from '../../../shared/components/data-table/data-table';
|
||||
import { DataTableColumn, DataTableRecord } from '../../../shared/components/data-table/data-table.types';
|
||||
|
||||
type OrganizationPlan = 'Enterprise' | 'Standard' | 'Trial';
|
||||
type OrganizationStatus = 'Active' | 'Trial' | 'Suspended';
|
||||
|
||||
interface DashboardStatCard {
|
||||
title: string;
|
||||
value: string;
|
||||
helper: string;
|
||||
accentClass: string;
|
||||
iconClass: string;
|
||||
iconBackgroundClass: string;
|
||||
helperClass: string;
|
||||
}
|
||||
|
||||
interface RecentOrganizationRow extends DataTableRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
organizationName: string;
|
||||
country: string;
|
||||
plan: OrganizationPlan;
|
||||
status: OrganizationStatus;
|
||||
created: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dashboard',
|
||||
standalone: true,
|
||||
imports: [DataTable],
|
||||
templateUrl: './dashboard.html',
|
||||
styleUrl: './dashboard.scss',
|
||||
})
|
||||
export class Dashboard {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly statCards = signal<DashboardStatCard[]>([
|
||||
{
|
||||
title: 'Total Organizations',
|
||||
value: '24',
|
||||
helper: 'All registered',
|
||||
accentClass: 'border-primary',
|
||||
iconClass: 'ti ti-building-community',
|
||||
iconBackgroundClass: 'bg-primary',
|
||||
helperClass: 'bg-primary/10 text-primary',
|
||||
},
|
||||
{
|
||||
title: 'Active',
|
||||
value: '19',
|
||||
helper: 'Currently operational',
|
||||
accentClass: 'border-primary',
|
||||
iconClass: 'ti ti-circle-check',
|
||||
iconBackgroundClass: 'bg-primary',
|
||||
helperClass: 'bg-primary/10 text-primary',
|
||||
},
|
||||
{
|
||||
title: 'Trial',
|
||||
value: '3',
|
||||
helper: 'Trial subscriptions',
|
||||
accentClass: 'border-primary',
|
||||
iconClass: 'ri-wallet-2-line',
|
||||
iconBackgroundClass: 'bg-primary',
|
||||
helperClass: 'bg-primary/10 text-primary',
|
||||
},
|
||||
{
|
||||
title: 'Suspended',
|
||||
value: '1',
|
||||
helper: 'Temporarily disabled',
|
||||
accentClass: 'border-primary',
|
||||
iconClass: 'ti ti-player-pause',
|
||||
iconBackgroundClass: 'bg-primary',
|
||||
helperClass: 'bg-primary/10 text-primary',
|
||||
},
|
||||
{
|
||||
title: 'Expired License',
|
||||
value: '1',
|
||||
helper: 'Needs renewal action',
|
||||
accentClass: 'border-primary',
|
||||
iconClass: 'ti ti-license',
|
||||
iconBackgroundClass: 'bg-primary',
|
||||
helperClass: 'bg-primary/10 text-primary',
|
||||
},
|
||||
{
|
||||
title: 'Active Users',
|
||||
value: '482',
|
||||
helper: 'Users with access',
|
||||
accentClass: 'border-primary',
|
||||
iconClass: 'ri-profile-line',
|
||||
iconBackgroundClass: 'bg-primary',
|
||||
helperClass: 'bg-primary/10 text-primary',
|
||||
},
|
||||
]);
|
||||
|
||||
readonly recentOrganizations = signal<RecentOrganizationRow[]>([
|
||||
{
|
||||
id: '1',
|
||||
code: 'ORG-0024',
|
||||
organizationName: 'Syscom Group',
|
||||
country: 'Saudi Arabia',
|
||||
plan: 'Enterprise',
|
||||
status: 'Active',
|
||||
created: '09-07-2026',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
code: 'ORG-0023',
|
||||
organizationName: 'Acme Trading',
|
||||
country: 'India',
|
||||
plan: 'Standard',
|
||||
status: 'Active',
|
||||
created: '01-07-2026',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
code: 'ORG-0022',
|
||||
organizationName: 'Falcon Retail LLC',
|
||||
country: 'UAE',
|
||||
plan: 'Trial',
|
||||
status: 'Trial',
|
||||
created: '28-06-2026',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
code: 'ORG-0025',
|
||||
organizationName: 'Syscom Group',
|
||||
country: 'Saudi Arabia',
|
||||
plan: 'Enterprise',
|
||||
status: 'Active',
|
||||
created: '09-07-2026',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
code: 'ORG-0026',
|
||||
organizationName: 'Acme Trading',
|
||||
country: 'India',
|
||||
plan: 'Standard',
|
||||
status: 'Active',
|
||||
created: '01-07-2026',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
code: 'ORG-0027',
|
||||
organizationName: 'Falcon Retail LLC',
|
||||
country: 'UAE',
|
||||
plan: 'Trial',
|
||||
status: 'Trial',
|
||||
created: '28-06-2026',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
code: 'ORG-0028',
|
||||
organizationName: 'Syscom Group',
|
||||
country: 'Saudi Arabia',
|
||||
plan: 'Enterprise',
|
||||
status: 'Active',
|
||||
created: '09-07-2026',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
code: 'ORG-0029',
|
||||
organizationName: 'Acme Trading',
|
||||
country: 'India',
|
||||
plan: 'Standard',
|
||||
status: 'Active',
|
||||
created: '01-07-2026',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
code: 'ORG-0030',
|
||||
organizationName: 'Falcon Retail LLC',
|
||||
country: 'UAE',
|
||||
plan: 'Trial',
|
||||
status: 'Trial',
|
||||
created: '28-06-2026',
|
||||
},
|
||||
]);
|
||||
|
||||
readonly columns = signal<DataTableColumn<RecentOrganizationRow>[]>([
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: false, align: 'left' },
|
||||
{
|
||||
key: 'organizationName',
|
||||
label: 'Name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
align: 'left',
|
||||
},
|
||||
{ key: 'country', label: 'Country', header: 'Country', sortable: false },
|
||||
{
|
||||
key: 'plan',
|
||||
label: 'Plan',
|
||||
header: 'Plan',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
badgeClass: value =>
|
||||
value === 'Trial'
|
||||
? 'badge bg-warning/10 text-warning'
|
||||
: 'badge bg-light text-defaulttextcolor',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
badgeClass: value =>
|
||||
value === 'Active'
|
||||
? 'badge bg-success/10 text-success'
|
||||
: value === 'Trial'
|
||||
? 'badge bg-warning/10 text-warning'
|
||||
: 'badge bg-danger/10 text-danger',
|
||||
},
|
||||
{ key: 'created', label: 'Created', header: 'Created', sortable: false },
|
||||
]);
|
||||
|
||||
readonly totalRecords = signal<number>(6);
|
||||
readonly queryState = {
|
||||
pageIndex: signal<number>(0)
|
||||
};
|
||||
readonly emptyMessage = signal<string>('No Organizations');
|
||||
readonly emptyDescription = signal<string>('Start by adding your first organization');
|
||||
|
||||
readonly actions = signal<any[]>([
|
||||
{ id: 'edit', label: 'Edit', icon: 'ti ti-edit' },
|
||||
{ id: 'delete', label: 'Delete', icon: 'ti ti-trash' },
|
||||
]);
|
||||
|
||||
onAddOrganization(): void {
|
||||
void this.router.navigate(['/organizations/onboarding']);
|
||||
}
|
||||
|
||||
onSearch(searchTerm: string) {
|
||||
console.log('Search:', searchTerm);
|
||||
}
|
||||
|
||||
onPageChange(event: any) {
|
||||
console.log('Page changed:', event);
|
||||
}
|
||||
|
||||
onSortChange(event: any) {
|
||||
console.log('Sort changed:', event);
|
||||
}
|
||||
|
||||
onActionClick(event: any) {
|
||||
const { actionId, record } = event;
|
||||
|
||||
switch (actionId) {
|
||||
case 'edit':
|
||||
console.log('Edit organization:', record);
|
||||
// Implement edit logic here
|
||||
break;
|
||||
case 'delete':
|
||||
console.log('Delete organization:', record);
|
||||
// Implement delete logic here
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown action:', actionId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1 +1,44 @@
|
||||
<p>organization-list works!</p>
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<div class="xl:col-span-12 col-span-12">
|
||||
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
||||
<form [formGroup]="countryFilterForm" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
||||
<app-autocomplete
|
||||
formControlName="countryId"
|
||||
inputId="organization-country-filter"
|
||||
variant="floating"
|
||||
size="sm"
|
||||
label="Country"
|
||||
placeholder="Search"
|
||||
[searchFn]="searchCountries"
|
||||
[displayWith]="displayCountry"
|
||||
[valueWith]="countryValue"
|
||||
[selectedItem]="selectedCountryLookup()"
|
||||
[minSearchLength]="1"
|
||||
[debounceTime]="300"
|
||||
[limit]="50"
|
||||
[clearable]="true"
|
||||
[hideValidation]="true"
|
||||
wrapperClass="!mb-0 w-full"
|
||||
(itemSelected)="onCountryLookupSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
||||
<button type="button" class="ti-btn ti-btn-sm ti-btn-primary-full !mb-0 flex min-h-8 w-full items-center justify-center gap-1.5 !px-3 md:!w-auto" (click)="applyCountryFilter()">
|
||||
<i class="ti ti-filter" aria-hidden="true"></i>
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</app-filter-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-data-table [columns]="columns()" [rows]="organizations()" [actions]="actions()"
|
||||
[totalRecords]="totalRecords()" [pageIndex]="queryState.pageIndex()" [pageSize]="queryState.pageSize()"
|
||||
tableTitle="Organizations" buttonTitle="Add" [showSearch]="true" rowColorMode="none"
|
||||
[showAddButton]="true" searchPlaceholder="Search..." [searchDebounceTime]="300"
|
||||
[emptyMessage]="emptyMessage()" [emptyDescription]="emptyDescription()"
|
||||
(addClicked)="onAddOrganization()" (searchChanged)="onSearch($event)" (pageChanged)="onPageChange($event)"
|
||||
(sortChanged)="onSortChange($event)" (actionClicked)="onActionClick($event)" toolTip="Add Organization" />
|
||||
|
||||
|
||||
@@ -1,11 +1,251 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { of, map } from 'rxjs';
|
||||
|
||||
import { DataTable } from '../../../shared/components/data-table/data-table';
|
||||
import { DataTableQueryState } from '../../../shared/components/data-table/data-table-query.state';
|
||||
import {
|
||||
DataTableAction,
|
||||
DataTableActionEvent,
|
||||
DataTableColumn,
|
||||
DataTablePageEvent,
|
||||
DataTableRecord,
|
||||
DataTableSortEvent,
|
||||
} from '../../../shared/components/data-table/data-table.types';
|
||||
import { Autocomplete } from '../../../shared/components/form/autocomplete/autocomplete';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteResolveValueFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn,
|
||||
} from '../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FilterCard } from '../../../shared/components/filter-card/filter-card';
|
||||
import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api';
|
||||
|
||||
type OrganizationStatus = 'Active' | 'Trial' | 'Suspended';
|
||||
|
||||
interface CountryLookup {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface OrganizationListRow extends DataTableRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
organizationName: string;
|
||||
countryId: string;
|
||||
country: string;
|
||||
plan: string;
|
||||
status: OrganizationStatus;
|
||||
expiry: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'organization-list',
|
||||
imports: [],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable],
|
||||
templateUrl: './organization-list.html',
|
||||
styleUrl: './organization-list.scss',
|
||||
})
|
||||
export class OrganizationList {
|
||||
|
||||
|
||||
export class OrganizationList {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly countryApi = inject(CountryService);
|
||||
|
||||
readonly queryState = new DataTableQueryState();
|
||||
|
||||
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedFormCountry = signal<CountryLookupDto | null>(null);
|
||||
readonly selectedCountryId = signal<string | null>(null);
|
||||
readonly appliedCountryId = signal<string | null>(null);
|
||||
|
||||
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
||||
countryId: [''],
|
||||
});
|
||||
|
||||
readonly allOrganizations = signal<OrganizationListRow[]>([
|
||||
{
|
||||
id: '1',
|
||||
code: 'ORG-0024',
|
||||
organizationName: 'Syscom Group',
|
||||
countryId: 'c1',
|
||||
country: 'Saudi Arabia',
|
||||
plan: 'Enterprise',
|
||||
status: 'Active',
|
||||
expiry: '31-12-2026'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
code: 'ORG-0023',
|
||||
organizationName: 'Acme Trading',
|
||||
countryId: 'c2',
|
||||
country: 'India',
|
||||
plan: 'Standard',
|
||||
status: 'Active',
|
||||
expiry: '31-03-2027'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
code: 'ORG-0022',
|
||||
organizationName: 'Falcon Retail LLC',
|
||||
countryId: 'c3',
|
||||
country: 'UAE',
|
||||
plan: 'Trial',
|
||||
status: 'Trial',
|
||||
expiry: '28-07-2026'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
code: 'ORG-0021',
|
||||
organizationName: 'Oasis Foods',
|
||||
countryId: 'c1',
|
||||
country: 'Saudi Arabia',
|
||||
plan: 'Standard',
|
||||
status: 'Suspended',
|
||||
expiry: '15-06-2026'
|
||||
},
|
||||
]);
|
||||
|
||||
readonly organizations = signal<OrganizationListRow[]>([]);
|
||||
readonly totalRecords = signal(0);
|
||||
|
||||
readonly searchCountries: AutocompleteSearchFn<CountryLookupDto> = (term, limit) => this.countryApi.autocomplete(term, limit);
|
||||
readonly displayCountry: AutocompleteDisplayFn<CountryLookupDto> = country => country.name;
|
||||
readonly countryValue: AutocompleteValueFn<CountryLookupDto, string> = country => country.id;
|
||||
readonly resolveCountry: AutocompleteResolveValueFn<CountryLookupDto, string> =
|
||||
value => this.countryApi.getCountryById(value).pipe(
|
||||
map(country => ({ id: country.id, iso2: country.iso2, name: country.name }))
|
||||
);
|
||||
|
||||
readonly columns = signal<DataTableColumn<OrganizationListRow>[]>([
|
||||
{ key: 'code', label: 'Code', header: 'Code', sortable: true },
|
||||
{
|
||||
key: 'organizationName',
|
||||
label: 'Organization Name',
|
||||
header: 'Organization Name',
|
||||
sortable: true,
|
||||
align: 'left',
|
||||
},
|
||||
{ key: 'country', label: 'Country', header: 'Country', sortable: true },
|
||||
{
|
||||
key: 'plan',
|
||||
label: 'Plan',
|
||||
header: 'Plan',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
badgeClass: value =>
|
||||
value === 'Trial'
|
||||
? 'badge bg-warning/10 text-warning'
|
||||
: 'badge bg-light text-defaulttextcolor',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
badge: true,
|
||||
badgeClass: value => {
|
||||
if (value === 'Active') return 'badge bg-success/10 text-success';
|
||||
if (value === 'Trial') return 'badge bg-warning/10 text-warning';
|
||||
return 'badge bg-danger/10 text-danger';
|
||||
},
|
||||
},
|
||||
{ key: 'expiry', label: 'Expiry', header: 'Expiry', sortable: true },
|
||||
]);
|
||||
|
||||
readonly actions = signal<DataTableAction<OrganizationListRow>[]>([
|
||||
{ type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' },
|
||||
{ type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' },
|
||||
{
|
||||
type: 'suspend',
|
||||
label: 'Suspend',
|
||||
icon: 'ti ti-player-pause',
|
||||
className: 'text-warning',
|
||||
visible: row => row.status !== 'Suspended',
|
||||
},
|
||||
]);
|
||||
|
||||
readonly emptyMessage = signal('No organizations found');
|
||||
readonly emptyDescription = signal('There are no organizations available for the current filters.');
|
||||
|
||||
constructor() {
|
||||
this.refreshTable();
|
||||
}
|
||||
|
||||
onCountryLookupSelected(country: CountryLookupDto): void {
|
||||
this.selectedCountryLookup.set(country);
|
||||
}
|
||||
|
||||
applyCountryFilter(): void {
|
||||
const selectedCountryId = this.countryFilterForm.controls.countryId.value.trim();
|
||||
this.appliedCountryId.set(selectedCountryId || null);
|
||||
this.queryState.pageIndex.set(1);
|
||||
this.refreshTable();
|
||||
}
|
||||
|
||||
onSearch(value: string): void {
|
||||
this.queryState.setSearch(value);
|
||||
this.refreshTable();
|
||||
}
|
||||
|
||||
onPageChange(event: DataTablePageEvent): void {
|
||||
this.queryState.setPage(event);
|
||||
this.refreshTable();
|
||||
}
|
||||
|
||||
onSortChange(event: DataTableSortEvent): void {
|
||||
this.queryState.setSort(event);
|
||||
this.refreshTable();
|
||||
}
|
||||
|
||||
onAddOrganization(): void {
|
||||
void this.router.navigate(['/organizations/onboarding']);
|
||||
}
|
||||
|
||||
onActionClick(event: DataTableActionEvent<OrganizationListRow>): void {
|
||||
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
|
||||
}
|
||||
|
||||
private refreshTable(): void {
|
||||
const countryId = this.appliedCountryId();
|
||||
const search = this.queryState.searchText().trim().toLowerCase();
|
||||
const sortBy = this.queryState.sortColumn();
|
||||
const sortDir = this.queryState.sortDirection();
|
||||
|
||||
let rows = this.allOrganizations().filter(row => {
|
||||
const matchesCountry = !countryId || row.countryId === countryId;
|
||||
const matchesSearch = !search
|
||||
|| row.code.toLowerCase().includes(search)
|
||||
|| row.organizationName.toLowerCase().includes(search)
|
||||
|| row.country.toLowerCase().includes(search)
|
||||
|| row.plan.toLowerCase().includes(search)
|
||||
|| row.status.toLowerCase().includes(search)
|
||||
|| row.expiry.toLowerCase().includes(search);
|
||||
|
||||
return matchesCountry && matchesSearch;
|
||||
});
|
||||
|
||||
if (sortBy) {
|
||||
rows = [...rows].sort((left, right) => {
|
||||
const leftValue = String(left[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
|
||||
const rightValue = String(right[sortBy as keyof OrganizationListRow] ?? '').toLowerCase();
|
||||
const compared = leftValue.localeCompare(rightValue);
|
||||
return sortDir === 'asc' ? compared : -compared;
|
||||
});
|
||||
}
|
||||
|
||||
const total = rows.length;
|
||||
const pageIndex = this.queryState.pageIndex();
|
||||
const pageSize = this.queryState.pageSize();
|
||||
const start = (pageIndex - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
|
||||
this.totalRecords.set(total);
|
||||
this.organizations.set(rows.slice(start, end));
|
||||
}
|
||||
}
|
||||
|
||||
+236
-220
@@ -1,239 +1,255 @@
|
||||
<div class="w-full">
|
||||
<!-- Stepper Nav -->
|
||||
<nav aria-label="Organization onboarding progress">
|
||||
<ul
|
||||
class="relative flex-row gap-x-2 space-y-4
|
||||
sm:flex sm:space-y-0"
|
||||
>
|
||||
@for (step of steps(); track step.key; let index = $index) {
|
||||
<li
|
||||
class="group flex flex-1 basis-0 shrink items-center gap-x-2"
|
||||
[class.active]="isCurrentStep(index)"
|
||||
[class.success]="isCompletedStep(index)"
|
||||
[attr.data-hs-stepper-nav-item]="
|
||||
'{"index":' + (index + 1) + '}'
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex min-h-7 min-w-7 items-center
|
||||
text-start text-xs align-middle
|
||||
focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||
[class.cursor-pointer]="canOpenStep(index)"
|
||||
[class.cursor-default]="!canOpenStep(index)"
|
||||
[attr.aria-current]="
|
||||
isCurrentStep(index) ? 'step' : null
|
||||
"
|
||||
[attr.aria-disabled]="
|
||||
canOpenStep(index) ? null : true
|
||||
"
|
||||
(click)="selectStep(index)"
|
||||
>
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center
|
||||
rounded-full bg-gray-100 font-medium text-gray-800
|
||||
group-focus:bg-gray-200 dark:bg-bodybg dark:text-white
|
||||
dark:group-focus:bg-gray-600
|
||||
hs-stepper-active:!bg-primary
|
||||
hs-stepper-active:!text-white
|
||||
hs-stepper-success:!bg-primary
|
||||
hs-stepper-success:!text-white
|
||||
hs-stepper-completed:!bg-success
|
||||
hs-stepper-completed:group-focus:!bg-success"
|
||||
>
|
||||
<span
|
||||
class="hs-stepper-success:hidden
|
||||
hs-stepper-completed:hidden"
|
||||
<!-- Custom Stepper Navigation Card -->
|
||||
<div class="stepper-header-card mb-4 sm:mb-6">
|
||||
<!-- Mobile Current Step Indicator Banner (< 640px) -->
|
||||
<div class="mb-3 flex items-center justify-between rounded-lg bg-primary/5 px-3 py-2 text-xs font-medium text-primary sm:hidden">
|
||||
<span class="flex items-center gap-1.5 min-w-0 truncate">
|
||||
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-[0.65rem] font-bold text-white">
|
||||
{{ currentStepIndex() + 1 }}
|
||||
</span>
|
||||
<span class="truncate">Step {{ currentStepIndex() + 1 }} of {{ steps().length }}: <strong class="font-semibold">{{ currentStepLabel() }}</strong></span>
|
||||
</span>
|
||||
<span class="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[0.6875rem] font-semibold text-primary ms-2">
|
||||
{{ getProgressPercent() }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Stepper Navigation Track -->
|
||||
<div class="py-1">
|
||||
<nav aria-label="Organization onboarding progress" class="relative w-full">
|
||||
<!-- Background Track Line -->
|
||||
<div
|
||||
class="absolute top-4.5 sm:top-8 h-1 bg-gray-200 dark:bg-slate-700/60 z-0 transition-all duration-300"
|
||||
[style.left]="getTrackLineLeft()"
|
||||
[style.right]="getTrackLineLeft()"
|
||||
></div>
|
||||
|
||||
<!-- Gradient Progress Line -->
|
||||
<div
|
||||
class="stepper-progress-line absolute top-4.5 sm:top-8 h-1 z-0 transition-all duration-500 ease-in-out"
|
||||
[style.left]="getTrackLineLeft()"
|
||||
[style.width]="getProgressLineWidth()"
|
||||
></div>
|
||||
|
||||
<!-- Stepper Nodes Grid -->
|
||||
<ul class="relative z-10 flex w-full justify-between items-start">
|
||||
@for (step of steps(); track step.key; let index = $index) {
|
||||
<li class="flex flex-1 flex-col items-center text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="step-node-btn group flex flex-col items-center focus:outline-none"
|
||||
[class.cursor-pointer]="canOpenStep(index)"
|
||||
[class.cursor-default]="!canOpenStep(index)"
|
||||
[disabled]="!canOpenStep(index)"
|
||||
(click)="selectStep(index)"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<!-- Step Circle Node -->
|
||||
<div
|
||||
class="step-circle"
|
||||
[class.active]="isCurrentStep(index)"
|
||||
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
|
||||
[class.pending]="isPendingStep(index)"
|
||||
>
|
||||
<!-- Icons by Step Key / Index -->
|
||||
@switch (step.key) {
|
||||
@case ('basics') {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 sm:size-7">
|
||||
<path d="M19 4h-3.5c-.27-1.16-1.31-2-2.5-2h-2c-1.19 0-2.23.84-2.5 2H5c-1.1 0-2 .9-2 2v13c0 1.1.9 2 2 2h8.08c-.05-.33-.08-.66-.08-1 0-3.31 2.69-6 6-6 .34 0 .67.03 1 .08V6c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM7 9h7v2H7V9zm0 4h5v2H7v-2z"/>
|
||||
<path d="M18 13c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm-1 7.2-2.2-2.2 1.4-1.4 0.8 0.8 3.2-3.2 1.4 1.4-4.6 4.6z"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('localization') {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
|
||||
<circle cx="10" cy="10" r="7.5"/>
|
||||
<path d="M2.5 10h15"/>
|
||||
<path d="M10 2.5a11 11 0 0 1 0 15a11 11 0 0 1 0-15"/>
|
||||
<path d="M12 21.5v-7.5h3.5v7.5M15.5 21.5v-9.5h3.5v9.5M19 21.5v-6h3v6" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('plan-limits') {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
|
||||
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" stroke="none" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
<path d="M8.5 13.5l2.2-2.2 1.8 1.8 3-3.2m0 0h-2.5m2.5 0v2.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('admin-user') {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 sm:size-7">
|
||||
<path d="M12 6a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm10 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/>
|
||||
<path d="M12 7.5c-3 0-8 1.5-8 4v2.5h8.5c-.32-.73-.5-1.54-.5-2.4 0-1.48.55-2.83 1.46-3.87C13.01 7.6 12.45 7.5 12 7.5zM4 11.5c-1.8 0-5 .9-5 2.5V16h4v-2c0-.94.32-1.8.85-2.5H4zm13.15-4c.45.64.75 1.4.81 2.22C19.06 10.28 20 11.23 20 12.5v1.5h4v-1.5c0-1.6-3.2-2.5-5-2.5h-1.85z"/>
|
||||
<path d="M17 12c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm0 7c-1.5 0-2.8-.78-3.5-1.95.04-1.16 2.33-1.8 3.5-1.8s3.46.64 3.5 1.8c-.7 1.17-2 1.95-3.5 1.95z"/>
|
||||
</svg>
|
||||
}
|
||||
@default {
|
||||
<span>{{ index + 1 }}</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
class="hidden size-3 shrink-0 hs-stepper-success:block"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
|
||||
<span class="sr-only">
|
||||
@if (isCompletedStep(index)) {
|
||||
Completed
|
||||
} @else if (isCurrentStep(index)) {
|
||||
Current step
|
||||
} @else {
|
||||
Pending
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="ms-2 whitespace-nowrap text-sm font-medium
|
||||
text-gray-800 dark:text-white
|
||||
hs-stepper-active:!text-primary
|
||||
hs-stepper-success:!text-primary
|
||||
hs-stepper-completed:!text-success"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (index < steps().length - 1) {
|
||||
<div
|
||||
class="hidden h-px w-full flex-1 bg-gray-200
|
||||
dark:bg-bodybg sm:block
|
||||
hs-stepper-success:!bg-primary
|
||||
hs-stepper-completed:!bg-success"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<!-- Step Label -->
|
||||
<span
|
||||
class="step-label hidden sm:block"
|
||||
[class.active]="isCurrentStep(index)"
|
||||
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
|
||||
[class.pending]="isPendingStep(index)"
|
||||
>
|
||||
{{ index + 1 }}. {{ step.label }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- End Stepper Nav -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Stepper Navigation Card -->
|
||||
|
||||
<!-- Stepper Content -->
|
||||
<div class="mt-5 sm:mt-8">
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-gray-200
|
||||
bg-gray-50 p-6 dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
<!-- Stepper Form Content -->
|
||||
<div>
|
||||
<div class="rounded-xl border border-dashed border-gray-200 bg-white p-3.5 sm:p-6 dark:border-white/10 dark:bg-bodybg">
|
||||
<ng-content />
|
||||
</div>
|
||||
|
||||
<!-- Button Group -->
|
||||
<footer
|
||||
class="mt-5 flex flex-col-reverse gap-3
|
||||
sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<!-- Cancel or Back -->
|
||||
<div class="flex w-full sm:w-auto">
|
||||
@if (isFirstStep()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-light h-10 w-full justify-center
|
||||
gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="navigationDisabled()"
|
||||
(click)="requestCancel()"
|
||||
>
|
||||
<i class="ri-close-line" aria-hidden="true"></i>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-light h-10 w-full justify-center
|
||||
gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="navigationDisabled()"
|
||||
(click)="requestBack()"
|
||||
>
|
||||
<i
|
||||
class="ri-arrow-left-s-line rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<!-- Button Group Footer -->
|
||||
<footer class="onboarding-action-footer mt-5">
|
||||
<!-- Mobile View (< 640px) -->
|
||||
<div class="flex flex-col gap-2.5 sm:hidden">
|
||||
<div class="w-full">
|
||||
@if (!isLastStep()) {
|
||||
<app-button
|
||||
action="next"
|
||||
label="Next"
|
||||
[showIcon]="true"
|
||||
[iconPosition]="'right'"
|
||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
||||
[fullWidth]="true"
|
||||
className="onboarding-nav-btn w-full"
|
||||
(buttonClicked)="requestNext()"
|
||||
></app-button>
|
||||
} @else {
|
||||
<app-button
|
||||
action="approve"
|
||||
label="Finish & Provision"
|
||||
loadingLabel="Preparing..."
|
||||
[showIcon]="true"
|
||||
[loading]="finishing()"
|
||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
||||
[fullWidth]="true"
|
||||
className="w-full"
|
||||
(buttonClicked)="requestFinish()"
|
||||
></app-button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span>Back</span>
|
||||
</button>
|
||||
}
|
||||
<div class="grid grid-cols-2 gap-2.5 w-full max-[420px]:grid-cols-1">
|
||||
<div class="min-w-0">
|
||||
@if (isFirstStep()) {
|
||||
<app-button
|
||||
action="cancel"
|
||||
label="Cancel"
|
||||
[showIcon]="true"
|
||||
[disabled]="navigationDisabled()"
|
||||
[fullWidth]="true"
|
||||
className="w-full"
|
||||
(buttonClicked)="requestCancel()"
|
||||
></app-button>
|
||||
} @else {
|
||||
<app-button
|
||||
action="previous"
|
||||
label="Back"
|
||||
[showIcon]="true"
|
||||
[disabled]="navigationDisabled()"
|
||||
[fullWidth]="true"
|
||||
className="onboarding-nav-btn w-full"
|
||||
(buttonClicked)="requestBack()"
|
||||
></app-button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<app-button
|
||||
action="save"
|
||||
[variant]="'custom'"
|
||||
label="Save Draft"
|
||||
loadingLabel="Saving..."
|
||||
[showIcon]="true"
|
||||
[loading]="savingDraft()"
|
||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
||||
[fullWidth]="true"
|
||||
className="onboarding-save-btn ti-btn-outline-primary whitespace-nowrap w-full"
|
||||
(buttonClicked)="requestSaveDraft()"
|
||||
></app-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save and Next/Finish -->
|
||||
<div
|
||||
class="flex w-full flex-col gap-2
|
||||
sm:w-auto sm:flex-row"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-outline-primary h-10 w-full
|
||||
justify-center gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="
|
||||
navigationDisabled() ||
|
||||
savingDraft() ||
|
||||
finishing()
|
||||
"
|
||||
(click)="requestSaveDraft()"
|
||||
>
|
||||
@if (savingDraft()) {
|
||||
<span
|
||||
class="size-4 animate-spin rounded-full border-2
|
||||
border-current border-t-transparent"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<span>Saving...</span>
|
||||
<!-- Desktop View (>= 640px) -->
|
||||
<div class="hidden w-full sm:grid sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-center sm:gap-3">
|
||||
<!-- Left: Cancel or Back -->
|
||||
<div class="justify-self-start">
|
||||
@if (isFirstStep()) {
|
||||
<app-button
|
||||
action="cancel"
|
||||
label="Cancel"
|
||||
[showIcon]="true"
|
||||
[disabled]="navigationDisabled()"
|
||||
(buttonClicked)="requestCancel()"
|
||||
></app-button>
|
||||
} @else {
|
||||
<i class="ri-save-line" aria-hidden="true"></i>
|
||||
<span>Save Draft</span>
|
||||
<app-button
|
||||
action="previous"
|
||||
label="Back"
|
||||
[showIcon]="true"
|
||||
[disabled]="navigationDisabled()"
|
||||
className="onboarding-nav-btn"
|
||||
(buttonClicked)="requestBack()"
|
||||
></app-button>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!isLastStep()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-primary-full h-10 w-full
|
||||
justify-center gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="
|
||||
navigationDisabled() ||
|
||||
savingDraft() ||
|
||||
finishing()
|
||||
"
|
||||
(click)="requestNext()"
|
||||
>
|
||||
<span>Next</span>
|
||||
<!-- Center: Save Draft -->
|
||||
<div class="justify-self-center">
|
||||
<app-button
|
||||
action="save"
|
||||
[variant]="'custom'"
|
||||
label="Save Draft"
|
||||
loadingLabel="Saving..."
|
||||
[showIcon]="true"
|
||||
[loading]="savingDraft()"
|
||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
||||
className="onboarding-save-btn ti-btn-outline-primary whitespace-nowrap px-4"
|
||||
(buttonClicked)="requestSaveDraft()"
|
||||
></app-button>
|
||||
</div>
|
||||
|
||||
<i
|
||||
class="ri-arrow-right-s-line rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-success-full h-10 w-full
|
||||
justify-center gap-2 disabled:pointer-events-none
|
||||
disabled:opacity-50 sm:w-44"
|
||||
[disabled]="
|
||||
navigationDisabled() ||
|
||||
savingDraft() ||
|
||||
finishing()
|
||||
"
|
||||
(click)="requestFinish()"
|
||||
>
|
||||
@if (finishing()) {
|
||||
<span
|
||||
class="size-4 animate-spin rounded-full border-2
|
||||
border-current border-t-transparent"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<span>Provisioning...</span>
|
||||
} @else {
|
||||
<i
|
||||
class="ri-checkbox-circle-line"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
|
||||
<span>Finish & Provision</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<!-- Right: Save Draft & Next/Finish -->
|
||||
<div class="justify-self-end">
|
||||
@if (!isLastStep()) {
|
||||
<app-button
|
||||
action="next"
|
||||
label="Next"
|
||||
[showIcon]="true"
|
||||
[iconPosition]="'right'"
|
||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
||||
className="onboarding-nav-btn"
|
||||
(buttonClicked)="requestNext()"
|
||||
></app-button>
|
||||
} @else {
|
||||
<app-button
|
||||
action="approve"
|
||||
label="Finish & Provision"
|
||||
loadingLabel="Preparing..."
|
||||
[showIcon]="true"
|
||||
[loading]="finishing()"
|
||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
||||
className="sm:w-44"
|
||||
(buttonClicked)="requestFinish()"
|
||||
></app-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- End Button Group -->
|
||||
<!-- End Button Group Footer -->
|
||||
</div>
|
||||
<!-- End Stepper Content -->
|
||||
<!-- End Stepper Form Content -->
|
||||
</div>
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
.stepper-header-card {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0.25rem 0 0.75rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
padding: 0.5rem 0 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stepper-nav-scroll-wrapper {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.stepper-progress-line {
|
||||
background: var(--primary, #7c3deb);
|
||||
background: linear-gradient(90deg, var(--primary, #7c3deb) 0%, #a855f7 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.step-node-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.step-circle {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
// Pending Step
|
||||
&.pending {
|
||||
background-color: #f0f2f5;
|
||||
border: 2px solid #cbd5e1;
|
||||
color: #94a3b8;
|
||||
|
||||
:host-context(.dark) & {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
// Completed Step
|
||||
&.completed {
|
||||
background: var(--primary, #7c3deb);
|
||||
background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(124, 61, 235, 0.25);
|
||||
}
|
||||
|
||||
// Active Step with Glowing Aura in Theme Primary
|
||||
&.active {
|
||||
background: var(--primary, #7c3deb);
|
||||
background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 0 20px 5px rgba(124, 61, 235, 0.45),
|
||||
0 0 10px 2px rgba(168, 85, 247, 0.35);
|
||||
transform: scale(1.05);
|
||||
|
||||
@media (max-width: 639px) {
|
||||
box-shadow:
|
||||
0 0 10px 2px rgba(124, 61, 235, 0.35),
|
||||
0 0 5px 1px rgba(168, 85, 247, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-label {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
white-space: nowrap;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
|
||||
:host-context(.dark) & {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
&.completed {
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
|
||||
:host-context(.dark) & {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--primary, #7c3deb);
|
||||
font-weight: 700;
|
||||
|
||||
:host-context(.dark) & {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -4,6 +4,7 @@ import {
|
||||
input,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { Button } from '../../../../../shared/components/button/button';
|
||||
|
||||
export interface OnboardingStep {
|
||||
readonly key: string;
|
||||
@@ -13,7 +14,7 @@ export interface OnboardingStep {
|
||||
@Component({
|
||||
selector: 'onboarding-stepper',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [Button],
|
||||
templateUrl: './onboarding-stepper.html',
|
||||
styleUrl: './onboarding-stepper.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -34,6 +35,33 @@ export class OnboardingStepper {
|
||||
readonly finishClicked = output<void>();
|
||||
readonly cancelClicked = output<void>();
|
||||
|
||||
currentStepLabel(): string {
|
||||
return this.steps()[this.currentStepIndex()]?.label ?? '';
|
||||
}
|
||||
|
||||
getProgressPercent(): number {
|
||||
const total = this.steps().length;
|
||||
if (total === 0) return 0;
|
||||
return Math.round(((this.currentStepIndex() + 1) / total) * 100);
|
||||
}
|
||||
|
||||
getTrackLineLeft(): string {
|
||||
const total = this.steps().length;
|
||||
if (total <= 0) return '0%';
|
||||
return `${100 / (total * 2)}%`;
|
||||
}
|
||||
|
||||
getProgressLineWidth(): string {
|
||||
const total = this.steps().length;
|
||||
if (total <= 1) return '0%';
|
||||
const maxSpan = (100 * (total - 1)) / total;
|
||||
const progressRatio = Math.min(
|
||||
1,
|
||||
Math.max(0, this.currentStepIndex() / (total - 1))
|
||||
);
|
||||
return `${maxSpan * progressRatio}%`;
|
||||
}
|
||||
|
||||
isFirstStep(): boolean {
|
||||
return this.currentStepIndex() === 0;
|
||||
}
|
||||
|
||||
+57
-60
@@ -1,66 +1,63 @@
|
||||
<div class="main-content">
|
||||
<div class="container-fluid">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-semibold text-defaulttextcolor">
|
||||
Create Organization
|
||||
</h1>
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<h1 class="text-xl font-semibold text-defaulttextcolor">
|
||||
Create Organization
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="rounded-xl border border-defaultborder bg-white
|
||||
p-3.5 sm:p-5 shadow-sm dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
<onboarding-stepper
|
||||
[steps]="steps"
|
||||
[currentStepIndex]="currentStepIndex()"
|
||||
[completedStepIndexes]="completedStepIndexes()"
|
||||
[savingDraft]="savingDraft()"
|
||||
[finishing]="finishing()"
|
||||
[navigationDisabled]="navigationDisabled()"
|
||||
(stepSelected)="onStepSelected($event)"
|
||||
(backClicked)="onBack()"
|
||||
(nextClicked)="onNext()"
|
||||
(saveDraftClicked)="onSaveDraft()"
|
||||
(finishClicked)="onFinish()"
|
||||
(cancelClicked)="onCancel()"
|
||||
>
|
||||
<div [class.hidden]="currentStep().key !== 'basics'" [attr.aria-hidden]="currentStep().key !== 'basics'">
|
||||
<app-organization-basics-step />
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="rounded-xl border border-defaultborder bg-white
|
||||
p-5 shadow-sm dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
<onboarding-stepper
|
||||
[steps]="steps"
|
||||
[currentStepIndex]="currentStepIndex()"
|
||||
[completedStepIndexes]="completedStepIndexes()"
|
||||
[savingDraft]="savingDraft()"
|
||||
[finishing]="finishing()"
|
||||
[navigationDisabled]="navigationDisabled()"
|
||||
(stepSelected)="onStepSelected($event)"
|
||||
(backClicked)="onBack()"
|
||||
(nextClicked)="onNext()"
|
||||
(saveDraftClicked)="onSaveDraft()"
|
||||
(finishClicked)="onFinish()"
|
||||
(cancelClicked)="onCancel()"
|
||||
>
|
||||
@switch (currentStep().key) {
|
||||
@case ('basics') {
|
||||
<app-organization-basics-step #basicsStep />
|
||||
}
|
||||
<div [class.hidden]="currentStep().key !== 'localization'" [attr.aria-hidden]="currentStep().key !== 'localization'">
|
||||
<app-organization-localization-step />
|
||||
</div>
|
||||
|
||||
@case ('localization') {
|
||||
<div
|
||||
class="min-h-72 rounded-xl border border-dashed
|
||||
border-defaultborder bg-gray-50 p-6
|
||||
dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
Localization content
|
||||
</div>
|
||||
}
|
||||
<div [class.hidden]="currentStep().key !== 'plan-limits'" [attr.aria-hidden]="currentStep().key !== 'plan-limits'">
|
||||
<app-organization-plan-limits-step />
|
||||
</div>
|
||||
|
||||
@case ('plan-limits') {
|
||||
<div
|
||||
class="min-h-72 rounded-xl border border-dashed
|
||||
border-defaultborder bg-gray-50 p-6
|
||||
dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
Plan and Limits content
|
||||
</div>
|
||||
}
|
||||
<div [class.hidden]="currentStep().key !== 'admin-user'" [attr.aria-hidden]="currentStep().key !== 'admin-user'">
|
||||
<app-organization-admin-step [reviewData]="stateService.onboardingData()" />
|
||||
</div>
|
||||
</onboarding-stepper>
|
||||
</section>
|
||||
|
||||
@case ('admin-user') {
|
||||
<div
|
||||
class="min-h-72 rounded-xl border border-dashed
|
||||
border-defaultborder bg-gray-50 p-6
|
||||
dark:border-white/10 dark:bg-bodybg"
|
||||
>
|
||||
Admin and User content
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</onboarding-stepper>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<app-confirm-dialog
|
||||
#cancelConfirmDialog
|
||||
title="Discard onboarding changes?"
|
||||
text="Your unsaved changes will be lost."
|
||||
confirmButtonText="Discard and Leave"
|
||||
cancelButtonText="Stay"
|
||||
(confirmed)="onDiscardAndLeave()"
|
||||
>
|
||||
<span class="hidden"></span>
|
||||
</app-confirm-dialog>
|
||||
|
||||
<app-confirm-dialog
|
||||
#finishConfirmDialog
|
||||
title="Finish organization onboarding?"
|
||||
text="All details are valid. Provisioning will begin after the backend integration is connected."
|
||||
confirmButtonText="Confirm"
|
||||
cancelButtonText="Review"
|
||||
(confirmed)="onProvisioningConfirmed()"
|
||||
>
|
||||
<span class="hidden"></span>
|
||||
</app-confirm-dialog>
|
||||
|
||||
+214
-29
@@ -4,7 +4,8 @@ import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
signal
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -15,13 +16,26 @@ import {
|
||||
OnboardingStepper
|
||||
} from './components/onboarding-stepper/onboarding-stepper';
|
||||
import { OrganizationBasicsStepComponent } from './steps/organization-basics/organization-basics';
|
||||
import { OrganizationLocalizationStepComponent } from './steps/organization-localization/organization-localization';
|
||||
import { OrganizationPlanLimitsStepComponent } from './steps/organization-plan-limits/organization-plan-limits';
|
||||
import { OrganizationAdminStepComponent } from './steps/organization-admin/organization-admin';
|
||||
import { OrganizationOnboardingStateService } from './services/organization-onboarding-state.service';
|
||||
import { OrganizationOnboardingService } from './services/organization-onboarding.service';
|
||||
import { ConfirmDialog } from '../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
import { OnboardingStepForm } from './models/organization-onboarding.model';
|
||||
import { OrganizationProvisioningRequest } from './models/organization-provisioning.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-organization-onboarding',
|
||||
standalone: true,
|
||||
imports: [OnboardingStepper, OrganizationBasicsStepComponent],
|
||||
imports: [
|
||||
OnboardingStepper,
|
||||
OrganizationBasicsStepComponent,
|
||||
OrganizationLocalizationStepComponent,
|
||||
OrganizationPlanLimitsStepComponent,
|
||||
OrganizationAdminStepComponent,
|
||||
ConfirmDialog,
|
||||
],
|
||||
templateUrl: './organization-onboarding.html',
|
||||
styleUrl: './organization-onboarding.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -31,7 +45,7 @@ export class OrganizationOnboarding {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly stateService = inject(OrganizationOnboardingStateService);
|
||||
readonly stateService = inject(OrganizationOnboardingStateService);
|
||||
private readonly onboardingService = inject(OrganizationOnboardingService);
|
||||
|
||||
readonly steps: readonly OnboardingStep[] = this.stateService.createStepDefinitions();
|
||||
@@ -39,7 +53,15 @@ export class OrganizationOnboarding {
|
||||
readonly savingDraft = signal(false);
|
||||
readonly finishing = signal(false);
|
||||
|
||||
private basicsStep?: OrganizationBasicsStepComponent;
|
||||
private readonly basicsStep = viewChild(OrganizationBasicsStepComponent);
|
||||
private readonly localizationStep = viewChild(OrganizationLocalizationStepComponent);
|
||||
private readonly planLimitsStep = viewChild(OrganizationPlanLimitsStepComponent);
|
||||
private readonly adminStep = viewChild(OrganizationAdminStepComponent);
|
||||
private readonly cancelConfirmDialog = viewChild<ConfirmDialog>('cancelConfirmDialog');
|
||||
private readonly finishConfirmDialog = viewChild<ConfirmDialog>('finishConfirmDialog');
|
||||
private savedDraftSnapshot = '';
|
||||
|
||||
readonly provisioningRequest = signal<OrganizationProvisioningRequest | null>(null);
|
||||
|
||||
readonly currentStepIndex = this.stateService.currentStepIndex;
|
||||
readonly completedStepIndexes = this.stateService.completedStepIndexes;
|
||||
@@ -63,28 +85,24 @@ export class OrganizationOnboarding {
|
||||
)
|
||||
.subscribe(draft => {
|
||||
if (!draft) {
|
||||
this.savedDraftSnapshot = this.serializeDraftState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateService.restoreDraft(draft);
|
||||
this.savedDraftSnapshot = this.serializeDraftState();
|
||||
this.hydrateActiveStep();
|
||||
});
|
||||
}
|
||||
|
||||
registerBasicsStep(component: OrganizationBasicsStepComponent): void {
|
||||
this.basicsStep = component;
|
||||
const basicsDraft = this.stateService.onboardingDraft().basics;
|
||||
|
||||
if (basicsDraft) {
|
||||
component.patchValue(basicsDraft);
|
||||
}
|
||||
}
|
||||
|
||||
onStepSelected(index: number): void {
|
||||
if (!this.stateService.canOpenStep(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.captureActiveStepDraft();
|
||||
this.stateService.setCurrentStepIndex(index);
|
||||
this.hydrateActiveStep();
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
@@ -94,7 +112,9 @@ export class OrganizationOnboarding {
|
||||
return;
|
||||
}
|
||||
|
||||
this.captureActiveStepDraft();
|
||||
this.stateService.setCurrentStepIndex(currentIndex - 1);
|
||||
this.hydrateActiveStep();
|
||||
}
|
||||
|
||||
onNext(): void {
|
||||
@@ -104,17 +124,16 @@ export class OrganizationOnboarding {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentIndex === 0) {
|
||||
if (!this.basicsStep?.validate()) {
|
||||
this.stateService.unmarkStepCompleted(currentIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateService.updateBasics(this.basicsStep.getValue());
|
||||
const activeStep = this.getActiveStep();
|
||||
if (!activeStep?.validate()) {
|
||||
this.stateService.unmarkStepCompleted(currentIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
this.storeValidatedActiveStep(activeStep);
|
||||
this.stateService.markStepCompleted(currentIndex);
|
||||
this.stateService.setCurrentStepIndex(currentIndex + 1);
|
||||
this.hydrateActiveStep();
|
||||
}
|
||||
|
||||
onSaveDraft(): void {
|
||||
@@ -122,9 +141,7 @@ export class OrganizationOnboarding {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentStepIndex() === 0 && this.basicsStep) {
|
||||
this.stateService.updateBasicsDraft(this.basicsStep.getDraftValue());
|
||||
}
|
||||
this.captureActiveStepDraft();
|
||||
|
||||
this.savingDraft.set(true);
|
||||
|
||||
@@ -137,6 +154,7 @@ export class OrganizationOnboarding {
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.savedDraftSnapshot = this.serializeDraftState();
|
||||
this.toastr.success('Onboarding draft saved successfully.', 'Draft saved');
|
||||
},
|
||||
error: () => {
|
||||
@@ -150,15 +168,182 @@ export class OrganizationOnboarding {
|
||||
return;
|
||||
}
|
||||
|
||||
this.finishing.set(true);
|
||||
const activeStep = this.adminStep();
|
||||
if (!activeStep?.validate()) {
|
||||
this.stateService.unmarkStepCompleted(3);
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.finishing.set(false);
|
||||
this.stateService.updateAdmin(activeStep.getValue());
|
||||
this.stateService.markStepCompleted(3);
|
||||
|
||||
const data = this.stateService.onboardingData();
|
||||
if (!data.basics || !data.localization || !data.planLimits || !data.admin) {
|
||||
this.toastr.error('Complete and validate every onboarding step before provisioning.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.provisioningRequest.set({
|
||||
basics: data.basics,
|
||||
localization: data.localization,
|
||||
planLimits: data.planLimits,
|
||||
admin: data.admin,
|
||||
});
|
||||
void this.finishConfirmDialog()?.open();
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.stateService.clear();
|
||||
void this.router.navigate(['/configuration/organizations']);
|
||||
this.captureActiveStepDraft();
|
||||
if (this.serializeDraftState() !== this.savedDraftSnapshot) {
|
||||
void this.cancelConfirmDialog()?.open();
|
||||
return;
|
||||
}
|
||||
|
||||
void this.leaveOnboarding(false);
|
||||
}
|
||||
}
|
||||
|
||||
onDiscardAndLeave(): void {
|
||||
void this.leaveOnboarding(true);
|
||||
}
|
||||
|
||||
onProvisioningConfirmed(): void {
|
||||
this.toastr.info(
|
||||
'The provisioning request is ready. Backend provisioning is not connected yet.',
|
||||
'Provisioning pending'
|
||||
);
|
||||
}
|
||||
|
||||
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
|
||||
switch (this.currentStepIndex()) {
|
||||
case 0:
|
||||
return this.basicsStep();
|
||||
case 1:
|
||||
return this.localizationStep();
|
||||
case 2:
|
||||
return this.planLimitsStep();
|
||||
case 3:
|
||||
return this.adminStep();
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private storeValidatedActiveStep(step: OnboardingStepForm<unknown>): void {
|
||||
switch (this.currentStepIndex()) {
|
||||
case 0:
|
||||
this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue());
|
||||
break;
|
||||
case 1:
|
||||
this.stateService.updateLocalization((step as OrganizationLocalizationStepComponent).getValue());
|
||||
break;
|
||||
case 2:
|
||||
this.stateService.updatePlanLimits((step as OrganizationPlanLimitsStepComponent).getValue());
|
||||
break;
|
||||
case 3:
|
||||
this.stateService.updateAdmin((step as OrganizationAdminStepComponent).getValue());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private captureActiveStepDraft(): void {
|
||||
const currentIndex = this.currentStepIndex();
|
||||
const validatedValue = this.getValidatedStepValue(currentIndex);
|
||||
|
||||
switch (currentIndex) {
|
||||
case 0:
|
||||
this.stateService.updateBasicsDraft(this.basicsStep()?.getDraftValue() ?? null);
|
||||
break;
|
||||
case 1:
|
||||
this.stateService.updateLocalizationDraft(this.localizationStep()?.getDraftValue() ?? null);
|
||||
break;
|
||||
case 2:
|
||||
this.stateService.updatePlanLimitsDraft(this.planLimitsStep()?.getDraftValue() ?? null);
|
||||
break;
|
||||
case 3:
|
||||
this.stateService.updateAdminDraft(this.adminStep()?.getDraftValue() ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
const draftValue = this.getDraftStepValue(currentIndex);
|
||||
if (
|
||||
this.stateService.isStepCompleted(currentIndex) &&
|
||||
JSON.stringify(draftValue) !== JSON.stringify(validatedValue)
|
||||
) {
|
||||
this.stateService.invalidateStep(currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private getValidatedStepValue(index: number): unknown {
|
||||
const data = this.stateService.onboardingData();
|
||||
return [data.basics, data.localization, data.planLimits, data.admin][index] ?? null;
|
||||
}
|
||||
|
||||
private getDraftStepValue(index: number): unknown {
|
||||
const draft = this.stateService.onboardingDraft();
|
||||
return [draft.basics, draft.localization, draft.planLimits, draft.admin][index] ?? null;
|
||||
}
|
||||
|
||||
private hydrateActiveStep(): void {
|
||||
queueMicrotask(() => {
|
||||
const draft = this.stateService.onboardingDraft();
|
||||
switch (this.currentStepIndex()) {
|
||||
case 0:
|
||||
if (draft.basics) this.basicsStep()?.patchValue(draft.basics);
|
||||
break;
|
||||
case 1:
|
||||
if (draft.localization) {
|
||||
this.localizationStep()?.patchValue(draft.localization);
|
||||
}
|
||||
this.applyCountryDefaults();
|
||||
break;
|
||||
case 2:
|
||||
if (draft.planLimits) this.planLimitsStep()?.patchValue(draft.planLimits);
|
||||
break;
|
||||
case 3:
|
||||
if (draft.admin) this.adminStep()?.patchValue(draft.admin);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyCountryDefaults(): void {
|
||||
const countryIso2 = this.stateService.basics()?.registrationCountry.iso2 ?? null;
|
||||
this.onboardingService.getCountryLocalizationDefaults(countryIso2)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(defaults => {
|
||||
if (defaults) {
|
||||
this.localizationStep()?.applyCountryDefaults(defaults, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private serializeDraftState(): string {
|
||||
const draft = this.stateService.buildDraft();
|
||||
return JSON.stringify({
|
||||
currentStepIndex: draft.currentStepIndex,
|
||||
completedStepIndexes: draft.completedStepIndexes,
|
||||
basics: draft.basics,
|
||||
localization: draft.localization,
|
||||
planLimits: draft.planLimits,
|
||||
admin: draft.admin,
|
||||
});
|
||||
}
|
||||
|
||||
private async leaveOnboarding(clearDraft: boolean): Promise<void> {
|
||||
if (clearDraft) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.onboardingService.clearDraft()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({ next: resolve, error: reject });
|
||||
});
|
||||
} catch {
|
||||
this.toastr.error('Unable to clear the saved draft.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.stateService.clear();
|
||||
await this.router.navigate(['/organizations']);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-7
@@ -57,7 +57,7 @@ export class OrganizationOnboardingStateService {
|
||||
});
|
||||
|
||||
setCurrentStepIndex(index: number): void {
|
||||
this.currentStepIndexState.set(Math.max(0, index));
|
||||
this.currentStepIndexState.set(Math.min(3, Math.max(0, index)));
|
||||
}
|
||||
|
||||
updateBasics(value: OrganizationBasicsValue | null): void {
|
||||
@@ -108,8 +108,27 @@ export class OrganizationOnboardingStateService {
|
||||
this.completedStepIndexesState.update(current => current.filter(item => item !== index));
|
||||
}
|
||||
|
||||
invalidateStep(index: number): void {
|
||||
this.unmarkStepCompleted(index);
|
||||
this.onboardingDataState.update(current => {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return { ...current, basics: null };
|
||||
case 1:
|
||||
return { ...current, localization: null };
|
||||
case 2:
|
||||
return { ...current, planLimits: null };
|
||||
case 3:
|
||||
return { ...current, admin: null };
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setCompletedStepIndexes(indexes: readonly number[]): void {
|
||||
const uniqueIndexes = [...new Set(indexes.filter(index => index >= 0))].sort((left, right) => left - right);
|
||||
const uniqueIndexes = [...new Set(indexes.filter(index => index >= 0 && index <= 3))]
|
||||
.sort((left, right) => left - right);
|
||||
this.completedStepIndexesState.set(uniqueIndexes);
|
||||
}
|
||||
|
||||
@@ -158,7 +177,7 @@ export class OrganizationOnboardingStateService {
|
||||
? { ...draft.admin } as OrganizationAdminValue
|
||||
: null,
|
||||
});
|
||||
this.currentStepIndexState.set(Math.max(0, draft.currentStepIndex));
|
||||
this.currentStepIndexState.set(Math.min(3, Math.max(0, draft.currentStepIndex)));
|
||||
this.setCompletedStepIndexes(draft.completedStepIndexes);
|
||||
this.savedDraftIdState.set(draft.draftId);
|
||||
this.savedAtState.set(draft.savedAt);
|
||||
@@ -180,10 +199,10 @@ export class OrganizationOnboardingStateService {
|
||||
|
||||
createStepDefinitions(): readonly OnboardingStepDefinition[] {
|
||||
return [
|
||||
{ key: 'basics', label: 'Basics' },
|
||||
{ key: 'basics', label: 'Basic' },
|
||||
{ key: 'localization', label: 'Localization' },
|
||||
{ key: 'plan-limits', label: 'Plan & Limits' },
|
||||
{ key: 'admin-user', label: 'Admin & User' },
|
||||
{ key: 'plan-limits', label: 'Plans and Limits' },
|
||||
{ key: 'admin-user', label: 'Admin & Users' },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-48
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { FormSelect } from '../../../../shared/components/form/form-select/form-select';
|
||||
import { Observable, catchError, map, of, throwError } from 'rxjs';
|
||||
import { Observable, map, of, throwError } from 'rxjs';
|
||||
import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models';
|
||||
import {
|
||||
CountryLookupDto,
|
||||
CountryService,
|
||||
@@ -29,10 +29,6 @@ import {
|
||||
OrganizationPlanLookupValue,
|
||||
TimeFormatValue,
|
||||
} from '../models/organization-onboarding.model';
|
||||
import {
|
||||
OrganizationProvisioningRequest,
|
||||
OrganizationProvisioningResult,
|
||||
} from '../models/organization-provisioning.model';
|
||||
|
||||
const ORGANIZATION_ONBOARDING_DRAFT_KEY = 'organization-onboarding-draft-v1';
|
||||
|
||||
@@ -84,31 +80,31 @@ const PLAN_DEFAULTS: readonly OrganizationPlanDefaults[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DATE_FORMAT_OPTIONS: readonly FormSelect<DateFormatValue>[] = [
|
||||
// { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
|
||||
// { value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
|
||||
// { value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
||||
const DATE_FORMAT_OPTIONS: readonly FormSelectOption<DateFormatValue>[] = [
|
||||
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
|
||||
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
|
||||
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
||||
];
|
||||
|
||||
const TIME_FORMAT_OPTIONS: readonly FormSelect<TimeFormatValue>[] = [
|
||||
// { value: 'TwelveHour', label: '12 hour' },
|
||||
// { value: 'TwentyFourHour', label: '24 hour' },
|
||||
const TIME_FORMAT_OPTIONS: readonly FormSelectOption<TimeFormatValue>[] = [
|
||||
{ value: 'TwelveHour', label: '12 hour' },
|
||||
{ value: 'TwentyFourHour', label: '24 hour' },
|
||||
];
|
||||
|
||||
const NUMBER_FORMAT_OPTIONS: readonly FormSelect<NumberFormatValue>[] = [
|
||||
// { value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' },
|
||||
// { value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' },
|
||||
const NUMBER_FORMAT_OPTIONS: readonly FormSelectOption<NumberFormatValue>[] = [
|
||||
{ value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' },
|
||||
{ value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' },
|
||||
];
|
||||
|
||||
const FISCAL_YEAR_OPTIONS: readonly FormSelect<FiscalYearConventionValue>[] = [
|
||||
// { value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' },
|
||||
// { value: 'AprilToMarch', label: 'April - March' },
|
||||
// { value: 'JulyToJune', label: 'July - June' },
|
||||
const FISCAL_YEAR_OPTIONS: readonly FormSelectOption<FiscalYearConventionValue>[] = [
|
||||
{ value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' },
|
||||
{ value: 'AprilToMarch', label: 'April - March' },
|
||||
{ value: 'JulyToJune', label: 'July - June' },
|
||||
];
|
||||
|
||||
const LICENSE_TYPE_OPTIONS: readonly FormSelect<OrganizationLicenseType>[] = [
|
||||
// { value: 'Trial', label: 'Trial' },
|
||||
// { value: 'Paid', label: 'Paid' },
|
||||
const LICENSE_TYPE_OPTIONS: readonly FormSelectOption<OrganizationLicenseType>[] = [
|
||||
{ value: 'Trial', label: 'Trial' },
|
||||
{ value: 'Paid', label: 'Paid' },
|
||||
];
|
||||
|
||||
const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
|
||||
@@ -202,35 +198,32 @@ export class OrganizationOnboardingService {
|
||||
}))
|
||||
);
|
||||
}
|
||||
loadLanguageOptions(limit = 50):readonly FormSelect<DateFormatValue>[] {
|
||||
return DATE_FORMAT_OPTIONS;
|
||||
loadLanguageOptions(limit = 50): Observable<readonly FormSelectOption<string>[]> {
|
||||
return this.languageService.autocomplete('', limit).pipe(
|
||||
map(items => items.map(item => ({
|
||||
value: item.id,
|
||||
label: `${item.code} - ${item.name}`,
|
||||
})))
|
||||
);
|
||||
}
|
||||
// loadLanguageOptions(limit = 50): Observable<readonly FormSelect<string>[]> {
|
||||
// // return this.languageService.autocomplete('', limit).pipe(
|
||||
// // map(items => items.map(item => ({
|
||||
// // value: item.id,
|
||||
// // label: `${item.code} - ${item.name}`,
|
||||
// // })))
|
||||
// // );
|
||||
// }
|
||||
|
||||
getDateFormatOptions(): readonly FormSelect<DateFormatValue>[] {
|
||||
getDateFormatOptions(): readonly FormSelectOption<DateFormatValue>[] {
|
||||
return DATE_FORMAT_OPTIONS;
|
||||
}
|
||||
|
||||
getTimeFormatOptions(): readonly FormSelect<TimeFormatValue>[] {
|
||||
getTimeFormatOptions(): readonly FormSelectOption<TimeFormatValue>[] {
|
||||
return TIME_FORMAT_OPTIONS;
|
||||
}
|
||||
|
||||
getNumberFormatOptions(): readonly FormSelect<NumberFormatValue>[] {
|
||||
getNumberFormatOptions(): readonly FormSelectOption<NumberFormatValue>[] {
|
||||
return NUMBER_FORMAT_OPTIONS;
|
||||
}
|
||||
|
||||
getFiscalYearOptions(): readonly FormSelect<FiscalYearConventionValue>[] {
|
||||
getFiscalYearOptions(): readonly FormSelectOption<FiscalYearConventionValue>[] {
|
||||
return FISCAL_YEAR_OPTIONS;
|
||||
}
|
||||
|
||||
getLicenseTypeOptions(): readonly FormSelect<OrganizationLicenseType>[] {
|
||||
getLicenseTypeOptions(): readonly FormSelectOption<OrganizationLicenseType>[] {
|
||||
return LICENSE_TYPE_OPTIONS;
|
||||
}
|
||||
|
||||
@@ -271,7 +264,11 @@ loadLanguageOptions(limit = 50):readonly FormSelect<DateFormatValue>[] {
|
||||
|
||||
return of(parsedDraft);
|
||||
} catch (error) {
|
||||
sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
||||
try {
|
||||
sessionStorage.removeItem(ORGANIZATION_ONBOARDING_DRAFT_KEY);
|
||||
} catch {
|
||||
// The original storage or parsing error is the useful error to report.
|
||||
}
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
@@ -294,13 +291,6 @@ loadLanguageOptions(limit = 50):readonly FormSelect<DateFormatValue>[] {
|
||||
}
|
||||
}
|
||||
|
||||
provisionOrganization(_request: OrganizationProvisioningRequest): Observable<OrganizationProvisioningResult> {
|
||||
return of({
|
||||
status: 'not-configured',
|
||||
message: 'Provisioning is not configured yet for organization onboarding.',
|
||||
});
|
||||
}
|
||||
|
||||
private filterLookupValues<TValue extends OnboardingLookupValue>(
|
||||
source: readonly TValue[],
|
||||
term: string | null,
|
||||
@@ -325,8 +315,21 @@ loadLanguageOptions(limit = 50):readonly FormSelect<DateFormatValue>[] {
|
||||
return (
|
||||
value.schemaVersion === 1 &&
|
||||
typeof value.currentStepIndex === 'number' &&
|
||||
Number.isInteger(value.currentStepIndex) &&
|
||||
value.currentStepIndex >= 0 &&
|
||||
value.currentStepIndex <= 3 &&
|
||||
Array.isArray(value.completedStepIndexes) &&
|
||||
typeof value.savedAt === 'string'
|
||||
value.completedStepIndexes.every(index => Number.isInteger(index) && index >= 0 && index <= 3) &&
|
||||
typeof value.savedAt === 'string' &&
|
||||
(value.draftId === null || typeof value.draftId === 'string') &&
|
||||
this.isDraftSection(value.basics) &&
|
||||
this.isDraftSection(value.localization) &&
|
||||
this.isDraftSection(value.planLimits) &&
|
||||
this.isDraftSection(value.admin)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isDraftSection(value: unknown): boolean {
|
||||
return value === null || (typeof value === 'object' && !Array.isArray(value));
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<form [formGroup]="form" autocomplete="off">
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
<div class="col-span-12">
|
||||
<h2 class="text-base font-semibold text-defaulttextcolor dark:text-white">
|
||||
Organization Contact
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="organizationEmail"
|
||||
inputId="organization-contact-email"
|
||||
variant="floating"
|
||||
label="Organization Email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autocomplete="organization email"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Organization Email is required.',
|
||||
email: 'Enter a valid Organization Email.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="organizationPhone"
|
||||
inputId="organization-contact-phone"
|
||||
variant="floating"
|
||||
label="Organization Phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autocomplete="organization tel"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Organization Phone is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 mt-2">
|
||||
<h2 class="text-base font-semibold text-defaulttextcolor dark:text-white">
|
||||
First Administrator
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-textmuted">
|
||||
A set-password link will be sent to this administrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="administratorFullName"
|
||||
inputId="administrator-full-name"
|
||||
variant="floating"
|
||||
label="Full Name"
|
||||
autocomplete="name"
|
||||
[required]="true"
|
||||
[maxLength]="150"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Administrator Full Name is required.',
|
||||
maxlength: 'Full Name cannot exceed 150 characters.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="administratorEmail"
|
||||
inputId="administrator-email"
|
||||
variant="floating"
|
||||
label="Email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autocomplete="email"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{
|
||||
required: 'Administrator Email is required.',
|
||||
email: 'Enter a valid Administrator Email.'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<app-form-input
|
||||
formControlName="administratorMobile"
|
||||
inputId="administrator-mobile"
|
||||
variant="floating"
|
||||
label="Mobile"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autocomplete="tel"
|
||||
[required]="true"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Administrator Mobile is required.' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="mt-6 border-t border-defaultborder pt-5 dark:border-white/10">
|
||||
<h2 class="text-base font-semibold text-defaulttextcolor dark:text-white">
|
||||
Final Review
|
||||
</h2>
|
||||
|
||||
<dl class="mt-3 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-xs text-textmuted">Organization</dt>
|
||||
<dd class="font-medium text-defaulttextcolor dark:text-white">
|
||||
{{ reviewData().basics?.organizationName || 'Not completed' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-textmuted">Country</dt>
|
||||
<dd class="font-medium text-defaulttextcolor dark:text-white">
|
||||
{{ reviewData().basics?.registrationCountry?.label || 'Not completed' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-textmuted">Localization</dt>
|
||||
<dd class="font-medium text-defaulttextcolor dark:text-white">
|
||||
{{ reviewData().localization?.timeZone?.label || 'Not completed' }} ·
|
||||
{{ reviewData().localization?.currency?.secondaryLabel || reviewData().localization?.currency?.label || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-textmuted">Plan and limits</dt>
|
||||
<dd class="font-medium text-defaulttextcolor dark:text-white">
|
||||
{{ reviewData().planLimits?.subscriptionPlan?.label || 'Not completed' }}
|
||||
@if (reviewData().planLimits) {
|
||||
· {{ reviewData().planLimits?.maximumCompanies }} companies ·
|
||||
{{ reviewData().planLimits?.maximumUsers }} users ·
|
||||
{{ reviewData().planLimits?.maximumStorageGb }} GB
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-textmuted">Access dates</dt>
|
||||
<dd class="font-medium text-defaulttextcolor dark:text-white">
|
||||
{{ reviewData().planLimits?.systemAccessStartDate || 'Not completed' }} –
|
||||
{{ reviewData().planLimits?.systemAccessEndDate || 'No end date' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-textmuted">Administrator</dt>
|
||||
<dd class="font-medium text-defaulttextcolor dark:text-white">
|
||||
{{ form.controls.administratorFullName.value || 'Not entered' }}
|
||||
@if (form.controls.administratorEmail.value) {
|
||||
· {{ form.controls.administratorEmail.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
|
||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||
import {
|
||||
OnboardingStepForm,
|
||||
OrganizationAdminValue,
|
||||
OrganizationOnboardingData,
|
||||
} from '../../models/organization-onboarding.model';
|
||||
|
||||
interface OrganizationAdminFormModel {
|
||||
readonly organizationEmail: string;
|
||||
readonly organizationPhone: string;
|
||||
readonly administratorFullName: string;
|
||||
readonly administratorEmail: string;
|
||||
readonly administratorMobile: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-organization-admin-step',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, FormInput],
|
||||
templateUrl: './organization-admin.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrganizationAdminStepComponent implements OnboardingStepForm<OrganizationAdminValue> {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly reviewData = input.required<OrganizationOnboardingData>();
|
||||
readonly submitAttempted = signal(false);
|
||||
|
||||
readonly form = this.formBuilder.nonNullable.group({
|
||||
organizationEmail: ['', [Validators.required, Validators.email]],
|
||||
organizationPhone: ['', [Validators.required]],
|
||||
administratorFullName: ['', [Validators.required, Validators.maxLength(150)]],
|
||||
administratorEmail: ['', [Validators.required, Validators.email]],
|
||||
administratorMobile: ['', [Validators.required]],
|
||||
});
|
||||
|
||||
validate(): boolean {
|
||||
this.submitAttempted.set(true);
|
||||
if (this.form.valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.form.markAllAsTouched();
|
||||
this.focusFirstInvalidControl();
|
||||
return false;
|
||||
}
|
||||
|
||||
getValue(): OrganizationAdminValue {
|
||||
const value = this.form.getRawValue() as OrganizationAdminFormModel;
|
||||
return this.normalize(value);
|
||||
}
|
||||
|
||||
getDraftValue(): Partial<OrganizationAdminValue> {
|
||||
return this.normalize(this.form.getRawValue() as OrganizationAdminFormModel);
|
||||
}
|
||||
|
||||
patchValue(value: Partial<OrganizationAdminValue>): void {
|
||||
this.form.patchValue({
|
||||
organizationEmail: value.organizationEmail ?? '',
|
||||
organizationPhone: value.organizationPhone ?? '',
|
||||
administratorFullName: value.administratorFullName ?? '',
|
||||
administratorEmail: value.administratorEmail ?? '',
|
||||
administratorMobile: value.administratorMobile ?? '',
|
||||
}, { emitEvent: false });
|
||||
this.submitAttempted.set(false);
|
||||
}
|
||||
|
||||
private normalize(value: OrganizationAdminFormModel): OrganizationAdminValue {
|
||||
return {
|
||||
organizationEmail: value.organizationEmail.trim(),
|
||||
organizationPhone: value.organizationPhone.trim(),
|
||||
administratorFullName: value.administratorFullName.trim(),
|
||||
administratorEmail: value.administratorEmail.trim(),
|
||||
administratorMobile: value.administratorMobile.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private focusFirstInvalidControl(): void {
|
||||
queueMicrotask(() => {
|
||||
this.elementRef.nativeElement
|
||||
.querySelector<HTMLElement>('[data-form-control].ng-invalid, .ng-invalid [data-form-control]')
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
+77
-4
@@ -63,20 +63,93 @@
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="additionalLanguageIds"
|
||||
inputId="organization-additional-languages"
|
||||
[id]="'organization-additional-languages'"
|
||||
variant="floating"
|
||||
label="Additional Languages"
|
||||
mode="multiple"
|
||||
[options]="additionalLanguageOptions()"
|
||||
[multiple]="true"
|
||||
[showCheckboxes]="true"
|
||||
[showSelectAll]="true"
|
||||
[clearable]="true"
|
||||
[searchable]="true"
|
||||
[placeholder]="'Select Additional Languages'"
|
||||
bindLabel="label"
|
||||
bindValue="value"
|
||||
[selectClass]="'rounded-sm'"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="dateFormat"
|
||||
[id]="'organization-date-format'"
|
||||
variant="floating"
|
||||
label="Date Format"
|
||||
[options]="dateFormatOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[selectClass]="'rounded-sm'"
|
||||
bindLabel="label"
|
||||
bindValue="value"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Date Format is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="timeFormat"
|
||||
[id]="'organization-time-format'"
|
||||
variant="floating"
|
||||
label="Time Format"
|
||||
[options]="timeFormatOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[selectClass]="'rounded-sm'"
|
||||
bindLabel="label"
|
||||
bindValue="value"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Time Format is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="numberFormat"
|
||||
[id]="'organization-number-format'"
|
||||
variant="floating"
|
||||
label="Number Format"
|
||||
[options]="numberFormatOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[selectClass]="'rounded-sm'"
|
||||
bindLabel="label"
|
||||
bindValue="value"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Number Format is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="fiscalYearConvention"
|
||||
[id]="'organization-fiscal-year-convention'"
|
||||
variant="floating"
|
||||
label="Fiscal Year Convention"
|
||||
[options]="fiscalYearOptions"
|
||||
[required]="true"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[selectClass]="'rounded-sm'"
|
||||
bindLabel="label"
|
||||
bindValue="value"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Fiscal Year Convention is required.' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
+106
-57
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, inject, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
TimezoneLookupDto,
|
||||
} from '../../../../global-masters/timezones/public-api';
|
||||
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
|
||||
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
||||
import {
|
||||
AutocompleteDisplayFn,
|
||||
AutocompleteResolveValueFn,
|
||||
AutocompleteSearchFn,
|
||||
AutocompleteValueFn,
|
||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
||||
import { FormSelectOption } from '../../../../../shared/components/form/models/form-select.models';
|
||||
import {
|
||||
CountryLocalizationDefaults,
|
||||
@@ -45,6 +45,15 @@ interface OrganizationLocalizationFormModel {
|
||||
readonly fiscalYearConvention: FiscalYearConventionValue | null;
|
||||
}
|
||||
|
||||
type ThemeSelectValue =
|
||||
| string
|
||||
| number
|
||||
| readonly string[]
|
||||
| readonly number[]
|
||||
| string[]
|
||||
| number[]
|
||||
| null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-organization-localization-step',
|
||||
standalone: true,
|
||||
@@ -64,11 +73,18 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
||||
readonly currencySelection = signal<CurrencyLookupDto | null>(null);
|
||||
readonly defaultLanguageSelection = signal<LanguageLookupDto | null>(null);
|
||||
readonly additionalLanguageOptions = signal<readonly FormSelectOption<string>[]>([]);
|
||||
readonly additionalLanguageThemeOptions = computed(() =>
|
||||
this.additionalLanguageOptions().map(option => ({ label: option.label, value: option.value }))
|
||||
);
|
||||
|
||||
readonly dateFormatOptions = this.onboardingService.getDateFormatOptions();
|
||||
readonly dateFormatOptions = this.onboardingService.getDateFormatOptions();
|
||||
readonly timeFormatOptions = this.onboardingService.getTimeFormatOptions();
|
||||
readonly numberFormatOptions = this.onboardingService.getNumberFormatOptions();
|
||||
readonly fiscalYearOptions = this.onboardingService.getFiscalYearOptions();
|
||||
readonly dateFormatThemeOptions = this.toThemeOptions(this.dateFormatOptions);
|
||||
readonly timeFormatThemeOptions = this.toThemeOptions(this.timeFormatOptions);
|
||||
readonly numberFormatThemeOptions = this.toThemeOptions(this.numberFormatOptions);
|
||||
readonly fiscalYearThemeOptions = this.toThemeOptions(this.fiscalYearOptions);
|
||||
|
||||
readonly form = this.formBuilder.group({
|
||||
timeZoneId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
@@ -130,17 +146,17 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
||||
this.onboardingService.resolveLanguage(value);
|
||||
|
||||
constructor() {
|
||||
// this.onboardingService.loadLanguageOptions(100)
|
||||
// .pipe(
|
||||
// catchError(() => {
|
||||
// this.toastr.error('Unable to load language options.');
|
||||
// return of<readonly FormSelectOption<string>[]>([]);
|
||||
// }),
|
||||
// takeUntilDestroyed(this.destroyRef)
|
||||
// )
|
||||
// .subscribe(options => {
|
||||
// this.additionalLanguageOptions.update(current => this.mergeLanguageOptions(current, options));
|
||||
// });
|
||||
this.onboardingService.loadLanguageOptions(100)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.toastr.error('Unable to load language options.');
|
||||
return of<readonly FormSelectOption<string>[]>([]);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(options => {
|
||||
this.additionalLanguageOptions.update(current => this.mergeLanguageOptions(current, options));
|
||||
});
|
||||
|
||||
this.form.controls.defaultLanguageId.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
@@ -201,24 +217,24 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
||||
return {
|
||||
timeZone: this.timeZoneSelection()
|
||||
? {
|
||||
id: this.timeZoneSelection()!.id,
|
||||
label: this.timeZoneSelection()!.displayName,
|
||||
secondaryLabel: this.timeZoneSelection()!.ianaId,
|
||||
}
|
||||
id: this.timeZoneSelection()!.id,
|
||||
label: this.timeZoneSelection()!.displayName,
|
||||
secondaryLabel: this.timeZoneSelection()!.ianaId,
|
||||
}
|
||||
: undefined,
|
||||
currency: this.currencySelection()
|
||||
? {
|
||||
id: this.currencySelection()!.id,
|
||||
label: this.currencySelection()!.name,
|
||||
secondaryLabel: this.currencySelection()!.code,
|
||||
}
|
||||
id: this.currencySelection()!.id,
|
||||
label: this.currencySelection()!.name,
|
||||
secondaryLabel: this.currencySelection()!.code,
|
||||
}
|
||||
: undefined,
|
||||
defaultLanguage: this.defaultLanguageSelection()
|
||||
? {
|
||||
id: this.defaultLanguageSelection()!.id,
|
||||
label: this.defaultLanguageSelection()!.name,
|
||||
secondaryLabel: this.defaultLanguageSelection()!.code,
|
||||
}
|
||||
id: this.defaultLanguageSelection()!.id,
|
||||
label: this.defaultLanguageSelection()!.name,
|
||||
secondaryLabel: this.defaultLanguageSelection()!.code,
|
||||
}
|
||||
: undefined,
|
||||
additionalLanguageIds: value.additionalLanguageIds,
|
||||
additionalLanguageSelections: this.resolveAdditionalLanguageSelections(value.additionalLanguageIds),
|
||||
@@ -245,31 +261,31 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
||||
this.timeZoneSelection.set(
|
||||
value.timeZone
|
||||
? {
|
||||
id: value.timeZone.id,
|
||||
ianaId: value.timeZone.secondaryLabel ?? value.timeZone.label,
|
||||
displayName: value.timeZone.label,
|
||||
}
|
||||
id: value.timeZone.id,
|
||||
ianaId: value.timeZone.secondaryLabel ?? value.timeZone.label,
|
||||
displayName: value.timeZone.label,
|
||||
}
|
||||
: null
|
||||
);
|
||||
this.currencySelection.set(
|
||||
value.currency
|
||||
? {
|
||||
id: value.currency.id,
|
||||
code: value.currency.secondaryLabel ?? value.currency.label,
|
||||
name: value.currency.label,
|
||||
symbol: '',
|
||||
}
|
||||
id: value.currency.id,
|
||||
code: value.currency.secondaryLabel ?? value.currency.label,
|
||||
name: value.currency.label,
|
||||
symbol: '',
|
||||
}
|
||||
: null
|
||||
);
|
||||
this.defaultLanguageSelection.set(
|
||||
value.defaultLanguage
|
||||
? {
|
||||
id: value.defaultLanguage.id,
|
||||
code: value.defaultLanguage.secondaryLabel ?? value.defaultLanguage.label,
|
||||
name: value.defaultLanguage.label,
|
||||
nativeName: value.defaultLanguage.label,
|
||||
isRightToLeft: false,
|
||||
}
|
||||
id: value.defaultLanguage.id,
|
||||
code: value.defaultLanguage.secondaryLabel ?? value.defaultLanguage.label,
|
||||
name: value.defaultLanguage.label,
|
||||
nativeName: value.defaultLanguage.label,
|
||||
isRightToLeft: false,
|
||||
}
|
||||
: null
|
||||
);
|
||||
this.additionalLanguageOptions.update(current => this.mergeSelectionOptions(current, value.additionalLanguageSelections ?? []));
|
||||
@@ -291,31 +307,31 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
||||
this.timeZoneSelection.set(
|
||||
replaceExisting || !this.timeZoneSelection()
|
||||
? {
|
||||
id: defaults.timeZone.id,
|
||||
ianaId: defaults.timeZone.secondaryLabel ?? defaults.timeZone.label,
|
||||
displayName: defaults.timeZone.label,
|
||||
}
|
||||
id: defaults.timeZone.id,
|
||||
ianaId: defaults.timeZone.secondaryLabel ?? defaults.timeZone.label,
|
||||
displayName: defaults.timeZone.label,
|
||||
}
|
||||
: this.timeZoneSelection()
|
||||
);
|
||||
this.currencySelection.set(
|
||||
replaceExisting || !this.currencySelection()
|
||||
? {
|
||||
id: defaults.currency.id,
|
||||
code: defaults.currency.secondaryLabel ?? defaults.currency.label,
|
||||
name: defaults.currency.label,
|
||||
symbol: '',
|
||||
}
|
||||
id: defaults.currency.id,
|
||||
code: defaults.currency.secondaryLabel ?? defaults.currency.label,
|
||||
name: defaults.currency.label,
|
||||
symbol: '',
|
||||
}
|
||||
: this.currencySelection()
|
||||
);
|
||||
this.defaultLanguageSelection.set(
|
||||
replaceExisting || !this.defaultLanguageSelection()
|
||||
? {
|
||||
id: defaults.defaultLanguage.id,
|
||||
code: defaults.defaultLanguage.secondaryLabel ?? defaults.defaultLanguage.label,
|
||||
name: defaults.defaultLanguage.label,
|
||||
nativeName: defaults.defaultLanguage.label,
|
||||
isRightToLeft: false,
|
||||
}
|
||||
id: defaults.defaultLanguage.id,
|
||||
code: defaults.defaultLanguage.secondaryLabel ?? defaults.defaultLanguage.label,
|
||||
name: defaults.defaultLanguage.label,
|
||||
nativeName: defaults.defaultLanguage.label,
|
||||
isRightToLeft: false,
|
||||
}
|
||||
: this.defaultLanguageSelection()
|
||||
);
|
||||
|
||||
@@ -364,6 +380,35 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
||||
this.defaultLanguageSelection.set(null);
|
||||
}
|
||||
|
||||
onAdditionalLanguagesChanged(value: ThemeSelectValue): void {
|
||||
const selectedIds = Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
const defaultLanguageId = this.form.controls.defaultLanguageId.value;
|
||||
|
||||
this.form.controls.additionalLanguageIds.setValue(
|
||||
defaultLanguageId ? selectedIds.filter(id => id !== defaultLanguageId) : selectedIds
|
||||
);
|
||||
}
|
||||
|
||||
onDateFormatChanged(value: ThemeSelectValue): void {
|
||||
this.form.controls.dateFormat.setValue(typeof value === 'string' ? value as DateFormatValue : null);
|
||||
}
|
||||
|
||||
onTimeFormatChanged(value: ThemeSelectValue): void {
|
||||
this.form.controls.timeFormat.setValue(typeof value === 'string' ? value as TimeFormatValue : null);
|
||||
}
|
||||
|
||||
onNumberFormatChanged(value: ThemeSelectValue): void {
|
||||
this.form.controls.numberFormat.setValue(typeof value === 'string' ? value as NumberFormatValue : null);
|
||||
}
|
||||
|
||||
onFiscalYearConventionChanged(value: ThemeSelectValue): void {
|
||||
this.form.controls.fiscalYearConvention.setValue(
|
||||
typeof value === 'string' ? value as FiscalYearConventionValue : null
|
||||
);
|
||||
}
|
||||
|
||||
private resolveAdditionalLanguageSelections(languageIds: readonly string[]): readonly OnboardingLookupValue[] {
|
||||
const uniqueIds = [...new Set(languageIds)];
|
||||
const optionMap = new Map(this.additionalLanguageOptions().map(option => [option.value, option.label]));
|
||||
@@ -406,4 +451,8 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
|
||||
invalidElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toThemeOptions<TValue extends string>(options: readonly FormSelectOption<TValue>[]) {
|
||||
return options.map(option => ({ label: option.label, value: option.value }));
|
||||
}
|
||||
}
|
||||
|
||||
+20
-3
@@ -19,8 +19,23 @@
|
||||
(cleared)="onPlanCleared()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-select
|
||||
formControlName="licenseType"
|
||||
[id]="'organization-license-type'"
|
||||
variant="floating"
|
||||
label="License Type"
|
||||
[options]="licenseTypeThemeOptions"
|
||||
[clearable]="false"
|
||||
[searchable]="false"
|
||||
[placeholder]="'Select License Type'"
|
||||
bindLabel="label"
|
||||
bindValue="value"
|
||||
[className]="'rounded-sm !p-0'"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'License Type is required.' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||
<app-form-input
|
||||
@@ -89,6 +104,7 @@
|
||||
variant="floating"
|
||||
label="Go-Live Date"
|
||||
[required]="true"
|
||||
[variant]="'floating'"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'Go-Live Date is required.' }"
|
||||
/>
|
||||
@@ -101,6 +117,7 @@
|
||||
variant="floating"
|
||||
label="System Access Start Date"
|
||||
[required]="true"
|
||||
[variant]="'floating'"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="{ required: 'System Access Start Date is required.' }"
|
||||
/>
|
||||
@@ -135,4 +152,4 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
+18
-1
@@ -41,6 +41,15 @@ interface OrganizationPlanLimitsFormModel {
|
||||
readonly systemAccessEndDate: string | null;
|
||||
}
|
||||
|
||||
type ThemeSelectValue =
|
||||
| string
|
||||
| number
|
||||
| readonly string[]
|
||||
| readonly number[]
|
||||
| string[]
|
||||
| number[]
|
||||
| null;
|
||||
|
||||
const accessDateValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
|
||||
const value = control.value as OrganizationPlanLimitsFormModel;
|
||||
|
||||
@@ -84,6 +93,10 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
||||
readonly planSelection = signal<OrganizationPlanLookupValue | null>(null);
|
||||
readonly limitsEditable = signal(true);
|
||||
readonly licenseTypeOptions = this.onboardingService.getLicenseTypeOptions();
|
||||
readonly licenseTypeThemeOptions = this.licenseTypeOptions.map(option => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}));
|
||||
|
||||
readonly form = this.formBuilder.group({
|
||||
subscriptionPlanId: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||
@@ -227,6 +240,10 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
||||
this.planSelection.set(null);
|
||||
}
|
||||
|
||||
onLicenseTypeChanged(value: ThemeSelectValue): void {
|
||||
this.form.controls.licenseType.setValue(typeof value === 'string' ? value as OrganizationLicenseType : null);
|
||||
}
|
||||
|
||||
private applyPlanDefaults(defaults: OrganizationPlanDefaults): void {
|
||||
this.planSelection.set(defaults.plan);
|
||||
this.limitsEditable.set(defaults.limitsEditable);
|
||||
@@ -263,4 +280,4 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
||||
invalidElement?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Routes } from '@angular/router';
|
||||
export const organizationsRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./dashboard/dashboard').then((m) => m.Dashboard),
|
||||
data: { childTitle: 'Dashboard', parentTitle: 'Organizations', subParentTitle: 'Global Organization Management' },
|
||||
},
|
||||
{
|
||||
path: 'list',
|
||||
loadComponent: () => import('./organization-list/organization-list').then((m) => m.OrganizationList),
|
||||
data: { childTitle: 'Organization Management', parentTitle: 'Organizations', subParentTitle: 'Configuration' },
|
||||
},
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Platform</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Database connections, testing, and tenant assignments will be added here.</p>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './platform-list.html',
|
||||
styleUrl: './platform-list.scss',
|
||||
})
|
||||
export class PlatformList {}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const platformRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./pages/platform-list/platform-list').then((m) => m.PlatformList),
|
||||
data: { childTitle: 'Platform', parentTitle: 'Platform', subParentTitle: 'Infrastructure' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,121 @@
|
||||
<div class="box custom-card">
|
||||
<div class="box-header justify-between">
|
||||
<div class="box-title">
|
||||
Branding
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box-body">
|
||||
<form [formGroup]="brandingForm" autocomplete="off">
|
||||
|
||||
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
|
||||
|
||||
<!-- Organization Logo -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="border border-dashed border-gray-400 rounded-md bg-gray-50/30 flex flex-col items-center justify-center py-8 px-4 text-center text-sm text-gray-500 cursor-pointer hover:bg-gray-100 transition-colors h-full">
|
||||
<span class="flex items-center gap-1 italic mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 text-gray-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75 12 3m0 0 3.75 3.75M12 3v18" />
|
||||
</svg>
|
||||
Organization Logo
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 italic">(click or drag file here)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- White Logo -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="border border-dashed border-gray-400 rounded-md bg-gray-50/30 flex flex-col items-center justify-center py-8 px-4 text-center text-sm text-gray-500 cursor-pointer hover:bg-gray-100 transition-colors h-full">
|
||||
<span class="flex items-center gap-1 italic mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 text-gray-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75 12 3m0 0 3.75 3.75M12 3v18" />
|
||||
</svg>
|
||||
White Logo (dark bg)
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 italic">(click or drag file here)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favicon -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="border border-dashed border-gray-400 rounded-md bg-gray-50/30 flex flex-col items-center justify-center py-8 px-4 text-center text-sm text-gray-500 cursor-pointer hover:bg-gray-100 transition-colors h-full">
|
||||
<span class="flex items-center gap-1 italic mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 text-gray-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75 12 3m0 0 3.75 3.75M12 3v18" />
|
||||
</svg>
|
||||
Favicon / Icon
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 italic">(click or drag file here)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Digital Seal -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<div
|
||||
class="border border-dashed border-gray-400 rounded-md bg-gray-50/30 flex flex-col items-center justify-center py-8 px-4 text-center text-sm text-gray-500 cursor-pointer hover:bg-gray-100 transition-colors h-full">
|
||||
<span class="flex items-center gap-1 italic mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 text-gray-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75 12 3m0 0 3.75 3.75M12 3v18" />
|
||||
</svg>
|
||||
Digital Seal & Signature
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 italic">(click or drag file here)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brand Color -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<app-form-input formControlName="brandColor" inputId="brand-color" label="Brand Color"
|
||||
placeholder="e.g. #1F3A93" variant="floating">
|
||||
</app-form-input>
|
||||
</div>
|
||||
|
||||
<!-- Theme Dropdown -->
|
||||
<div class="col-span-12 md:col-span-3">
|
||||
<app-form-select formControlName="theme" [id]="'branding-theme'" variant="floating" label="Theme"
|
||||
[options]="themeOptions" [clearable]="false" [searchable]="false" [selectClass]="'rounded-sm'"
|
||||
bindLabel="label" bindValue="value"></app-form-select>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="col-span-12 text-xs text-gray-500 italic mt-4">
|
||||
All settings have working defaults — nothing here blocks onboarding. Security tab holds MFA / password policy
|
||||
/ session / IP restriction with platform defaults.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="box-footer">
|
||||
<!-- Mobile View (< 640px) -->
|
||||
<div class="flex flex-col gap-2.5 sm:hidden">
|
||||
<div class="w-full">
|
||||
<app-button action="save" label="Save Changes" [showIcon]="true" [fullWidth]="true"
|
||||
className="onboarding-nav-btn w-full"></app-button>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<app-button action="cancel" label="Discard" [showIcon]="true" [fullWidth]="true"
|
||||
className="w-full"></app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop View (>= 640px) -->
|
||||
<div class="hidden w-full sm:flex sm:justify-between sm:items-center sm:gap-3">
|
||||
<div class="justify-self-start">
|
||||
<app-button action="cancel" label="Discard" [showIcon]="true"></app-button>
|
||||
</div>
|
||||
<div class="justify-self-end">
|
||||
<app-button action="save" label="Save Changes" [showIcon]="true"
|
||||
className="onboarding-nav-btn"></app-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Button } from '../../../../shared/components/button/button';
|
||||
import { FormSelect } from '../../../../shared/components/form/form-select/form-select';
|
||||
import { FormInput } from '../../../../shared/components/form/form-input/form-input';
|
||||
import { FormSelectOption } from '../../../../shared/components/form/models/form-select.models';
|
||||
|
||||
@Component({
|
||||
selector: 'branding',
|
||||
imports: [ReactiveFormsModule, Button, FormSelect, FormInput],
|
||||
templateUrl: './branding.html',
|
||||
styleUrl: './branding.scss',
|
||||
})
|
||||
export class Branding {
|
||||
private fb = inject(FormBuilder);
|
||||
|
||||
brandingForm: FormGroup = this.fb.group({
|
||||
theme: ['default-light'],
|
||||
brandColor: ['#1F3A93']
|
||||
});
|
||||
|
||||
themeOptions: FormSelectOption[] = [
|
||||
{ label: 'Default Light', value: 'default-light' },
|
||||
{ label: 'Dark Mode', value: 'dark' }
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const settingsRoutes: Routes = [
|
||||
{
|
||||
path: 'branding',
|
||||
loadComponent: () => import('./pages/branding/branding').then((m) => m.Branding),
|
||||
data: { childTitle: 'Branding Management', parentTitle: 'Settings', subParentTitle: 'Configuration' },
|
||||
},
|
||||
];
|
||||
@@ -8,6 +8,8 @@
|
||||
title() ||
|
||||
'Button'
|
||||
"
|
||||
[attr.aria-expanded]="ariaExpanded()"
|
||||
[attr.aria-controls]="ariaControls()"
|
||||
[attr.aria-busy]="loading() ? true : null"
|
||||
(click)="onClick($event)"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
:host-context(.modern-modal-footer) button,
|
||||
:host-context(.onboarding-action-footer) button {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: .3s ease;
|
||||
}
|
||||
|
||||
:host-context(.modern-modal-footer) button.ti-btn-light,
|
||||
:host-context(.onboarding-action-footer) button.ti-btn-light {
|
||||
border: 1px solid #dbe3ef;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
:host-context(.modern-modal-footer) button.ti-btn-light:hover:not(:disabled),
|
||||
:host-context(.onboarding-action-footer) button.ti-btn-light:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:host-context(.modern-modal-footer) button.ti-btn-primary-full,
|
||||
:host-context(.modern-modal-footer) button.ti-btn-success-full,
|
||||
:host-context(.onboarding-action-footer) button.ti-btn-primary-full,
|
||||
:host-context(.onboarding-action-footer) button.ti-btn-success-full {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
background: #7c3deb;
|
||||
box-shadow: 0 8px 20px rgba(125, 61, 235, 0.468);
|
||||
transition: .35s ease;
|
||||
}
|
||||
|
||||
:host-context(.modern-modal-footer) button.ti-btn-primary-full:hover:not(:disabled),
|
||||
:host-context(.modern-modal-footer) button.ti-btn-success-full:hover:not(:disabled),
|
||||
:host-context(.onboarding-action-footer) button.ti-btn-primary-full:hover:not(:disabled),
|
||||
:host-context(.onboarding-action-footer) button.ti-btn-success-full:hover:not(:disabled) {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px rgba(37, 99, 235, .45);
|
||||
}
|
||||
|
||||
:host-context(.modern-modal-footer) button.ti-btn-primary-full::before,
|
||||
:host-context(.modern-modal-footer) button.ti-btn-success-full::before,
|
||||
:host-context(.onboarding-action-footer) button.ti-btn-primary-full::before,
|
||||
:host-context(.onboarding-action-footer) button.ti-btn-success-full::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -120%;
|
||||
width: 70%;
|
||||
height: 100%;
|
||||
background: linear-gradient(120deg, transparent, rgba(255, 255, 255, .55), transparent);
|
||||
transform: skewX(-25deg);
|
||||
animation: saveButtonShine 2s infinite;
|
||||
}
|
||||
|
||||
:host-context(.modern-modal-footer) button:disabled,
|
||||
:host-context(.onboarding-action-footer) button:disabled {
|
||||
opacity: .55;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:host-context(.onboarding-action-footer) button.onboarding-nav-btn {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:host-context(.onboarding-action-footer) button.onboarding-save-btn:hover:not(:disabled) {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px rgba(37, 99, 235, .45);
|
||||
}
|
||||
|
||||
@keyframes saveButtonShine {
|
||||
0% {
|
||||
left: -120%;
|
||||
}
|
||||
|
||||
40% {
|
||||
left: 130%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 130%;
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,7 @@ const BUTTON_ACTION_CONFIG: Record<ButtonAction, ButtonActionConfig> = {
|
||||
selector: 'app-button',
|
||||
standalone: true,
|
||||
templateUrl: './button.html',
|
||||
styleUrl: './button.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class Button {
|
||||
@@ -231,6 +232,8 @@ export class Button {
|
||||
readonly iconClass = input('');
|
||||
|
||||
readonly ariaLabel = input<string | null>(null);
|
||||
readonly ariaExpanded = input<boolean | null>(null);
|
||||
readonly ariaControls = input<string | null>(null);
|
||||
readonly title = input<string | null>(null);
|
||||
|
||||
readonly buttonClicked = output<MouseEvent>();
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
dark:hover:bg-white/10
|
||||
" [disabled]="isActionDisabled(action, row)" [class.opacity-50]="isActionDisabled(action, row)"
|
||||
[class.cursor-not-allowed]="isActionDisabled(action, row)"
|
||||
[class.!text-danger]="action.type === 'delete'" [attr.aria-label]="getActionLabel(action)"
|
||||
[class.!text-danger]="action.type === 'delete'"
|
||||
(click)="onDropdownActionClick($event, action, row)">
|
||||
@if (action.icon) {
|
||||
<i [class]="action.icon"></i>
|
||||
|
||||
@@ -517,6 +517,11 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
|
||||
}
|
||||
|
||||
// 2. Automatic status detection
|
||||
|
||||
if (this.rowColorMode() === 'none') {
|
||||
return baseClass;
|
||||
}
|
||||
|
||||
const status =
|
||||
row['status'] ??
|
||||
row['state'] ??
|
||||
|
||||
@@ -77,6 +77,15 @@
|
||||
padding-block: 0.625rem 0.375rem;
|
||||
}
|
||||
|
||||
:host-context(app-filter-card) .autocomplete-control--floating {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
:host-context(app-filter-card) .autocomplete-control--floating .autocomplete-input--floating {
|
||||
min-height: calc(2rem - 2px);
|
||||
padding-block: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.autocomplete-dropdown-indicator {
|
||||
position: absolute;
|
||||
inset-inline-end: 0.75rem;
|
||||
|
||||
@@ -135,6 +135,8 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
readonly formDisabled = signal(false);
|
||||
readonly panelWidth = signal(0);
|
||||
readonly focused = signal(false);
|
||||
readonly isForFilter = signal(false);
|
||||
|
||||
|
||||
readonly resolvedInputId = computed(() => this.inputId()?.trim() || this.generatedId);
|
||||
readonly panelId = computed(() => `${this.resolvedInputId()}-listbox`);
|
||||
@@ -170,6 +172,11 @@ export class Autocomplete<TItem, TValue> implements ControlValueAccessor {
|
||||
}
|
||||
return this.options().length ? '' : this.emptyText();
|
||||
});
|
||||
|
||||
// readonly resolveHeight = computed(() => {
|
||||
// return this.he() === 'sm' ? 'h-8' : 'h-10';
|
||||
// });
|
||||
|
||||
readonly resolvedInputClass = computed(() => {
|
||||
this.controlStateVersion();
|
||||
const control = this.control();
|
||||
|
||||
@@ -13,27 +13,29 @@
|
||||
[showValidationWhenDirty]="showValidationWhenDirty()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="validationMessages()"
|
||||
[wrapperClass]="wrapperClass()"
|
||||
[wrapperClass]="(isFloating() ? 'date-picker-floating ' : '') + wrapperClass()"
|
||||
[labelClass]="labelClass()"
|
||||
[contentClass]="fieldContentClass()"
|
||||
>
|
||||
<input
|
||||
data-form-control
|
||||
[id]="inputId()"
|
||||
type="date"
|
||||
[class]="inputClass()"
|
||||
[value]="value() ?? ''"
|
||||
[placeholder]="isFloating() ? ' ' : placeholder()"
|
||||
[required]="required()"
|
||||
[readOnly]="readonly()"
|
||||
[disabled]="isDisabled()"
|
||||
[attr.min]="min()"
|
||||
[attr.max]="max()"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.aria-invalid]="showError() ? 'true' : null"
|
||||
[attr.aria-required]="required() ? 'true' : null"
|
||||
(focus)="onFocus()"
|
||||
(input)="onInput($event)"
|
||||
(blur)="onBlur()"
|
||||
/>
|
||||
<div class="relative w-full">
|
||||
<input
|
||||
data-form-control
|
||||
[id]="inputId()"
|
||||
type="date"
|
||||
[class]="inputClass()"
|
||||
[value]="value() ?? ''"
|
||||
[placeholder]="isFloating() ? ' ' : placeholder()"
|
||||
[required]="required()"
|
||||
[readOnly]="readonly()"
|
||||
[disabled]="isDisabled()"
|
||||
[attr.min]="min()"
|
||||
[attr.max]="max()"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.aria-invalid]="showError() ? 'true' : null"
|
||||
[attr.aria-required]="required() ? 'true' : null"
|
||||
(focus)="onFocus()"
|
||||
(input)="onInput($event)"
|
||||
(blur)="onBlur()"
|
||||
/>
|
||||
</div>
|
||||
</app-form-field>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .date-picker-floating .floating-label .form-control {
|
||||
min-height: 2.5rem !important;
|
||||
height: 2.5rem !important;
|
||||
border: 1px solid var(--color-inputborder);
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--color-white);
|
||||
color: transparent !important;
|
||||
font-size: 0.8125rem;
|
||||
padding-block: 0.5rem 0.15rem !important;
|
||||
padding-inline: 0.75rem !important;
|
||||
box-shadow: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Hide native datetime format (dd/mm/yyyy) when un-floated and not focused */
|
||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-fields-wrapper,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-text,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-month-field,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-day-field,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-year-field {
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .date-picker-floating .floating-label > label {
|
||||
position: absolute !important;
|
||||
inset-inline-start: 0.75rem !important;
|
||||
padding-inline: 0.35rem !important;
|
||||
background-color: var(--color-white) !important;
|
||||
font-weight: 400 !important;
|
||||
color: #8c9097 !important;
|
||||
z-index: 9999 !important;
|
||||
overflow: visible !important;
|
||||
white-space: nowrap !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* Float label and highlight purple on focus or when date is selected */
|
||||
:host ::ng-deep .date-picker-floating .floating-label--float > label,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within > label {
|
||||
top: 0 !important;
|
||||
transform: translateY(-50%) scale(0.85) !important;
|
||||
transform-origin: left center !important;
|
||||
font-weight: 400 !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .date-picker-floating .floating-label--float .form-control,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control {
|
||||
color: var(--color-defaulttextcolor) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
/* Show native datetime format / selected date value when floated or focused */
|
||||
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit,
|
||||
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-fields-wrapper,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-fields-wrapper,
|
||||
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-text,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-text,
|
||||
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-month-field,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-month-field,
|
||||
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-day-field,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-day-field,
|
||||
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-year-field,
|
||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-year-field {
|
||||
color: var(--color-defaulttextcolor) !important;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .date-picker-floating .form-control,
|
||||
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label > label {
|
||||
background-color: var(--color-bodybg) !important;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label--float .form-control,
|
||||
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label:focus-within .form-control {
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit,
|
||||
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit {
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
|
||||
/* Calendar icon picker indicator styling */
|
||||
:host ::ng-deep .form-control::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease, filter 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Invert calendar picker icon color to white in dark mode */
|
||||
:host-context(.dark) ::ng-deep .form-control::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) !important;
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ export class FormField {
|
||||
|
||||
const value = control.value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
return value !== null && value !== undefined && value !== '';
|
||||
});
|
||||
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
.shared-form-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
//min-height: 1.6rem;
|
||||
padding: 0.75rem 0.75rem 0.5rem;
|
||||
border: 1px solid var(--color-inputborder);
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--color-white);
|
||||
color: var(--color-defaulttextcolor);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25;
|
||||
line-height: 10px;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
@@ -1,225 +1,181 @@
|
||||
<app-form-field
|
||||
[label]="label()"
|
||||
[inputId]="resolvedInputId()"
|
||||
[control]="control()"
|
||||
[required]="required()"
|
||||
[disabled]="isDisabled()"
|
||||
[description]="description()"
|
||||
[hint]="hint()"
|
||||
[floating]="isFloating()"
|
||||
[forceFloat]="shouldFloat()"
|
||||
[labelPosition]="labelPosition()"
|
||||
[hideLabel]="hideLabel()"
|
||||
[hideValidation]="hideValidation()"
|
||||
[showValidationWhenDirty]="showValidationWhenDirty()"
|
||||
[submitAttempted]="submitAttempted()"
|
||||
[validationMessages]="validationMessages()"
|
||||
[wrapperClass]="(isFloating() ? 'form-select-floating ' : '') + wrapperClass()"
|
||||
[labelClass]="labelClass()"
|
||||
[contentClass]="fieldContentClass()"
|
||||
>
|
||||
<ng-select
|
||||
#select
|
||||
bindLabel="label"
|
||||
bindValue="value"
|
||||
appearance="outline"
|
||||
[items]="selectItems()"
|
||||
[ngModel]="modelValue()"
|
||||
[multiple]="isMultiple()"
|
||||
[searchable]="false"
|
||||
[editableSearchTerm]="resolvedEditableSearchTerm()"
|
||||
[clearable]="clearable()"
|
||||
[hideSelected]="hideSelected()"
|
||||
[closeOnSelect]="resolvedCloseOnSelect()"
|
||||
[maxSelectedItems]="maxSelectedItems()"
|
||||
[placeholder]="resolvedPlaceholder()"
|
||||
[loading]="loading()"
|
||||
[loadingText]="loadingText()"
|
||||
[notFoundText]="notFoundText()"
|
||||
[typeToSearchText]="typeToSearchText()"
|
||||
[clearAllText]="clearAllText()"
|
||||
[appendTo]="appendTo()"
|
||||
[dropdownPosition]="dropdownPosition()"
|
||||
[virtualScroll]="virtualScroll()"
|
||||
[bufferAmount]="bufferAmount()"
|
||||
[groupBy]="groupBy()"
|
||||
[selectableGroup]="selectableGroup()"
|
||||
[selectableGroupAsModel]="selectableGroupAsModel()"
|
||||
[selectOnTab]="selectOnTab()"
|
||||
[clearOnBackspace]="clearOnBackspace()"
|
||||
[readonly]="resolvedReadonly()"
|
||||
[labelForId]="resolvedInputId()"
|
||||
[inputAttrs]="inputAttrs()"
|
||||
[ariaLabel]="ariaLabel() || label() || resolvedPlaceholder()"
|
||||
[ngClass]="resolvedSelectClass()"
|
||||
(ngModelChange)="onValueChange($event)"
|
||||
(open)="onOpen()"
|
||||
(close)="onClose()"
|
||||
(focus)="onFocus()"
|
||||
(blur)="onBlur()"
|
||||
(clear)="onClear()"
|
||||
(scroll)="onScroll($event)"
|
||||
(scrollToEnd)="onScrollToEnd()"
|
||||
>
|
||||
@if (showDropdownHeader()) {
|
||||
<app-form-field [label]="label()" [inputId]="resolvedInputId()" [control]="control()" [required]="required()"
|
||||
[disabled]="isDisabled()" [description]="description()" [hint]="hint()" [floating]="isFloating()"
|
||||
[forceFloat]="shouldFloat()" [labelPosition]="labelPosition()" [hideLabel]="hideLabel()"
|
||||
[hideValidation]="hideValidation()" [showValidationWhenDirty]="showValidationWhenDirty()"
|
||||
[submitAttempted]="submitAttempted()" [validationMessages]="validationMessages()"
|
||||
[wrapperClass]="(isFloating() ? 'form-select-floating ' : '') + wrapperClass()" [labelClass]="labelClass()"
|
||||
[contentClass]="fieldContentClass()">
|
||||
<div class="relative w-full">
|
||||
<ng-select #select [bindLabel]="bindLabel()" [bindValue]="bindValue()" appearance="outline" [items]="selectItems()"
|
||||
[ngModel]="modelValue()" [multiple]="resolvedIsMultiple()" [searchable]="searchable()"
|
||||
[editableSearchTerm]="resolvedEditableSearchTerm()" [clearable]="clearable()" [hideSelected]="hideSelected()"
|
||||
[closeOnSelect]="resolvedCloseOnSelect()" [maxSelectedItems]="maxSelectedItems()"
|
||||
[placeholder]="isFloating() ? '' : resolvedPlaceholder()" [loading]="loading()" [loadingText]="loadingText()"
|
||||
[notFoundText]="notFoundText()" [typeToSearchText]="typeToSearchText()" [clearAllText]="clearAllText()"
|
||||
[appendTo]="appendTo()" [dropdownPosition]="dropdownPosition()" [virtualScroll]="virtualScroll()"
|
||||
[bufferAmount]="bufferAmount()" [groupBy]="groupBy()" [selectableGroup]="selectableGroup()"
|
||||
[selectableGroupAsModel]="selectableGroupAsModel()" [selectOnTab]="selectOnTab()"
|
||||
[clearOnBackspace]="clearOnBackspace()" [readonly]="resolvedReadonly()" [labelForId]="resolvedInputId()"
|
||||
[inputAttrs]="inputAttrs()" [ariaLabel]="ariaLabel() || label() || resolvedPlaceholder()"
|
||||
[ngClass]="resolvedSelectClass()" (ngModelChange)="onValueChange($event)" (open)="onOpen()" (close)="onClose()"
|
||||
(focus)="onFocus()" (blur)="onBlur()" (clear)="onClear()" (scroll)="onScroll($event)"
|
||||
(scrollToEnd)="onScrollToEnd()">
|
||||
@if (showDropdownHeader()) {
|
||||
<ng-template ng-header-tmp>
|
||||
@if (searchable()) {
|
||||
<div class="w-full">
|
||||
<input
|
||||
type="search"
|
||||
class="form-control form-control-sm w-full rounded-sm border-defaultborder text-defaulttextcolor dark:border-defaultborder/10 dark:text-white/70"
|
||||
[value]="searchTerm()"
|
||||
[placeholder]="resolvedSearchPlaceholder()"
|
||||
[disabled]="isDisabled()"
|
||||
autocomplete="off"
|
||||
(input)="onSearchInput($event)"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative p-2 border-b border-defaultborder dark:border-defaultborder/10">
|
||||
<i class="ri-search-line absolute inset-y-0 start-4 my-auto h-4 w-4 text-textmuted leading-none"></i>
|
||||
<input type="search"
|
||||
class="form-control form-control-sm !h-8 !min-h-0 w-full rounded-sm border border-defaultborder !ps-8 !pe-3 !py-1 text-xs text-defaulttextcolor placeholder:text-textmuted dark:border-defaultborder/20 dark:bg-bodybg dark:text-white/80 focus:border-primary"
|
||||
[value]="searchTerm()" [placeholder]="resolvedSearchPlaceholder()" [disabled]="isDisabled()"
|
||||
autocomplete="off" (input)="onSearchInput($event)" (keydown)="$event.stopPropagation()" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isMultiple() && showSelectAll()) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 border-b border-defaultborder px-3 py-2 text-start text-[0.8125rem] text-defaulttextcolor hover:bg-light dark:border-defaultborder/10 dark:text-white/70 dark:hover:bg-black/20"
|
||||
[class.text-primary]="allSelected() || partiallySelected()"
|
||||
[disabled]="isDisabled()"
|
||||
(click)="toggleSelectAll()"
|
||||
>
|
||||
@if (showCheckboxes()) {
|
||||
<span
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm border border-defaultborder bg-white text-[0.625rem] dark:border-defaultborder/10 dark:bg-bodybg"
|
||||
[class.bg-primary]="allSelected() || partiallySelected()"
|
||||
[class.text-white]="allSelected() || partiallySelected()"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@if (allSelected()) {
|
||||
<i class="ri-check-line leading-none"></i>
|
||||
} @else if (partiallySelected()) {
|
||||
<i class="ri-subtract-line leading-none"></i>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{{ selectAllLabel() }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<ng-template ng-option-tmp let-item="item" let-item$="item$">
|
||||
<div [class]="getOptionContainerClass(item)">
|
||||
@if (isMultiple() && showCheckboxes()) {
|
||||
@if (resolvedIsMultiple() && showSelectAll()) {
|
||||
<button type="button"
|
||||
class="flex w-full items-center gap-2 border-b border-defaultborder px-3 py-2 text-start text-[0.8125rem] text-defaulttextcolor hover:bg-light dark:border-defaultborder/10 dark:text-white/70 dark:hover:bg-black/20"
|
||||
[class.text-primary]="allSelected() || partiallySelected()" [disabled]="isDisabled()"
|
||||
(click)="toggleSelectAll()">
|
||||
@if (showCheckboxes()) {
|
||||
<span
|
||||
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-sm border border-defaultborder bg-white text-[0.625rem] dark:border-defaultborder/10 dark:bg-bodybg"
|
||||
[class.bg-primary]="isOptionSelected(item)"
|
||||
[class.text-white]="isOptionSelected(item)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@if (isOptionSelected(item)) {
|
||||
<i class="ri-check-line leading-none"></i>
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm border text-[0.625rem] transition-colors"
|
||||
[class.border-primary]="allSelected() || partiallySelected()"
|
||||
[class.bg-primary]="allSelected() || partiallySelected()"
|
||||
[class.text-white]="allSelected() || partiallySelected()"
|
||||
[class.border-defaultborder]="!allSelected() && !partiallySelected()"
|
||||
[class.bg-white]="!allSelected() && !partiallySelected()"
|
||||
[class.dark:border-defaultborder/20]="!allSelected() && !partiallySelected()"
|
||||
[class.dark:bg-bodybg]="!allSelected() && !partiallySelected()" aria-hidden="true">
|
||||
@if (allSelected()) {
|
||||
<i class="ri-check-line leading-none font-bold"></i>
|
||||
} @else if (partiallySelected()) {
|
||||
<i class="ri-subtract-line leading-none font-bold"></i>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
@if (item.prefixText) {
|
||||
<span class="min-w-0 flex-1 truncate font-medium">
|
||||
{{ selectAllLabel() }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<ng-template ng-option-tmp let-item="item" let-item$="item$">
|
||||
<div [class]="getOptionContainerClass(item)">
|
||||
@if (resolvedIsMultiple() && showCheckboxes()) {
|
||||
<span
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-sm border text-[0.625rem] transition-colors"
|
||||
[class.border-primary]="isOptionSelected(item)" [class.bg-primary]="isOptionSelected(item)"
|
||||
[class.text-white]="isOptionSelected(item)" [class.border-defaultborder]="!isOptionSelected(item)"
|
||||
[class.bg-white]="!isOptionSelected(item)" [class.dark:border-defaultborder/20]="!isOptionSelected(item)"
|
||||
[class.dark:bg-bodybg]="!isOptionSelected(item)" aria-hidden="true">
|
||||
@if (isOptionSelected(item)) {
|
||||
<i class="ri-check-line leading-none font-bold"></i>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (item.prefixText) {
|
||||
<span [class]="getOptionPrefixClass(item)">
|
||||
{{ item.prefixText }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span [class]="getOptionLabelClass(item)">
|
||||
{{ getOptionDisplayLabel(item, item$.label) }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span [class]="getOptionLabelClass(item)">
|
||||
{{ getOptionDisplayLabel(item, item$.label) }}
|
||||
</span>
|
||||
|
||||
@if (item.description) {
|
||||
@if (item.description) {
|
||||
<span class="mt-0.5 block truncate text-[0.75rem] text-inherit opacity-80">
|
||||
{{ item.description }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template ng-label-tmp let-item="item" let-clear="clear" let-label="label">
|
||||
<span class="inline-flex min-w-0 items-center gap-1">
|
||||
@if (getSelectionPrefixText(item)) {
|
||||
<ng-template ng-label-tmp let-item="item" let-clear="clear" let-label="label">
|
||||
<span class="inline-flex min-w-0 items-center gap-1">
|
||||
@if (getSelectionPrefixText(item)) {
|
||||
<span class="shrink-0 text-primary">
|
||||
{{ getSelectionPrefixText(item) }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
<span class="truncate">
|
||||
{{ getSelectionDisplayLabel(item, label) }}
|
||||
</span>
|
||||
<span class="truncate">
|
||||
{{ getSelectionDisplayLabel(item, label) }}
|
||||
</span>
|
||||
|
||||
@if (isMultiple() && clearable() && !resolvedReadonly()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ms-1 inline-flex text-white/80 hover:text-white"
|
||||
aria-label="Remove selected option"
|
||||
(click)="clear(item)"
|
||||
>
|
||||
@if (resolvedIsMultiple() && clearable() && !resolvedReadonly()) {
|
||||
<button type="button" class="ms-1 inline-flex text-white/80 hover:text-white"
|
||||
aria-label="Remove selected option" (click)="clear(item)">
|
||||
<i class="ri-close-line leading-none"></i>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</ng-template>
|
||||
}
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
@if (isMultiple()) {
|
||||
@if (resolvedIsMultiple()) {
|
||||
<ng-template ng-multi-label-tmp let-items="items" let-clear="clear">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<div class="flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden">
|
||||
@if (items.length > 0) {
|
||||
<span class="inline-flex max-w-full items-center gap-1 rounded-sm bg-primary px-2 py-0.5 text-[0.75rem] text-white">
|
||||
<span class="truncate">
|
||||
{{ getMultiLabelText(items) }}
|
||||
</span>
|
||||
|
||||
@if (clearable() && !resolvedReadonly()) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex text-white/80 hover:text-white"
|
||||
aria-label="Remove selected option"
|
||||
(click)="clear(items[0])"
|
||||
>
|
||||
<i class="ri-close-line leading-none"></i>
|
||||
</button>
|
||||
}
|
||||
<span
|
||||
class="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-sm bg-primary px-2 py-0.5 text-[0.75rem] text-white">
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{{ getFirstOptionLabel(items) }}
|
||||
</span>
|
||||
|
||||
@if (items.length > 1) {
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-white/25 px-1.5 py-0.5 text-[0.6875rem] font-bold text-white shadow-xs">
|
||||
+{{ items.length - 1 }}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (clearable() && !resolvedReadonly()) {
|
||||
<button type="button" class="shrink-0 inline-flex text-white/80 hover:text-white ms-0.5"
|
||||
aria-label="Remove selected option" (click)="clear(items[0])">
|
||||
<i class="ri-close-line leading-none"></i>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isMultiple() && showMultiSelectFooter()) {
|
||||
@if (resolvedIsMultiple() && showMultiSelectFooter()) {
|
||||
<ng-template ng-footer-tmp>
|
||||
<div class="flex items-center justify-between gap-2 border-t border-defaultborder px-3 py-2 dark:border-defaultborder/10">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-t border-defaultborder px-3 py-2 dark:border-defaultborder/10">
|
||||
<span class="text-[0.75rem] text-textmuted">
|
||||
{{ pendingValue().length }} selected
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-light !px-3 !py-1.5 !text-[0.75rem]"
|
||||
(click)="cancelSelection(select)"
|
||||
>
|
||||
<button type="button" class="ti-btn ti-btn-light !px-3 !py-1.5 !text-[0.75rem]"
|
||||
(click)="cancelSelection(select)">
|
||||
{{ cancelLabel() }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ti-btn ti-btn-primary-full !px-3 !py-1.5 !text-[0.75rem]"
|
||||
(click)="confirmSelection(select)"
|
||||
>
|
||||
<button type="button" class="ti-btn ti-btn-primary-full !px-3 !py-1.5 !text-[0.75rem]"
|
||||
(click)="confirmSelection(select)">
|
||||
{{ confirmLabel() }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
</ng-select>
|
||||
|
||||
@if (!loading()) {
|
||||
<span class="form-select-dropdown-indicator" [class.is-expanded]="dropdownOpen()" aria-hidden="true">
|
||||
<i class="ri-arrow-down-s-line"></i>
|
||||
</span>
|
||||
}
|
||||
</ng-select>
|
||||
</app-form-field>
|
||||
</div>
|
||||
</app-form-field>
|
||||
@@ -5,31 +5,170 @@
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label .ng-select-container {
|
||||
min-height: 2.5rem;
|
||||
min-height: 2.5rem !important;
|
||||
height: 2.5rem !important;
|
||||
border: 1px solid var(--color-inputborder);
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--color-white);
|
||||
color: var(--color-defaulttextcolor);
|
||||
font-size: 0.8125rem;
|
||||
box-shadow: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label > label {
|
||||
padding-inline: 0.25rem;
|
||||
:host ::ng-deep .form-select-floating .floating-label {
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label>label {
|
||||
position: absolute !important;
|
||||
inset-inline-start: 0.75rem !important;
|
||||
padding-inline: 0.35rem !important;
|
||||
background-color: var(--color-white) !important;
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 400 !important;
|
||||
color: #8c9097 !important;
|
||||
z-index: 9999 !important;
|
||||
overflow: visible !important;
|
||||
white-space: nowrap !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label--float>label,
|
||||
:host ::ng-deep .form-select-floating .floating-label:focus-within>label,
|
||||
:host ::ng-deep .form-select-floating:has(.ng-select-has-value)>label {
|
||||
top: 0 !important;
|
||||
transform: translateY(-50%) scale(0.85) !important;
|
||||
transform-origin: left center !important;
|
||||
font-weight: 400 !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select .ng-arrow-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.form-select-dropdown-indicator {
|
||||
position: absolute;
|
||||
inset-inline-end: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-textmuted, #8c9097);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
z-index: 9999 !important;
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
|
||||
&.is-expanded {
|
||||
transform: translateY(-50%) rotate(180deg) !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label .ng-select .ng-select-container .ng-value-container {
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 2.25rem;
|
||||
padding-block: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label--float .ng-select .ng-select-container .ng-value-container,
|
||||
:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select .ng-select-container .ng-value-container {
|
||||
padding-top: 0.5rem !important;
|
||||
padding-bottom: 0.15rem !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-value {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-placeholder {
|
||||
top: auto;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label--float .ng-select-container,
|
||||
:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select-container {
|
||||
border-color: var(--color-primary);
|
||||
:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select-container,
|
||||
:host ::ng-deep .form-select-floating .ng-select.ng-select-has-value .ng-select-container,
|
||||
:host ::ng-deep .form-select-floating .ng-select.ng-select-opened .ng-select-container,
|
||||
:host ::ng-deep .form-select-floating .ng-select.ng-select-focused .ng-select-container {
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .form-select-floating .floating-label--invalid .ng-select-container {
|
||||
border-color: var(--color-danger);
|
||||
border-color: var(--color-danger) !important;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .form-select-floating .ng-select-container,
|
||||
:host-context(.dark) ::ng-deep .form-select-floating .floating-label > label {
|
||||
:host-context(.dark) ::ng-deep .form-select-floating .floating-label>label {
|
||||
background-color: var(--color-bodybg) !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select .ng-dropdown-panel {
|
||||
border-radius: 0.5rem !important;
|
||||
border: 1px solid var(--color-defaultborder, #e9edf6) !important;
|
||||
background-color: var(--color-white, #ffffff) !important;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05) !important;
|
||||
overflow: hidden !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .ng-select .ng-dropdown-panel {
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
background-color: var(--color-bodybg, #1a1e25) !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-header {
|
||||
border-bottom: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select .ng-dropdown-panel .ng-header-tmp button,
|
||||
:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select.ng-select-multiple .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected:not(.ng-option-marked) {
|
||||
background-color: transparent !important;
|
||||
color: var(--color-defaulttextcolor) !important;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .ng-select.ng-select-multiple .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected:not(.ng-option-marked) {
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
|
||||
background-color: var(--color-primary) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked * {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Component,
|
||||
Injector,
|
||||
computed,
|
||||
effect,
|
||||
forwardRef,
|
||||
inject,
|
||||
input,
|
||||
@@ -64,6 +65,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
private readonly generatedInputId = `form-select-${FormSelect.nextId++}`;
|
||||
|
||||
readonly inputId = input<string | null>(null);
|
||||
readonly id = input<string | null>(null);
|
||||
|
||||
readonly label = input('');
|
||||
readonly variant = input<'default' | 'floating'>('default');
|
||||
@@ -71,13 +73,19 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
readonly name = input<string | null>(null);
|
||||
|
||||
readonly options = input<readonly FormSelectOption<TValue>[]>([]);
|
||||
readonly bindLabel = input('label');
|
||||
readonly bindValue = input('value');
|
||||
|
||||
readonly mode = input<FormSelectMode>('single');
|
||||
readonly multiple = input<boolean | null>(null);
|
||||
readonly defaultValue = input<FormSelectValue<TValue> | null>(null);
|
||||
|
||||
readonly searchable = input(true);
|
||||
|
||||
readonly searchMode = input<FormSelectSearchMode>('client');
|
||||
|
||||
readonly searchPlaceholder = input<string | null>(null);
|
||||
|
||||
readonly editableSearchTerm = input<boolean | null>(null);
|
||||
|
||||
readonly clearable = input(true);
|
||||
@@ -159,6 +167,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
readonly fieldContentClass = input('');
|
||||
|
||||
readonly selectClass = input('');
|
||||
readonly className = input('');
|
||||
|
||||
readonly ariaLabel = input<string | null>(null);
|
||||
|
||||
@@ -193,9 +202,9 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
readonly searchTerm = signal('');
|
||||
readonly hasFocus = signal(false);
|
||||
|
||||
private onChange: (value: FormSelectValue<TValue>) => void = () => {};
|
||||
private onChange: (value: FormSelectValue<TValue>) => void = () => { };
|
||||
|
||||
private onTouched: () => void = () => {};
|
||||
private onTouched: () => void = () => { };
|
||||
|
||||
readonly control = computed<AbstractControl | null>(() => {
|
||||
return this.injector.get(NgControl, null, {
|
||||
@@ -205,7 +214,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
});
|
||||
|
||||
readonly resolvedInputId = computed(() =>
|
||||
this.inputId()?.trim() || this.generatedInputId
|
||||
this.inputId()?.trim() || this.id()?.trim() || this.generatedInputId
|
||||
);
|
||||
|
||||
readonly resolvedName = computed(() =>
|
||||
@@ -214,6 +223,10 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
|
||||
readonly isMultiple = computed(() => this.mode() === 'multiple');
|
||||
|
||||
readonly resolvedIsMultiple = computed(() =>
|
||||
this.multiple() ?? this.mode() === 'multiple'
|
||||
);
|
||||
|
||||
readonly isDisabled = computed(() =>
|
||||
this.disabled() ||
|
||||
this.formDisabled() ||
|
||||
@@ -235,7 +248,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
);
|
||||
|
||||
readonly resolvedCloseOnSelect = computed(() =>
|
||||
this.closeOnSelect() ?? !this.isMultiple()
|
||||
this.closeOnSelect() ?? !this.resolvedIsMultiple()
|
||||
);
|
||||
|
||||
readonly resolvedEditableSearchTerm = computed(() =>
|
||||
@@ -276,7 +289,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
readonly showDropdownHeader = computed(() =>
|
||||
this.searchable() ||
|
||||
(
|
||||
this.isMultiple() &&
|
||||
this.resolvedIsMultiple() &&
|
||||
this.showSelectAll()
|
||||
)
|
||||
);
|
||||
@@ -291,7 +304,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
|
||||
readonly modelValue = computed<FormSelectValue<TValue>>(() => {
|
||||
if (
|
||||
this.isMultiple() &&
|
||||
this.resolvedIsMultiple() &&
|
||||
this.showMultiSelectFooter() &&
|
||||
this.dropdownOpen()
|
||||
) {
|
||||
@@ -353,7 +366,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
);
|
||||
|
||||
readonly resolvedSearchPlaceholder = computed(() =>
|
||||
this.placeholder().trim() ||
|
||||
this.searchPlaceholder()?.trim() ||
|
||||
(
|
||||
this.label().trim()
|
||||
? `Search ${this.label().trim()}`
|
||||
@@ -361,7 +374,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
readonly describedBy = computed(() => {
|
||||
const ids: string[] = [];
|
||||
@@ -437,12 +450,25 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
'app-form-select-control',
|
||||
showInvalidState ? 'is-invalid' : '',
|
||||
this.isDisabled() ? 'opacity-60 pointer-events-none' : '',
|
||||
this.selectClass()
|
||||
this.selectClass(),
|
||||
this.className()
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const defaultValue = this.defaultValue();
|
||||
|
||||
if (this.control() || defaultValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value.set(this.normalizeValue(defaultValue));
|
||||
});
|
||||
}
|
||||
|
||||
writeValue(value: FormSelectValue<TValue>): void {
|
||||
this.value.set(this.normalizeValue(value));
|
||||
}
|
||||
@@ -463,7 +489,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
const normalizedValue = this.normalizeValue(incomingValue);
|
||||
|
||||
if (
|
||||
this.isMultiple() &&
|
||||
this.resolvedIsMultiple() &&
|
||||
this.showMultiSelectFooter() &&
|
||||
this.dropdownOpen()
|
||||
) {
|
||||
@@ -492,7 +518,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
}
|
||||
|
||||
toggleSelectAll(): void {
|
||||
if (!this.isMultiple() || this.isDisabled()) {
|
||||
if (!this.resolvedIsMultiple() || this.isDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -516,7 +542,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
this.searchTerm.set('');
|
||||
|
||||
if (
|
||||
this.isMultiple() &&
|
||||
this.resolvedIsMultiple() &&
|
||||
this.showMultiSelectFooter()
|
||||
) {
|
||||
this.pendingValue.set([
|
||||
@@ -559,7 +585,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
}
|
||||
|
||||
confirmSelection(select: NgSelectComponent): void {
|
||||
if (!this.isMultiple()) {
|
||||
if (!this.resolvedIsMultiple()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -594,15 +620,18 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
: firstLabel;
|
||||
}
|
||||
|
||||
getFirstOptionLabel(items: readonly FormSelectOption<TValue>[]): string {
|
||||
return items[0]?.label ?? '';
|
||||
}
|
||||
|
||||
trackOption(option: FormSelectOption<TValue>): TValue {
|
||||
return option.value;
|
||||
}
|
||||
|
||||
getOptionContainerClass(option: FormSelectOption<TValue>): string {
|
||||
return [
|
||||
'flex min-w-0 w-full items-start gap-2 text-inherit',
|
||||
option.disabled ? 'text-textmuted opacity-60' : '',
|
||||
this.isOptionSelected(option) && !option.disabled ? 'text-white' : ''
|
||||
'flex min-w-0 w-full items-center gap-2 text-inherit',
|
||||
option.disabled ? 'text-textmuted opacity-60' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@@ -660,7 +689,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
|
||||
private activeSelectedValues(): readonly TValue[] {
|
||||
if (
|
||||
this.isMultiple() &&
|
||||
this.resolvedIsMultiple() &&
|
||||
this.showMultiSelectFooter() &&
|
||||
this.dropdownOpen()
|
||||
) {
|
||||
@@ -679,7 +708,7 @@ export class FormSelect<TValue extends FormSelectPrimitive = string> implements
|
||||
}
|
||||
|
||||
private normalizeValue(value: FormSelectValue<TValue>): FormSelectValue<TValue> {
|
||||
if (this.isMultiple()) {
|
||||
if (this.resolvedIsMultiple()) {
|
||||
return this.isValueArray(value)
|
||||
? value.filter(item => !this.isEmptyStringValue(item))
|
||||
: [];
|
||||
|
||||
@@ -461,208 +461,6 @@
|
||||
}
|
||||
|
||||
|
||||
/* ============================
|
||||
Cancel Button
|
||||
============================ */
|
||||
|
||||
|
||||
::ng-deep .modern-modal-footer app-button:first-child button {
|
||||
|
||||
|
||||
height: 36px;
|
||||
|
||||
padding: 0 16px;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
border: 1px solid #dbe3ef;
|
||||
|
||||
background: #ffffff;
|
||||
|
||||
color: #475569;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
transition: .3s ease;
|
||||
|
||||
}
|
||||
|
||||
|
||||
::ng-deep .modern-modal-footer app-button:first-child button:hover {
|
||||
|
||||
|
||||
background: #f8fafc;
|
||||
|
||||
border-color: #cbd5e1;
|
||||
|
||||
transform: translateY(-2px);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================
|
||||
Save Button
|
||||
============================ */
|
||||
|
||||
|
||||
::ng-deep .modern-modal-footer app-button:last-child button {
|
||||
|
||||
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
height: 36px;
|
||||
|
||||
padding: 0 16px;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
border: none;
|
||||
|
||||
|
||||
color: #ffffff;
|
||||
|
||||
|
||||
// background:
|
||||
|
||||
// linear-gradient(135deg,
|
||||
// #111C43,
|
||||
// #2563eb);
|
||||
background: #7c3deb;
|
||||
|
||||
box-shadow:
|
||||
|
||||
0 8px 20px rgba(125, 61, 235, 0.468);
|
||||
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
|
||||
transition: .35s ease;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
::ng-deep .modern-modal-footer app-button:last-child button:hover {
|
||||
|
||||
|
||||
transform: translateY(-3px);
|
||||
|
||||
|
||||
box-shadow:
|
||||
|
||||
0 12px 28px rgba(37, 99, 235, .45);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================
|
||||
Shine Animation
|
||||
============================ */
|
||||
|
||||
|
||||
::ng-deep .modern-modal-footer app-button:last-child button::before {
|
||||
|
||||
|
||||
content: "";
|
||||
|
||||
|
||||
position: absolute;
|
||||
|
||||
|
||||
top: 0;
|
||||
|
||||
left: -120%;
|
||||
|
||||
|
||||
width: 70%;
|
||||
|
||||
|
||||
height: 100%;
|
||||
|
||||
|
||||
background:
|
||||
|
||||
linear-gradient(120deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, .55),
|
||||
transparent);
|
||||
|
||||
|
||||
transform: skewX(-25deg);
|
||||
|
||||
|
||||
animation: saveButtonShine 2s infinite;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes saveButtonShine {
|
||||
|
||||
|
||||
0% {
|
||||
|
||||
left: -120%;
|
||||
|
||||
}
|
||||
|
||||
|
||||
40% {
|
||||
|
||||
left: 130%;
|
||||
|
||||
}
|
||||
|
||||
|
||||
100% {
|
||||
|
||||
left: 130%;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* Disabled */
|
||||
|
||||
::ng-deep .modern-modal-footer button:disabled {
|
||||
|
||||
|
||||
opacity: .55;
|
||||
|
||||
cursor: not-allowed;
|
||||
|
||||
transform: none !important;
|
||||
|
||||
box-shadow: none !important;
|
||||
|
||||
}
|
||||
|
||||
::ng-deep .modern-modal-footer app-button button {
|
||||
|
||||
height: 36px;
|
||||
|
||||
padding: 0 16px;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
transition: .3s ease;
|
||||
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
Mobile
|
||||
========================================================== */
|
||||
|
||||
@@ -21,24 +21,12 @@ export const content: Routes = [
|
||||
path: 'tenants',
|
||||
loadChildren: () => import('../../features/tenants/tenants.routes').then((m) => m.tenantsRoutes),
|
||||
},
|
||||
{
|
||||
path: 'billing',
|
||||
loadChildren: () => import('../../features/billing/billing.routes').then((m) => m.billingRoutes),
|
||||
},
|
||||
{
|
||||
path: 'localization',
|
||||
loadChildren: () => import('../../features/localization/localization.routes').then((m) => m.localizationRoutes),
|
||||
},
|
||||
{
|
||||
path: 'theming',
|
||||
loadChildren: () => import('../../features/theming/theming.routes').then((m) => m.themingRoutes),
|
||||
},
|
||||
{
|
||||
path: 'platform',
|
||||
loadChildren: () => import('../../features/platform/platform.routes').then((m) => m.platformRoutes),
|
||||
},
|
||||
{
|
||||
path: 'monitoring',
|
||||
loadChildren: () => import('../../features/monitoring/monitoring.routes').then((m) => m.monitoringRoutes),
|
||||
},
|
||||
path: 'settings',
|
||||
loadChildren: () => import('../../features/settings/settings.routes').then((m) => m.settingsRoutes),
|
||||
}
|
||||
];
|
||||
|
||||
+10
-13
@@ -36,27 +36,23 @@ body {
|
||||
}
|
||||
|
||||
.total-mail-recepients::-webkit-scrollbar {
|
||||
@apply h-0!;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
.custom-scrollbar-width::-webkit-scrollbar {
|
||||
@apply w-[5px]!;
|
||||
width: 5px !important;
|
||||
}
|
||||
|
||||
.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-selected {
|
||||
background-color: var(--color-primary) !important;
|
||||
color: var(--color-white) !important;
|
||||
.ng-dropdown-panel .ng-dropdown-header,
|
||||
.ng-select.app-form-select-control .ng-dropdown-header {
|
||||
border-bottom: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ng-select.app-form-select-control .ng-dropdown-panel {
|
||||
z-index: 99 !important;
|
||||
}
|
||||
|
||||
.ng-select.app-form-select-control .ng-dropdown-header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.swal2-styled.app-confirm-dialog-btn {
|
||||
min-width: 110px;
|
||||
min-height: 44px;
|
||||
@@ -69,6 +65,7 @@ body {
|
||||
.ti-btn-primary-full {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.ti-form-select{
|
||||
border: 1px solid #7c3deb !important;
|
||||
}
|
||||
|
||||
.ti-form-select {
|
||||
border: 1px solid #7c3deb !important;
|
||||
}
|
||||
Reference in New Issue
Block a user