new design implement for login screen, organization onboarding form changes, new filter implement in all screens, remove icons from validation,
This commit is contained in:
@@ -56,6 +56,7 @@
|
|||||||
"src/.htaccess"
|
"src/.htaccess"
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"node_modules/flatpickr/dist/flatpickr.css",
|
||||||
"node_modules/@ng-select/ng-select/themes/default.theme.css",
|
"node_modules/@ng-select/ng-select/themes/default.theme.css",
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+22
@@ -23,8 +23,10 @@
|
|||||||
"@ng-select/ng-select": "^21.8.2",
|
"@ng-select/ng-select": "^21.8.2",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tsparticles/angular": "^3.0.0",
|
"@tsparticles/angular": "^3.0.0",
|
||||||
|
"angularx-flatpickr": "^8.1.0",
|
||||||
"apexcharts": "^5.10.5",
|
"apexcharts": "^5.10.5",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"flatpickr": "^4.6.13",
|
||||||
"gridjs": "^6.2.0",
|
"gridjs": "^6.2.0",
|
||||||
"ng-apexcharts": "^2.3.0",
|
"ng-apexcharts": "^2.3.0",
|
||||||
"ng2-charts": "^10.0.0",
|
"ng2-charts": "^10.0.0",
|
||||||
@@ -7851,6 +7853,20 @@
|
|||||||
"typescript-eslint": "^8.0.0"
|
"typescript-eslint": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/angularx-flatpickr": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/angularx-flatpickr/-/angularx-flatpickr-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-U+WXMUXGEiQbdMGSPk7T+HehmAFdVWKu3XlCXFM8mYCCB/fWHW8sbHstxxZgOymD5Q1kfLaHNob1MxhWUgv1hg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/core": ">=17.0.0",
|
||||||
|
"@angular/forms": ">=17.0.0",
|
||||||
|
"flatpickr": "^4.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-escapes": {
|
"node_modules/ansi-escapes": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||||
@@ -9574,6 +9590,12 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/flatpickr": {
|
||||||
|
"version": "4.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
||||||
|
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.2",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
|
|||||||
@@ -42,8 +42,10 @@
|
|||||||
"@ng-select/ng-select": "^21.8.2",
|
"@ng-select/ng-select": "^21.8.2",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tsparticles/angular": "^3.0.0",
|
"@tsparticles/angular": "^3.0.0",
|
||||||
|
"angularx-flatpickr": "^8.1.0",
|
||||||
"apexcharts": "^5.10.5",
|
"apexcharts": "^5.10.5",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"flatpickr": "^4.6.13",
|
||||||
"gridjs": "^6.2.0",
|
"gridjs": "^6.2.0",
|
||||||
"ng-apexcharts": "^2.3.0",
|
"ng-apexcharts": "^2.3.0",
|
||||||
"ng2-charts": "^10.0.0",
|
"ng2-charts": "^10.0.0",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,29 @@
|
|||||||
|
<input
|
||||||
|
#nativeInput
|
||||||
|
[id]="id()"
|
||||||
|
type="text"
|
||||||
|
[placeholder]="placeholder()"
|
||||||
|
[ngModel]="value"
|
||||||
|
(ngModelChange)="onValueChange($event)"
|
||||||
|
(focus)="onInputFocus()"
|
||||||
|
(blur)="onInputBlur()"
|
||||||
|
mwlFlatpickr
|
||||||
|
(flatpickrReady)="onReady($event)"
|
||||||
|
(flatpickrChange)="onFlatpickrChange($event)"
|
||||||
|
(flatpickrOpen)="onOpen()"
|
||||||
|
(flatpickrClose)="onClose()"
|
||||||
|
(flatpickrMonthChange)="onMonthChange()"
|
||||||
|
(flatpickrYearChange)="onYearChange()"
|
||||||
|
[class]="class()"
|
||||||
|
[mode]="mode()"
|
||||||
|
[inline]="inline()"
|
||||||
|
[noCalendar]="noCalendar()"
|
||||||
|
[altInput]="altInput()"
|
||||||
|
[convertModelValue]="false"
|
||||||
|
[enableTime]="enableTime()"
|
||||||
|
[dateFormat]="dateFormat()"
|
||||||
|
[minDate]="minDate()!"
|
||||||
|
[maxDate]="maxDate()!"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[readonly]="readonly()"
|
||||||
|
/>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/* SpkFlatpickr Styles - Ultra-Modern Ynex Calendar Header Layout */
|
||||||
|
:host ::ng-deep .flatpickr-calendar {
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
|
||||||
|
.flatpickr-months {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
height: 3rem !important;
|
||||||
|
padding: 0.35rem 0.5rem !important;
|
||||||
|
position: relative !important;
|
||||||
|
|
||||||
|
.flatpickr-prev-month,
|
||||||
|
.flatpickr-next-month {
|
||||||
|
position: relative !important;
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
height: 2rem !important;
|
||||||
|
width: 2rem !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 0.375rem !important;
|
||||||
|
color: var(--color-primary, #7c3deb) !important;
|
||||||
|
transition: background-color 0.2s ease !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(124, 61, 235, 0.1) !important;
|
||||||
|
color: var(--color-primary, #7c3deb) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-primary, #7c3deb) !important;
|
||||||
|
width: 0.9rem !important;
|
||||||
|
height: 0.9rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-current-month {
|
||||||
|
position: relative !important;
|
||||||
|
left: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 100% !important;
|
||||||
|
top: auto !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
gap: 0.4rem !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
|
||||||
|
/* Common Pill Select Styling for both Month & Year Dropdowns */
|
||||||
|
.flatpickr-monthDropdown-months,
|
||||||
|
.spk-flatpickr-year-select {
|
||||||
|
appearance: none !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
-moz-appearance: none !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
color: var(--color-primary, #7c3deb) !important;
|
||||||
|
background-color: rgba(124, 61, 235, 0.08) !important;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237c3deb'%3E%3Cpath d='M12 15l-4-4h8z'/%3E%3C/svg%3E") !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
background-position: right 0.45rem center !important;
|
||||||
|
background-size: 0.85rem !important;
|
||||||
|
border: 1px solid rgba(124, 61, 235, 0.22) !important;
|
||||||
|
border-radius: 0.45rem !important;
|
||||||
|
padding: 0.25rem 1.5rem 0.25rem 0.65rem !important;
|
||||||
|
height: 2rem !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
transition: all 0.2s ease-in-out !important;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background-color: rgba(124, 61, 235, 0.15) !important;
|
||||||
|
border-color: rgba(124, 61, 235, 0.45) !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(124, 61, 235, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown option list styling */
|
||||||
|
option,
|
||||||
|
.flatpickr-monthDropdown-month {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
padding: 0.4rem 0.6rem !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
color: #334155 !important;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:checked {
|
||||||
|
background-color: var(--color-primary, #7c3deb) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark &,
|
||||||
|
[data-theme-mode="dark"] & {
|
||||||
|
background-color: #1e293b !important;
|
||||||
|
color: #f1f5f9 !important;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:checked {
|
||||||
|
background-color: #7c3deb !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
ViewChild,
|
||||||
|
forwardRef,
|
||||||
|
input
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { FlatpickrDefaults, FlatpickrDirective, provideFlatpickrDefaults } from 'angularx-flatpickr';
|
||||||
|
|
||||||
|
export type SpkFlatpickrMode = 'single' | 'multiple' | 'range';
|
||||||
|
|
||||||
|
export interface DateRangeValue {
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatePickerValue = string | DateRangeValue | null;
|
||||||
|
|
||||||
|
export interface FlatPickrOutputOptions {
|
||||||
|
selectedDates: Date[];
|
||||||
|
dateString: string;
|
||||||
|
instance: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalendarViewMode = 'calendar' | 'month' | 'year';
|
||||||
|
|
||||||
|
export function formatSingleDate(raw: any): string | null {
|
||||||
|
if (raw == null || raw === '') return null;
|
||||||
|
if (raw instanceof Date) {
|
||||||
|
if (isNaN(raw.getTime())) return null;
|
||||||
|
const year = raw.getFullYear();
|
||||||
|
const month = String(raw.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(raw.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
if (typeof raw === 'number') {
|
||||||
|
const d = new Date(raw);
|
||||||
|
return isNaN(d.getTime()) ? null : formatSingleDate(d);
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const datePart = trimmed.split(/[T\s]/)[0];
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return datePart;
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
const year = parsed.getFullYear();
|
||||||
|
const month = String(parsed.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(parsed.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFlatpickrValue(val: DatePickerValue, mode: SpkFlatpickrMode): string | null {
|
||||||
|
if (!val) return null;
|
||||||
|
if (mode === 'range') {
|
||||||
|
if (typeof val === 'object' && 'from' in val) {
|
||||||
|
const from = formatSingleDate(val.from);
|
||||||
|
const to = formatSingleDate(val.to);
|
||||||
|
if (from && to) return `${from} to ${to}`;
|
||||||
|
if (from) return from;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatSingleDate(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromFlatpickrValue(raw: any, mode: SpkFlatpickrMode): DatePickerValue {
|
||||||
|
if (!raw) return null;
|
||||||
|
if (mode === 'range') {
|
||||||
|
let fromStr: string | null = null;
|
||||||
|
let toStr: string | null = null;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
fromStr = formatSingleDate(raw[0]);
|
||||||
|
toStr = formatSingleDate(raw[1]);
|
||||||
|
} else if (typeof raw === 'object' && !(raw instanceof Date)) {
|
||||||
|
fromStr = formatSingleDate(raw.from);
|
||||||
|
toStr = formatSingleDate(raw.to);
|
||||||
|
} else if (typeof raw === 'string') {
|
||||||
|
const parts = raw.split(/\s+to\s+|\s+-\s+/i);
|
||||||
|
fromStr = formatSingleDate(parts[0]);
|
||||||
|
toStr = formatSingleDate(parts[1]);
|
||||||
|
}
|
||||||
|
return (fromStr || toStr) ? { from: fromStr, to: toStr } : null;
|
||||||
|
}
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return formatSingleDate(raw[0]);
|
||||||
|
}
|
||||||
|
return formatSingleDate(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'spk-flatpickr',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FlatpickrDirective, FormsModule, ReactiveFormsModule],
|
||||||
|
providers: [
|
||||||
|
FlatpickrDefaults,
|
||||||
|
provideFlatpickrDefaults(),
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => SpkFlatpickr),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
templateUrl: './spk-flatpickr.html',
|
||||||
|
styleUrl: './spk-flatpickr.scss'
|
||||||
|
})
|
||||||
|
export class SpkFlatpickr implements ControlValueAccessor, OnDestroy {
|
||||||
|
@ViewChild('nativeInput', { static: false }) nativeInputRef?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
readonly id = input<string>('');
|
||||||
|
readonly altInput = input<boolean>(false);
|
||||||
|
readonly convertModelValue = input<boolean>(false);
|
||||||
|
readonly enableTime = input<boolean>(false);
|
||||||
|
readonly noCalendar = input<boolean>(false);
|
||||||
|
readonly inline = input<boolean>(false);
|
||||||
|
readonly class = input<string>('');
|
||||||
|
readonly dateFormat = input<string>('Y-m-d');
|
||||||
|
readonly placeholder = input<string>('');
|
||||||
|
readonly mode = input<SpkFlatpickrMode>('single');
|
||||||
|
readonly minDate = input<string | Date | undefined>(undefined);
|
||||||
|
readonly maxDate = input<string | Date | undefined>(undefined);
|
||||||
|
readonly readonly = input<boolean>(false);
|
||||||
|
|
||||||
|
@Output() focus = new EventEmitter<void>();
|
||||||
|
@Output() blur = new EventEmitter<void>();
|
||||||
|
|
||||||
|
value: any = null;
|
||||||
|
disabled: boolean = false;
|
||||||
|
fpInstance: any = null;
|
||||||
|
|
||||||
|
private viewMode: CalendarViewMode = 'calendar';
|
||||||
|
private viewContainerEl: HTMLElement | null = null;
|
||||||
|
private yearPageOffset = 0;
|
||||||
|
private pendingIgnoredElements: HTMLElement[] = [];
|
||||||
|
private scrollResizeHandler: () => void = () => this.repositionCalendar();
|
||||||
|
|
||||||
|
private onChange: (value: any) => void = () => { };
|
||||||
|
private onTouched: () => void = () => { };
|
||||||
|
|
||||||
|
readonly monthsList = [
|
||||||
|
{ index: 0, shortName: 'Jan', name: 'January' },
|
||||||
|
{ index: 1, shortName: 'Feb', name: 'February' },
|
||||||
|
{ index: 2, shortName: 'Mar', name: 'March' },
|
||||||
|
{ index: 3, shortName: 'Apr', name: 'April' },
|
||||||
|
{ index: 4, shortName: 'May', name: 'May' },
|
||||||
|
{ index: 5, shortName: 'Jun', name: 'June' },
|
||||||
|
{ index: 6, shortName: 'Jul', name: 'July' },
|
||||||
|
{ index: 7, shortName: 'Aug', name: 'August' },
|
||||||
|
{ index: 8, shortName: 'Sep', name: 'September' },
|
||||||
|
{ index: 9, shortName: 'Oct', name: 'October' },
|
||||||
|
{ index: 10, shortName: 'Nov', name: 'November' },
|
||||||
|
{ index: 11, shortName: 'Dec', name: 'December' },
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.removePositionListeners();
|
||||||
|
if (this.viewContainerEl && this.viewContainerEl.parentNode) {
|
||||||
|
this.viewContainerEl.parentNode.removeChild(this.viewContainerEl);
|
||||||
|
}
|
||||||
|
this.viewContainerEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
if (this.fpInstance && typeof this.fpInstance.open === 'function') {
|
||||||
|
this.fpInstance.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (this.fpInstance && typeof this.fpInstance.close === 'function') {
|
||||||
|
this.fpInstance.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(): void {
|
||||||
|
if (this.fpInstance) {
|
||||||
|
if (this.fpInstance.isOpen) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.value = null;
|
||||||
|
if (this.fpInstance && typeof this.fpInstance.clear === 'function') {
|
||||||
|
this.fpInstance.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerIgnoredElement(el: HTMLElement): void {
|
||||||
|
if (!el) return;
|
||||||
|
if (this.fpInstance && this.fpInstance.config) {
|
||||||
|
if (!this.fpInstance.config.ignoredFocusElements) {
|
||||||
|
this.fpInstance.config.ignoredFocusElements = [];
|
||||||
|
}
|
||||||
|
if (!this.fpInstance.config.ignoredFocusElements.includes(el)) {
|
||||||
|
this.fpInstance.config.ignoredFocusElements.push(el);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!this.pendingIgnoredElements.includes(el)) {
|
||||||
|
this.pendingIgnoredElements.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeValue(val: any): void {
|
||||||
|
const normalized = val ?? null;
|
||||||
|
if (this.isSameValue(this.value, normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fpInstance && this.fpInstance.isOpen && this.mode() === 'range' && this.fpInstance.selectedDates?.length === 1) {
|
||||||
|
this.value = normalized;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.value = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: any): void {
|
||||||
|
this.onChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: any): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueChange(newVal: any): void {
|
||||||
|
const normalized = newVal ?? null;
|
||||||
|
if (this.isSameValue(this.value, normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.value = normalized;
|
||||||
|
this.onChange(this.value);
|
||||||
|
this.onTouched();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFlatpickrChange(event: FlatPickrOutputOptions): void {
|
||||||
|
if (!event) return;
|
||||||
|
const rawValue = (event.dateString && event.dateString.trim())
|
||||||
|
? event.dateString
|
||||||
|
: (event.selectedDates && event.selectedDates.length > 0 ? event.selectedDates : null);
|
||||||
|
|
||||||
|
this.onValueChange(rawValue);
|
||||||
|
|
||||||
|
if (this.mode() === 'single' && event.selectedDates && event.selectedDates.length === 1) {
|
||||||
|
this.close();
|
||||||
|
} else if (this.mode() === 'range' && event.selectedDates && event.selectedDates.length === 2) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputFocus(): void {
|
||||||
|
this.focus.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputBlur(): void {
|
||||||
|
this.blur.emit();
|
||||||
|
this.onTouched();
|
||||||
|
}
|
||||||
|
|
||||||
|
onReady(event: FlatPickrOutputOptions): void {
|
||||||
|
this.fpInstance = event.instance;
|
||||||
|
if (this.fpInstance && this.fpInstance.config) {
|
||||||
|
if (!this.fpInstance.config.ignoredFocusElements) {
|
||||||
|
this.fpInstance.config.ignoredFocusElements = [];
|
||||||
|
}
|
||||||
|
this.pendingIgnoredElements.forEach(el => {
|
||||||
|
if (!this.fpInstance.config.ignoredFocusElements.includes(el)) {
|
||||||
|
this.fpInstance.config.ignoredFocusElements.push(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.pendingIgnoredElements = [];
|
||||||
|
}
|
||||||
|
this.initViewContainer();
|
||||||
|
this.ensureCustomHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
this.setCalendarView('calendar');
|
||||||
|
this.ensureCustomHeader();
|
||||||
|
this.repositionCalendar();
|
||||||
|
this.attachPositionListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
this.setCalendarView('calendar');
|
||||||
|
this.removePositionListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMonthChange(): void {
|
||||||
|
this.ensureCustomHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
onYearChange(): void {
|
||||||
|
this.ensureCustomHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
repositionCalendar(): void {
|
||||||
|
if (!this.fpInstance || !this.fpInstance.calendarContainer || !this.fpInstance.isOpen) return;
|
||||||
|
|
||||||
|
const calendar = this.fpInstance.calendarContainer as HTMLElement;
|
||||||
|
const inputEl = (this.nativeInputRef?.nativeElement || this.fpInstance.element || this.fpInstance._input) as HTMLElement;
|
||||||
|
|
||||||
|
if (!calendar || !inputEl) return;
|
||||||
|
|
||||||
|
const rect = inputEl.getBoundingClientRect();
|
||||||
|
const calendarHeight = calendar.offsetHeight || 310;
|
||||||
|
const calendarWidth = calendar.offsetWidth || 300;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom;
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
|
||||||
|
const showAbove = spaceBelow < calendarHeight && spaceAbove >= calendarHeight;
|
||||||
|
|
||||||
|
let top: number;
|
||||||
|
if (showAbove) {
|
||||||
|
top = window.scrollY + rect.top - calendarHeight - 6;
|
||||||
|
calendar.classList.add('arrowBottom');
|
||||||
|
calendar.classList.remove('arrowTop');
|
||||||
|
} else {
|
||||||
|
top = window.scrollY + rect.bottom + 6;
|
||||||
|
calendar.classList.add('arrowTop');
|
||||||
|
calendar.classList.remove('arrowBottom');
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = window.scrollX + rect.left;
|
||||||
|
if (left + calendarWidth > viewportWidth - 12) {
|
||||||
|
left = Math.max(12, window.scrollX + viewportWidth - calendarWidth - 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.style.position = 'absolute';
|
||||||
|
calendar.style.top = `${Math.max(0, top)}px`;
|
||||||
|
calendar.style.left = `${Math.max(0, left)}px`;
|
||||||
|
calendar.style.right = 'auto';
|
||||||
|
calendar.style.zIndex = '99999';
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachPositionListeners(): void {
|
||||||
|
window.addEventListener('resize', this.scrollResizeHandler, { passive: true });
|
||||||
|
window.addEventListener('scroll', this.scrollResizeHandler, { capture: true, passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private removePositionListeners(): void {
|
||||||
|
window.removeEventListener('resize', this.scrollResizeHandler);
|
||||||
|
window.removeEventListener('scroll', this.scrollResizeHandler, { capture: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSameValue(v1: any, v2: any): boolean {
|
||||||
|
if (v1 === v2) return true;
|
||||||
|
if (v1 == null && v2 == null) return true;
|
||||||
|
if (v1 == null || v2 == null) return false;
|
||||||
|
if (typeof v1 === 'string' && typeof v2 === 'string') return v1.trim() === v2.trim();
|
||||||
|
if (typeof v1 === 'object' && typeof v2 === 'object') {
|
||||||
|
if ('from' in v1 && 'from' in v2) {
|
||||||
|
return v1.from === v2.from && v1.to === v2.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initViewContainer(): void {
|
||||||
|
if (!this.fpInstance || !this.fpInstance.calendarContainer) return;
|
||||||
|
const container = this.fpInstance.calendarContainer as HTMLElement;
|
||||||
|
|
||||||
|
if (container.querySelector('.spk-calendar-view-container')) {
|
||||||
|
this.viewContainerEl = container.querySelector('.spk-calendar-view-container');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.style.position = 'relative';
|
||||||
|
|
||||||
|
const viewContainer = document.createElement('div');
|
||||||
|
viewContainer.className = 'spk-calendar-view-container absolute left-1 right-1 top-[2.4rem] h-auto z-[1000] bg-white dark:bg-[#1e293b] p-1.5 rounded-lg border border-defaultborder dark:border-white/10 shadow-2xl flex flex-col animate-fade-in';
|
||||||
|
viewContainer.style.display = 'none';
|
||||||
|
|
||||||
|
container.appendChild(viewContainer);
|
||||||
|
this.viewContainerEl = viewContainer;
|
||||||
|
|
||||||
|
this.registerIgnoredElement(viewContainer);
|
||||||
|
|
||||||
|
const preventBubbling = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
viewContainer.addEventListener('mousedown', preventBubbling);
|
||||||
|
viewContainer.addEventListener('mouseup', preventBubbling);
|
||||||
|
viewContainer.addEventListener('click', (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleContainerClick(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCustomHeader(): void {
|
||||||
|
if (!this.fpInstance || !this.fpInstance.calendarContainer) return;
|
||||||
|
const container = this.fpInstance.calendarContainer as HTMLElement;
|
||||||
|
|
||||||
|
const currentMonthContainer = container.querySelector('.flatpickr-current-month');
|
||||||
|
if (!currentMonthContainer) return;
|
||||||
|
|
||||||
|
const nativeElements = currentMonthContainer.querySelectorAll(
|
||||||
|
'.flatpickr-monthDropdown-months, .numInputWrapper, .cur-year, .cur-month'
|
||||||
|
);
|
||||||
|
nativeElements.forEach(el => {
|
||||||
|
(el as HTMLElement).style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
let pillsWrapper = currentMonthContainer.querySelector('.spk-header-pills-wrapper') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!pillsWrapper) {
|
||||||
|
pillsWrapper = document.createElement('div');
|
||||||
|
pillsWrapper.className = 'spk-header-pills-wrapper flex items-center justify-center gap-1.5';
|
||||||
|
|
||||||
|
const monthPill = document.createElement('button');
|
||||||
|
monthPill.type = 'button';
|
||||||
|
monthPill.className = 'spk-month-pill-btn px-2.5 py-1 text-xs font-semibold rounded-md bg-white dark:bg-[#1e293b] text-primary shadow-xs border border-primary/20 hover:border-primary/40 hover:bg-primary/5 transition-all flex items-center gap-1 cursor-pointer';
|
||||||
|
monthPill.setAttribute('aria-label', 'Select Month');
|
||||||
|
|
||||||
|
const yearPill = document.createElement('button');
|
||||||
|
yearPill.type = 'button';
|
||||||
|
yearPill.className = 'spk-year-pill-btn px-2.5 py-1 text-xs font-semibold rounded-md bg-white dark:bg-[#1e293b] text-primary shadow-xs border border-primary/20 hover:border-primary/40 hover:bg-primary/5 transition-all flex items-center gap-1 cursor-pointer';
|
||||||
|
yearPill.setAttribute('aria-label', 'Select Year');
|
||||||
|
|
||||||
|
pillsWrapper.appendChild(monthPill);
|
||||||
|
pillsWrapper.appendChild(yearPill);
|
||||||
|
currentMonthContainer.appendChild(pillsWrapper);
|
||||||
|
|
||||||
|
this.registerIgnoredElement(monthPill);
|
||||||
|
this.registerIgnoredElement(yearPill);
|
||||||
|
this.registerIgnoredElement(pillsWrapper);
|
||||||
|
|
||||||
|
const preventFocusLoss = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHeaderInteraction = (e: Event, targetView: CalendarViewMode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setCalendarView(this.viewMode === targetView ? 'calendar' : targetView);
|
||||||
|
};
|
||||||
|
|
||||||
|
monthPill.addEventListener('mousedown', preventFocusLoss);
|
||||||
|
monthPill.addEventListener('mouseup', preventFocusLoss);
|
||||||
|
monthPill.addEventListener('click', (e) => handleHeaderInteraction(e, 'month'));
|
||||||
|
|
||||||
|
yearPill.addEventListener('mousedown', preventFocusLoss);
|
||||||
|
yearPill.addEventListener('mouseup', preventFocusLoss);
|
||||||
|
yearPill.addEventListener('click', (e) => handleHeaderInteraction(e, 'year'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMonthIdx = this.fpInstance.currentMonth;
|
||||||
|
const currentYear = this.fpInstance.currentYear;
|
||||||
|
const monthName = this.monthsList[currentMonthIdx]?.name || 'Month';
|
||||||
|
|
||||||
|
const monthPillBtn = pillsWrapper.querySelector('.spk-month-pill-btn');
|
||||||
|
const yearPillBtn = pillsWrapper.querySelector('.spk-year-pill-btn');
|
||||||
|
|
||||||
|
if (monthPillBtn) {
|
||||||
|
monthPillBtn.innerHTML = `<span>${monthName}</span> <i class="ri-arrow-down-s-line text-sm opacity-70"></i>`;
|
||||||
|
}
|
||||||
|
if (yearPillBtn) {
|
||||||
|
yearPillBtn.innerHTML = `<span>${currentYear}</span> <i class="ri-arrow-down-s-line text-sm opacity-70"></i>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCalendarView(mode: CalendarViewMode): void {
|
||||||
|
this.viewMode = mode;
|
||||||
|
if (!this.viewContainerEl) return;
|
||||||
|
|
||||||
|
if (mode === 'calendar') {
|
||||||
|
this.viewContainerEl.style.display = 'none';
|
||||||
|
this.viewContainerEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewContainerEl.style.display = 'flex';
|
||||||
|
if (mode === 'month') {
|
||||||
|
this.renderMonthContent();
|
||||||
|
} else if (mode === 'year') {
|
||||||
|
this.yearPageOffset = 0;
|
||||||
|
this.renderYearContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMonthContent(): void {
|
||||||
|
if (!this.viewContainerEl || !this.fpInstance) return;
|
||||||
|
|
||||||
|
const currentMonth = this.fpInstance.currentMonth;
|
||||||
|
const currentYear = this.fpInstance.currentYear;
|
||||||
|
|
||||||
|
let monthsHtml = '';
|
||||||
|
this.monthsList.forEach(m => {
|
||||||
|
const isCurrent = m.index === currentMonth;
|
||||||
|
const disabled = this.isMonthDisabled(m.index, currentYear);
|
||||||
|
const activeClass = isCurrent
|
||||||
|
? 'bg-primary text-white border-primary font-bold shadow-xs scale-[1.02]'
|
||||||
|
: 'bg-white dark:bg-[#1e293b] text-slate-700 dark:text-slate-200 border-slate-200/80 dark:border-white/10 hover:border-primary/50 hover:bg-primary/5 hover:text-primary';
|
||||||
|
const disabledClass = disabled ? 'opacity-30 cursor-not-allowed pointer-events-none' : 'cursor-pointer';
|
||||||
|
|
||||||
|
monthsHtml += `
|
||||||
|
<button type="button" class="spk-month-btn py-1 px-1 text-[0.75rem] rounded-md font-medium transition-all text-center border ${activeClass} ${disabledClass}" data-action="select-month" data-month="${m.index}">
|
||||||
|
${m.shortName}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.viewContainerEl.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between px-0.5 pb-1 mb-1 border-b border-slate-200 dark:border-white/10">
|
||||||
|
<span class="text-[0.7rem] font-bold text-primary dark:text-primary-light uppercase tracking-wider ps-1">Select Month</span>
|
||||||
|
<button type="button" class="spk-close-btn p-0.5 text-slate-400 hover:text-red-500 dark:hover:text-red-400 rounded-md cursor-pointer transition-colors" data-action="close">
|
||||||
|
<i class="ri-close-line text-xs pointer-events-none"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-1 p-1 bg-slate-50/70 dark:bg-white/5 rounded-lg border border-slate-200/60 dark:border-white/10">
|
||||||
|
${monthsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderYearContent(): void {
|
||||||
|
if (!this.viewContainerEl || !this.fpInstance) return;
|
||||||
|
|
||||||
|
const currentYear = this.fpInstance.currentYear;
|
||||||
|
const baseYear = Math.floor(currentYear / 12) * 12 + (this.yearPageOffset * 12);
|
||||||
|
const startYear = baseYear;
|
||||||
|
const endYear = baseYear + 11;
|
||||||
|
|
||||||
|
let yearsHtml = '';
|
||||||
|
const minYr = this.getMinYear();
|
||||||
|
const maxYr = this.getMaxYear();
|
||||||
|
|
||||||
|
for (let y = startYear; y <= endYear; y++) {
|
||||||
|
const isCurrent = y === currentYear;
|
||||||
|
const disabled = (minYr !== null && y < minYr) || (maxYr !== null && y > maxYr);
|
||||||
|
const activeClass = isCurrent
|
||||||
|
? 'bg-primary text-white border-primary font-bold shadow-xs scale-[1.02]'
|
||||||
|
: 'bg-white dark:bg-[#1e293b] text-slate-700 dark:text-slate-200 border-slate-200/80 dark:border-white/10 hover:border-primary/50 hover:bg-primary/5 hover:text-primary';
|
||||||
|
const disabledClass = disabled ? 'opacity-30 cursor-not-allowed pointer-events-none' : 'cursor-pointer';
|
||||||
|
|
||||||
|
yearsHtml += `
|
||||||
|
<button type="button" class="spk-year-btn py-1 px-1 text-[0.75rem] rounded-md font-medium transition-all text-center border ${activeClass} ${disabledClass}" data-action="select-year" data-year="${y}">
|
||||||
|
${y}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewContainerEl.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between px-0.5 pb-1 mb-1 border-b border-slate-200 dark:border-white/10">
|
||||||
|
<div class="flex items-center gap-1.5 mx-auto">
|
||||||
|
<button type="button" class="spk-prev-years-btn px-1.5 py-0.5 text-primary bg-primary/10 dark:bg-primary/20 hover:bg-primary hover:text-white rounded-md transition-all cursor-pointer flex items-center justify-center border border-primary/20" data-action="prev-year-page" title="Previous 12 years">
|
||||||
|
<i class="ri-arrow-left-s-line text-[0.65rem] font-bold pointer-events-none"></i>
|
||||||
|
</button>
|
||||||
|
<span class="text-[0.7rem] font-bold text-primary dark:text-primary-light uppercase tracking-wider px-1.5 py-0.5 rounded-md bg-primary/5 dark:bg-primary/10 border border-primary/15">${startYear} - ${endYear}</span>
|
||||||
|
<button type="button" class="spk-next-years-btn px-1.5 py-0.5 text-primary bg-primary/10 dark:bg-primary/20 hover:bg-primary hover:text-white rounded-md transition-all cursor-pointer flex items-center justify-center border border-primary/20" data-action="next-year-page" title="Next 12 years">
|
||||||
|
<i class="ri-arrow-right-s-line text-[0.65rem] font-bold pointer-events-none"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="spk-close-btn p-0.5 text-slate-400 hover:text-red-500 dark:hover:text-red-400 rounded-md cursor-pointer transition-colors" data-action="close">
|
||||||
|
<i class="ri-close-line text-xs pointer-events-none"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-1 p-1 bg-slate-50/70 dark:bg-white/5 rounded-lg border border-slate-200/60 dark:border-white/10">
|
||||||
|
${yearsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleContainerClick(e: Event): void {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const actionEl = target.closest('[data-action]') as HTMLElement;
|
||||||
|
if (!actionEl) return;
|
||||||
|
|
||||||
|
const action = actionEl.getAttribute('data-action');
|
||||||
|
|
||||||
|
if (action === 'select-month') {
|
||||||
|
const monthIdx = parseInt(actionEl.getAttribute('data-month') || '0', 10);
|
||||||
|
this.fpInstance.changeMonth(monthIdx, false);
|
||||||
|
this.setCalendarView('calendar');
|
||||||
|
this.ensureCustomHeader();
|
||||||
|
} else if (action === 'select-year') {
|
||||||
|
const year = parseInt(actionEl.getAttribute('data-year') || '0', 10);
|
||||||
|
this.fpInstance.changeYear(year);
|
||||||
|
this.setCalendarView('calendar');
|
||||||
|
this.ensureCustomHeader();
|
||||||
|
} else if (action === 'prev-year-page') {
|
||||||
|
this.yearPageOffset--;
|
||||||
|
this.renderYearContent();
|
||||||
|
} else if (action === 'next-year-page') {
|
||||||
|
this.yearPageOffset++;
|
||||||
|
this.renderYearContent();
|
||||||
|
} else if (action === 'close') {
|
||||||
|
this.setCalendarView('calendar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMonthDisabled(monthIndex: number, currentYear: number): boolean {
|
||||||
|
const minYr = this.getMinYear();
|
||||||
|
const maxYr = this.getMaxYear();
|
||||||
|
const minMo = this.getMinMonth();
|
||||||
|
const maxMo = this.getMaxMonth();
|
||||||
|
|
||||||
|
if (minYr !== null && currentYear < minYr) return true;
|
||||||
|
if (maxYr !== null && currentYear > maxYr) return true;
|
||||||
|
if (minYr !== null && currentYear === minYr && minMo !== null && monthIndex < minMo) return true;
|
||||||
|
if (maxYr !== null && currentYear === maxYr && maxMo !== null && maxMo < monthIndex) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMinYear(): number | null {
|
||||||
|
const min = this.minDate();
|
||||||
|
if (!min) return null;
|
||||||
|
if (typeof min === 'string') return new Date(min).getFullYear();
|
||||||
|
if (min instanceof Date) return min.getFullYear();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxYear(): number | null {
|
||||||
|
const max = this.maxDate();
|
||||||
|
if (!max) return null;
|
||||||
|
if (typeof max === 'string') return new Date(max).getFullYear();
|
||||||
|
if (max instanceof Date) return max.getFullYear();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMinMonth(): number | null {
|
||||||
|
const min = this.minDate();
|
||||||
|
if (!min) return null;
|
||||||
|
if (typeof min === 'string') return new Date(min).getMonth();
|
||||||
|
if (min instanceof Date) return min.getMonth();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxMonth(): number | null {
|
||||||
|
const max = this.maxDate();
|
||||||
|
if (!max) return null;
|
||||||
|
if (typeof max === 'string') return new Date(max).getMonth();
|
||||||
|
if (max instanceof Date) return max.getMonth();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,85 +1,119 @@
|
|||||||
<div
|
<div
|
||||||
class="grid grid-cols-12 h-screen min-h-screen overflow-hidden bg-bodybg dark:bg-bodybg text-defaulttextcolor text-defaultsize">
|
class="grid grid-cols-12 min-h-screen h-screen overflow-hidden bg-[#f4f2fb] text-defaulttextcolor font-sans relative p-0">
|
||||||
<div class="hidden xl:block xl:col-span-6 h-screen"> <img src="./assets/images/authentication/auth-cover.png"
|
|
||||||
class="w-full h-full object-cover object-left" alt="login cover" /> </div>
|
|
||||||
<div class="xl:col-span-6 col-span-12 min-h-[100dvh] flex items-center justify-center px-6 bg-bodybg dark:bg-bodybg">
|
|
||||||
<div class="authentication authentication-basic w-full max-w-[420px]">
|
|
||||||
|
|
||||||
<div class="my-[2.5rem] flex justify-center">
|
|
||||||
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-logo">
|
|
||||||
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-dark">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box !mb-0">
|
<!-- Extreme Lower Right Light Purple Spark Graphic -->
|
||||||
<div class="box-body !p-[3rem]">
|
<div class="fixed bottom-8 right-10 pointer-events-none opacity-60 z-0">
|
||||||
<p class="h5 font-semibold mb-2 text-center">Sign In</p>
|
<svg width="42" height="42" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<p class="mb-4 text-[#8c9097] dark:text-white/50 opacity-[0.7] font-normal text-center">
|
<path d="M12 0L14.59 9.41L24 12L14.59 14.59L12 24L9.41 14.59L0 12L9.41 9.41L12 0Z" fill="#c3b4f5" />
|
||||||
Welcome back !
|
</svg>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="adminLoginForm" (ngSubmit)="login()">
|
<!-- Left Marketing Panel (60% Width) - Full Banner Image (Straight left, rounded right corners) -->
|
||||||
@if (loginError) {
|
<div
|
||||||
<div class="text-danger mb-3">{{ loginError }}</div>
|
class="hidden lg:flex lg:col-span-7 xl:col-span-7 relative h-full w-full overflow-hidden items-center justify-center bg-white rounded-r-[32px]">
|
||||||
}
|
<img src="assets/images/authentication/auth-cover.png" alt="Biz360 Banner"
|
||||||
|
class="w-full h-full object-cover object-center rounded-r-[32px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-12 gap-y-4">
|
<!-- Right Login Panel (40% Width) -->
|
||||||
<div class="xl:col-span-12 col-span-12">
|
<div
|
||||||
<label for="signin-username" class="form-label text-default">User Name</label>
|
class="lg:col-span-5 xl:col-span-5 col-span-12 min-h-[100dvh] h-full flex items-center justify-center p-4 sm:p-6 relative overflow-y-auto z-10 bg-[#f4f2fb]">
|
||||||
<input type="text" class="form-control form-control-lg w-full !rounded-md"
|
|
||||||
id="signin-username" placeholder="user name" formControlName="username"
|
|
||||||
autocomplete="username">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-12 col-span-12 mb-2">
|
<!-- Central Floating Card Encased in Lavender-Purple Frame -->
|
||||||
<label for="signin-password" class="form-label text-default block">Password</label>
|
<div
|
||||||
|
class="w-full max-w-[420px] bg-[#c3b4f5] p-[14px] sm:p-[16px] rounded-[34px] shadow-xl transition-all duration-300 relative z-10">
|
||||||
|
|
||||||
<a routerLink="/authentication/reset-password/basic"
|
<!-- Inner White Card Area -->
|
||||||
class="ltr:float-right rtl:float-left text-danger">
|
<div class="bg-white rounded-[24px] px-6 sm:px-8 py-8 sm:py-9 shadow-inner flex flex-col">
|
||||||
Forgot password ?
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="input-group">
|
<!-- Stylized Gradient Purple-and-Indigo Winged 'V' Logo & Header -->
|
||||||
<input class="form-control form-control-lg !rounded-s-md" id="signin-password"
|
<div class="flex flex-col items-center text-center mb-6">
|
||||||
placeholder="password" formControlName="password"
|
<div class="mb-3 flex justify-center">
|
||||||
autocomplete="current-password"
|
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-logo">
|
||||||
[type]="visibilityMap['Angular'] ? 'text' : 'password'">
|
</div>
|
||||||
|
|
||||||
<button aria-label="button" class="ti-btn ti-btn-light !rounded-s-none !mb-0"
|
|
||||||
type="button" (click)="toggleVisibility('Angular')" id="button-addon2">
|
|
||||||
<i class="{{ iconMap['Angular'] }} align-middle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5">
|
|
||||||
<div class="form-check !ps-0">
|
|
||||||
<input class="form-check-input" type="checkbox" id="defaultCheck1"
|
|
||||||
formControlName="rememberMe">
|
|
||||||
|
|
||||||
<label class="form-check-label text-[#8c9097] dark:text-white/50 font-normal"
|
|
||||||
for="defaultCheck1">
|
|
||||||
Remember password ?
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-12 col-span-12 grid mt-2">
|
|
||||||
<button type="submit" [disabled]="adminLoginForm.invalid || isSubmitting"
|
|
||||||
class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium inline-flex items-center justify-center gap-2">
|
|
||||||
|
|
||||||
<span [style.display]="isSubmitting ? '' : 'none'" class="ti-spinner text-white"
|
|
||||||
role="status" aria-label="loading"></span>
|
|
||||||
<span [style.display]="isSubmitting ? '' : 'none'">Signing In...</span>
|
|
||||||
<span [style.display]="isSubmitting ? 'none' : ''">Sign In</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<h3 class="text-2xl font-bold text-slate-900 mb-1">Sign In</h3>
|
||||||
|
<p class="text-xs sm:text-sm text-slate-400 font-normal">Welcome back! Please enter your details.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form -->
|
||||||
|
<form [formGroup]="adminLoginForm" (ngSubmit)="login()" class="space-y-4">
|
||||||
|
@if (loginError) {
|
||||||
|
<div class="p-3 text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
{{ loginError }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- User Name Field (White Input Background) -->
|
||||||
|
<div>
|
||||||
|
<label for="signin-username" class="block text-sm font-semibold text-slate-800 mb-1.5">User Name</label>
|
||||||
|
<input type="text" id="signin-username" placeholder="user name" formControlName="username"
|
||||||
|
autocomplete="username"
|
||||||
|
class="login-input w-full px-4 py-3 bg-white border border-slate-300 rounded-xl text-base font-semibold text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-400/50 focus:border-purple-500 focus:bg-white transition-all duration-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Field (White Input Background & Visibility Toggle) -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
|
<label for="signin-password" class="block text-sm font-semibold text-slate-800">Password</label>
|
||||||
|
<a routerLink="/authentication/reset-password/basic"
|
||||||
|
class="text-xs sm:text-sm font-semibold text-[#7c3aed] hover:text-[#6d28d9] transition-colors">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
<input [type]="visibilityMap['Angular'] ? 'text' : 'password'" id="signin-password" placeholder="password"
|
||||||
|
formControlName="password" autocomplete="current-password"
|
||||||
|
class="login-input w-full pl-4 pr-11 py-3 bg-white border border-slate-300 rounded-xl text-base font-semibold text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-400/50 focus:border-purple-500 focus:bg-white transition-all duration-200" />
|
||||||
|
<button type="button" (click)="toggleVisibility('Angular')"
|
||||||
|
class="absolute right-3.5 text-slate-400 hover:text-slate-600 focus:outline-none p-1 transition-colors"
|
||||||
|
aria-label="Toggle password visibility">
|
||||||
|
@if (visibilityMap['Angular']) {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path
|
||||||
|
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24">
|
||||||
|
</path>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remember Password Checkbox -->
|
||||||
|
<div class="flex items-center gap-2 pt-1 pb-1">
|
||||||
|
<input type="checkbox" id="rememberMe" formControlName="rememberMe"
|
||||||
|
class="w-4 h-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500 cursor-pointer" />
|
||||||
|
<label for="rememberMe" class="text-xs sm:text-sm text-slate-700 font-semibold cursor-pointer select-none">
|
||||||
|
Remember password?
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button: Gradient Purple-to-Pink -->
|
||||||
|
<div class="pt-2">
|
||||||
|
<button type="submit" [disabled]="isSubmitting"
|
||||||
|
class="w-full py-3.5 px-4 bg-gradient-to-r from-[#7c3aed] via-[#a855f7] to-[#d946ef] text-white font-bold text-base rounded-xl shadow-lg shadow-purple-500/30 hover:shadow-purple-500/45 hover:opacity-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center gap-2">
|
||||||
|
@if (isSubmitting) {
|
||||||
|
<span class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
<span>Signing In...</span>
|
||||||
|
} @else {
|
||||||
|
<span>Sign In</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,15 +1,40 @@
|
|||||||
::ng-deep{
|
.login-input {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
border-color: #cbd5e1 !important;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.firebase {
|
&:-webkit-autofill,
|
||||||
width: 30px;
|
&:-webkit-autofill:hover,
|
||||||
height: 30px;
|
&:-webkit-autofill:focus,
|
||||||
}
|
&:-webkit-autofill:active {
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important;
|
||||||
|
-webkit-text-fill-color: #0f172a !important;
|
||||||
|
caret-color: #0f172a !important;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
.rounded-lg{
|
&:focus {
|
||||||
width: 120px;
|
background-color: #ffffff !important;
|
||||||
justify-content: center;
|
color: #0f172a !important;
|
||||||
margin: auto;
|
border-color: #a855f7 !important;
|
||||||
border-radius: 0.75rem;
|
box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.25) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::ng-deep {
|
||||||
|
.firebase {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-lg {
|
||||||
|
width: 120px;
|
||||||
|
justify-content: center;
|
||||||
|
margin: auto;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export class Login {
|
|||||||
private readonly fallbackRoute = '/dashboards/crm';
|
private readonly fallbackRoute = '/dashboards/crm';
|
||||||
|
|
||||||
public readonly adminLoginForm = this.formBuilder.nonNullable.group({
|
public readonly adminLoginForm = this.formBuilder.nonNullable.group({
|
||||||
username: ['', [Validators.required, Validators.email]],
|
username: ['superadmin@syscom.dev', [Validators.required]],
|
||||||
password: ['', Validators.required],
|
password: ['', Validators.required],
|
||||||
rememberMe: [false]
|
rememberMe: [false]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,63 +1,3 @@
|
|||||||
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
|
||||||
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter($event)" 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="city-country-filter"
|
|
||||||
variant="floating"
|
|
||||||
size="sm"
|
|
||||||
label="Country"
|
|
||||||
placeholder="Search country"
|
|
||||||
[searchFn]="filterCountrySearchFn"
|
|
||||||
[valueWith]="filterCountryValueFn"
|
|
||||||
[displayWith]="filterCountryDisplayFn"
|
|
||||||
[selectedItem]="selectedCountry()"
|
|
||||||
[minSearchLength]="0"
|
|
||||||
[debounceTime]="300"
|
|
||||||
[limit]="50"
|
|
||||||
[clearable]="true"
|
|
||||||
[hideValidation]="true"
|
|
||||||
wrapperClass="!mb-0 w-full"
|
|
||||||
(itemSelected)="onFilterCountryChanged($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
|
||||||
<app-autocomplete
|
|
||||||
formControlName="stateId"
|
|
||||||
inputId="city-state-filter"
|
|
||||||
variant="floating"
|
|
||||||
size="sm"
|
|
||||||
label="State"
|
|
||||||
placeholder="Search state"
|
|
||||||
[searchFn]="filterStateSearchFn"
|
|
||||||
[valueWith]="filterStateValueFn"
|
|
||||||
[displayWith]="filterStateDisplayFn"
|
|
||||||
[selectedItem]="selectedFilterState()"
|
|
||||||
[minSearchLength]="0"
|
|
||||||
[debounceTime]="300"
|
|
||||||
[limit]="50"
|
|
||||||
[clearable]="true"
|
|
||||||
[hideValidation]="true"
|
|
||||||
wrapperClass="!mb-0 w-full"
|
|
||||||
(itemSelected)="onFilterStateChanged($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-12 sm:col-span-2 lg:col-span-1">
|
|
||||||
<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)="onApplyFilter($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</app-filter-card>
|
|
||||||
|
|
||||||
<app-data-table
|
<app-data-table
|
||||||
[columns]="columns()"
|
[columns]="columns()"
|
||||||
[rows]="tableStore.rows()"
|
[rows]="tableStore.rows()"
|
||||||
@@ -69,6 +9,8 @@
|
|||||||
buttonTitle="Add"
|
buttonTitle="Add"
|
||||||
[showSearch]="true"
|
[showSearch]="true"
|
||||||
[showAddButton]="true"
|
[showAddButton]="true"
|
||||||
|
[showFilterButton]="true"
|
||||||
|
[filterActive]="showFilters()"
|
||||||
searchPlaceholder="Search cities..."
|
searchPlaceholder="Search cities..."
|
||||||
[searchDebounceTime]="300"
|
[searchDebounceTime]="300"
|
||||||
toolTip="Add City"
|
toolTip="Add City"
|
||||||
@@ -77,7 +19,77 @@
|
|||||||
(pageChanged)="tableStore.onPageChange($event)"
|
(pageChanged)="tableStore.onPageChange($event)"
|
||||||
(sortChanged)="tableStore.onSortChange($event)"
|
(sortChanged)="tableStore.onSortChange($event)"
|
||||||
(actionClicked)="onActionClick($event)"
|
(actionClicked)="onActionClick($event)"
|
||||||
/>
|
(filterClicked)="onToggleFilters()">
|
||||||
|
<ng-template appDataTableToolbar>
|
||||||
|
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter($event)" autocomplete="off"
|
||||||
|
class="flex flex-wrap items-end gap-3 w-full">
|
||||||
|
<div class="w-64 min-w-[200px]">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="countryId"
|
||||||
|
inputId="city-country-filter"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Search country"
|
||||||
|
[searchFn]="filterCountrySearchFn"
|
||||||
|
[valueWith]="filterCountryValueFn"
|
||||||
|
[displayWith]="filterCountryDisplayFn"
|
||||||
|
[selectedItem]="selectedCountry()"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="true"
|
||||||
|
[hideValidation]="true"
|
||||||
|
wrapperClass="!mb-0 w-full"
|
||||||
|
(itemSelected)="onFilterCountryChanged($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-64 min-w-[200px]">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="stateId"
|
||||||
|
inputId="city-state-filter"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="State"
|
||||||
|
placeholder="Search state"
|
||||||
|
[searchFn]="filterStateSearchFn"
|
||||||
|
[valueWith]="filterStateValueFn"
|
||||||
|
[displayWith]="filterStateDisplayFn"
|
||||||
|
[selectedItem]="selectedFilterState()"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[debounceTime]="300"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="true"
|
||||||
|
[hideValidation]="true"
|
||||||
|
wrapperClass="!mb-0 w-full"
|
||||||
|
(itemSelected)="onFilterStateChanged($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<app-button
|
||||||
|
action="custom"
|
||||||
|
label="Apply"
|
||||||
|
icon="ti ti-filter"
|
||||||
|
variant="primary-full"
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="!rounded-full shadow-sm !mb-0 min-h-8"
|
||||||
|
/>
|
||||||
|
<app-button
|
||||||
|
action="custom"
|
||||||
|
label="Reset"
|
||||||
|
icon="ti ti-refresh"
|
||||||
|
variant="outline-primary"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="!rounded-full shadow-sm !mb-0 min-h-8"
|
||||||
|
(buttonClicked)="onResetFilter()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-template>
|
||||||
|
</app-data-table>
|
||||||
|
|
||||||
<app-confirm-dialog
|
<app-confirm-dialog
|
||||||
title="Delete City"
|
title="Delete City"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { StateLookupDto } from '../../../states/models/state.model';
|
|||||||
import { CityService } from '../../data-access/city.service';
|
import { CityService } from '../../data-access/city.service';
|
||||||
import { CountryService } from '../../../countries/data-access/country.service';
|
import { CountryService } from '../../../countries/data-access/country.service';
|
||||||
import { StateService } from '../../../states/data-access/state.service';
|
import { StateService } from '../../../states/data-access/state.service';
|
||||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table';
|
||||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||||
import {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
AutocompleteSearchFn,
|
AutocompleteSearchFn,
|
||||||
AutocompleteValueFn
|
AutocompleteValueFn
|
||||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
|
|
||||||
import { Button } from '../../../../../shared/components/button/button';
|
import { Button } from '../../../../../shared/components/button/button';
|
||||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
||||||
import { CityFormModalComponent } from '../../components/city-form-modal/city-form-modal';
|
import { CityFormModalComponent } from '../../components/city-form-modal/city-form-modal';
|
||||||
@@ -51,9 +50,9 @@ interface CityTableRow extends DataTableRecord {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
DataTable,
|
DataTable,
|
||||||
|
DataTableToolbarDirective,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
FilterCard,
|
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
CityFormModalComponent
|
CityFormModalComponent
|
||||||
@@ -78,6 +77,8 @@ export class CityList implements OnInit {
|
|||||||
readonly pendingDeleteCity = signal<CityTableRow | null>(null);
|
readonly pendingDeleteCity = signal<CityTableRow | null>(null);
|
||||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||||
|
|
||||||
|
readonly showFilters = signal(false);
|
||||||
|
|
||||||
readonly filterForm = this.formBuilder.nonNullable.group({
|
readonly filterForm = this.formBuilder.nonNullable.group({
|
||||||
countryId: [''],
|
countryId: [''],
|
||||||
stateId: [{ value: '', disabled: true }]
|
stateId: [{ value: '', disabled: true }]
|
||||||
@@ -258,4 +259,9 @@ export class CityList implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onToggleFilters(): void {
|
||||||
|
this.showFilters.update(value => !value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,34 @@
|
|||||||
<app-filter-card title="Filter" titleIcon="ti ti-filter" headerClass="!py-2" bodyClass="!px-4 !py-2.5">
|
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
|
||||||
<form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off" class="grid w-full grid-cols-12 items-end gap-3">
|
[totalRecords]="tableStore.totalRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
<div class="col-span-12 sm:col-span-5 lg:col-span-2">
|
[pageSize]="tableStore.queryState.pageSize()" tableTitle="States" buttonTitle="Add" [showSearch]="true"
|
||||||
<app-autocomplete
|
[showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()" searchPlaceholder="Search states..."
|
||||||
formControlName="countryId"
|
[searchDebounceTime]="300" toolTip="Add State" (addClicked)="onAddState()"
|
||||||
inputId="state-country-filter"
|
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
|
||||||
variant="floating"
|
(sortChanged)="tableStore.onSortChange($event)" (actionClicked)="onActionClick($event)"
|
||||||
size="sm"
|
(filterClicked)="onToggleFilters()">
|
||||||
label="Country"
|
<ng-template appDataTableToolbar>
|
||||||
placeholder="Search"
|
<form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off"
|
||||||
[searchFn]="searchCountries"
|
class="flex flex-wrap items-end gap-3 w-full">
|
||||||
[displayWith]="displayCountry"
|
<div class="w-64 min-w-[200px]">
|
||||||
[valueWith]="countryValue"
|
<app-autocomplete formControlName="countryId" inputId="state-country-filter" variant="floating" size="sm"
|
||||||
[selectedItem]="selectedCountryLookup()"
|
label="Country" placeholder="Search country" [searchFn]="searchCountries" [displayWith]="displayCountry"
|
||||||
[minSearchLength]="1"
|
[valueWith]="countryValue" [selectedItem]="selectedCountryLookup()" [minSearchLength]="1" [debounceTime]="300"
|
||||||
[debounceTime]="300"
|
[limit]="50" [clearable]="true" [hideValidation]="true" wrapperClass="!mb-0 w-full"
|
||||||
[limit]="50"
|
(itemSelected)="onFilterCountrySelected($event)" />
|
||||||
[clearable]="true"
|
</div>
|
||||||
[hideValidation]="true"
|
<div class="flex items-center gap-2">
|
||||||
wrapperClass="!mb-0 w-full"
|
<app-button action="custom" label="Apply" icon="ti ti-filter" variant="primary-full" type="submit" size="sm"
|
||||||
(itemSelected)="onFilterCountrySelected($event)"
|
className="!rounded-full shadow-sm !mb-0 min-h-8" />
|
||||||
/>
|
<app-button action="custom" label="Reset" icon="ti ti-refresh" variant="outline-primary" type="button" size="sm"
|
||||||
</div>
|
className="!rounded-full shadow-sm !mb-0 min-h-8" (buttonClicked)="onResetFilter()" />
|
||||||
<div class="col-span-12 sm:col-span-3 lg:col-span-1">
|
</div>
|
||||||
<app-button
|
</form>
|
||||||
action="custom"
|
</ng-template>
|
||||||
label="Filter"
|
</app-data-table>
|
||||||
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)="onApplyFilter()"
|
|
||||||
></app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</app-filter-card>
|
|
||||||
|
|
||||||
<app-data-table
|
<app-confirm-dialog title="Delete State" text="Do you really want to delete this state?" confirmButtonText="Delete"
|
||||||
[columns]="columns()"
|
cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()" (cancelled)="onDeleteCancelled()" />
|
||||||
[rows]="tableStore.rows()"
|
|
||||||
[actions]="actions()"
|
|
||||||
[totalRecords]="tableStore.totalRecords()"
|
|
||||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
|
||||||
[pageSize]="tableStore.queryState.pageSize()"
|
|
||||||
tableTitle="States"
|
|
||||||
buttonTitle="Add"
|
|
||||||
[showSearch]="true"
|
|
||||||
[showAddButton]="true"
|
|
||||||
searchPlaceholder="Search states..."
|
|
||||||
[searchDebounceTime]="300"
|
|
||||||
toolTip="Add State"
|
|
||||||
(addClicked)="onAddState()"
|
|
||||||
(searchChanged)="tableStore.onSearch($event)"
|
|
||||||
(pageChanged)="tableStore.onPageChange($event)"
|
|
||||||
(sortChanged)="tableStore.onSortChange($event)"
|
|
||||||
(actionClicked)="onActionClick($event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-confirm-dialog
|
<app-state-form-modal [open]="tableStore.showModal()" [mode]="tableStore.modalMode()"
|
||||||
title="Delete State"
|
[stateId]="tableStore.selectedItem()?.id ?? null" (saved)="tableStore.refresh(); tableStore.closeModal()"
|
||||||
text="Do you really want to delete this state?"
|
(closed)="tableStore.closeModal()" />
|
||||||
confirmButtonText="Delete"
|
|
||||||
cancelButtonText="Cancel"
|
|
||||||
(confirmed)="onDeleteConfirmed()"
|
|
||||||
(cancelled)="onDeleteCancelled()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-state-form-modal
|
|
||||||
[open]="tableStore.showModal()"
|
|
||||||
[mode]="tableStore.modalMode()"
|
|
||||||
[stateId]="tableStore.selectedItem()?.id ?? null"
|
|
||||||
(saved)="tableStore.refresh(); tableStore.closeModal()"
|
|
||||||
(closed)="tableStore.closeModal()"
|
|
||||||
/>
|
|
||||||
@@ -8,7 +8,7 @@ import { catchError, finalize, map } from 'rxjs/operators';
|
|||||||
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
||||||
import { StateDto, UpdateStateRequest } from '../../models/state.model';
|
import { StateDto, UpdateStateRequest } from '../../models/state.model';
|
||||||
import { StateService } from '../../data-access/state.service';
|
import { StateService } from '../../data-access/state.service';
|
||||||
import { DataTable } from '../../../../../shared/components/data-table/data-table';
|
import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table';
|
||||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||||
import {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
AutocompleteValueFn
|
AutocompleteValueFn
|
||||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
|
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';
|
import { Button as AppButton } from '../../../../../shared/components/button/button';
|
||||||
import { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal';
|
import { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal';
|
||||||
|
|
||||||
@@ -44,10 +43,10 @@ interface StateTableRow extends DataTableRecord {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
DataTable,
|
DataTable,
|
||||||
|
DataTableToolbarDirective,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
FilterCard,
|
|
||||||
AppButton,
|
AppButton,
|
||||||
StateFormModalComponent
|
StateFormModalComponent
|
||||||
],
|
],
|
||||||
@@ -69,6 +68,8 @@ export class StateList implements OnInit {
|
|||||||
readonly pendingDeleteState = signal<StateTableRow | null>(null);
|
readonly pendingDeleteState = signal<StateTableRow | null>(null);
|
||||||
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
|
||||||
|
|
||||||
|
readonly showFilters = signal(false);
|
||||||
|
|
||||||
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
||||||
countryId: ['']
|
countryId: ['']
|
||||||
});
|
});
|
||||||
@@ -203,4 +204,8 @@ export class StateList implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onToggleFilters(): void {
|
||||||
|
this.showFilters.update(value => !value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,3 @@
|
|||||||
<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" (ngSubmit)="applyCountryFilter()" 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">
|
|
||||||
<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()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</app-filter-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-data-table
|
<app-data-table
|
||||||
[columns]="columns()"
|
[columns]="columns()"
|
||||||
[rows]="tableStore.rows()"
|
[rows]="tableStore.rows()"
|
||||||
@@ -52,6 +10,8 @@
|
|||||||
[showSearch]="true"
|
[showSearch]="true"
|
||||||
rowColorMode="none"
|
rowColorMode="none"
|
||||||
[showAddButton]="true"
|
[showAddButton]="true"
|
||||||
|
[showFilterButton]="true"
|
||||||
|
[filterActive]="showFilters()"
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
[searchDebounceTime]="300"
|
[searchDebounceTime]="300"
|
||||||
(addClicked)="onAddOrganization()"
|
(addClicked)="onAddOrganization()"
|
||||||
@@ -59,5 +19,54 @@
|
|||||||
(pageChanged)="tableStore.onPageChange($event)"
|
(pageChanged)="tableStore.onPageChange($event)"
|
||||||
(sortChanged)="tableStore.onSortChange($event)"
|
(sortChanged)="tableStore.onSortChange($event)"
|
||||||
(actionClicked)="onActionClick($event)"
|
(actionClicked)="onActionClick($event)"
|
||||||
toolTip="Add Organization"
|
(filterClicked)="onToggleFilters()"
|
||||||
/>
|
toolTip="Add Organization">
|
||||||
|
<ng-template appDataTableToolbar>
|
||||||
|
<form [formGroup]="countryFilterForm" (ngSubmit)="applyCountryFilter()" autocomplete="off"
|
||||||
|
class="flex flex-wrap items-end gap-3 w-full">
|
||||||
|
<div class="w-64 min-w-[200px]">
|
||||||
|
<app-autocomplete
|
||||||
|
formControlName="countryId"
|
||||||
|
inputId="organization-country-filter"
|
||||||
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="Country"
|
||||||
|
placeholder="Search country"
|
||||||
|
[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="flex items-center gap-2">
|
||||||
|
<app-button
|
||||||
|
action="custom"
|
||||||
|
label="Apply"
|
||||||
|
icon="ti ti-filter"
|
||||||
|
variant="primary-full"
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="!rounded-full shadow-sm !mb-0 min-h-8"
|
||||||
|
/>
|
||||||
|
<app-button
|
||||||
|
action="custom"
|
||||||
|
label="Reset"
|
||||||
|
icon="ti ti-refresh"
|
||||||
|
variant="outline-primary"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="!rounded-full shadow-sm !mb-0 min-h-8"
|
||||||
|
(buttonClicked)="onResetFilter()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-template>
|
||||||
|
</app-data-table>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
|
|||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import { DataTable } from '../../../shared/components/data-table/data-table';
|
import { DataTable, DataTableToolbarDirective } from '../../../shared/components/data-table/data-table';
|
||||||
import { DataTableStore } from '../../../shared/components/data-table/data-table.store';
|
import { DataTableStore } from '../../../shared/components/data-table/data-table.store';
|
||||||
import {
|
import {
|
||||||
DataTableAction,
|
DataTableAction,
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
AutocompleteSearchFn,
|
AutocompleteSearchFn,
|
||||||
AutocompleteValueFn,
|
AutocompleteValueFn,
|
||||||
} from '../../../shared/components/form/autocomplete/autocomplete.types';
|
} from '../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
import { FilterCard } from '../../../shared/components/filter-card/filter-card';
|
|
||||||
import { Button } from '../../../shared/components/button/button';
|
import { Button } from '../../../shared/components/button/button';
|
||||||
import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api';
|
import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api';
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ interface OrganizationListRow extends DataTableRecord {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'organization-list',
|
selector: 'organization-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable, Button],
|
imports: [ReactiveFormsModule, Autocomplete, DataTable, DataTableToolbarDirective, Button],
|
||||||
providers: [DataTableStore],
|
providers: [DataTableStore],
|
||||||
templateUrl: './organization-list.html',
|
templateUrl: './organization-list.html',
|
||||||
styleUrl: './organization-list.scss',
|
styleUrl: './organization-list.scss',
|
||||||
@@ -54,6 +53,7 @@ export class OrganizationList implements OnInit {
|
|||||||
|
|
||||||
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
|
||||||
readonly appliedCountryId = signal<string | null>(null);
|
readonly appliedCountryId = signal<string | null>(null);
|
||||||
|
readonly showFilters = signal(false);
|
||||||
|
|
||||||
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
readonly countryFilterForm = this.formBuilder.nonNullable.group({
|
||||||
countryId: [''],
|
countryId: [''],
|
||||||
@@ -268,6 +268,17 @@ export class OrganizationList implements OnInit {
|
|||||||
void this.router.navigate(['/organizations/onboarding']);
|
void this.router.navigate(['/organizations/onboarding']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onResetFilter(): void {
|
||||||
|
this.countryFilterForm.reset({ countryId: '' });
|
||||||
|
this.selectedCountryLookup.set(null);
|
||||||
|
this.appliedCountryId.set(null);
|
||||||
|
this.tableStore.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleFilters(): void {
|
||||||
|
this.showFilters.update(value => !value);
|
||||||
|
}
|
||||||
|
|
||||||
onActionClick(event: DataTableActionEvent<OrganizationListRow>): void {
|
onActionClick(event: DataTableActionEvent<OrganizationListRow>): void {
|
||||||
if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') {
|
if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') {
|
||||||
void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } });
|
void this.router.navigate(['/organizations/onboarding'], { queryParams: { id: event.row.id } });
|
||||||
@@ -276,3 +287,4 @@ export class OrganizationList implements OnInit {
|
|||||||
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
|
this.toastr.info(`${event.action.label} clicked for ${event.row.organizationName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-201
@@ -2,12 +2,15 @@
|
|||||||
<!-- Custom Stepper Navigation Card -->
|
<!-- Custom Stepper Navigation Card -->
|
||||||
<div class="stepper-header-card mb-4 sm:mb-6">
|
<div class="stepper-header-card mb-4 sm:mb-6">
|
||||||
<!-- Mobile Current Step Indicator Banner (< 640px) -->
|
<!-- 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">
|
<div
|
||||||
|
class="stepper-mobile-banner mb-3 flex items-center justify-between rounded-lg px-3 py-2 text-xs font-medium sm:hidden">
|
||||||
<span class="flex items-center gap-1.5 min-w-0 truncate">
|
<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">
|
<span
|
||||||
|
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-[0.65rem] font-bold text-white">
|
||||||
{{ currentStepIndex() + 1 }}
|
{{ currentStepIndex() + 1 }}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate">Step {{ currentStepIndex() + 1 }} of {{ steps().length }}: <strong class="font-semibold">{{ currentStepLabel() }}</strong></span>
|
<span class="truncate">Step {{ currentStepIndex() + 1 }} of {{ steps().length }}: <strong
|
||||||
|
class="font-semibold">{{ currentStepLabel() }}</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[0.6875rem] font-semibold text-primary ms-2">
|
<span class="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[0.6875rem] font-semibold text-primary ms-2">
|
||||||
{{ getProgressPercent() }}%
|
{{ getProgressPercent() }}%
|
||||||
@@ -18,85 +21,80 @@
|
|||||||
<div class="py-1">
|
<div class="py-1">
|
||||||
<nav aria-label="Organization onboarding progress" class="relative w-full">
|
<nav aria-label="Organization onboarding progress" class="relative w-full">
|
||||||
<!-- Background Track Line -->
|
<!-- Background Track Line -->
|
||||||
<div
|
<div class="stepper-bg-track absolute top-4.5 sm:top-8 h-1 z-0 transition-all duration-300"
|
||||||
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>
|
||||||
[style.left]="getTrackLineLeft()"
|
|
||||||
[style.right]="getTrackLineLeft()"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Gradient Progress Line -->
|
<!-- Gradient Progress Line -->
|
||||||
<div
|
<div class="stepper-progress-line absolute top-4.5 sm:top-8 h-1 z-0 transition-all duration-500 ease-in-out"
|
||||||
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>
|
||||||
[style.left]="getTrackLineLeft()"
|
|
||||||
[style.width]="getProgressLineWidth()"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Stepper Nodes Grid -->
|
<!-- Stepper Nodes Grid -->
|
||||||
<ul class="relative z-10 flex w-full justify-between items-start">
|
<ul class="relative z-10 flex w-full justify-between items-start">
|
||||||
@for (step of steps(); track step.key; let index = $index) {
|
@for (step of steps(); track step.key; let index = $index) {
|
||||||
<li class="flex flex-1 flex-col items-center text-center">
|
<li class="flex flex-1 flex-col items-center text-center">
|
||||||
<button
|
<button type="button" class="step-node-btn group flex flex-col items-center focus:outline-none"
|
||||||
type="button"
|
[class.cursor-pointer]="canOpenStep(index)" [class.cursor-default]="!canOpenStep(index)"
|
||||||
class="step-node-btn group flex flex-col items-center focus:outline-none"
|
[disabled]="!canOpenStep(index)" (click)="selectStep(index)">
|
||||||
[class.cursor-pointer]="canOpenStep(index)"
|
<!-- Step Circle Node -->
|
||||||
[class.cursor-default]="!canOpenStep(index)"
|
<div class="step-circle" [class.active]="isCurrentStep(index)"
|
||||||
[disabled]="!canOpenStep(index)"
|
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
|
||||||
(click)="selectStep(index)"
|
[class.pending]="isPendingStep(index)">
|
||||||
>
|
<!-- Icons by Step Key / Index -->
|
||||||
<!-- Step Circle Node -->
|
@switch (step.key) {
|
||||||
<div
|
@case ('basics') {
|
||||||
class="step-circle"
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||||
[class.active]="isCurrentStep(index)"
|
class="size-5 sm:size-7">
|
||||||
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
|
<path
|
||||||
[class.pending]="isPendingStep(index)"
|
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
|
||||||
<!-- Icons by Step Key / Index -->
|
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" />
|
||||||
@switch (step.key) {
|
</svg>
|
||||||
@case ('basics') {
|
}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 sm:size-7">
|
@case ('localization') {
|
||||||
<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"/>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
<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"/>
|
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
|
||||||
</svg>
|
<circle cx="10" cy="10" r="7.5" />
|
||||||
}
|
<path d="M2.5 10h15" />
|
||||||
@case ('localization') {
|
<path d="M10 2.5a11 11 0 0 1 0 15a11 11 0 0 1 0-15" />
|
||||||
<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 21.5v-7.5h3.5v7.5M15.5 21.5v-9.5h3.5v9.5M19 21.5v-6h3v6" fill="currentColor"
|
||||||
<circle cx="10" cy="10" r="7.5"/>
|
stroke="none" />
|
||||||
<path d="M2.5 10h15"/>
|
</svg>
|
||||||
<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"/>
|
@case ('plan-limits') {
|
||||||
</svg>
|
<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">
|
||||||
@case ('plan-limits') {
|
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" stroke="none" />
|
||||||
<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
|
||||||
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" stroke="none" />
|
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="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"
|
||||||
<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"/>
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
@case ('admin-user') {
|
@case ('admin-user') {
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 sm:size-7">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
|
||||||
<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"/>
|
class="size-5 sm:size-7">
|
||||||
<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
|
||||||
<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"/>
|
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" />
|
||||||
</svg>
|
<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" />
|
||||||
@default {
|
<path
|
||||||
<span>{{ index + 1 }}</span>
|
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>
|
||||||
}
|
}
|
||||||
</div>
|
@default {
|
||||||
|
<span>{{ index + 1 }}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Step Label -->
|
<!-- Step Label -->
|
||||||
<span
|
<span class="step-label hidden sm:block" [class.active]="isCurrentStep(index)"
|
||||||
class="step-label hidden sm:block"
|
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
|
||||||
[class.active]="isCurrentStep(index)"
|
[class.pending]="isPendingStep(index)">
|
||||||
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
|
{{ index + 1 }}. {{ step.label }}
|
||||||
[class.pending]="isPendingStep(index)"
|
</span>
|
||||||
>
|
</button>
|
||||||
{{ index + 1 }}. {{ step.label }}
|
</li>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -106,144 +104,52 @@
|
|||||||
|
|
||||||
<!-- Stepper Form Content -->
|
<!-- Stepper Form Content -->
|
||||||
<div>
|
<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">
|
<div class="stepper-content-box rounded-xl p-3.5 sm:p-6">
|
||||||
<ng-content />
|
<ng-content />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Button Group Footer -->
|
<!-- Button Group Footer -->
|
||||||
<footer class="modern-modal-footer mt-5 px-6">
|
<footer class="modern-modal-footer mt-5 px-2 sm:px-6">
|
||||||
<!-- Mobile View (< 640px) -->
|
<div class="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center sm:justify-end sm:gap-3">
|
||||||
<div class="flex flex-col gap-2.5 sm:hidden">
|
<!-- Secondary Actions: Cancel/Back & Save Draft (Left group on Desktop, Bottom row on Mobile) -->
|
||||||
<!-- Back or Cancel button -->
|
<div class="grid grid-cols-2 gap-2.5 w-full sm:flex sm:w-auto sm:items-center sm:gap-3">
|
||||||
<div class="w-full">
|
<!-- Cancel on Step 1, or Back on Step > 1 -->
|
||||||
@if (isFirstStep()) {
|
<div class="min-w-0 sm:w-auto">
|
||||||
<app-button
|
@if (isFirstStep()) {
|
||||||
action="cancel"
|
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true"
|
||||||
label="Cancel"
|
[disabled]="navigationDisabled()" [fullWidth]="true"
|
||||||
[showIcon]="true"
|
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
|
||||||
[disabled]="navigationDisabled()"
|
(buttonClicked)="requestCancel()"></app-button>
|
||||||
[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>
|
|
||||||
|
|
||||||
<!-- Save Draft & Next/Finish side by side -->
|
|
||||||
<div class="grid grid-cols-2 gap-2.5 w-full max-[420px]:grid-cols-1">
|
|
||||||
<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 class="min-w-0">
|
|
||||||
@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 {
|
} @else {
|
||||||
<app-button
|
|
||||||
action="approve"
|
<app-button action="previous" [variant]="'custom'" label="Back" [showIcon]="true"
|
||||||
label="Finish & Provision"
|
[disabled]="navigationDisabled()" [fullWidth]="true"
|
||||||
loadingLabel="Preparing..."
|
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
|
||||||
[showIcon]="true"
|
(buttonClicked)="requestBack()"></app-button>
|
||||||
[loading]="finishing()"
|
|
||||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
|
||||||
[fullWidth]="true"
|
|
||||||
className="w-full"
|
|
||||||
(buttonClicked)="requestFinish()"
|
|
||||||
></app-button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop View (>= 640px) -->
|
<!-- Save Draft -->
|
||||||
<div class="hidden w-full sm:flex sm:items-center sm:justify-between sm:gap-3">
|
<div class="min-w-0 sm:w-auto">
|
||||||
<!-- Left: Cancel or Back -->
|
<app-button action="save" [variant]="'custom'" label="Save Draft" loadingLabel="Saving..." [showIcon]="true"
|
||||||
<div>
|
[loading]="savingDraft()" [disabled]="navigationDisabled() || savingDraft() || finishing()"
|
||||||
@if (isFirstStep()) {
|
[fullWidth]="true"
|
||||||
<app-button
|
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-36"
|
||||||
action="cancel"
|
(buttonClicked)="requestSaveDraft()"></app-button>
|
||||||
label="Cancel"
|
</div>
|
||||||
[showIcon]="true"
|
|
||||||
[disabled]="navigationDisabled()"
|
|
||||||
(buttonClicked)="requestCancel()"
|
|
||||||
></app-button>
|
|
||||||
} @else {
|
|
||||||
<app-button
|
|
||||||
action="previous"
|
|
||||||
label="Back"
|
|
||||||
[showIcon]="true"
|
|
||||||
[disabled]="navigationDisabled()"
|
|
||||||
className="onboarding-nav-btn"
|
|
||||||
(buttonClicked)="requestBack()"
|
|
||||||
></app-button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Save Draft & Next/Finish -->
|
<!-- Primary Action: Next or Finish & Provision (Top row on Mobile, Far Right on Desktop) -->
|
||||||
<div class="flex items-center gap-2.5 sm:gap-3">
|
<div class="w-full sm:w-auto">
|
||||||
<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>
|
|
||||||
|
|
||||||
@if (!isLastStep()) {
|
@if (!isLastStep()) {
|
||||||
<app-button
|
<app-button action="next" label="Next" [showIcon]="true" [iconPosition]="'right'"
|
||||||
action="next"
|
[disabled]="navigationDisabled() || savingDraft() || finishing()" [fullWidth]="true"
|
||||||
label="Next"
|
className="onboarding-nav-btn justify-center sm:w-36" (buttonClicked)="requestNext()"></app-button>
|
||||||
[showIcon]="true"
|
|
||||||
[iconPosition]="'right'"
|
|
||||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
|
||||||
className="onboarding-nav-btn"
|
|
||||||
(buttonClicked)="requestNext()"
|
|
||||||
></app-button>
|
|
||||||
} @else {
|
} @else {
|
||||||
<app-button
|
<app-button action="approve" label="Finish & Provision" loadingLabel="Preparing..." [showIcon]="true"
|
||||||
action="approve"
|
[loading]="finishing()" [disabled]="navigationDisabled() || savingDraft() || finishing()" [fullWidth]="true"
|
||||||
label="Finish & Provision"
|
className="onboarding-nav-btn justify-center sm:w-44" (buttonClicked)="requestFinish()"></app-button>
|
||||||
loadingLabel="Preparing..."
|
|
||||||
[showIcon]="true"
|
|
||||||
[loading]="finishing()"
|
|
||||||
[disabled]="navigationDisabled() || savingDraft() || finishing()"
|
|
||||||
className="sm:w-44"
|
|
||||||
(buttonClicked)="requestFinish()"
|
|
||||||
></app-button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+48
-16
@@ -9,8 +9,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepper-nav-scroll-wrapper {
|
.stepper-bg-track {
|
||||||
-webkit-overflow-scrolling: touch;
|
background-color: #e2e8f0;
|
||||||
|
|
||||||
|
:host-context(.dark) &,
|
||||||
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
background-color: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepper-progress-line {
|
.stepper-progress-line {
|
||||||
@@ -19,6 +24,29 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stepper-mobile-banner {
|
||||||
|
background-color: rgba(124, 61, 235, 0.08);
|
||||||
|
color: var(--primary, #7c3deb);
|
||||||
|
|
||||||
|
:host-context(.dark) &,
|
||||||
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
background-color: rgba(168, 85, 247, 0.15) !important;
|
||||||
|
color: #c084fc !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-content-box {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px dashed #e2e8f0;
|
||||||
|
|
||||||
|
:host-context(.dark) &,
|
||||||
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
background-color: var(--bg-body, #1a1c1e) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.step-node-btn {
|
.step-node-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -48,14 +76,15 @@
|
|||||||
|
|
||||||
// Pending Step
|
// Pending Step
|
||||||
&.pending {
|
&.pending {
|
||||||
background-color: #f0f2f5;
|
background-color: #f1f5f9;
|
||||||
border: 2px solid #cbd5e1;
|
border: 2px solid #cbd5e1;
|
||||||
color: #94a3b8;
|
color: #64748b;
|
||||||
|
|
||||||
:host-context(.dark) & {
|
:host-context(.dark) &,
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
background-color: var(--bg-body, #181920) !important;
|
||||||
color: #64748b;
|
border: 2px solid rgba(255, 255, 255, 0.25) !important;
|
||||||
|
color: #94a3b8 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +94,7 @@
|
|||||||
background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%);
|
background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: 0 4px 12px rgba(124, 61, 235, 0.25);
|
box-shadow: 0 4px 12px rgba(124, 61, 235, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active Step with Glowing Aura in Theme Primary
|
// Active Step with Glowing Aura in Theme Primary
|
||||||
@@ -101,11 +130,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.pending {
|
&.pending {
|
||||||
color: #94a3b8;
|
color: #64748b;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
:host-context(.dark) & {
|
:host-context(.dark) &,
|
||||||
color: #64748b;
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
color: #94a3b8 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +143,9 @@
|
|||||||
color: #334155;
|
color: #334155;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
:host-context(.dark) & {
|
:host-context(.dark) &,
|
||||||
color: #cbd5e1;
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
color: #cbd5e1 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,8 +153,9 @@
|
|||||||
color: var(--primary, #7c3deb);
|
color: var(--primary, #7c3deb);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
||||||
:host-context(.dark) & {
|
:host-context(.dark) &,
|
||||||
color: #ffffff;
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
color: #c084fc !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-4
@@ -87,10 +87,7 @@ export class OnboardingStepper {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return index >= 0 && index < this.steps().length;
|
||||||
index < this.currentStepIndex() ||
|
|
||||||
this.isCompletedStep(index)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectStep(index: number): void {
|
selectStep(index: number): void {
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@ export class OrganizationOnboardingStateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canOpenStep(index: number): boolean {
|
canOpenStep(index: number): boolean {
|
||||||
return index <= this.currentStepIndexState() || this.completedStepIndexesState().includes(index);
|
return index >= 0 && index <= 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
isStepCompleted(index: number): boolean {
|
isStepCompleted(index: number): boolean {
|
||||||
|
|||||||
+93
-20
@@ -24,25 +24,82 @@ import {
|
|||||||
import { ORGANIZATION_TYPE_OPTIONS } from '../../constants/organization-onboarding.constants';
|
import { ORGANIZATION_TYPE_OPTIONS } from '../../constants/organization-onboarding.constants';
|
||||||
|
|
||||||
const DATE_FORMAT_OPTIONS: readonly FormSelectOption<DateFormatValue>[] = [
|
const DATE_FORMAT_OPTIONS: readonly FormSelectOption<DateFormatValue>[] = [
|
||||||
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
|
// --- Standard Slashes ---
|
||||||
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
|
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY (e.g., 29/07/2026 - UK, India, Europe)' },
|
||||||
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY (e.g., 07/29/2026 - US)' },
|
||||||
|
{ value: 'YYYY/MM/DD', label: 'YYYY/MM/DD (e.g., 2026/07/29 - East Asia)' },
|
||||||
|
|
||||||
|
// --- ISO / Standard Hyphens ---
|
||||||
|
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD (e.g., 2026-07-29 - ISO 8601 Standard)' },
|
||||||
|
{ value: 'DD-MM-YYYY', label: 'DD-MM-YYYY (e.g., 29-07-2026)' },
|
||||||
|
{ value: 'MM-DD-YYYY', label: 'MM-DD-YYYY (e.g., 07-29-2026)' },
|
||||||
|
|
||||||
|
// --- European Dots ---
|
||||||
|
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY (e.g., 29.07.2026 - Germany, Russia, Latin Europe)' },
|
||||||
|
{ value: 'YYYY.MM.DD', label: 'YYYY.MM.DD (e.g., 2026.07.29 - Hungary, China)' },
|
||||||
|
|
||||||
|
// --- Text-Based Formats ---
|
||||||
|
{ value: 'DD MMM YYYY', label: 'DD MMM YYYY (e.g., 29 Jul 2026)' },
|
||||||
|
{ value: 'MMM DD, YYYY', label: 'MMM DD, YYYY (e.g., Jul 29, 2026)' },
|
||||||
|
{ value: 'DD MMMM YYYY', label: 'DD MMMM YYYY (e.g., 29 July 2026)' },
|
||||||
|
{ value: 'MMMM DD, YYYY', label: 'MMMM DD, YYYY (e.g., July 29, 2026)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TIME_FORMAT_OPTIONS: readonly FormSelectOption<TimeFormatValue>[] = [
|
export const TIME_FORMAT_OPTIONS: readonly FormSelectOption<TimeFormatValue>[] = [
|
||||||
{ value: 0, label: '12 hour' },
|
{
|
||||||
{ value: 1, label: '24 hour' },
|
value: 'TwelveHour',
|
||||||
|
label: '12-Hour (e.g., 01:30 PM)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'TwentyFourHour',
|
||||||
|
label: '24-Hour (e.g., 13:30)',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const NUMBER_FORMAT_OPTIONS: readonly FormSelectOption<NumberFormatValue>[] = [
|
const NUMBER_FORMAT_OPTIONS: readonly FormSelectOption<NumberFormatValue>[] = [
|
||||||
{ value: 'OneTwoThreeCommaFourFiveSixPointSevenEight', label: '123,456.78' },
|
{
|
||||||
{ value: 'OneTwoThreePointFourFiveSixCommaSevenEight', label: '123.456,78' },
|
value: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
||||||
|
label: '1,234,567.89 (Western Standard)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'OneTwoThreePointFourFiveSixCommaSevenEight',
|
||||||
|
label: '1.234.567,89 (European Standard)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'OneTwoThreeSpaceFourFiveSixCommaSevenEight',
|
||||||
|
label: '1 234 567,89 (International - Space with Comma)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'OneTwoThreeSpaceFourFiveSixPointSevenEight',
|
||||||
|
label: '1 234 567.89 (International - Space with Period)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'TwelveLakhThirtyFourThousandFiveHundredSixtySevenPointEightNine',
|
||||||
|
label: '12,34,567.89 (South Asian / Indian Standard)'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const FISCAL_YEAR_OPTIONS: readonly FormSelectOption<FiscalYearConventionValue>[] = [
|
const FISCAL_YEAR_OPTIONS: readonly FormSelectOption<FiscalYearConventionValue>[] = [
|
||||||
{ value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' },
|
{
|
||||||
{ value: 'AprilToMarch', label: 'April - March' },
|
value: 'JanuaryToDecember',
|
||||||
{ value: 'JulyToJune', label: 'July - June' },
|
label: 'January 1 – December 31 (Calendar Year)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'AprilToMarch',
|
||||||
|
label: 'April 1 – March 31 (e.g., India, UK, Canada, Japan)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'JulyToJune',
|
||||||
|
label: 'July 1 – June 30 (e.g., Australia, New Zealand, Egypt)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'OctoberToSeptember',
|
||||||
|
label: 'October 1 – September 30 (e.g., US Federal, Thailand)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'March21ToMarch20',
|
||||||
|
label: 'March 21 – March 20 (e.g., Iran, Afghanistan)',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const LICENSE_TYPE_OPTIONS: readonly FormSelectOption<OrganizationLicenseType>[] = [
|
const LICENSE_TYPE_OPTIONS: readonly FormSelectOption<OrganizationLicenseType>[] = [
|
||||||
@@ -58,7 +115,7 @@ const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
|
|||||||
defaultLanguage: { id: 'en-in', label: 'English', secondaryLabel: 'en-IN' },
|
defaultLanguage: { id: 'en-in', label: 'English', secondaryLabel: 'en-IN' },
|
||||||
dateFormat: 'DD/MM/YYYY',
|
dateFormat: 'DD/MM/YYYY',
|
||||||
timeFormat: 0,
|
timeFormat: 0,
|
||||||
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
numberFormat: 'TwelveLakhThirtyFourThousandFiveHundredSixtySevenPointEightNine',
|
||||||
fiscalYearConvention: 'AprilToMarch',
|
fiscalYearConvention: 'AprilToMarch',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,7 +126,7 @@ const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
|
|||||||
dateFormat: 'MM/DD/YYYY',
|
dateFormat: 'MM/DD/YYYY',
|
||||||
timeFormat: 0,
|
timeFormat: 0,
|
||||||
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
|
||||||
fiscalYearConvention: 'CalendarYear',
|
fiscalYearConvention: 'JanuaryToDecember', // or 'OctoberToSeptember' if defaulting to US Federal fiscal year
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -127,13 +184,29 @@ export class OrganizationOnboardingService {
|
|||||||
|
|
||||||
getSubscriptionPlans(term = '', limit = 50): Observable<readonly SubscriptionPlanDto[]> {
|
getSubscriptionPlans(term = '', limit = 50): Observable<readonly SubscriptionPlanDto[]> {
|
||||||
const params = new HttpParams().set('term', term).set('limit', limit);
|
const params = new HttpParams().set('term', term).set('limit', limit);
|
||||||
return this.http.get<readonly SubscriptionPlanDto[]>(ORGANIZATION_ONBOARDING_ENDPOINTS.subscriptionPlans, { params }).pipe(
|
return this.http
|
||||||
catchError(() => of([
|
.get<SubscriptionPlanDto[] | { value?: SubscriptionPlanDto[]; data?: SubscriptionPlanDto[] }>(
|
||||||
{ id: 'starter', name: 'Starter', code: 'STARTER', maxCompanies: 1, maxUsers: 10, maxStorageGb: 10, defaultTrialDays: 14 },
|
ORGANIZATION_ONBOARDING_ENDPOINTS.subscriptionPlans,
|
||||||
{ id: 'growth', name: 'Growth', code: 'GROWTH', maxCompanies: 5, maxUsers: 100, maxStorageGb: 100, defaultTrialDays: 30 },
|
{ params }
|
||||||
{ id: 'enterprise', name: 'Enterprise', code: 'ENTERPRISE', maxCompanies: 25, maxUsers: 1000, maxStorageGb: 500, defaultTrialDays: 30 },
|
)
|
||||||
]))
|
.pipe(
|
||||||
);
|
map((res) => {
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (res && Array.isArray(res.value)) {
|
||||||
|
return res.value;
|
||||||
|
}
|
||||||
|
if (res && Array.isArray(res.data)) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Error fetching subscription plans:', error);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchSubscriptionPlans(term: string | null, limit = 10): Observable<readonly OrganizationPlanLookupValue[]> {
|
searchSubscriptionPlans(term: string | null, limit = 10): Observable<readonly OrganizationPlanLookupValue[]> {
|
||||||
|
|||||||
+39
-11
@@ -34,16 +34,30 @@ export type TimeFormatValue = 0 | 1 | 'TwelveHour' | 'TwentyFourHour';
|
|||||||
export type DateFormatValue =
|
export type DateFormatValue =
|
||||||
| 'DD/MM/YYYY'
|
| 'DD/MM/YYYY'
|
||||||
| 'MM/DD/YYYY'
|
| 'MM/DD/YYYY'
|
||||||
| 'YYYY-MM-DD';
|
| 'YYYY/MM/DD'
|
||||||
|
| 'YYYY-MM-DD'
|
||||||
|
| 'DD-MM-YYYY'
|
||||||
|
| 'MM-DD-YYYY'
|
||||||
|
| 'DD.MM.YYYY'
|
||||||
|
| 'YYYY.MM.DD'
|
||||||
|
| 'DD MMM YYYY'
|
||||||
|
| 'MMM DD, YYYY'
|
||||||
|
| 'DD MMMM YYYY'
|
||||||
|
| 'MMMM DD, YYYY';
|
||||||
|
|
||||||
export type NumberFormatValue =
|
export type NumberFormatValue =
|
||||||
| 'OneTwoThreeCommaFourFiveSixPointSevenEight'
|
| 'OneTwoThreeCommaFourFiveSixPointSevenEight'
|
||||||
| 'OneTwoThreePointFourFiveSixCommaSevenEight';
|
| 'OneTwoThreePointFourFiveSixCommaSevenEight'
|
||||||
|
| 'OneTwoThreeSpaceFourFiveSixCommaSevenEight'
|
||||||
|
| 'OneTwoThreeSpaceFourFiveSixPointSevenEight'
|
||||||
|
| 'TwelveLakhThirtyFourThousandFiveHundredSixtySevenPointEightNine';
|
||||||
|
|
||||||
export type FiscalYearConventionValue =
|
export type FiscalYearConventionValue =
|
||||||
| 'CalendarYear'
|
| 'JanuaryToDecember'
|
||||||
| 'AprilToMarch'
|
| 'AprilToMarch'
|
||||||
| 'JulyToJune';
|
| 'JulyToJune'
|
||||||
|
| 'OctoberToSeptember'
|
||||||
|
| 'March21ToMarch20';
|
||||||
|
|
||||||
export function mapFiscalYearToMmDd(fiscalYear: string | null | undefined): string {
|
export function mapFiscalYearToMmDd(fiscalYear: string | null | undefined): string {
|
||||||
if (!fiscalYear) return '01-01';
|
if (!fiscalYear) return '01-01';
|
||||||
@@ -60,11 +74,22 @@ export function mapFiscalYearToMmDd(fiscalYear: string | null | undefined): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapMmDdToFiscalYear(mmDd: string | null | undefined): FiscalYearConventionValue {
|
export function mapMmDdToFiscalYear(mmDd: string | null | undefined): FiscalYearConventionValue {
|
||||||
if (!mmDd) return 'CalendarYear';
|
if (!mmDd) return 'JanuaryToDecember';
|
||||||
if (mmDd === '04-01') return 'AprilToMarch';
|
|
||||||
if (mmDd === '07-01') return 'JulyToJune';
|
switch (mmDd) {
|
||||||
if (mmDd === '01-01') return 'CalendarYear';
|
case '01-01':
|
||||||
return (mmDd as FiscalYearConventionValue) ?? 'CalendarYear';
|
return 'JanuaryToDecember';
|
||||||
|
case '04-01':
|
||||||
|
return 'AprilToMarch';
|
||||||
|
case '07-01':
|
||||||
|
return 'JulyToJune';
|
||||||
|
case '10-01':
|
||||||
|
return 'OctoberToSeptember';
|
||||||
|
case '03-21':
|
||||||
|
return 'March21ToMarch20';
|
||||||
|
default:
|
||||||
|
return 'JanuaryToDecember';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapNumberFormatToApi(numberFormat: string | null | undefined): string {
|
export function mapNumberFormatToApi(numberFormat: string | null | undefined): string {
|
||||||
@@ -112,8 +137,11 @@ export function mapApiToLicenseType(licenseType: unknown): OrganizationLicenseTy
|
|||||||
|
|
||||||
export interface SubscriptionPlanDto {
|
export interface SubscriptionPlanDto {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
code: string;
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price?: number;
|
||||||
|
currencyId?: string | null;
|
||||||
|
billingCycle?: number;
|
||||||
maxCompanies?: number;
|
maxCompanies?: number;
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
maxStorageGb?: number;
|
maxStorageGb?: number;
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export class OrganizationOnboarding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onStepSelected(index: number): void {
|
onStepSelected(index: number): void {
|
||||||
if (!this.stateService.canOpenStep(index)) {
|
if (!this.stateService.canOpenStep(index) || index === this.currentStepIndex()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,15 +135,10 @@ export class OrganizationOnboarding {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStep = this.getActiveStep();
|
if (!this.validateStepsUpTo(currentIndex)) {
|
||||||
if (!activeStep?.validate()) {
|
|
||||||
this.stateService.unmarkStepCompleted(currentIndex);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.storeValidatedActiveStep(activeStep);
|
|
||||||
this.stateService.markStepCompleted(currentIndex);
|
|
||||||
|
|
||||||
this.persistStepToServer(currentIndex, true)
|
this.persistStepToServer(currentIndex, true)
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
@@ -165,16 +160,13 @@ export class OrganizationOnboarding {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStep = this.getActiveStep();
|
const currentIndex = this.currentStepIndex();
|
||||||
if (!activeStep?.validate()) {
|
if (!this.validateStepsUpTo(currentIndex)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.storeValidatedActiveStep(activeStep);
|
|
||||||
|
|
||||||
this.savingDraft.set(true);
|
this.savingDraft.set(true);
|
||||||
|
|
||||||
const currentIndex = this.currentStepIndex();
|
|
||||||
this.persistStepToServer(currentIndex, false)
|
this.persistStepToServer(currentIndex, false)
|
||||||
.pipe(
|
.pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
@@ -199,16 +191,10 @@ export class OrganizationOnboarding {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStep = this.adminStep();
|
if (!this.validateStepsUpTo(this.steps.length - 1)) {
|
||||||
if (!activeStep?.validate()) {
|
|
||||||
this.stateService.unmarkStepCompleted(3);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminValue = activeStep.getValue();
|
|
||||||
this.stateService.updateAdmin(adminValue);
|
|
||||||
this.stateService.markStepCompleted(3);
|
|
||||||
|
|
||||||
const data = this.stateService.onboardingData();
|
const data = this.stateService.onboardingData();
|
||||||
if (!data.basics || !data.localization || !data.planLimits || !data.admin) {
|
if (!data.basics || !data.localization || !data.planLimits || !data.admin) {
|
||||||
this.toastr.error('Complete and validate every onboarding step before provisioning.');
|
this.toastr.error('Complete and validate every onboarding step before provisioning.');
|
||||||
@@ -337,8 +323,42 @@ export class OrganizationOnboarding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
|
private validateStepsUpTo(targetIndex: number): boolean {
|
||||||
switch (this.currentStepIndex()) {
|
this.captureActiveStepDraft();
|
||||||
|
|
||||||
|
for (let i = 0; i <= targetIndex; i++) {
|
||||||
|
const stepComp = this.getStepComponent(i);
|
||||||
|
if (!stepComp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stepComp.validate()) {
|
||||||
|
this.stateService.unmarkStepCompleted(i);
|
||||||
|
|
||||||
|
if (i !== this.currentStepIndex()) {
|
||||||
|
this.stateService.setCurrentStepIndex(i);
|
||||||
|
this.hydrateActiveStep();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.getStepComponent(i)?.validate();
|
||||||
|
});
|
||||||
|
const stepLabel = this.steps[i]?.label ?? `Step ${i + 1}`;
|
||||||
|
this.toastr.warning(
|
||||||
|
`Please complete all required fields in Step ${i + 1} (${stepLabel}) before proceeding.`,
|
||||||
|
'Validation Required'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
this.storeValidatedStep(i, stepComp);
|
||||||
|
this.stateService.markStepCompleted(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStepComponent(index: number): OnboardingStepForm<unknown> | undefined {
|
||||||
|
switch (index) {
|
||||||
case 0:
|
case 0:
|
||||||
return this.basicsStep();
|
return this.basicsStep();
|
||||||
case 1:
|
case 1:
|
||||||
@@ -352,8 +372,12 @@ export class OrganizationOnboarding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private storeValidatedActiveStep(step: OnboardingStepForm<unknown>): void {
|
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
|
||||||
switch (this.currentStepIndex()) {
|
return this.getStepComponent(this.currentStepIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
private storeValidatedStep(index: number, step: OnboardingStepForm<unknown>): void {
|
||||||
|
switch (index) {
|
||||||
case 0:
|
case 0:
|
||||||
this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue());
|
this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue());
|
||||||
break;
|
break;
|
||||||
@@ -369,6 +393,10 @@ export class OrganizationOnboarding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private storeValidatedActiveStep(step: OnboardingStepForm<unknown>): void {
|
||||||
|
this.storeValidatedStep(this.currentStepIndex(), step);
|
||||||
|
}
|
||||||
|
|
||||||
private captureActiveStepDraft(): void {
|
private captureActiveStepDraft(): void {
|
||||||
const currentIndex = this.currentStepIndex();
|
const currentIndex = this.currentStepIndex();
|
||||||
const validatedValue = this.getValidatedStepValue(currentIndex);
|
const validatedValue = this.getValidatedStepValue(currentIndex);
|
||||||
|
|||||||
+7
-21
@@ -105,8 +105,8 @@
|
|||||||
inputId="organization-go-live-date"
|
inputId="organization-go-live-date"
|
||||||
variant="floating"
|
variant="floating"
|
||||||
label="Go-Live Date"
|
label="Go-Live Date"
|
||||||
|
mode="single"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[variant]="'floating'"
|
|
||||||
[submitAttempted]="submitAttempted()"
|
[submitAttempted]="submitAttempted()"
|
||||||
[validationMessages]="{ required: 'Go-Live Date is required.' }"
|
[validationMessages]="{ required: 'Go-Live Date is required.' }"
|
||||||
/>
|
/>
|
||||||
@@ -114,30 +114,16 @@
|
|||||||
|
|
||||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
||||||
<app-form-date-picker
|
<app-form-date-picker
|
||||||
formControlName="systemAccessStartDate"
|
formControlName="systemAccessPeriod"
|
||||||
inputId="organization-system-access-start-date"
|
inputId="organization-system-access-period"
|
||||||
variant="floating"
|
variant="floating"
|
||||||
label="System Access From Date"
|
label="System Access Period"
|
||||||
|
mode="range"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[variant]="'floating'"
|
|
||||||
[submitAttempted]="submitAttempted()"
|
|
||||||
[validationMessages]="{ required: 'System Access Start Date is required.' }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-12 md:col-span-6 xl:col-span-4">
|
|
||||||
<app-form-date-picker
|
|
||||||
formControlName="systemAccessEndDate"
|
|
||||||
inputId="organization-system-access-end-date"
|
|
||||||
variant="floating"
|
|
||||||
label="System Access To Date"
|
|
||||||
[required]="requiresAccessEndDate()"
|
|
||||||
[submitAttempted]="submitAttempted()"
|
[submitAttempted]="submitAttempted()"
|
||||||
[validationMessages]="{
|
[validationMessages]="{
|
||||||
required: 'System Access End Date is required for Trial.',
|
required: 'System Access Period is required.',
|
||||||
trialEndDateRequired: 'System Access End Date is required for Trial.',
|
trialEndDateRequired: 'System Access End Date is required for Trial.'
|
||||||
accessDateRange: 'System Access End Date must be on or after the access start date.',
|
|
||||||
trialEndDateOrder: 'Trial end date must be later than the access start date.'
|
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+38
-28
@@ -18,7 +18,7 @@ import {
|
|||||||
AutocompleteSearchFn,
|
AutocompleteSearchFn,
|
||||||
AutocompleteValueFn,
|
AutocompleteValueFn,
|
||||||
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
|
||||||
import { FormDatePicker } from '../../../../../shared/components/form/form-date-picker/form-date-picker';
|
import { DateRangeValue, FormDatePicker } from '../../../../../shared/components/form/form-date-picker/form-date-picker';
|
||||||
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
|
||||||
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
import { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
|
||||||
import {
|
import {
|
||||||
@@ -38,8 +38,7 @@ interface OrganizationPlanLimitsFormModel {
|
|||||||
readonly maximumUsers: number | null;
|
readonly maximumUsers: number | null;
|
||||||
readonly maximumStorageGb: number | null;
|
readonly maximumStorageGb: number | null;
|
||||||
readonly goLiveDate: string | null;
|
readonly goLiveDate: string | null;
|
||||||
readonly systemAccessStartDate: string | null;
|
readonly systemAccessPeriod: DateRangeValue | null;
|
||||||
readonly systemAccessEndDate: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThemeSelectValue =
|
type ThemeSelectValue =
|
||||||
@@ -59,17 +58,19 @@ const accessDateValidator: ValidatorFn = (control: AbstractControl): ValidationE
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errors: ValidationErrors = {};
|
const errors: ValidationErrors = {};
|
||||||
const { licenseType, systemAccessStartDate, systemAccessEndDate } = value;
|
const { licenseType, systemAccessPeriod } = value;
|
||||||
|
const from = systemAccessPeriod?.from ?? null;
|
||||||
|
const to = systemAccessPeriod?.to ?? null;
|
||||||
|
|
||||||
if (licenseType === 'Trial' && !systemAccessEndDate) {
|
if (licenseType === 'Trial' && (!systemAccessPeriod || !to)) {
|
||||||
errors['trialEndDateRequired'] = true;
|
errors['trialEndDateRequired'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (systemAccessStartDate && systemAccessEndDate && systemAccessStartDate > systemAccessEndDate) {
|
if (from && to && from > to) {
|
||||||
errors['accessDateRange'] = true;
|
errors['accessDateRange'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (licenseType === 'Trial' && systemAccessStartDate && systemAccessEndDate && systemAccessStartDate >= systemAccessEndDate) {
|
if (licenseType === 'Trial' && from && to && from >= to) {
|
||||||
errors['trialEndDateOrder'] = true;
|
errors['trialEndDateOrder'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +107,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
maximumUsers: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
|
maximumUsers: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
|
||||||
maximumStorageGb: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
|
maximumStorageGb: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
|
||||||
goLiveDate: this.formBuilder.control<string | null>(null, [Validators.required]),
|
goLiveDate: this.formBuilder.control<string | null>(null, [Validators.required]),
|
||||||
systemAccessStartDate: this.formBuilder.control<string | null>(null, [Validators.required]),
|
systemAccessPeriod: this.formBuilder.control<DateRangeValue | null>(null, [Validators.required]),
|
||||||
systemAccessEndDate: this.formBuilder.control<string | null>(null),
|
|
||||||
}, { validators: accessDateValidator });
|
}, { validators: accessDateValidator });
|
||||||
|
|
||||||
readonly requiresAccessEndDate = computed(() => this.form.controls.licenseType.value === 'Trial');
|
readonly requiresAccessEndDate = computed(() => this.form.controls.licenseType.value === 'Trial');
|
||||||
@@ -120,8 +120,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly displayPlan: AutocompleteDisplayFn<OrganizationPlanLookupValue> = item =>
|
readonly displayPlan: AutocompleteDisplayFn<OrganizationPlanLookupValue> = item => item.label;
|
||||||
[item.label, item.code].filter(Boolean).join(' - ');
|
|
||||||
|
|
||||||
readonly planValue: AutocompleteValueFn<OrganizationPlanLookupValue, string> = item => item.id;
|
readonly planValue: AutocompleteValueFn<OrganizationPlanLookupValue, string> = item => item.id;
|
||||||
|
|
||||||
@@ -133,12 +132,12 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe(licenseType => {
|
.subscribe(licenseType => {
|
||||||
if (licenseType === 'Trial') {
|
if (licenseType === 'Trial') {
|
||||||
this.form.controls.systemAccessEndDate.addValidators([Validators.required]);
|
this.form.controls.systemAccessPeriod.addValidators([Validators.required]);
|
||||||
} else {
|
} else {
|
||||||
this.form.controls.systemAccessEndDate.removeValidators([Validators.required]);
|
this.form.controls.systemAccessPeriod.removeValidators([Validators.required]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.form.controls.systemAccessEndDate.updateValueAndValidity({ emitEvent: false });
|
this.form.controls.systemAccessPeriod.updateValueAndValidity({ emitEvent: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
this.form.controls.subscriptionPlanId.valueChanges
|
this.form.controls.subscriptionPlanId.valueChanges
|
||||||
@@ -186,8 +185,11 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
getValue(): OrganizationPlanLimitsValue {
|
getValue(): OrganizationPlanLimitsValue {
|
||||||
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
|
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
|
||||||
const subscriptionPlan = this.planSelection();
|
const subscriptionPlan = this.planSelection();
|
||||||
|
const period = value.systemAccessPeriod;
|
||||||
|
const systemAccessStartDate = period?.from ?? null;
|
||||||
|
const systemAccessEndDate = period?.to ?? null;
|
||||||
|
|
||||||
if (!subscriptionPlan || !value.licenseType || !value.maximumCompanies || !value.maximumUsers || !value.maximumStorageGb || !value.goLiveDate || !value.systemAccessStartDate) {
|
if (!subscriptionPlan || !value.licenseType || !value.maximumCompanies || !value.maximumUsers || !value.maximumStorageGb || !value.goLiveDate || !systemAccessStartDate) {
|
||||||
throw new Error('Organization plan and limits values are incomplete.');
|
throw new Error('Organization plan and limits values are incomplete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +200,15 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
maximumUsers: value.maximumUsers,
|
maximumUsers: value.maximumUsers,
|
||||||
maximumStorageGb: value.maximumStorageGb,
|
maximumStorageGb: value.maximumStorageGb,
|
||||||
goLiveDate: value.goLiveDate,
|
goLiveDate: value.goLiveDate,
|
||||||
systemAccessStartDate: value.systemAccessStartDate,
|
systemAccessStartDate,
|
||||||
systemAccessEndDate: value.systemAccessEndDate,
|
systemAccessEndDate,
|
||||||
limitsEditable: this.limitsEditable(),
|
limitsEditable: this.limitsEditable(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getDraftValue(): Partial<OrganizationPlanLimitsValue> {
|
getDraftValue(): Partial<OrganizationPlanLimitsValue> {
|
||||||
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
|
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
|
||||||
|
const period = value.systemAccessPeriod;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscriptionPlan: this.planSelection() ?? undefined,
|
subscriptionPlan: this.planSelection() ?? undefined,
|
||||||
@@ -214,8 +217,8 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
maximumUsers: value.maximumUsers ?? undefined,
|
maximumUsers: value.maximumUsers ?? undefined,
|
||||||
maximumStorageGb: value.maximumStorageGb ?? undefined,
|
maximumStorageGb: value.maximumStorageGb ?? undefined,
|
||||||
goLiveDate: value.goLiveDate ?? undefined,
|
goLiveDate: value.goLiveDate ?? undefined,
|
||||||
systemAccessStartDate: value.systemAccessStartDate ?? undefined,
|
systemAccessStartDate: period?.from ?? undefined,
|
||||||
systemAccessEndDate: value.systemAccessEndDate,
|
systemAccessEndDate: period?.to ?? undefined,
|
||||||
limitsEditable: this.limitsEditable(),
|
limitsEditable: this.limitsEditable(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -232,6 +235,10 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
code: value['SubscriptionPlan']?.code ?? '',
|
code: value['SubscriptionPlan']?.code ?? '',
|
||||||
} : planId ? { id: planId, label: '', code: '' } : null);
|
} : planId ? { id: planId, label: '', code: '' } : null);
|
||||||
|
|
||||||
|
const from = value.systemAccessStartDate ?? value['SystemAccessFrom'] ?? null;
|
||||||
|
const to = value.systemAccessEndDate ?? value['SystemAccessTo'] ?? null;
|
||||||
|
const systemAccessPeriod: DateRangeValue | null = (from || to) ? { from, to } : null;
|
||||||
|
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
subscriptionPlanId: planId,
|
subscriptionPlanId: planId,
|
||||||
licenseType: normalizedLicenseType,
|
licenseType: normalizedLicenseType,
|
||||||
@@ -239,8 +246,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
maximumUsers: value.maximumUsers ?? value['MaxUsers'] ?? null,
|
maximumUsers: value.maximumUsers ?? value['MaxUsers'] ?? null,
|
||||||
maximumStorageGb: value.maximumStorageGb ?? value['MaxStorageGb'] ?? null,
|
maximumStorageGb: value.maximumStorageGb ?? value['MaxStorageGb'] ?? null,
|
||||||
goLiveDate: value.goLiveDate ?? value['GoLiveDate'] ?? null,
|
goLiveDate: value.goLiveDate ?? value['GoLiveDate'] ?? null,
|
||||||
systemAccessStartDate: value.systemAccessStartDate ?? value['SystemAccessFrom'] ?? null,
|
systemAccessPeriod,
|
||||||
systemAccessEndDate: value.systemAccessEndDate ?? value['SystemAccessTo'] ?? null,
|
|
||||||
}, { emitEvent: false });
|
}, { emitEvent: false });
|
||||||
|
|
||||||
this.planSelection.set(subPlan ?? value.subscriptionPlan ?? null);
|
this.planSelection.set(subPlan ?? value.subscriptionPlan ?? null);
|
||||||
@@ -265,20 +271,24 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
|
|||||||
this.planSelection.set(defaults.plan);
|
this.planSelection.set(defaults.plan);
|
||||||
this.limitsEditable.set(defaults.limitsEditable);
|
this.limitsEditable.set(defaults.limitsEditable);
|
||||||
|
|
||||||
|
const currentPeriod = this.form.controls.systemAccessPeriod.value;
|
||||||
|
let from = currentPeriod?.from ?? this.form.controls.goLiveDate.value ?? new Date().toISOString().split('T')[0];
|
||||||
|
let to = currentPeriod?.to ?? null;
|
||||||
|
|
||||||
|
if (defaults.licenseType === 'Trial' && defaults.defaultTrialDays && !to) {
|
||||||
|
const startDate = new Date(from);
|
||||||
|
startDate.setDate(startDate.getDate() + defaults.defaultTrialDays);
|
||||||
|
to = startDate.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
const patch: Record<string, unknown> = {
|
const patch: Record<string, unknown> = {
|
||||||
licenseType: defaults.licenseType,
|
licenseType: defaults.licenseType,
|
||||||
maximumCompanies: defaults.maximumCompanies,
|
maximumCompanies: defaults.maximumCompanies,
|
||||||
maximumUsers: defaults.maximumUsers,
|
maximumUsers: defaults.maximumUsers,
|
||||||
maximumStorageGb: defaults.maximumStorageGb,
|
maximumStorageGb: defaults.maximumStorageGb,
|
||||||
|
systemAccessPeriod: (from || to) ? { from, to } : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (defaults.licenseType === 'Trial' && defaults.defaultTrialDays && !this.form.controls.systemAccessEndDate.value) {
|
|
||||||
const startDateStr = this.form.controls.systemAccessStartDate.value || new Date().toISOString().split('T')[0];
|
|
||||||
const startDate = new Date(startDateStr);
|
|
||||||
startDate.setDate(startDate.getDate() + defaults.defaultTrialDays);
|
|
||||||
patch['systemAccessEndDate'] = startDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.form.patchValue(patch, { emitEvent: false });
|
this.form.patchValue(patch, { emitEvent: false });
|
||||||
this.applyLimitsEditableState();
|
this.applyLimitsEditableState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,12 +215,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box-footer modern-modal-footer flex flex-col-reverse sm:flex-row sm:justify-between sm:items-center gap-3">
|
<div class="box-footer modern-modal-footer flex flex-col sm:flex-row sm:justify-end sm:items-center gap-3">
|
||||||
<div class="w-full sm:w-auto">
|
<div class="grid grid-cols-2 gap-3 w-full sm:flex sm:w-auto sm:items-center sm:gap-3">
|
||||||
<app-button action="cancel" label="Cancel" [showIcon]="true" [fullWidth]="true" (buttonClicked)="onDiscard()" />
|
<div class="w-full sm:w-auto">
|
||||||
</div>
|
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true" [fullWidth]="true"
|
||||||
<div class="w-full sm:w-auto">
|
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
|
||||||
<app-button action="save" label="Save" [showIcon]="true" [fullWidth]="true" (buttonClicked)="onSave()" />
|
(buttonClicked)="onDiscard()"></app-button>
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-auto">
|
||||||
|
<app-button action="save" label="Save" [showIcon]="true" [fullWidth]="true"
|
||||||
|
className="sm:w-32 justify-center" (buttonClicked)="onSave()"></app-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,36 +1,75 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&.w-full {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.modern-modal-footer) button.w-full,
|
||||||
|
:host-context(.onboarding-action-footer) button.w-full {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
:host-context(.modern-modal-footer) button,
|
:host-context(.modern-modal-footer) button,
|
||||||
:host-context(.onboarding-action-footer) button {
|
:host-context(.onboarding-action-footer) button {
|
||||||
height: 36px;
|
height: 36px !important;
|
||||||
padding: 0 16px;
|
min-height: 36px !important;
|
||||||
border-radius: 10px;
|
max-height: 36px !important;
|
||||||
font-size: 13px;
|
padding: 0 16px !important;
|
||||||
font-weight: 600;
|
border-radius: 10px !important;
|
||||||
transition: .3s ease;
|
font-size: 13px !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
transition: .3s ease !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.modern-modal-footer) button.ti-btn-light,
|
:host-context(.modern-modal-footer) button.ti-btn-light,
|
||||||
:host-context(.onboarding-action-footer) button.ti-btn-light {
|
:host-context(.onboarding-action-footer) button.ti-btn-light {
|
||||||
border: 1px solid #dbe3ef;
|
border: 1.5px solid #dbe3ef !important;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
|
|
||||||
|
:host-context(.dark) &,
|
||||||
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.modern-modal-footer) button.ti-btn-light:hover:not(:disabled),
|
:host-context(.modern-modal-footer) button.ti-btn-light:hover:not(:disabled),
|
||||||
:host-context(.onboarding-action-footer) button.ti-btn-light:hover:not(:disabled) {
|
:host-context(.onboarding-action-footer) button.ti-btn-light:hover:not(:disabled) {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-color: #cbd5e1;
|
border-color: #cbd5e1 !important;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
:host-context(.dark) &,
|
||||||
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
background: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.25) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.modern-modal-footer) button.ti-btn-primary-full,
|
:host-context(.modern-modal-footer) button.ti-btn-primary-full,
|
||||||
:host-context(.modern-modal-footer) button.ti-btn-success-full {
|
:host-context(.modern-modal-footer) button.ti-btn-success-full {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: none;
|
border: 1.5px solid transparent !important;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: #7c3deb;
|
background: #7c3deb;
|
||||||
box-shadow: 0 8px 20px rgba(125, 61, 235, 0.468);
|
box-shadow: 0 8px 20px rgba(125, 61, 235, 0.468);
|
||||||
transition: .35s ease;
|
transition: .35s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.modern-modal-footer) button.ti-btn-primary-full:hover:not(:disabled),
|
:host-context(.modern-modal-footer) button.ti-btn-primary-full:hover:not(:disabled),
|
||||||
@@ -39,8 +78,35 @@
|
|||||||
box-shadow: 0 12px 28px rgba(37, 99, 235, .45);
|
box-shadow: 0 12px 28px rgba(37, 99, 235, .45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Single Solid Button Rule using CSS :has()
|
||||||
|
When an outline button (Cancel/Save Draft) is hovered, demote the default
|
||||||
|
solid button ('Next') to outline style and remove its glow/box-shadow.
|
||||||
|
========================================================================== */
|
||||||
|
:host-context(.modern-modal-footer:has(.ti-btn-outline-primary:hover:not(:disabled))) button.ti-btn-primary-full:not(:hover),
|
||||||
|
:host-context(.modern-modal-footer:has(.onboarding-save-btn:hover:not(:disabled))) button.ti-btn-primary-full:not(:hover) {
|
||||||
|
background: #ffffff !important;
|
||||||
|
color: var(--primary, #7c3deb) !important;
|
||||||
|
border: 1.5px solid var(--primary, #7c3deb) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
opacity: 0 !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) &,
|
||||||
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #c084fc !important;
|
||||||
|
border-color: #a855f7 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:host-context(.modern-modal-footer) button.ti-btn-primary-full::before,
|
:host-context(.modern-modal-footer) button.ti-btn-primary-full::before,
|
||||||
:host-context(.modern-modal-footer) button.ti-btn-success-full::before{
|
:host-context(.modern-modal-footer) button.ti-btn-outline-primary::before,
|
||||||
|
:host-context(.modern-modal-footer) button.ti-btn-success-full::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -60,14 +126,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.modern-modal-footer) button.onboarding-nav-btn {
|
:host-context(.modern-modal-footer) button.onboarding-nav-btn {
|
||||||
height: 34px;
|
height: 36px;
|
||||||
padding: 0 12px;
|
padding: 0 16px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.modern-modal-footer) button.onboarding-save-btn {
|
||||||
|
border: 1.5px solid var(--primary, #7c3deb) !important;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--primary, #7c3deb);
|
||||||
|
transition: all 0.35s ease !important;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:host-context(.dark) &,
|
||||||
|
:host-context([data-theme-mode="dark"]) & {
|
||||||
|
background: transparent;
|
||||||
|
color: #c084fc;
|
||||||
|
border-color: #a855f7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.modern-modal-footer) button.onboarding-save-btn:hover:not(:disabled) {
|
:host-context(.modern-modal-footer) button.onboarding-save-btn:hover:not(:disabled) {
|
||||||
|
background: var(--primary, #7c3deb) !important;
|
||||||
|
background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: transparent !important;
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 12px 28px rgba(37, 99, 235, .45);
|
box-shadow: 0 12px 28px rgba(124, 61, 235, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes saveButtonShine {
|
@keyframes saveButtonShine {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type IconPosition = 'left' | 'right';
|
|||||||
export type ButtonVariant =
|
export type ButtonVariant =
|
||||||
| 'primary'
|
| 'primary'
|
||||||
| 'primary-full'
|
| 'primary-full'
|
||||||
|
| 'outline-primary'
|
||||||
| 'secondary'
|
| 'secondary'
|
||||||
| 'secondary-full'
|
| 'secondary-full'
|
||||||
| 'success'
|
| 'success'
|
||||||
@@ -201,6 +202,9 @@ const BUTTON_ACTION_CONFIG: Record<ButtonAction, ButtonActionConfig> = {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './button.html',
|
templateUrl: './button.html',
|
||||||
styleUrl: './button.scss',
|
styleUrl: './button.scss',
|
||||||
|
host: {
|
||||||
|
'[class.w-full]': 'fullWidth()'
|
||||||
|
},
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class Button {
|
export class Button {
|
||||||
@@ -293,6 +297,7 @@ export class Button {
|
|||||||
const variants: Record<ButtonVariant, string> = {
|
const variants: Record<ButtonVariant, string> = {
|
||||||
primary: 'ti-btn-primary',
|
primary: 'ti-btn-primary',
|
||||||
'primary-full': 'ti-btn-primary-full',
|
'primary-full': 'ti-btn-primary-full',
|
||||||
|
'outline-primary': 'ti-btn-outline-primary',
|
||||||
|
|
||||||
secondary: 'ti-btn-secondary',
|
secondary: 'ti-btn-secondary',
|
||||||
'secondary-full': 'ti-btn-secondary-full',
|
'secondary-full': 'ti-btn-secondary-full',
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
{{ tableTitle () }}
|
{{ tableTitle () }}
|
||||||
</h5>
|
</h5>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
@if(showFilterButton()){
|
||||||
|
<div animate.enter="filter-btn-enter" animate.leave="filter-btn-leave">
|
||||||
|
<app-button action="custom" icon="ti ti-filter" label="Filter" size="sm" iconClass="!text-[1rem]"
|
||||||
|
[variant]="filterActive() ? 'primary-full' : 'outline-primary'"
|
||||||
|
className="!rounded-full shadow-sm !whitespace-nowrap" (buttonClicked)="onFilterClick($event)" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if(showAddButton()){
|
@if(showAddButton()){
|
||||||
<div>
|
<div>
|
||||||
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
|
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
|
||||||
@@ -30,6 +37,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (toolbarTemplate(); as toolbar) {
|
||||||
|
<div class="table-filter-row" [class.is-open]="filterActive()">
|
||||||
|
<div class="table-filter-row-inner">
|
||||||
|
<div class="table-filter-row-content" #filterRowContent>
|
||||||
|
<ng-container [ngTemplateOutlet]="toolbar.templateRef" />
|
||||||
|
@if (filterActionsTemplate(); as filterActions) {
|
||||||
|
<div class="table-filter-row-actions" [class.is-pinned]="filterRowOverflowing()">
|
||||||
|
<ng-container [ngTemplateOutlet]="filterActions.templateRef" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="ti-btn ti-btn-icon ti-btn-sm ti-btn-light !rounded-full !m-0 shrink-0 filter-close-btn"
|
||||||
|
aria-label="Close filter" (click)="onFilterClick($event)">
|
||||||
|
<i class="ti ti-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="box-body !p-0 modern-table-body">
|
<div class="box-body !p-0 modern-table-body">
|
||||||
<div class="table-responsive overflow-x-auto overflow-y-visible modern-table-wrapper">
|
<div class="table-responsive overflow-x-auto overflow-y-visible modern-table-wrapper">
|
||||||
<table [class]="tableClass()">
|
<table [class]="tableClass()">
|
||||||
|
|||||||
@@ -254,3 +254,157 @@
|
|||||||
.data-table-modern .custom-floating-input[type='number'] {
|
.data-table-modern .custom-floating-input[type='number'] {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Filter toggle button (in the toolbar) */
|
||||||
|
.filter-btn-enter {
|
||||||
|
animation: filterButtonIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn-leave {
|
||||||
|
animation: filterButtonOut 0.15s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes filterButtonIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes filterButtonOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width filter row, joined to the table card */
|
||||||
|
.table-filter-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 3%, transparent);
|
||||||
|
transition: grid-template-rows 0.38s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
border-bottom-width 0.38s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row.is-open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
border-bottom-color: var(--color-defaultborder);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .table-filter-row {
|
||||||
|
border-color: color-mix(in srgb, #fff 10%, transparent);
|
||||||
|
background-color: color-mix(in srgb, #fff 3%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-inner {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.28s ease 0.06s, padding 0.38s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row:not(.is-open) .table-filter-row-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row.is-open .table-filter-row-inner {
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.32s ease 0.08s, padding 0.38s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: -0.5rem;
|
||||||
|
padding-top: 10px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--color-primary) 40%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-content>* {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-content::-webkit-scrollbar {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-content::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 45%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-content:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row-actions.is-pinned {
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
background: linear-gradient(to right, transparent, color-mix(in srgb, var(--color-primary) 3%, var(--color-white)) 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .table-filter-row-actions.is-pinned {
|
||||||
|
background: linear-gradient(to right, transparent, color-mix(in srgb, #fff 3%, var(--color-bodybg)) 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-row .filter-close-btn {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-close-btn {
|
||||||
|
transition: transform 0.15s ease, background-color 0.15s ease;
|
||||||
|
border-color: #7c3deb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-close-btn:hover {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-close-btn:active {
|
||||||
|
transform: rotate(90deg) scale(0.9);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NgTemplateOutlet } from '@angular/common';
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { Component, DestroyRef, HostListener, computed, contentChildren, effect, inject, input, output, signal } from '@angular/core';
|
import { Component, DestroyRef, ElementRef, HostListener, computed, contentChildren, effect, inject, input, output, signal, viewChild, contentChild } from '@angular/core';
|
||||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
@@ -10,9 +10,9 @@ import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angul
|
|||||||
import { DataTableAction, DataTableActionEvent, DataTableCellContext, DataTableColumn, DataTablePageEvent, DataTableRecord, DataTableSortEvent } from './data-table.types';
|
import { DataTableAction, DataTableActionEvent, DataTableCellContext, DataTableColumn, DataTablePageEvent, DataTableRecord, DataTableSortEvent } from './data-table.types';
|
||||||
|
|
||||||
import { Button } from '../button/button';
|
import { Button } from '../button/button';
|
||||||
import { contentChild } from '@angular/core';
|
|
||||||
|
|
||||||
import { DataTableToolbarDirective } from '../../directives/data-table-toolbar/data-table-toolbar.directive';
|
import { DataTableToolbarDirective } from '../../directives/data-table-toolbar/data-table-toolbar.directive';
|
||||||
|
import { DataTableFilterActionsDirective } from '../../directives/data-table-filter-actions/data-table-filter-actions.directive';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-data-table',
|
selector: 'app-data-table',
|
||||||
@@ -34,6 +34,34 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
|
|||||||
readonly cellTemplates = contentChildren(DataTableCellDirective);
|
readonly cellTemplates = contentChildren(DataTableCellDirective);
|
||||||
readonly toolbarTemplate = contentChild(DataTableToolbarDirective);
|
readonly toolbarTemplate = contentChild(DataTableToolbarDirective);
|
||||||
|
|
||||||
|
readonly filterActionsTemplate = contentChild(DataTableFilterActionsDirective);
|
||||||
|
|
||||||
|
readonly filterRowContent = viewChild<ElementRef<HTMLElement>>('filterRowContent');
|
||||||
|
readonly filterRowOverflowing = signal(false);
|
||||||
|
private filterRowResizeObserver?: ResizeObserver;
|
||||||
|
|
||||||
|
private readonly filterRowContentEffect = effect(onCleanup => {
|
||||||
|
const element = this.filterRowContent()?.nativeElement;
|
||||||
|
|
||||||
|
this.filterRowResizeObserver?.disconnect();
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
this.filterRowOverflowing.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkOverflow = () => {
|
||||||
|
this.filterRowOverflowing.set(element.scrollWidth > element.clientWidth + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkOverflow();
|
||||||
|
|
||||||
|
this.filterRowResizeObserver = new ResizeObserver(checkOverflow);
|
||||||
|
this.filterRowResizeObserver.observe(element);
|
||||||
|
|
||||||
|
onCleanup(() => this.filterRowResizeObserver?.disconnect());
|
||||||
|
});
|
||||||
|
|
||||||
private readonly defaultRowClasses = [
|
private readonly defaultRowClasses = [
|
||||||
'table-primary',
|
'table-primary',
|
||||||
'table-secondary',
|
'table-secondary',
|
||||||
@@ -110,6 +138,8 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
|
|||||||
searchDebounceTime = input(300);
|
searchDebounceTime = input(300);
|
||||||
emptyMessage = input('No records found');
|
emptyMessage = input('No records found');
|
||||||
emptyDescription = input('There is currently no data to display.');
|
emptyDescription = input('There is currently no data to display.');
|
||||||
|
showFilterButton = input<boolean>(false);
|
||||||
|
filterActive = input<boolean>(false);
|
||||||
/*---------------------------*/
|
/*---------------------------*/
|
||||||
|
|
||||||
/* --------- Permission inputs ---- */
|
/* --------- Permission inputs ---- */
|
||||||
@@ -141,6 +171,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
|
|||||||
actionHeaderClass = input<string>('!text-center');
|
actionHeaderClass = input<string>('!text-center');
|
||||||
actionCellClass = input<string>('!text-center');
|
actionCellClass = input<string>('!text-center');
|
||||||
addClicked = output<void>();
|
addClicked = output<void>();
|
||||||
|
filterClicked = output<void>();
|
||||||
|
|
||||||
totalVisibleColumns = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0));
|
totalVisibleColumns = computed(() => this.columns().length + (this.actions().length > 0 ? 1 : 0));
|
||||||
|
|
||||||
@@ -563,6 +594,16 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
|
|||||||
return this.composeClass(baseClass, '');
|
return this.composeClass(baseClass, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFilterClick(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
this.closeActionMenu();
|
||||||
|
this.filterClicked.emit();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { DataTableCellDirective } from '../../directives/data-table-cell.directive';
|
export { DataTableCellDirective } from '../../directives/data-table-cell.directive';
|
||||||
|
export { DataTableToolbarDirective } from '../../directives/data-table-toolbar/data-table-toolbar.directive';
|
||||||
|
export { DataTableFilterActionsDirective } from '../../directives/data-table-filter-actions/data-table-filter-actions.directive';
|
||||||
|
|||||||
@@ -1,41 +1,39 @@
|
|||||||
<app-form-field
|
<app-form-field [label]="label()" [inputId]="inputId()" [control]="control()" [required]="required()"
|
||||||
[label]="label()"
|
[disabled]="isDisabled()" [description]="description()" [hint]="hint()" [help]="help()" [floating]="isFloating()"
|
||||||
[inputId]="inputId()"
|
[forceFloat]="shouldFloat()" [hideValidation]="hideValidation()"
|
||||||
[control]="control()"
|
[showValidationWhenDirty]="showValidationWhenDirty()" [submitAttempted]="submitAttempted()"
|
||||||
[required]="required()"
|
|
||||||
[disabled]="isDisabled()"
|
|
||||||
[description]="description()"
|
|
||||||
[hint]="hint()"
|
|
||||||
[help]="help()"
|
|
||||||
[floating]="isFloating()"
|
|
||||||
[forceFloat]="shouldFloat()"
|
|
||||||
[hideValidation]="hideValidation()"
|
|
||||||
[showValidationWhenDirty]="showValidationWhenDirty()"
|
|
||||||
[submitAttempted]="submitAttempted()"
|
|
||||||
[validationMessages]="validationMessages()"
|
[validationMessages]="validationMessages()"
|
||||||
[wrapperClass]="(isFloating() ? 'date-picker-floating ' : '') + wrapperClass()"
|
[wrapperClass]="(isFloating() ? 'date-picker-floating ' : '') + wrapperClass()" [labelClass]="labelClass()"
|
||||||
[labelClass]="labelClass()"
|
[contentClass]="fieldContentClass()">
|
||||||
[contentClass]="fieldContentClass()"
|
<div #wrapperDiv class="relative w-full flex items-center cursor-pointer" (click)="openCalendar($event)">
|
||||||
>
|
<spk-flatpickr #flatpickrComponent [id]="inputId()" [placeholder]="isFloating() ? ' ' : placeholder()" [mode]="mode()"
|
||||||
<div class="relative w-full">
|
[minDate]="min() || undefined" [maxDate]="max() || undefined" [readonly]="readonly()" [disabled]="isDisabled()"
|
||||||
<input
|
[altInput]="false" [convertModelValue]="true" [enableTime]="false" [dateFormat]="'Y-m-d'"
|
||||||
data-form-control
|
[class]="inputClass() + ' !pe-14 ' + (mode() === 'range' && isRangeSelected() && !focused() ? '!text-transparent' : '')"
|
||||||
[id]="inputId()"
|
[ngModel]="flatpickrValue()" (ngModelChange)="onDateChange($event)" (focus)="onFocus()" (blur)="onBlur()" />
|
||||||
type="date"
|
|
||||||
[class]="inputClass()"
|
@if (mode() === 'range' && isRangeSelected() && !focused()) {
|
||||||
[value]="value() ?? ''"
|
<div
|
||||||
[placeholder]="isFloating() ? ' ' : placeholder()"
|
class="pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3 text-xs sm:text-sm font-medium text-defaulttextcolor dark:text-white/90">
|
||||||
[required]="required()"
|
<span>{{ rangeFrom() }}</span>
|
||||||
[readOnly]="readonly()"
|
<span
|
||||||
[disabled]="isDisabled()"
|
class="badge bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light font-bold text-[0.7rem] uppercase tracking-wider mx-2 px-2 py-0.5 rounded border border-primary/20">
|
||||||
[attr.min]="min()"
|
TO
|
||||||
[attr.max]="max()"
|
</span>
|
||||||
[attr.aria-label]="label()"
|
<span>{{ rangeTo() }}</span>
|
||||||
[attr.aria-invalid]="showError() ? 'true' : null"
|
</div>
|
||||||
[attr.aria-required]="required() ? 'true' : null"
|
}
|
||||||
(focus)="onFocus()"
|
<div class="absolute inset-y-0 end-0 flex items-center pe-3 gap-1 z-10">
|
||||||
(input)="onInput($event)"
|
@if (clearable() && hasValue() && !isDisabled() && !readonly()) {
|
||||||
(blur)="onBlur()"
|
<button #clearBtn type="button"
|
||||||
/>
|
class="spk-date-picker-clear-btn text-[#8c9097] dark:text-white/50 hover:text-red-500 dark:hover:text-red-400 p-0.5 rounded-full transition-colors cursor-pointer flex items-center justify-center"
|
||||||
|
(click)="clearDate($event)" aria-label="Clear date">
|
||||||
|
<i class="ri-close-line text-[1.1rem]"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<span #calendarIcon class="pointer-events-none text-[#8c9097] dark:text-white/50 flex items-center justify-center" aria-hidden="true">
|
||||||
|
<i class="ri-calendar-line text-[1.1rem]"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
@@ -4,107 +4,82 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host ::ng-deep .date-picker-floating .floating-label .form-control {
|
:host ::ng-deep {
|
||||||
min-height: 2.5rem !important;
|
spk-flatpickr {
|
||||||
height: 2.5rem !important;
|
display: block;
|
||||||
border: 1px solid var(--color-inputborder);
|
flex: 1 1 auto;
|
||||||
border-radius: 0.5rem;
|
min-width: 0;
|
||||||
background-color: var(--color-white);
|
width: 100%;
|
||||||
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 */
|
.date-picker-floating .floating-label .form-control {
|
||||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit,
|
min-height: 2.5rem !important;
|
||||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-fields-wrapper,
|
height: 2.5rem !important;
|
||||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-text,
|
border: 1px solid var(--color-inputborder, #e2e8f0);
|
||||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-month-field,
|
border-radius: 0.5rem !important;
|
||||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-day-field,
|
background-color: var(--color-white, #ffffff);
|
||||||
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-year-field {
|
color: var(--color-defaulttextcolor, #334155);
|
||||||
color: transparent !important;
|
font-size: 0.8125rem;
|
||||||
}
|
padding-block: 0.5rem 0.15rem !important;
|
||||||
|
padding-inline: 0.75rem 2.5rem !important;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
:host ::ng-deep .date-picker-floating .floating-label > label {
|
.date-picker-floating .floating-label > label {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
inset-inline-start: 0.75rem !important;
|
inset-inline-start: 0.75rem !important;
|
||||||
padding-inline: 0.35rem !important;
|
padding-inline: 0.35rem !important;
|
||||||
background-color: var(--color-white) !important;
|
background-color: var(--color-white, #ffffff) !important;
|
||||||
font-weight: 400 !important;
|
font-weight: 400 !important;
|
||||||
color: #8c9097 !important;
|
color: #8c9097 !important;
|
||||||
z-index: 9999 !important;
|
z-index: 10 !important;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
white-space: nowrap !important;
|
white-space: nowrap !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Float label and highlight purple on focus or when date is selected */
|
/* Float label and highlight purple on focus or when date is selected */
|
||||||
:host ::ng-deep .date-picker-floating .floating-label--float > label,
|
.date-picker-floating .floating-label--float > label,
|
||||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within > label {
|
.date-picker-floating .floating-label:focus-within > label {
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
transform: translateY(-50%) scale(0.85) !important;
|
transform: translateY(-50%) scale(0.85) !important;
|
||||||
transform-origin: left center !important;
|
transform-origin: left center !important;
|
||||||
font-weight: 400 !important;
|
font-weight: 400 !important;
|
||||||
color: var(--color-primary) !important;
|
color: var(--color-primary, #7c3deb) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host ::ng-deep .date-picker-floating .floating-label--float .form-control,
|
.date-picker-floating .floating-label--float .form-control,
|
||||||
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control {
|
.date-picker-floating .floating-label:focus-within .form-control {
|
||||||
color: var(--color-defaulttextcolor) !important;
|
color: var(--color-defaulttextcolor, #334155) !important;
|
||||||
border-color: var(--color-primary) !important;
|
border-color: var(--color-primary, #7c3deb) !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 */
|
/* Dark mode styling replacing deprecated :host-context */
|
||||||
:host-context(.dark) ::ng-deep .form-control::-webkit-calendar-picker-indicator {
|
.dark,
|
||||||
filter: invert(1) !important;
|
[data-theme-mode="dark"] {
|
||||||
opacity: 0.8;
|
:host ::ng-deep {
|
||||||
|
.date-picker-floating .floating-label .form-control {
|
||||||
|
background-color: var(--color-bodybg, #1a1c1e) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.date-picker-floating .floating-label > label {
|
||||||
opacity: 1;
|
background-color: var(--color-bodybg, #1a1c1e) !important;
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker-floating .floating-label--float > label,
|
||||||
|
.date-picker-floating .floating-label:focus-within > label {
|
||||||
|
color: #c084fc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker-floating .floating-label--float .form-control,
|
||||||
|
.date-picker-floating .floating-label:focus-within .form-control {
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: #a855f7 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,52 @@
|
|||||||
import {
|
import {
|
||||||
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
ElementRef,
|
||||||
Injector,
|
Injector,
|
||||||
|
ViewChild,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
signal,
|
signal,
|
||||||
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
ControlValueAccessor,
|
ControlValueAccessor,
|
||||||
|
FormsModule,
|
||||||
NG_VALUE_ACCESSOR,
|
NG_VALUE_ACCESSOR,
|
||||||
NgControl,
|
NgControl,
|
||||||
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
|
|
||||||
import { FormField } from '../form-field/form-field';
|
import { FormField } from '../form-field/form-field';
|
||||||
import { ValidationMessageMap } from '../form-validation-message/form-validation-message';
|
import { ValidationMessageMap } from '../form-validation-message/form-validation-message';
|
||||||
|
import {
|
||||||
|
SpkFlatpickr,
|
||||||
|
SpkFlatpickrMode,
|
||||||
|
formatSingleDate,
|
||||||
|
fromFlatpickrValue,
|
||||||
|
toFlatpickrValue,
|
||||||
|
} from '../../../../@spk/plugins&reusable/spk-flatpickr/spk-flatpickr';
|
||||||
|
|
||||||
|
export interface DateRangeValue {
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatePickerValue = string | DateRangeValue | null;
|
||||||
|
|
||||||
|
export function formatSingleDateValue(raw: any): string | null {
|
||||||
|
return formatSingleDate(raw);
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-form-date-picker',
|
selector: 'app-form-date-picker',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormField],
|
imports: [FormsModule, ReactiveFormsModule, FormField, SpkFlatpickr],
|
||||||
templateUrl: './form-date-picker.html',
|
templateUrl: './form-date-picker.html',
|
||||||
styleUrl: './form-date-picker.scss',
|
styleUrl: './form-date-picker.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -34,17 +58,25 @@ import { ValidationMessageMap } from '../form-validation-message/form-validation
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FormDatePicker implements ControlValueAccessor {
|
export class FormDatePicker implements ControlValueAccessor, AfterViewInit {
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
|
||||||
|
readonly flatpickrComponent = viewChild<SpkFlatpickr>('flatpickrComponent');
|
||||||
|
|
||||||
|
@ViewChild('wrapperDiv', { static: false }) wrapperDivRef?: ElementRef<HTMLElement>;
|
||||||
|
@ViewChild('calendarIcon', { static: false }) calendarIconRef?: ElementRef<HTMLElement>;
|
||||||
|
@ViewChild('clearBtn', { static: false }) clearBtnRef?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
readonly inputId = input.required<string>();
|
readonly inputId = input.required<string>();
|
||||||
readonly label = input.required<string>();
|
readonly label = input.required<string>();
|
||||||
readonly placeholder = input('');
|
readonly placeholder = input('');
|
||||||
|
readonly mode = input<SpkFlatpickrMode>('single');
|
||||||
readonly required = input(false);
|
readonly required = input(false);
|
||||||
readonly readonly = input(false);
|
readonly readonly = input(false);
|
||||||
readonly disabled = input(false);
|
readonly disabled = input(false);
|
||||||
readonly min = input<string | null>(null);
|
readonly clearable = input(true);
|
||||||
readonly max = input<string | null>(null);
|
readonly min = input<string | undefined>(undefined);
|
||||||
|
readonly max = input<string | undefined>(undefined);
|
||||||
readonly hint = input<string | null>(null);
|
readonly hint = input<string | null>(null);
|
||||||
readonly help = input<string | null>(null);
|
readonly help = input<string | null>(null);
|
||||||
readonly description = input<string | null>(null);
|
readonly description = input<string | null>(null);
|
||||||
@@ -57,12 +89,12 @@ export class FormDatePicker implements ControlValueAccessor {
|
|||||||
readonly labelClass = input('');
|
readonly labelClass = input('');
|
||||||
readonly fieldContentClass = input('');
|
readonly fieldContentClass = input('');
|
||||||
|
|
||||||
readonly value = signal<string | null>(null);
|
readonly value = signal<DatePickerValue>(null);
|
||||||
readonly formDisabled = signal(false);
|
readonly formDisabled = signal(false);
|
||||||
readonly focused = signal(false);
|
readonly focused = signal(false);
|
||||||
private readonly controlStateVersion = signal(0);
|
private readonly controlStateVersion = signal(0);
|
||||||
|
|
||||||
private onChange: (value: string | null) => void = () => {};
|
private onChange: (value: DatePickerValue) => void = () => {};
|
||||||
private onTouched: () => void = () => {};
|
private onTouched: () => void = () => {};
|
||||||
|
|
||||||
readonly control = computed<AbstractControl | null>(() =>
|
readonly control = computed<AbstractControl | null>(() =>
|
||||||
@@ -71,7 +103,16 @@ export class FormDatePicker implements ControlValueAccessor {
|
|||||||
|
|
||||||
readonly isFloating = computed(() => this.variant() === 'floating');
|
readonly isFloating = computed(() => this.variant() === 'floating');
|
||||||
readonly isDisabled = computed(() => this.disabled() || this.formDisabled());
|
readonly isDisabled = computed(() => this.disabled() || this.formDisabled());
|
||||||
readonly hasValue = computed(() => !!this.value());
|
readonly hasValue = computed(() => {
|
||||||
|
const val = this.value();
|
||||||
|
if (!val) return false;
|
||||||
|
if (typeof val === 'string') return !!val.trim();
|
||||||
|
if (typeof val === 'object' && val !== null && 'from' in val) {
|
||||||
|
return !!(val.from || val.to);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
readonly shouldFloat = computed(() => this.focused() || this.hasValue());
|
readonly shouldFloat = computed(() => this.focused() || this.hasValue());
|
||||||
readonly showError = computed(() => {
|
readonly showError = computed(() => {
|
||||||
this.controlStateVersion();
|
this.controlStateVersion();
|
||||||
@@ -84,6 +125,30 @@ export class FormDatePicker implements ControlValueAccessor {
|
|||||||
this.showError() ? 'is-invalid' : '',
|
this.showError() ? 'is-invalid' : '',
|
||||||
].filter(Boolean).join(' '));
|
].filter(Boolean).join(' '));
|
||||||
|
|
||||||
|
readonly flatpickrValue = computed(() => {
|
||||||
|
return toFlatpickrValue(this.value(), this.mode());
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly rangeFrom = computed<string | null>(() => {
|
||||||
|
const val = this.value();
|
||||||
|
if (this.mode() === 'range' && val && typeof val === 'object' && 'from' in val) {
|
||||||
|
return val.from ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly rangeTo = computed<string | null>(() => {
|
||||||
|
const val = this.value();
|
||||||
|
if (this.mode() === 'range' && val && typeof val === 'object' && 'to' in val) {
|
||||||
|
return val.to ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly isRangeSelected = computed<boolean>(() => {
|
||||||
|
return !!(this.rangeFrom() && this.rangeTo());
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect((onCleanup) => {
|
effect((onCleanup) => {
|
||||||
const control = this.control();
|
const control = this.control();
|
||||||
@@ -93,15 +158,15 @@ export class FormDatePicker implements ControlValueAccessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusSubscription = control.statusChanges.subscribe(() => {
|
const statusSubscription = control.statusChanges.subscribe(() => {
|
||||||
this.controlStateVersion.update(value => value + 1);
|
this.controlStateVersion.update(val => val + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const valueSubscription = control.valueChanges.subscribe(() => {
|
const valueSubscription = control.valueChanges.subscribe(() => {
|
||||||
this.controlStateVersion.update(value => value + 1);
|
this.controlStateVersion.update(val => val + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventsSubscription = control.events?.subscribe(() => {
|
const eventsSubscription = control.events?.subscribe(() => {
|
||||||
this.controlStateVersion.update(value => value + 1);
|
this.controlStateVersion.update(val => val + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -112,11 +177,48 @@ export class FormDatePicker implements ControlValueAccessor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
writeValue(value: string | null): void {
|
ngAfterViewInit(): void {
|
||||||
this.value.set(value ?? null);
|
const fp = this.flatpickrComponent();
|
||||||
|
if (fp) {
|
||||||
|
if (this.wrapperDivRef?.nativeElement) {
|
||||||
|
fp.registerIgnoredElement(this.wrapperDivRef.nativeElement);
|
||||||
|
}
|
||||||
|
if (this.calendarIconRef?.nativeElement) {
|
||||||
|
fp.registerIgnoredElement(this.calendarIconRef.nativeElement);
|
||||||
|
}
|
||||||
|
if (this.clearBtnRef?.nativeElement) {
|
||||||
|
fp.registerIgnoredElement(this.clearBtnRef.nativeElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerOnChange(fn: (value: string | null) => void): void {
|
writeValue(val: DatePickerValue): void {
|
||||||
|
if (!val) {
|
||||||
|
this.value.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode() === 'range') {
|
||||||
|
if (typeof val === 'object' && val !== null && ('from' in val || 'to' in val)) {
|
||||||
|
const range = val as DateRangeValue;
|
||||||
|
const from = formatSingleDate(range.from);
|
||||||
|
const to = formatSingleDate(range.to);
|
||||||
|
this.value.set(from || to ? { from, to } : null);
|
||||||
|
} else if (typeof val === 'string') {
|
||||||
|
const parts = val.split(/\s+to\s+|\s+-\s+/i);
|
||||||
|
const from = formatSingleDate(parts[0]);
|
||||||
|
const to = formatSingleDate(parts[1]);
|
||||||
|
this.value.set(from || to ? { from, to } : null);
|
||||||
|
} else {
|
||||||
|
this.value.set(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const normalized = formatSingleDate(val);
|
||||||
|
this.value.set(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: (value: DatePickerValue) => void): void {
|
||||||
this.onChange = fn;
|
this.onChange = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +230,50 @@ export class FormDatePicker implements ControlValueAccessor {
|
|||||||
this.formDisabled.set(isDisabled);
|
this.formDisabled.set(isDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
onInput(event: Event): void {
|
openCalendar(event?: MouseEvent): void {
|
||||||
const target = event.target as HTMLInputElement;
|
if (this.isDisabled() || this.readonly()) {
|
||||||
const nextValue = target.value.trim() || null;
|
return;
|
||||||
this.value.set(nextValue);
|
}
|
||||||
this.onChange(nextValue);
|
|
||||||
|
const target = event?.target as HTMLElement | undefined;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.closest('.spk-date-picker-clear-btn'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fp = this.flatpickrComponent();
|
||||||
|
if (fp) {
|
||||||
|
fp.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDate(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (this.isDisabled() || this.readonly()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.value.set(null);
|
||||||
|
this.onChange(null);
|
||||||
|
this.onTouched();
|
||||||
|
|
||||||
|
const fp = this.flatpickrComponent();
|
||||||
|
if (fp) {
|
||||||
|
fp.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDateChange(nextRawValue: any): void {
|
||||||
|
const normalized = fromFlatpickrValue(nextRawValue, this.mode());
|
||||||
|
this.value.set(normalized);
|
||||||
|
this.onChange(normalized);
|
||||||
|
this.onTouched();
|
||||||
}
|
}
|
||||||
|
|
||||||
onFocus(): void {
|
onFocus(): void {
|
||||||
@@ -143,5 +284,4 @@ export class FormDatePicker implements ControlValueAccessor {
|
|||||||
this.focused.set(false);
|
this.focused.set(false);
|
||||||
this.onTouched();
|
this.onTouched();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<i
|
<!-- <i
|
||||||
class="ri-error-warning-line me-1 align-middle"
|
class="ri-error-warning-line me-1 align-middle"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></i>
|
></i> -->
|
||||||
|
|
||||||
{{ message() }}
|
{{ message() }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -146,12 +146,10 @@
|
|||||||
<div class="modern-modal-footer">
|
<div class="modern-modal-footer">
|
||||||
|
|
||||||
@if (showCancelButton()) {
|
@if (showCancelButton()) {
|
||||||
<app-button
|
|
||||||
action="cancel"
|
<app-button action="cancel" [variant]="'custom'" [label]="cancelLabel()" [showIcon]="true"
|
||||||
[label]="cancelLabel()"
|
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center"
|
||||||
[showIcon]="true"
|
(buttonClicked)="close()"></app-button>
|
||||||
[disabled]="loading()"
|
|
||||||
(buttonClicked)="close()" />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showSubmitButton()) {
|
@if (showSubmitButton()) {
|
||||||
|
|||||||
@@ -460,6 +460,126 @@
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Dark Mode
|
||||||
|
========================================================== */
|
||||||
|
|
||||||
|
:host-context(.dark) .modern-modal {
|
||||||
|
background: var(--color-bodybg) !important;
|
||||||
|
box-shadow:
|
||||||
|
0 15px 40px rgba(0, 0, 0, .25),
|
||||||
|
0 30px 80px rgba(0, 0, 0, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .modern-modal-body {
|
||||||
|
color: var(--color-defaulttextcolor) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .modern-modal-body::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-inputborder) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .modern-modal-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-textmuted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) .modern-modal-footer {
|
||||||
|
background: var(--color-bodybg) !important;
|
||||||
|
border-top: 1px solid var(--color-inputborder) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) ::ng-deep .modern-modal-footer app-button:first-child button {
|
||||||
|
background: var(--color-light) !important;
|
||||||
|
border-color: var(--color-inputborder) !important;
|
||||||
|
color: var(--color-defaulttextcolor) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) ::ng-deep .modern-modal-footer app-button:first-child button:hover {
|
||||||
|
background: var(--color-bodybg) !important;
|
||||||
|
border-color: var(--color-textmuted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) ::ng-deep .modern-modal-footer app-button:last-child button {
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px rgba(125, 61, 235, 0.55) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.dark) ::ng-deep .modern-modal-footer app-button:last-child button:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 12px 28px rgba(125, 61, 235, .65) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================
|
||||||
|
Modal form-control borders in dark mode
|
||||||
|
========================================================== */
|
||||||
|
|
||||||
|
:host-context(.dark) ::ng-deep .modern-modal {
|
||||||
|
|
||||||
|
.shared-form-input,
|
||||||
|
.custom-floating-input,
|
||||||
|
.form-control,
|
||||||
|
.form-select,
|
||||||
|
.ti-form-input,
|
||||||
|
.ti-form-select,
|
||||||
|
.ti-form-control,
|
||||||
|
.ng-select-container,
|
||||||
|
textarea,
|
||||||
|
input[type="text"],
|
||||||
|
input:not([type]),
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="tel"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="search"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="month"],
|
||||||
|
input[type="time"],
|
||||||
|
input[type="week"] {
|
||||||
|
border-color: rgb(115 122 130) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:not(:checked) {
|
||||||
|
border-color: rgb(115 122 130) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* keep focus / validation colors */
|
||||||
|
.shared-form-control.is-focused .shared-form-input,
|
||||||
|
.shared-form-input:focus,
|
||||||
|
.custom-floating-input:focus,
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.ti-form-input:focus,
|
||||||
|
.ti-form-select:focus,
|
||||||
|
.ti-form-control:focus,
|
||||||
|
.ng-select.ng-select-focused .ng-select-container,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control.is-invalid,
|
||||||
|
.form-select.is-invalid,
|
||||||
|
.ti-form-input.is-invalid,
|
||||||
|
.ti-form-select.is-invalid,
|
||||||
|
.ti-form-control.is-invalid,
|
||||||
|
textarea.is-invalid,
|
||||||
|
.has-error .shared-form-input,
|
||||||
|
.ng-invalid.ng-touched,
|
||||||
|
.custom-floating-input.ng-invalid.ng-touched {
|
||||||
|
border-color: var(--color-danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input:checked,
|
||||||
|
.form-check-input.is-invalid {
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input.is-invalid:not(:checked) {
|
||||||
|
border-color: var(--color-danger) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ==========================================================
|
/* ==========================================================
|
||||||
Mobile
|
Mobile
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import { DataTableFilterActionsDirective } from './data-table-filter-actions.directive';
|
||||||
|
|
||||||
|
describe('DataTableFilterActionsDirective', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
const directive = new DataTableFilterActionsDirective();
|
||||||
|
expect(directive).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
TemplateRef,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appDataTableFilterActions]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class DataTableFilterActionsDirective {
|
||||||
|
|
||||||
|
readonly templateRef = inject(
|
||||||
|
TemplateRef<unknown>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
+90
-8
@@ -1,18 +1,11 @@
|
|||||||
@forward "tailwindcss";
|
@forward "tailwindcss";
|
||||||
@forward "../public/assets/css/style.css";
|
@forward "../public/assets/css/style.css";
|
||||||
|
|
||||||
|
|
||||||
/* ====================== ngx-toastr ====================== */
|
|
||||||
@forward "../node_modules/ngx-toastr/toastr.css";
|
@forward "../node_modules/ngx-toastr/toastr.css";
|
||||||
|
|
||||||
|
@import "flatpickr/dist/flatpickr.css";
|
||||||
/* inter */
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
/* Montserrat */
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&display=swap');
|
||||||
|
|
||||||
|
|
||||||
/* Etihad Altis Text (local font) */
|
/* Etihad Altis Text (local font) */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'EtihadAltis-Text';
|
font-family: 'EtihadAltis-Text';
|
||||||
@@ -69,3 +62,92 @@ body {
|
|||||||
.ti-form-select {
|
.ti-form-select {
|
||||||
border: 1px solid #7c3deb !important;
|
border: 1px solid #7c3deb !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ====================== Flatpickr Calendar Header Styling ====================== */
|
||||||
|
.flatpickr-calendar {
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border: 1px solid var(--color-inputborder, #e2e8f0) !important;
|
||||||
|
z-index: 99999 !important;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
display: inline-block !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
z-index: 99999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark &,
|
||||||
|
&.dark,
|
||||||
|
[data-theme-mode="dark"] & {
|
||||||
|
background-color: #1a1c1e !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-months {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
height: 3.2rem !important;
|
||||||
|
padding: 0.35rem 0.65rem !important;
|
||||||
|
position: relative !important;
|
||||||
|
background-color: rgba(124, 61, 235, 0.08) !important;
|
||||||
|
border-bottom: 1px solid rgba(124, 61, 235, 0.12) !important;
|
||||||
|
border-top-left-radius: 0.5rem !important;
|
||||||
|
border-top-right-radius: 0.5rem !important;
|
||||||
|
|
||||||
|
.dark &,
|
||||||
|
[data-theme-mode="dark"] & {
|
||||||
|
background-color: rgba(124, 61, 235, 0.16) !important;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-prev-month,
|
||||||
|
.flatpickr-next-month {
|
||||||
|
position: relative !important;
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
height: 2rem !important;
|
||||||
|
width: 2rem !important;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 0.375rem !important;
|
||||||
|
color: var(--color-primary, #7c3deb) !important;
|
||||||
|
transition: background-color 0.2s ease !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(124, 61, 235, 0.15) !important;
|
||||||
|
color: var(--color-primary, #7c3deb) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--color-primary, #7c3deb) !important;
|
||||||
|
width: 0.9rem !important;
|
||||||
|
height: 0.9rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-current-month {
|
||||||
|
position: relative !important;
|
||||||
|
left: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 100% !important;
|
||||||
|
top: auto !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user