diff --git a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html index a1ce082608..cca0f284f9 100644 --- a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html +++ b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.html @@ -49,18 +49,33 @@ @if (!row['_' + prop.name].component) { -
+ } @else { +
+ [ngClass]="entityPropTypeClasses[prop.type]" + [class.pointer]="prop.action" + > + } } @else { ' @@ -156,12 +153,6 @@ export class ExtensibleTableComponent implements OnChanges, AfterViewIn switch (prop.type) { case ePropType.Boolean: return this.getIcon(value); - case ePropType.Date: - return this.getDate(value, getShortDateFormat(this.config)); - case ePropType.Time: - return this.getDate(value, getShortTimeFormat(this.config)); - case ePropType.DateTime: - return this.getDate(value, getShortDateShortTimeFormat(this.config)); case ePropType.Enum: return this.getEnum(value, prop.enumList || []); default: diff --git a/npm/ng-packs/packages/core/ng-package.json b/npm/ng-packs/packages/core/ng-package.json index b73dba2fb7..9f9100d2fa 100644 --- a/npm/ng-packs/packages/core/ng-package.json +++ b/npm/ng-packs/packages/core/ng-package.json @@ -9,6 +9,7 @@ "angular-oauth2-oidc", "just-compare", "just-clone", - "ts-toolbelt" + "ts-toolbelt", + "luxon" ] } diff --git a/npm/ng-packs/packages/core/package.json b/npm/ng-packs/packages/core/package.json index b5efd135b9..7a10820ead 100644 --- a/npm/ng-packs/packages/core/package.json +++ b/npm/ng-packs/packages/core/package.json @@ -11,7 +11,8 @@ "just-clone": "^6.0.0", "just-compare": "^2.0.0", "ts-toolbelt": "^9.0.0", - "tslib": "^2.0.0" + "tslib": "^2.0.0", + "luxon": "^3.0.0" }, "publishConfig": { "access": "public" diff --git a/npm/ng-packs/packages/core/src/lib/core.module.ts b/npm/ng-packs/packages/core/src/lib/core.module.ts index 83881a5246..4505253aba 100644 --- a/npm/ng-packs/packages/core/src/lib/core.module.ts +++ b/npm/ng-packs/packages/core/src/lib/core.module.ts @@ -3,6 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi, withXsrfConfiguration, + HTTP_INTERCEPTORS, } from '@angular/common/http'; import { ModuleWithProviders, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -30,6 +31,7 @@ import { ShortTimePipe } from './pipes/short-time.pipe'; import { ShortDatePipe } from './pipes/short-date.pipe'; import { SafeHtmlPipe } from './pipes/safe-html.pipe'; import { provideAbpCoreChild, provideAbpCore, withOptions } from './providers'; +import { UtcToLocalPipe } from './pipes'; const standaloneDirectives = [ AutofocusDirective, @@ -72,6 +74,7 @@ const standaloneDirectives = [ ReactiveFormsModule, RouterModule, LocalizationModule, + UtcToLocalPipe, ...standaloneDirectives, ], declarations: [ diff --git a/npm/ng-packs/packages/core/src/lib/interceptors/index.ts b/npm/ng-packs/packages/core/src/lib/interceptors/index.ts index d7479a7684..ddcc17e23d 100644 --- a/npm/ng-packs/packages/core/src/lib/interceptors/index.ts +++ b/npm/ng-packs/packages/core/src/lib/interceptors/index.ts @@ -1 +1,2 @@ export * from './api.interceptor'; +export * from './timezone.interceptor'; diff --git a/npm/ng-packs/packages/core/src/lib/interceptors/timezone.interceptor.ts b/npm/ng-packs/packages/core/src/lib/interceptors/timezone.interceptor.ts new file mode 100644 index 0000000000..7e3a887361 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/interceptors/timezone.interceptor.ts @@ -0,0 +1,26 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { TimezoneService } from '../services'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class TimezoneInterceptor implements HttpInterceptor { + protected readonly timezoneService = inject(TimezoneService); + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + if (!this.timezoneService.isUtcClockEnabled) { + return next.handle(req); + } + const timezone = this.timezoneService.timezone; + if (timezone) { + req = req.clone({ + setHeaders: { + __timezone: timezone, + }, + }); + } + return next.handle(req); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/pipes/index.ts b/npm/ng-packs/packages/core/src/lib/pipes/index.ts index 1a981ca041..f0c7b4ff60 100644 --- a/npm/ng-packs/packages/core/src/lib/pipes/index.ts +++ b/npm/ng-packs/packages/core/src/lib/pipes/index.ts @@ -5,3 +5,4 @@ export * from './to-injector.pipe'; export * from './short-date.pipe'; export * from './short-time.pipe'; export * from './short-date-time.pipe'; +export * from './utc-to-local.pipe'; diff --git a/npm/ng-packs/packages/core/src/lib/pipes/utc-to-local.pipe.ts b/npm/ng-packs/packages/core/src/lib/pipes/utc-to-local.pipe.ts new file mode 100644 index 0000000000..1f06aba74f --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/pipes/utc-to-local.pipe.ts @@ -0,0 +1,50 @@ +import { Pipe, PipeTransform, Injectable, inject, LOCALE_ID } from '@angular/core'; +import { ConfigStateService, LocalizationService, TimeService, TimezoneService } from '../services'; +import { getShortDateFormat, getShortDateShortTimeFormat, getShortTimeFormat } from '../utils'; + +@Injectable() +@Pipe({ + name: 'abpUtcToLocal', +}) +export class UtcToLocalPipe implements PipeTransform { + protected readonly timezoneService = inject(TimezoneService); + protected readonly timeService = inject(TimeService); + protected readonly configState = inject(ConfigStateService); + protected readonly localizationService = inject(LocalizationService); + protected readonly locale = inject(LOCALE_ID); + + transform( + value: string | Date | null | undefined, + type: 'date' | 'datetime' | 'time', + ): string | Date { + if (!value) return ''; + + const date = new Date(value); + if (isNaN(date.getTime())) return ''; + + const format = this.getFormat(type); + + try { + if (this.timezoneService.isUtcClockEnabled) { + const timeZone = this.timezoneService.timezone; + return this.timeService.formatDateWithStandardOffset(date, format, timeZone); + } else { + return this.timeService.formatWithoutTimeZone(date, format); + } + } catch (err) { + return value; + } + } + + private getFormat(propType: 'date' | 'datetime' | 'time'): string { + switch (propType) { + case 'date': + return getShortDateFormat(this.configState); + case 'time': + return getShortTimeFormat(this.configState); + case 'datetime': + default: + return getShortDateShortTimeFormat(this.configState); + } + } +} diff --git a/npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts b/npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts index 2b44117acb..fafb51ff6d 100644 --- a/npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts +++ b/npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts @@ -1,6 +1,7 @@ import { makeEnvironmentProviders, Provider, inject, provideAppInitializer } from '@angular/core'; import { TitleStrategy } from '@angular/router'; import { + HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi, withXsrfConfiguration, @@ -24,6 +25,7 @@ import { DEFAULT_DYNAMIC_LAYOUTS } from '../constants'; import { LocalizationService, LocalStorageListenerService, AbpTitleStrategy } from '../services'; import { DefaultQueueManager, getInitialData, localeInitializer } from '../utils'; import { CookieLanguageProvider, IncludeLocalizationResourcesProvider, LocaleProvider } from './'; +import { TimezoneInterceptor } from '../interceptors'; export enum CoreFeatureKind { Options, @@ -128,6 +130,11 @@ export function provideAbpCore(...features: CoreFeature[]) { provide: TitleStrategy, useExisting: AbpTitleStrategy, }, + { + provide: HTTP_INTERCEPTORS, + useClass: TimezoneInterceptor, + multi: true, + }, ]; for (const feature of features) { diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts index c47bef3106..6b006be74e 100644 --- a/npm/ng-packs/packages/core/src/lib/services/index.ts +++ b/npm/ng-packs/packages/core/src/lib/services/index.ts @@ -23,3 +23,5 @@ export * from './window.service'; export * from './internet-connection-service'; export * from './local-storage-listener.service'; export * from './title-strategy.service'; +export * from './timezone.service'; +export * from './time.service'; diff --git a/npm/ng-packs/packages/core/src/lib/services/time.service.ts b/npm/ng-packs/packages/core/src/lib/services/time.service.ts new file mode 100644 index 0000000000..4161d55d07 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/time.service.ts @@ -0,0 +1,107 @@ +import { inject, Injectable, LOCALE_ID } from '@angular/core'; +import { DateTime } from 'luxon'; + +@Injectable({ + providedIn: 'root', +}) +export class TimeService { + private locale = inject(LOCALE_ID); + + /** + * Returns the current date and time in the specified timezone. + * + * @param zone - An IANA timezone name (e.g., 'Europe/Istanbul', 'UTC'); defaults to the system's local timezone. + * @returns A Luxon DateTime instance representing the current time in the given timezone. + */ + now(zone = 'local'): DateTime { + return DateTime.now().setZone(zone); + } + + /** + * Converts the input date to the specified timezone, applying any timezone and daylight saving time (DST) adjustments. + * + * This method: + * 1. Parses the input value into a Luxon DateTime object. + * 2. Applies the specified IANA timezone, including any DST shifts based on the given date. + * + * @param value - The ISO string or Date object to convert. + * @param zone - An IANA timezone name (e.g., 'America/New_York'). + * @returns A Luxon DateTime instance adjusted to the specified timezone and DST rules. + */ + toZone(value: string | Date, zone: string): DateTime { + return DateTime.fromISO(value instanceof Date ? value.toISOString() : value, { + zone, + }); + } + + /** + * Formats the input date by applying timezone and daylight saving time (DST) adjustments. + * + * This method: + * 1. Converts the input date to the specified timezone. + * 2. Formats the result using the given format and locale, reflecting any timezone or DST shifts. + * + * @param value - The ISO string or Date object to format. + * @param format - The format string (default: 'ff'). + * @param zone - Optional IANA timezone name (e.g., 'America/New_York'); defaults to the system's local timezone. + * @returns A formatted date string adjusted for the given timezone and DST rules. + */ + format(value: string | Date, format = 'ff', zone = 'local'): string { + return this.toZone(value, zone).setLocale(this.locale).toFormat(format); + } + + /** + * Formats a date using the standard time offset (ignoring daylight saving time) for the specified timezone. + * + * This method: + * 1. Converts the input date to UTC. + * 2. Calculates the standard UTC offset for the given timezone (based on January 1st to avoid DST). + * 3. Applies the standard offset manually to the UTC time. + * 4. Formats the result using the specified format and locale, without applying additional timezone shifts. + * + * @param value - The ISO string or Date object to format. + * @param format - The Luxon format string (default: 'ff'). + * @param zone - Optional IANA timezone name (e.g., 'America/New_York'); if omitted, system local timezone is used. + * @returns A formatted date string adjusted by standard time (non-DST). + */ + formatDateWithStandardOffset(value: string | Date, format = 'ff', zone?: string): string { + const utcDate = + typeof value === 'string' + ? DateTime.fromISO(value, { zone: 'UTC' }) + : DateTime.fromJSDate(value, { zone: 'UTC' }); + + if (!utcDate.isValid) return ''; + + const targetZone = zone ?? DateTime.local().zoneName; + + const januaryDate = DateTime.fromObject( + { year: utcDate.year, month: 1, day: 1 }, + { zone: targetZone }, + ); + const standardOffset = januaryDate.offset; + const dateWithStandardOffset = utcDate.plus({ minutes: standardOffset }); + + return dateWithStandardOffset.setZone('UTC').setLocale(this.locale).toFormat(format); + } + + /** + * Formats the input date using its original clock time, without converting based on timezone or DST + * + * This method: + * 1. Converts the input date to ISO string. + * 2. Calculates the date time in UTC, keeping the local time. + * 3. Formats the result using the specified format and locale, without shifting timezones. + * + * @param value - The ISO string or Date object to format. + * @param format - The format string (default: 'ff'). + * @returns A formatted date string without applying timezone. + */ + formatWithoutTimeZone(value: string | Date, format = 'ff'): string { + const isoString = value instanceof Date ? value.toISOString() : value; + + const dateTime = DateTime.fromISO(isoString) + .setZone('utc', { keepLocalTime: true }) + .setLocale(this.locale); + return dateTime.toFormat(format); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/services/timezone.service.ts b/npm/ng-packs/packages/core/src/lib/services/timezone.service.ts new file mode 100644 index 0000000000..2d6c103dcd --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/timezone.service.ts @@ -0,0 +1,63 @@ +import { inject, Injectable } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { ConfigStateService } from './config-state.service'; + +@Injectable({ + providedIn: 'root', +}) +export class TimezoneService { + protected readonly configState = inject(ConfigStateService); + protected readonly document = inject(DOCUMENT); + private readonly cookieKey = '__timezone'; + private timeZoneNameFromSettings: string | null | undefined; + public isUtcClockEnabled: boolean | undefined; + + constructor() { + this.configState.getOne$('setting').subscribe(settings => { + this.timeZoneNameFromSettings = settings?.values?.['Abp.Timing.TimeZone']; + }); + this.configState.getOne$('clock').subscribe(clock => { + this.isUtcClockEnabled = clock?.kind === 'Utc'; + }); + } + + /** + * Returns the effective timezone to be used across the application. + * + * This value is determined based on the clock kind setting in the configuration: + * - If clock kind is not equal to Utc, the browser's local timezone is returned. + * - If clock kind is equal to Utc, the configured timezone (`timeZoneNameFromSettings`) is returned if available; + * otherwise, the browser's timezone is used as a fallback. + * + * @returns The IANA timezone name (e.g., 'Europe/Istanbul', 'America/New_York'). + */ + get timezone(): string { + if (!this.isUtcClockEnabled) { + return this.getBrowserTimezone(); + } + return this.timeZoneNameFromSettings || this.getBrowserTimezone(); + } + + /** + * Retrieves the browser's local timezone based on the user's system settings. + * + * @returns The IANA timezone name (e.g., 'Europe/Istanbul', 'America/New_York'). + */ + getBrowserTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } + + /** + * Sets the application's timezone in a cookie to persist the user's selected timezone. + * + * This method sets the cookie only if the clock kind setting is set to UTC. + * The cookie is stored using the key defined by `this.cookieKey` and applied to the root path (`/`). + * + * @param timezone - The IANA timezone name to be stored (e.g., 'Europe/Istanbul'). + */ + setTimezone(timezone: string): void { + if (this.isUtcClockEnabled) { + this.document.cookie = `${this.cookieKey}=${timezone}; path=/`; + } + } +}