Merge pull request 'new design implement for login screen, organization onboarding form changes, new filter implement in all screens, remove icons from validation,' (#8) from feature/onboarding-workflow-sc into dev

Reviewed-on: sc/syscom-master-admin-ui#8
This commit is contained in:
2026-07-29 13:50:19 +00:00
40 changed files with 2407 additions and 793 deletions
+1
View File
@@ -56,6 +56,7 @@
"src/.htaccess"
],
"styles": [
"node_modules/flatpickr/dist/flatpickr.css",
"node_modules/@ng-select/ng-select/themes/default.theme.css",
"src/styles.scss"
],
+22
View File
@@ -23,8 +23,10 @@
"@ng-select/ng-select": "^21.8.2",
"@tailwindcss/forms": "^0.5.11",
"@tsparticles/angular": "^3.0.0",
"angularx-flatpickr": "^8.1.0",
"apexcharts": "^5.10.5",
"chart.js": "^4.5.1",
"flatpickr": "^4.6.13",
"gridjs": "^6.2.0",
"ng-apexcharts": "^2.3.0",
"ng2-charts": "^10.0.0",
@@ -7851,6 +7853,20 @@
"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": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -9574,6 +9590,12 @@
"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": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+2
View File
@@ -42,8 +42,10 @@
"@ng-select/ng-select": "^21.8.2",
"@tailwindcss/forms": "^0.5.11",
"@tsparticles/angular": "^3.0.0",
"angularx-flatpickr": "^8.1.0",
"apexcharts": "^5.10.5",
"chart.js": "^4.5.1",
"flatpickr": "^4.6.13",
"gridjs": "^6.2.0",
"ng-apexcharts": "^2.3.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
class="grid grid-cols-12 h-screen min-h-screen overflow-hidden bg-bodybg dark:bg-bodybg text-defaulttextcolor text-defaultsize">
<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]">
class="grid grid-cols-12 min-h-screen h-screen overflow-hidden bg-[#f4f2fb] text-defaulttextcolor font-sans relative p-0">
<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">
<div class="box-body !p-[3rem]">
<p class="h5 font-semibold mb-2 text-center">Sign In</p>
<p class="mb-4 text-[#8c9097] dark:text-white/50 opacity-[0.7] font-normal text-center">
Welcome back !
</p>
<!-- Extreme Lower Right Light Purple Spark Graphic -->
<div class="fixed bottom-8 right-10 pointer-events-none opacity-60 z-0">
<svg width="42" height="42" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0L14.59 9.41L24 12L14.59 14.59L12 24L9.41 14.59L0 12L9.41 9.41L12 0Z" fill="#c3b4f5" />
</svg>
</div>
<form [formGroup]="adminLoginForm" (ngSubmit)="login()">
@if (loginError) {
<div class="text-danger mb-3">{{ loginError }}</div>
}
<!-- Left Marketing Panel (60% Width) - Full Banner Image (Straight left, rounded right corners) -->
<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">
<div class="xl:col-span-12 col-span-12">
<label for="signin-username" class="form-label text-default">User Name</label>
<input type="text" class="form-control form-control-lg w-full !rounded-md"
id="signin-username" placeholder="user name" formControlName="username"
autocomplete="username">
</div>
<!-- Right Login Panel (40% Width) -->
<div
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]">
<div class="xl:col-span-12 col-span-12 mb-2">
<label for="signin-password" class="form-label text-default block">Password</label>
<!-- Central Floating Card Encased in Lavender-Purple Frame -->
<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"
class="ltr:float-right rtl:float-left text-danger">
Forgot password ?
</a>
<!-- Inner White Card Area -->
<div class="bg-white rounded-[24px] px-6 sm:px-8 py-8 sm:py-9 shadow-inner flex flex-col">
<div class="input-group">
<input class="form-control form-control-lg !rounded-s-md" id="signin-password"
placeholder="password" formControlName="password"
autocomplete="current-password"
[type]="visibilityMap['Angular'] ? 'text' : 'password'">
<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>
<!-- Stylized Gradient Purple-and-Indigo Winged 'V' Logo & Header -->
<div class="flex flex-col items-center text-center mb-6">
<div class="mb-3 flex justify-center">
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-logo">
</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>
<!-- 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>
@@ -1,15 +1,40 @@
::ng-deep{
.login-input {
background-color: #ffffff !important;
color: #0f172a !important;
border-color: #cbd5e1 !important;
&::placeholder {
color: #94a3b8 !important;
}
.firebase {
width: 30px;
height: 30px;
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-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{
width: 120px;
justify-content: center;
margin: auto;
border-radius: 0.75rem;
}
}
&:focus {
background-color: #ffffff !important;
color: #0f172a !important;
border-color: #a855f7 !important;
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';
public readonly adminLoginForm = this.formBuilder.nonNullable.group({
username: ['', [Validators.required, Validators.email]],
username: ['superadmin@syscom.dev', [Validators.required]],
password: ['', Validators.required],
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
[columns]="columns()"
[rows]="tableStore.rows()"
@@ -69,6 +9,8 @@
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
[showFilterButton]="true"
[filterActive]="showFilters()"
searchPlaceholder="Search cities..."
[searchDebounceTime]="300"
toolTip="Add City"
@@ -77,7 +19,77 @@
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($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
title="Delete City"
@@ -11,7 +11,7 @@ import { StateLookupDto } from '../../../states/models/state.model';
import { CityService } from '../../data-access/city.service';
import { CountryService } from '../../../countries/data-access/country.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 {
DataTableAction,
@@ -25,7 +25,6 @@ import {
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
import { FilterCard } from '../../../../../shared/components/filter-card/filter-card';
import { Button } from '../../../../../shared/components/button/button';
import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog';
import { CityFormModalComponent } from '../../components/city-form-modal/city-form-modal';
@@ -51,9 +50,9 @@ interface CityTableRow extends DataTableRecord {
standalone: true,
imports: [
DataTable,
DataTableToolbarDirective,
ReactiveFormsModule,
Autocomplete,
FilterCard,
Button,
ConfirmDialog,
CityFormModalComponent
@@ -78,6 +77,8 @@ export class CityList implements OnInit {
readonly pendingDeleteCity = signal<CityTableRow | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly showFilters = signal(false);
readonly filterForm = this.formBuilder.nonNullable.group({
countryId: [''],
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">
<form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter()" 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="state-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)="onFilterCountrySelected($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)="onApplyFilter()"
></app-button>
</div>
</form>
</app-filter-card>
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
[totalRecords]="tableStore.totalRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" tableTitle="States" buttonTitle="Add" [showSearch]="true"
[showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()" 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)"
(filterClicked)="onToggleFilters()">
<ng-template appDataTableToolbar>
<form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter()" autocomplete="off"
class="flex flex-wrap items-end gap-3 w-full">
<div class="w-64 min-w-[200px]">
<app-autocomplete formControlName="countryId" inputId="state-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)="onFilterCountrySelected($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-data-table
[columns]="columns()"
[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 title="Delete State" text="Do you really want to delete this state?" confirmButtonText="Delete"
cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()" (cancelled)="onDeleteCancelled()" />
<app-confirm-dialog
title="Delete State"
text="Do you really want to delete this state?"
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()"
/>
<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 { StateDto, UpdateStateRequest } from '../../models/state.model';
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 {
DataTableAction,
@@ -24,7 +24,6 @@ import {
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
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 { StateFormModalComponent } from '../../components/state-form-modal/state-form-modal';
@@ -44,10 +43,10 @@ interface StateTableRow extends DataTableRecord {
standalone: true,
imports: [
DataTable,
DataTableToolbarDirective,
ReactiveFormsModule,
Autocomplete,
ConfirmDialog,
FilterCard,
AppButton,
StateFormModalComponent
],
@@ -69,6 +68,8 @@ export class StateList implements OnInit {
readonly pendingDeleteState = signal<StateTableRow | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly showFilters = signal(false);
readonly countryFilterForm = this.formBuilder.nonNullable.group({
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
[columns]="columns()"
[rows]="tableStore.rows()"
@@ -52,6 +10,8 @@
[showSearch]="true"
rowColorMode="none"
[showAddButton]="true"
[showFilterButton]="true"
[filterActive]="showFilters()"
searchPlaceholder="Search..."
[searchDebounceTime]="300"
(addClicked)="onAddOrganization()"
@@ -59,5 +19,54 @@
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($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 { 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 {
DataTableAction,
@@ -20,7 +20,6 @@ import {
AutocompleteSearchFn,
AutocompleteValueFn,
} from '../../../shared/components/form/autocomplete/autocomplete.types';
import { FilterCard } from '../../../shared/components/filter-card/filter-card';
import { Button } from '../../../shared/components/button/button';
import { CountryLookupDto, CountryService } from '../../global-masters/countries/public-api';
@@ -40,7 +39,7 @@ interface OrganizationListRow extends DataTableRecord {
@Component({
selector: 'organization-list',
standalone: true,
imports: [ReactiveFormsModule, FilterCard, Autocomplete, DataTable, Button],
imports: [ReactiveFormsModule, Autocomplete, DataTable, DataTableToolbarDirective, Button],
providers: [DataTableStore],
templateUrl: './organization-list.html',
styleUrl: './organization-list.scss',
@@ -54,6 +53,7 @@ export class OrganizationList implements OnInit {
readonly selectedCountryLookup = signal<CountryLookupDto | null>(null);
readonly appliedCountryId = signal<string | null>(null);
readonly showFilters = signal(false);
readonly countryFilterForm = this.formBuilder.nonNullable.group({
countryId: [''],
@@ -268,6 +268,17 @@ export class OrganizationList implements OnInit {
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 {
if (event.action.type === 'edit' || (event.row.status as string) === 'Draft') {
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}`);
}
}
@@ -2,12 +2,15 @@
<!-- Custom Stepper Navigation Card -->
<div class="stepper-header-card mb-4 sm:mb-6">
<!-- Mobile Current Step Indicator Banner (< 640px) -->
<div class="mb-3 flex items-center justify-between rounded-lg bg-primary/5 px-3 py-2 text-xs font-medium text-primary sm:hidden">
<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 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 }}
</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 class="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[0.6875rem] font-semibold text-primary ms-2">
{{ getProgressPercent() }}%
@@ -18,85 +21,80 @@
<div class="py-1">
<nav aria-label="Organization onboarding progress" class="relative w-full">
<!-- Background Track Line -->
<div
class="absolute top-4.5 sm:top-8 h-1 bg-gray-200 dark:bg-slate-700/60 z-0 transition-all duration-300"
[style.left]="getTrackLineLeft()"
[style.right]="getTrackLineLeft()"
></div>
<div class="stepper-bg-track absolute top-4.5 sm:top-8 h-1 z-0 transition-all duration-300"
[style.left]="getTrackLineLeft()" [style.right]="getTrackLineLeft()"></div>
<!-- Gradient Progress Line -->
<div
class="stepper-progress-line absolute top-4.5 sm:top-8 h-1 z-0 transition-all duration-500 ease-in-out"
[style.left]="getTrackLineLeft()"
[style.width]="getProgressLineWidth()"
></div>
<div class="stepper-progress-line absolute top-4.5 sm:top-8 h-1 z-0 transition-all duration-500 ease-in-out"
[style.left]="getTrackLineLeft()" [style.width]="getProgressLineWidth()"></div>
<!-- Stepper Nodes Grid -->
<ul class="relative z-10 flex w-full justify-between items-start">
@for (step of steps(); track step.key; let index = $index) {
<li class="flex flex-1 flex-col items-center text-center">
<button
type="button"
class="step-node-btn group flex flex-col items-center focus:outline-none"
[class.cursor-pointer]="canOpenStep(index)"
[class.cursor-default]="!canOpenStep(index)"
[disabled]="!canOpenStep(index)"
(click)="selectStep(index)"
>
<!-- Step Circle Node -->
<div
class="step-circle"
[class.active]="isCurrentStep(index)"
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
[class.pending]="isPendingStep(index)"
>
<!-- Icons by Step Key / Index -->
@switch (step.key) {
@case ('basics') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 sm:size-7">
<path d="M19 4h-3.5c-.27-1.16-1.31-2-2.5-2h-2c-1.19 0-2.23.84-2.5 2H5c-1.1 0-2 .9-2 2v13c0 1.1.9 2 2 2h8.08c-.05-.33-.08-.66-.08-1 0-3.31 2.69-6 6-6 .34 0 .67.03 1 .08V6c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM7 9h7v2H7V9zm0 4h5v2H7v-2z"/>
<path d="M18 13c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm-1 7.2-2.2-2.2 1.4-1.4 0.8 0.8 3.2-3.2 1.4 1.4-4.6 4.6z"/>
</svg>
}
@case ('localization') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
<circle cx="10" cy="10" r="7.5"/>
<path d="M2.5 10h15"/>
<path d="M10 2.5a11 11 0 0 1 0 15a11 11 0 0 1 0-15"/>
<path d="M12 21.5v-7.5h3.5v7.5M15.5 21.5v-9.5h3.5v9.5M19 21.5v-6h3v6" fill="currentColor" stroke="none"/>
</svg>
}
@case ('plan-limits') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" stroke="none" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
<path d="M8.5 13.5l2.2-2.2 1.8 1.8 3-3.2m0 0h-2.5m2.5 0v2.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
}
@case ('admin-user') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 sm:size-7">
<path d="M12 6a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm10 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/>
<path d="M12 7.5c-3 0-8 1.5-8 4v2.5h8.5c-.32-.73-.5-1.54-.5-2.4 0-1.48.55-2.83 1.46-3.87C13.01 7.6 12.45 7.5 12 7.5zM4 11.5c-1.8 0-5 .9-5 2.5V16h4v-2c0-.94.32-1.8.85-2.5H4zm13.15-4c.45.64.75 1.4.81 2.22C19.06 10.28 20 11.23 20 12.5v1.5h4v-1.5c0-1.6-3.2-2.5-5-2.5h-1.85z"/>
<path d="M17 12c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm0 7c-1.5 0-2.8-.78-3.5-1.95.04-1.16 2.33-1.8 3.5-1.8s3.46.64 3.5 1.8c-.7 1.17-2 1.95-3.5 1.95z"/>
</svg>
}
@default {
<span>{{ index + 1 }}</span>
}
}
</div>
<li class="flex flex-1 flex-col items-center text-center">
<button type="button" class="step-node-btn group flex flex-col items-center focus:outline-none"
[class.cursor-pointer]="canOpenStep(index)" [class.cursor-default]="!canOpenStep(index)"
[disabled]="!canOpenStep(index)" (click)="selectStep(index)">
<!-- Step Circle Node -->
<div class="step-circle" [class.active]="isCurrentStep(index)"
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
[class.pending]="isPendingStep(index)">
<!-- Icons by Step Key / Index -->
@switch (step.key) {
@case ('basics') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="size-5 sm:size-7">
<path
d="M19 4h-3.5c-.27-1.16-1.31-2-2.5-2h-2c-1.19 0-2.23.84-2.5 2H5c-1.1 0-2 .9-2 2v13c0 1.1.9 2 2 2h8.08c-.05-.33-.08-.66-.08-1 0-3.31 2.69-6 6-6 .34 0 .67.03 1 .08V6c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM7 9h7v2H7V9zm0 4h5v2H7v-2z" />
<path
d="M18 13c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm-1 7.2-2.2-2.2 1.4-1.4 0.8 0.8 3.2-3.2 1.4 1.4-4.6 4.6z" />
</svg>
}
@case ('localization') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
<circle cx="10" cy="10" r="7.5" />
<path d="M2.5 10h15" />
<path d="M10 2.5a11 11 0 0 1 0 15a11 11 0 0 1 0-15" />
<path d="M12 21.5v-7.5h3.5v7.5M15.5 21.5v-9.5h3.5v9.5M19 21.5v-6h3v6" fill="currentColor"
stroke="none" />
</svg>
}
@case ('plan-limits') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="size-5 sm:size-7">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" stroke="none" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
<path d="M8.5 13.5l2.2-2.2 1.8 1.8 3-3.2m0 0h-2.5m2.5 0v2.5" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
}
@case ('admin-user') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="size-5 sm:size-7">
<path
d="M12 6a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm10 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z" />
<path
d="M12 7.5c-3 0-8 1.5-8 4v2.5h8.5c-.32-.73-.5-1.54-.5-2.4 0-1.48.55-2.83 1.46-3.87C13.01 7.6 12.45 7.5 12 7.5zM4 11.5c-1.8 0-5 .9-5 2.5V16h4v-2c0-.94.32-1.8.85-2.5H4zm13.15-4c.45.64.75 1.4.81 2.22C19.06 10.28 20 11.23 20 12.5v1.5h4v-1.5c0-1.6-3.2-2.5-5-2.5h-1.85z" />
<path
d="M17 12c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0 2a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm0 7c-1.5 0-2.8-.78-3.5-1.95.04-1.16 2.33-1.8 3.5-1.8s3.46.64 3.5 1.8c-.7 1.17-2 1.95-3.5 1.95z" />
</svg>
}
@default {
<span>{{ index + 1 }}</span>
}
}
</div>
<!-- Step Label -->
<span
class="step-label hidden sm:block"
[class.active]="isCurrentStep(index)"
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
[class.pending]="isPendingStep(index)"
>
{{ index + 1 }}. {{ step.label }}
</span>
</button>
</li>
<!-- Step Label -->
<span class="step-label hidden sm:block" [class.active]="isCurrentStep(index)"
[class.completed]="isCompletedStep(index) && !isCurrentStep(index)"
[class.pending]="isPendingStep(index)">
{{ index + 1 }}. {{ step.label }}
</span>
</button>
</li>
}
</ul>
</nav>
@@ -106,144 +104,52 @@
<!-- Stepper Form Content -->
<div>
<div class="rounded-xl border border-dashed border-gray-200 bg-white p-3.5 sm:p-6 dark:border-white/10 dark:bg-bodybg">
<div class="stepper-content-box rounded-xl p-3.5 sm:p-6">
<ng-content />
</div>
<!-- Button Group Footer -->
<footer class="modern-modal-footer mt-5 px-6">
<!-- Mobile View (< 640px) -->
<div class="flex flex-col gap-2.5 sm:hidden">
<!-- Back or Cancel button -->
<div class="w-full">
@if (isFirstStep()) {
<app-button
action="cancel"
label="Cancel"
[showIcon]="true"
[disabled]="navigationDisabled()"
[fullWidth]="true"
className="w-full"
(buttonClicked)="requestCancel()"
></app-button>
} @else {
<app-button
action="previous"
label="Back"
[showIcon]="true"
[disabled]="navigationDisabled()"
[fullWidth]="true"
className="onboarding-nav-btn w-full"
(buttonClicked)="requestBack()"
></app-button>
}
</div>
<!-- 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>
<footer class="modern-modal-footer mt-5 px-2 sm:px-6">
<div class="flex flex-col-reverse gap-2.5 sm:flex-row sm:items-center sm:justify-end sm:gap-3">
<!-- Secondary Actions: Cancel/Back & Save Draft (Left group on Desktop, Bottom row on Mobile) -->
<div class="grid grid-cols-2 gap-2.5 w-full sm:flex sm:w-auto sm:items-center sm:gap-3">
<!-- Cancel on Step 1, or Back on Step > 1 -->
<div class="min-w-0 sm:w-auto">
@if (isFirstStep()) {
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestCancel()"></app-button>
} @else {
<app-button
action="approve"
label="Finish & Provision"
loadingLabel="Preparing..."
[showIcon]="true"
[loading]="finishing()"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true"
className="w-full"
(buttonClicked)="requestFinish()"
></app-button>
<app-button action="previous" [variant]="'custom'" label="Back" [showIcon]="true"
[disabled]="navigationDisabled()" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(buttonClicked)="requestBack()"></app-button>
}
</div>
</div>
</div>
<!-- Desktop View (>= 640px) -->
<div class="hidden w-full sm:flex sm:items-center sm:justify-between sm:gap-3">
<!-- Left: Cancel or Back -->
<div>
@if (isFirstStep()) {
<app-button
action="cancel"
label="Cancel"
[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>
}
<!-- Save Draft -->
<div class="min-w-0 sm:w-auto">
<app-button action="save" [variant]="'custom'" label="Save Draft" loadingLabel="Saving..." [showIcon]="true"
[loading]="savingDraft()" [disabled]="navigationDisabled() || savingDraft() || finishing()"
[fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-36"
(buttonClicked)="requestSaveDraft()"></app-button>
</div>
</div>
<!-- Right: Save Draft & Next/Finish -->
<div class="flex items-center gap-2.5 sm:gap-3">
<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>
<!-- Primary Action: Next or Finish & Provision (Top row on Mobile, Far Right on Desktop) -->
<div class="w-full sm:w-auto">
@if (!isLastStep()) {
<app-button
action="next"
label="Next"
[showIcon]="true"
[iconPosition]="'right'"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
className="onboarding-nav-btn"
(buttonClicked)="requestNext()"
></app-button>
<app-button action="next" label="Next" [showIcon]="true" [iconPosition]="'right'"
[disabled]="navigationDisabled() || savingDraft() || finishing()" [fullWidth]="true"
className="onboarding-nav-btn justify-center sm:w-36" (buttonClicked)="requestNext()"></app-button>
} @else {
<app-button
action="approve"
label="Finish & Provision"
loadingLabel="Preparing..."
[showIcon]="true"
[loading]="finishing()"
[disabled]="navigationDisabled() || savingDraft() || finishing()"
className="sm:w-44"
(buttonClicked)="requestFinish()"
></app-button>
<app-button action="approve" label="Finish & Provision" loadingLabel="Preparing..." [showIcon]="true"
[loading]="finishing()" [disabled]="navigationDisabled() || savingDraft() || finishing()" [fullWidth]="true"
className="onboarding-nav-btn justify-center sm:w-44" (buttonClicked)="requestFinish()"></app-button>
}
</div>
</div>
@@ -9,8 +9,13 @@
}
}
.stepper-nav-scroll-wrapper {
-webkit-overflow-scrolling: touch;
.stepper-bg-track {
background-color: #e2e8f0;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
background-color: rgba(255, 255, 255, 0.12) !important;
}
}
.stepper-progress-line {
@@ -19,6 +24,29 @@
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 {
background: transparent;
border: none;
@@ -48,14 +76,15 @@
// Pending Step
&.pending {
background-color: #f0f2f5;
background-color: #f1f5f9;
border: 2px solid #cbd5e1;
color: #94a3b8;
color: #64748b;
:host-context(.dark) & {
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
color: #64748b;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
background-color: var(--bg-body, #181920) !important;
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%);
color: #ffffff;
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
@@ -101,11 +130,12 @@
}
&.pending {
color: #94a3b8;
color: #64748b;
font-weight: 400;
:host-context(.dark) & {
color: #64748b;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
color: #94a3b8 !important;
}
}
@@ -113,8 +143,9 @@
color: #334155;
font-weight: 500;
:host-context(.dark) & {
color: #cbd5e1;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
color: #cbd5e1 !important;
}
}
@@ -122,8 +153,9 @@
color: var(--primary, #7c3deb);
font-weight: 700;
:host-context(.dark) & {
color: #ffffff;
:host-context(.dark) &,
:host-context([data-theme-mode="dark"]) & {
color: #c084fc !important;
}
}
}
@@ -87,10 +87,7 @@ export class OnboardingStepper {
return false;
}
return (
index < this.currentStepIndex() ||
this.isCompletedStep(index)
);
return index >= 0 && index < this.steps().length;
}
selectStep(index: number): void {
@@ -141,7 +141,7 @@ export class OrganizationOnboardingStateService {
}
canOpenStep(index: number): boolean {
return index <= this.currentStepIndexState() || this.completedStepIndexesState().includes(index);
return index >= 0 && index <= 3;
}
isStepCompleted(index: number): boolean {
@@ -24,25 +24,82 @@ import {
import { ORGANIZATION_TYPE_OPTIONS } from '../../constants/organization-onboarding.constants';
const DATE_FORMAT_OPTIONS: readonly FormSelectOption<DateFormatValue>[] = [
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
// --- Standard Slashes ---
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY (e.g., 29/07/2026 - UK, India, Europe)' },
{ 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>[] = [
{ value: 0, label: '12 hour' },
{ value: 1, label: '24 hour' },
export const TIME_FORMAT_OPTIONS: readonly FormSelectOption<TimeFormatValue>[] = [
{
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>[] = [
{ 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>[] = [
{ value: 'CalendarYear', label: 'Calendar Year (Jan-Dec)' },
{ value: 'AprilToMarch', label: 'April - March' },
{ value: 'JulyToJune', label: 'July - June' },
{
value: 'JanuaryToDecember',
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>[] = [
@@ -58,7 +115,7 @@ const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
defaultLanguage: { id: 'en-in', label: 'English', secondaryLabel: 'en-IN' },
dateFormat: 'DD/MM/YYYY',
timeFormat: 0,
numberFormat: 'OneTwoThreeCommaFourFiveSixPointSevenEight',
numberFormat: 'TwelveLakhThirtyFourThousandFiveHundredSixtySevenPointEightNine',
fiscalYearConvention: 'AprilToMarch',
},
{
@@ -69,7 +126,7 @@ const LOCALIZATION_DEFAULTS: readonly CountryLocalizationDefaults[] = [
dateFormat: 'MM/DD/YYYY',
timeFormat: 0,
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[]> {
const params = new HttpParams().set('term', term).set('limit', limit);
return this.http.get<readonly SubscriptionPlanDto[]>(ORGANIZATION_ONBOARDING_ENDPOINTS.subscriptionPlans, { params }).pipe(
catchError(() => of([
{ id: 'starter', name: 'Starter', code: 'STARTER', maxCompanies: 1, maxUsers: 10, maxStorageGb: 10, defaultTrialDays: 14 },
{ id: 'growth', name: 'Growth', code: 'GROWTH', maxCompanies: 5, maxUsers: 100, maxStorageGb: 100, defaultTrialDays: 30 },
{ id: 'enterprise', name: 'Enterprise', code: 'ENTERPRISE', maxCompanies: 25, maxUsers: 1000, maxStorageGb: 500, defaultTrialDays: 30 },
]))
);
return this.http
.get<SubscriptionPlanDto[] | { value?: SubscriptionPlanDto[]; data?: SubscriptionPlanDto[] }>(
ORGANIZATION_ONBOARDING_ENDPOINTS.subscriptionPlans,
{ params }
)
.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[]> {
@@ -34,16 +34,30 @@ export type TimeFormatValue = 0 | 1 | 'TwelveHour' | 'TwentyFourHour';
export type DateFormatValue =
| 'DD/MM/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 =
| 'OneTwoThreeCommaFourFiveSixPointSevenEight'
| 'OneTwoThreePointFourFiveSixCommaSevenEight';
| 'OneTwoThreeCommaFourFiveSixPointSevenEight'
| 'OneTwoThreePointFourFiveSixCommaSevenEight'
| 'OneTwoThreeSpaceFourFiveSixCommaSevenEight'
| 'OneTwoThreeSpaceFourFiveSixPointSevenEight'
| 'TwelveLakhThirtyFourThousandFiveHundredSixtySevenPointEightNine';
export type FiscalYearConventionValue =
| 'CalendarYear'
| 'JanuaryToDecember'
| 'AprilToMarch'
| 'JulyToJune';
| 'JulyToJune'
| 'OctoberToSeptember'
| 'March21ToMarch20';
export function mapFiscalYearToMmDd(fiscalYear: string | null | undefined): string {
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 {
if (!mmDd) return 'CalendarYear';
if (mmDd === '04-01') return 'AprilToMarch';
if (mmDd === '07-01') return 'JulyToJune';
if (mmDd === '01-01') return 'CalendarYear';
return (mmDd as FiscalYearConventionValue) ?? 'CalendarYear';
if (!mmDd) return 'JanuaryToDecember';
switch (mmDd) {
case '01-01':
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 {
@@ -112,8 +137,11 @@ export function mapApiToLicenseType(licenseType: unknown): OrganizationLicenseTy
export interface SubscriptionPlanDto {
id: string;
name: string;
code: string;
name: string;
price?: number;
currencyId?: string | null;
billingCycle?: number;
maxCompanies?: number;
maxUsers?: number;
maxStorageGb?: number;
@@ -107,7 +107,7 @@ export class OrganizationOnboarding {
}
onStepSelected(index: number): void {
if (!this.stateService.canOpenStep(index)) {
if (!this.stateService.canOpenStep(index) || index === this.currentStepIndex()) {
return;
}
@@ -135,15 +135,10 @@ export class OrganizationOnboarding {
return;
}
const activeStep = this.getActiveStep();
if (!activeStep?.validate()) {
this.stateService.unmarkStepCompleted(currentIndex);
if (!this.validateStepsUpTo(currentIndex)) {
return;
}
this.storeValidatedActiveStep(activeStep);
this.stateService.markStepCompleted(currentIndex);
this.persistStepToServer(currentIndex, true)
.pipe(
takeUntilDestroyed(this.destroyRef)
@@ -165,16 +160,13 @@ export class OrganizationOnboarding {
return;
}
const activeStep = this.getActiveStep();
if (!activeStep?.validate()) {
const currentIndex = this.currentStepIndex();
if (!this.validateStepsUpTo(currentIndex)) {
return;
}
this.storeValidatedActiveStep(activeStep);
this.savingDraft.set(true);
const currentIndex = this.currentStepIndex();
this.persistStepToServer(currentIndex, false)
.pipe(
finalize(() => {
@@ -199,16 +191,10 @@ export class OrganizationOnboarding {
return;
}
const activeStep = this.adminStep();
if (!activeStep?.validate()) {
this.stateService.unmarkStepCompleted(3);
if (!this.validateStepsUpTo(this.steps.length - 1)) {
return;
}
const adminValue = activeStep.getValue();
this.stateService.updateAdmin(adminValue);
this.stateService.markStepCompleted(3);
const data = this.stateService.onboardingData();
if (!data.basics || !data.localization || !data.planLimits || !data.admin) {
this.toastr.error('Complete and validate every onboarding step before provisioning.');
@@ -337,8 +323,42 @@ export class OrganizationOnboarding {
}
}
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
switch (this.currentStepIndex()) {
private validateStepsUpTo(targetIndex: number): boolean {
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:
return this.basicsStep();
case 1:
@@ -352,8 +372,12 @@ export class OrganizationOnboarding {
}
}
private storeValidatedActiveStep(step: OnboardingStepForm<unknown>): void {
switch (this.currentStepIndex()) {
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
return this.getStepComponent(this.currentStepIndex());
}
private storeValidatedStep(index: number, step: OnboardingStepForm<unknown>): void {
switch (index) {
case 0:
this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue());
break;
@@ -369,6 +393,10 @@ export class OrganizationOnboarding {
}
}
private storeValidatedActiveStep(step: OnboardingStepForm<unknown>): void {
this.storeValidatedStep(this.currentStepIndex(), step);
}
private captureActiveStepDraft(): void {
const currentIndex = this.currentStepIndex();
const validatedValue = this.getValidatedStepValue(currentIndex);
@@ -105,8 +105,8 @@
inputId="organization-go-live-date"
variant="floating"
label="Go-Live Date"
mode="single"
[required]="true"
[variant]="'floating'"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Go-Live Date is required.' }"
/>
@@ -114,30 +114,16 @@
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<app-form-date-picker
formControlName="systemAccessStartDate"
inputId="organization-system-access-start-date"
formControlName="systemAccessPeriod"
inputId="organization-system-access-period"
variant="floating"
label="System Access From Date"
label="System Access Period"
mode="range"
[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()"
[validationMessages]="{
required: '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.'
required: 'System Access Period is required.',
trialEndDateRequired: 'System Access End Date is required for Trial.'
}"
/>
</div>
@@ -18,7 +18,7 @@ import {
AutocompleteSearchFn,
AutocompleteValueFn,
} 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 { FormSelect } from '../../../../../shared/components/form/form-select/form-select';
import {
@@ -38,8 +38,7 @@ interface OrganizationPlanLimitsFormModel {
readonly maximumUsers: number | null;
readonly maximumStorageGb: number | null;
readonly goLiveDate: string | null;
readonly systemAccessStartDate: string | null;
readonly systemAccessEndDate: string | null;
readonly systemAccessPeriod: DateRangeValue | null;
}
type ThemeSelectValue =
@@ -59,17 +58,19 @@ const accessDateValidator: ValidatorFn = (control: AbstractControl): ValidationE
}
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;
}
if (systemAccessStartDate && systemAccessEndDate && systemAccessStartDate > systemAccessEndDate) {
if (from && to && from > to) {
errors['accessDateRange'] = true;
}
if (licenseType === 'Trial' && systemAccessStartDate && systemAccessEndDate && systemAccessStartDate >= systemAccessEndDate) {
if (licenseType === 'Trial' && from && to && from >= to) {
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)]),
maximumStorageGb: this.formBuilder.control<number | null>(null, [Validators.required, Validators.min(1)]),
goLiveDate: this.formBuilder.control<string | null>(null, [Validators.required]),
systemAccessStartDate: this.formBuilder.control<string | null>(null, [Validators.required]),
systemAccessEndDate: this.formBuilder.control<string | null>(null),
systemAccessPeriod: this.formBuilder.control<DateRangeValue | null>(null, [Validators.required]),
}, { validators: accessDateValidator });
readonly requiresAccessEndDate = computed(() => this.form.controls.licenseType.value === 'Trial');
@@ -120,8 +120,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
})
);
readonly displayPlan: AutocompleteDisplayFn<OrganizationPlanLookupValue> = item =>
[item.label, item.code].filter(Boolean).join(' - ');
readonly displayPlan: AutocompleteDisplayFn<OrganizationPlanLookupValue> = item => item.label;
readonly planValue: AutocompleteValueFn<OrganizationPlanLookupValue, string> = item => item.id;
@@ -133,12 +132,12 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(licenseType => {
if (licenseType === 'Trial') {
this.form.controls.systemAccessEndDate.addValidators([Validators.required]);
this.form.controls.systemAccessPeriod.addValidators([Validators.required]);
} 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
@@ -186,8 +185,11 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
getValue(): OrganizationPlanLimitsValue {
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
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.');
}
@@ -198,14 +200,15 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
maximumUsers: value.maximumUsers,
maximumStorageGb: value.maximumStorageGb,
goLiveDate: value.goLiveDate,
systemAccessStartDate: value.systemAccessStartDate,
systemAccessEndDate: value.systemAccessEndDate,
systemAccessStartDate,
systemAccessEndDate,
limitsEditable: this.limitsEditable(),
};
}
getDraftValue(): Partial<OrganizationPlanLimitsValue> {
const value = this.form.getRawValue() as OrganizationPlanLimitsFormModel;
const period = value.systemAccessPeriod;
return {
subscriptionPlan: this.planSelection() ?? undefined,
@@ -214,8 +217,8 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
maximumUsers: value.maximumUsers ?? undefined,
maximumStorageGb: value.maximumStorageGb ?? undefined,
goLiveDate: value.goLiveDate ?? undefined,
systemAccessStartDate: value.systemAccessStartDate ?? undefined,
systemAccessEndDate: value.systemAccessEndDate,
systemAccessStartDate: period?.from ?? undefined,
systemAccessEndDate: period?.to ?? undefined,
limitsEditable: this.limitsEditable(),
};
}
@@ -232,6 +235,10 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
code: value['SubscriptionPlan']?.code ?? '',
} : 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({
subscriptionPlanId: planId,
licenseType: normalizedLicenseType,
@@ -239,8 +246,7 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
maximumUsers: value.maximumUsers ?? value['MaxUsers'] ?? null,
maximumStorageGb: value.maximumStorageGb ?? value['MaxStorageGb'] ?? null,
goLiveDate: value.goLiveDate ?? value['GoLiveDate'] ?? null,
systemAccessStartDate: value.systemAccessStartDate ?? value['SystemAccessFrom'] ?? null,
systemAccessEndDate: value.systemAccessEndDate ?? value['SystemAccessTo'] ?? null,
systemAccessPeriod,
}, { emitEvent: false });
this.planSelection.set(subPlan ?? value.subscriptionPlan ?? null);
@@ -265,20 +271,24 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
this.planSelection.set(defaults.plan);
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> = {
licenseType: defaults.licenseType,
maximumCompanies: defaults.maximumCompanies,
maximumUsers: defaults.maximumUsers,
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.applyLimitsEditableState();
}
@@ -215,12 +215,17 @@
</form>
</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="w-full sm:w-auto">
<app-button action="cancel" label="Cancel" [showIcon]="true" [fullWidth]="true" (buttonClicked)="onDiscard()" />
</div>
<div class="w-full sm:w-auto">
<app-button action="save" label="Save" [showIcon]="true" [fullWidth]="true" (buttonClicked)="onSave()" />
<div class="box-footer modern-modal-footer flex flex-col sm:flex-row sm:justify-end sm:items-center gap-3">
<div class="grid grid-cols-2 gap-3 w-full sm:flex sm:w-auto sm:items-center sm:gap-3">
<div class="w-full sm:w-auto">
<app-button action="cancel" [variant]="'custom'" label="Cancel" [showIcon]="true" [fullWidth]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center sm:w-32"
(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>
+101 -15
View File
@@ -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(.onboarding-action-footer) button {
height: 36px;
padding: 0 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
transition: .3s ease;
height: 36px !important;
min-height: 36px !important;
max-height: 36px !important;
padding: 0 16px !important;
border-radius: 10px !important;
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(.onboarding-action-footer) button.ti-btn-light {
border: 1px solid #dbe3ef;
border: 1.5px solid #dbe3ef !important;
background: #ffffff;
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(.onboarding-action-footer) button.ti-btn-light:hover:not(:disabled) {
background: #f8fafc;
border-color: #cbd5e1;
border-color: #cbd5e1 !important;
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-success-full {
position: relative;
overflow: hidden;
border: none;
border: 1.5px solid transparent !important;
color: #ffffff;
background: #7c3deb;
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),
@@ -39,8 +78,35 @@
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-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: "";
position: absolute;
top: 0;
@@ -60,14 +126,34 @@
}
:host-context(.modern-modal-footer) button.onboarding-nav-btn {
height: 34px;
padding: 0 12px;
font-size: 12px;
height: 36px;
padding: 0 16px;
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) {
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);
box-shadow: 0 12px 28px rgba(37, 99, 235, .45);
box-shadow: 0 12px 28px rgba(124, 61, 235, 0.45);
}
@keyframes saveButtonShine {
@@ -15,6 +15,7 @@ export type IconPosition = 'left' | 'right';
export type ButtonVariant =
| 'primary'
| 'primary-full'
| 'outline-primary'
| 'secondary'
| 'secondary-full'
| 'success'
@@ -201,6 +202,9 @@ const BUTTON_ACTION_CONFIG: Record<ButtonAction, ButtonActionConfig> = {
standalone: true,
templateUrl: './button.html',
styleUrl: './button.scss',
host: {
'[class.w-full]': 'fullWidth()'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Button {
@@ -293,6 +297,7 @@ export class Button {
const variants: Record<ButtonVariant, string> = {
primary: 'ti-btn-primary',
'primary-full': 'ti-btn-primary-full',
'outline-primary': 'ti-btn-outline-primary',
secondary: 'ti-btn-secondary',
'secondary-full': 'ti-btn-secondary-full',
@@ -7,6 +7,13 @@
{{ tableTitle () }}
</h5>
<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()){
<div>
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
@@ -30,6 +37,26 @@
</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="table-responsive overflow-x-auto overflow-y-visible modern-table-wrapper">
<table [class]="tableClass()">
@@ -254,3 +254,157 @@
.data-table-modern .custom-floating-input[type='number'] {
-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 { 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 { Subject } from 'rxjs';
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 { Button } from '../button/button';
import { contentChild } from '@angular/core';
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({
selector: 'app-data-table',
@@ -34,6 +34,34 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
readonly cellTemplates = contentChildren(DataTableCellDirective);
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 = [
'table-primary',
'table-secondary',
@@ -110,6 +138,8 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
searchDebounceTime = input(300);
emptyMessage = input('No records found');
emptyDescription = input('There is currently no data to display.');
showFilterButton = input<boolean>(false);
filterActive = input<boolean>(false);
/*---------------------------*/
/* --------- Permission inputs ---- */
@@ -141,6 +171,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
actionHeaderClass = input<string>('!text-center');
actionCellClass = input<string>('!text-center');
addClicked = output<void>();
filterClicked = output<void>();
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, '');
}
onFilterClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.closeActionMenu();
this.filterClicked.emit();
}
}
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
[label]="label()"
[inputId]="inputId()"
[control]="control()"
[required]="required()"
[disabled]="isDisabled()"
[description]="description()"
[hint]="hint()"
[help]="help()"
[floating]="isFloating()"
[forceFloat]="shouldFloat()"
[hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()"
[submitAttempted]="submitAttempted()"
<app-form-field [label]="label()" [inputId]="inputId()" [control]="control()" [required]="required()"
[disabled]="isDisabled()" [description]="description()" [hint]="hint()" [help]="help()" [floating]="isFloating()"
[forceFloat]="shouldFloat()" [hideValidation]="hideValidation()"
[showValidationWhenDirty]="showValidationWhenDirty()" [submitAttempted]="submitAttempted()"
[validationMessages]="validationMessages()"
[wrapperClass]="(isFloating() ? 'date-picker-floating ' : '') + wrapperClass()"
[labelClass]="labelClass()"
[contentClass]="fieldContentClass()"
>
<div class="relative w-full">
<input
data-form-control
[id]="inputId()"
type="date"
[class]="inputClass()"
[value]="value() ?? ''"
[placeholder]="isFloating() ? ' ' : placeholder()"
[required]="required()"
[readOnly]="readonly()"
[disabled]="isDisabled()"
[attr.min]="min()"
[attr.max]="max()"
[attr.aria-label]="label()"
[attr.aria-invalid]="showError() ? 'true' : null"
[attr.aria-required]="required() ? 'true' : null"
(focus)="onFocus()"
(input)="onInput($event)"
(blur)="onBlur()"
/>
[wrapperClass]="(isFloating() ? 'date-picker-floating ' : '') + wrapperClass()" [labelClass]="labelClass()"
[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()"
[minDate]="min() || undefined" [maxDate]="max() || undefined" [readonly]="readonly()" [disabled]="isDisabled()"
[altInput]="false" [convertModelValue]="true" [enableTime]="false" [dateFormat]="'Y-m-d'"
[class]="inputClass() + ' !pe-14 ' + (mode() === 'range' && isRangeSelected() && !focused() ? '!text-transparent' : '')"
[ngModel]="flatpickrValue()" (ngModelChange)="onDateChange($event)" (focus)="onFocus()" (blur)="onBlur()" />
@if (mode() === 'range' && isRangeSelected() && !focused()) {
<div
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">
<span>{{ rangeFrom() }}</span>
<span
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">
TO
</span>
<span>{{ rangeTo() }}</span>
</div>
}
<div class="absolute inset-y-0 end-0 flex items-center pe-3 gap-1 z-10">
@if (clearable() && hasValue() && !isDisabled() && !readonly()) {
<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>
</app-form-field>
@@ -4,107 +4,82 @@
min-width: 0;
}
:host ::ng-deep .date-picker-floating .floating-label .form-control {
min-height: 2.5rem !important;
height: 2.5rem !important;
border: 1px solid var(--color-inputborder);
border-radius: 0.5rem;
background-color: var(--color-white);
color: transparent !important;
font-size: 0.8125rem;
padding-block: 0.5rem 0.15rem !important;
padding-inline: 0.75rem !important;
box-shadow: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
}
:host ::ng-deep {
spk-flatpickr {
display: block;
flex: 1 1 auto;
min-width: 0;
width: 100%;
}
/* Hide native datetime format (dd/mm/yyyy) when un-floated and not focused */
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit,
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-fields-wrapper,
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-text,
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-month-field,
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-day-field,
:host ::ng-deep .date-picker-floating .floating-label:not(.floating-label--float):not(:focus-within) .form-control::-webkit-datetime-edit-year-field {
color: transparent !important;
}
.date-picker-floating .floating-label .form-control {
min-height: 2.5rem !important;
height: 2.5rem !important;
border: 1px solid var(--color-inputborder, #e2e8f0);
border-radius: 0.5rem !important;
background-color: var(--color-white, #ffffff);
color: var(--color-defaulttextcolor, #334155);
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 {
position: absolute !important;
inset-inline-start: 0.75rem !important;
padding-inline: 0.35rem !important;
background-color: var(--color-white) !important;
font-weight: 400 !important;
color: #8c9097 !important;
z-index: 9999 !important;
overflow: visible !important;
white-space: nowrap !important;
pointer-events: none !important;
}
.date-picker-floating .floating-label > label {
position: absolute !important;
inset-inline-start: 0.75rem !important;
padding-inline: 0.35rem !important;
background-color: var(--color-white, #ffffff) !important;
font-weight: 400 !important;
color: #8c9097 !important;
z-index: 10 !important;
overflow: visible !important;
white-space: nowrap !important;
pointer-events: none !important;
}
/* Float label and highlight purple on focus or when date is selected */
:host ::ng-deep .date-picker-floating .floating-label--float > label,
:host ::ng-deep .date-picker-floating .floating-label:focus-within > label {
top: 0 !important;
transform: translateY(-50%) scale(0.85) !important;
transform-origin: left center !important;
font-weight: 400 !important;
color: var(--color-primary) !important;
}
/* Float label and highlight purple on focus or when date is selected */
.date-picker-floating .floating-label--float > label,
.date-picker-floating .floating-label:focus-within > label {
top: 0 !important;
transform: translateY(-50%) scale(0.85) !important;
transform-origin: left center !important;
font-weight: 400 !important;
color: var(--color-primary, #7c3deb) !important;
}
:host ::ng-deep .date-picker-floating .floating-label--float .form-control,
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control {
color: var(--color-defaulttextcolor) !important;
border-color: var(--color-primary) !important;
}
/* Show native datetime format / selected date value when floated or focused */
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit,
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit,
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-fields-wrapper,
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-fields-wrapper,
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-text,
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-text,
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-month-field,
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-month-field,
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-day-field,
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-day-field,
:host ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit-year-field,
:host ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit-year-field {
color: var(--color-defaulttextcolor) !important;
}
:host-context(.dark) ::ng-deep .date-picker-floating .form-control,
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label > label {
background-color: var(--color-bodybg) !important;
}
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label--float .form-control,
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label:focus-within .form-control {
color: var(--color-white) !important;
}
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label--float .form-control::-webkit-datetime-edit,
:host-context(.dark) ::ng-deep .date-picker-floating .floating-label:focus-within .form-control::-webkit-datetime-edit {
color: var(--color-white) !important;
}
/* Calendar icon picker indicator styling */
:host ::ng-deep .form-control::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease, filter 0.2s ease;
&:hover {
opacity: 1;
.date-picker-floating .floating-label--float .form-control,
.date-picker-floating .floating-label:focus-within .form-control {
color: var(--color-defaulttextcolor, #334155) !important;
border-color: var(--color-primary, #7c3deb) !important;
}
}
/* Invert calendar picker icon color to white in dark mode */
:host-context(.dark) ::ng-deep .form-control::-webkit-calendar-picker-indicator {
filter: invert(1) !important;
opacity: 0.8;
/* Dark mode styling replacing deprecated :host-context */
.dark,
[data-theme-mode="dark"] {
: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 {
opacity: 1;
.date-picker-floating .floating-label > label {
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 {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
Injector,
ViewChild,
computed,
effect,
forwardRef,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormsModule,
NG_VALUE_ACCESSOR,
NgControl,
ReactiveFormsModule,
} from '@angular/forms';
import { FormField } from '../form-field/form-field';
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({
selector: 'app-form-date-picker',
standalone: true,
imports: [FormField],
imports: [FormsModule, ReactiveFormsModule, FormField, SpkFlatpickr],
templateUrl: './form-date-picker.html',
styleUrl: './form-date-picker.scss',
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);
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 label = input.required<string>();
readonly placeholder = input('');
readonly mode = input<SpkFlatpickrMode>('single');
readonly required = input(false);
readonly readonly = input(false);
readonly disabled = input(false);
readonly min = input<string | null>(null);
readonly max = input<string | null>(null);
readonly clearable = input(true);
readonly min = input<string | undefined>(undefined);
readonly max = input<string | undefined>(undefined);
readonly hint = input<string | null>(null);
readonly help = input<string | null>(null);
readonly description = input<string | null>(null);
@@ -57,12 +89,12 @@ export class FormDatePicker implements ControlValueAccessor {
readonly labelClass = input('');
readonly fieldContentClass = input('');
readonly value = signal<string | null>(null);
readonly value = signal<DatePickerValue>(null);
readonly formDisabled = signal(false);
readonly focused = signal(false);
private readonly controlStateVersion = signal(0);
private onChange: (value: string | null) => void = () => {};
private onChange: (value: DatePickerValue) => void = () => {};
private onTouched: () => void = () => {};
readonly control = computed<AbstractControl | null>(() =>
@@ -71,7 +103,16 @@ export class FormDatePicker implements ControlValueAccessor {
readonly isFloating = computed(() => this.variant() === 'floating');
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 showError = computed(() => {
this.controlStateVersion();
@@ -84,6 +125,30 @@ export class FormDatePicker implements ControlValueAccessor {
this.showError() ? 'is-invalid' : '',
].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() {
effect((onCleanup) => {
const control = this.control();
@@ -93,15 +158,15 @@ export class FormDatePicker implements ControlValueAccessor {
}
const statusSubscription = control.statusChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
this.controlStateVersion.update(val => val + 1);
});
const valueSubscription = control.valueChanges.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
this.controlStateVersion.update(val => val + 1);
});
const eventsSubscription = control.events?.subscribe(() => {
this.controlStateVersion.update(value => value + 1);
this.controlStateVersion.update(val => val + 1);
});
onCleanup(() => {
@@ -112,11 +177,48 @@ export class FormDatePicker implements ControlValueAccessor {
});
}
writeValue(value: string | null): void {
this.value.set(value ?? null);
ngAfterViewInit(): void {
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;
}
@@ -128,11 +230,50 @@ export class FormDatePicker implements ControlValueAccessor {
this.formDisabled.set(isDisabled);
}
onInput(event: Event): void {
const target = event.target as HTMLInputElement;
const nextValue = target.value.trim() || null;
this.value.set(nextValue);
this.onChange(nextValue);
openCalendar(event?: MouseEvent): void {
if (this.isDisabled() || this.readonly()) {
return;
}
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 {
@@ -143,5 +284,4 @@ export class FormDatePicker implements ControlValueAccessor {
this.focused.set(false);
this.onTouched();
}
}
@@ -4,10 +4,10 @@
role="alert"
aria-live="polite"
>
<i
<!-- <i
class="ri-error-warning-line me-1 align-middle"
aria-hidden="true"
></i>
></i> -->
{{ message() }}
</p>
+4 -6
View File
@@ -146,12 +146,10 @@
<div class="modern-modal-footer">
@if (showCancelButton()) {
<app-button
action="cancel"
[label]="cancelLabel()"
[showIcon]="true"
[disabled]="loading()"
(buttonClicked)="close()" />
<app-button action="cancel" [variant]="'custom'" [label]="cancelLabel()" [showIcon]="true"
className="onboarding-nav-btn onboarding-save-btn ti-btn-outline-primary whitespace-nowrap justify-center"
(buttonClicked)="close()"></app-button>
}
@if (showSubmitButton()) {
+120
View File
@@ -460,6 +460,126 @@
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
@@ -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();
});
});
@@ -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
View File
@@ -1,18 +1,11 @@
@forward "tailwindcss";
@forward "../public/assets/css/style.css";
/* ====================== ngx-toastr ====================== */
@forward "../node_modules/ngx-toastr/toastr.css";
/* inter */
@import "flatpickr/dist/flatpickr.css";
@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');
/* Etihad Altis Text (local font) */
@font-face {
font-family: 'EtihadAltis-Text';
@@ -69,3 +62,92 @@ body {
.ti-form-select {
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;
}
}
}