diff --git a/src/app/core/guards/navigation/unsaved-changes.guard.spec.ts b/src/app/core/guards/navigation/unsaved-changes.guard.spec.ts new file mode 100644 index 00000000..64255978 --- /dev/null +++ b/src/app/core/guards/navigation/unsaved-changes.guard.spec.ts @@ -0,0 +1,35 @@ +import { unsavedChangesGuard, CanComponentDeactivate } from './unsaved-changes.guard'; + +describe('unsavedChangesGuard', () => { + const runGuard = (component: CanComponentDeactivate) => + unsavedChangesGuard(component, {} as any, {} as any, {} as any); + + it('allows navigation without prompting when there are no unsaved changes', async () => { + const confirmDiscard = vi.fn(); + const component: CanComponentDeactivate = { hasUnsavedChanges: () => false, confirmDiscard }; + + const result = await runGuard(component); + + expect(result).toBe(true); + expect(confirmDiscard).not.toHaveBeenCalled(); + }); + + it("delegates to the component's own confirmDiscard() dialog when there are unsaved changes", async () => { + const confirmDiscard = vi.fn().mockResolvedValue(true); + const component: CanComponentDeactivate = { hasUnsavedChanges: () => true, confirmDiscard }; + + const result = await runGuard(component); + + expect(confirmDiscard).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + }); + + it('blocks navigation when confirmDiscard() resolves false', async () => { + const confirmDiscard = vi.fn().mockResolvedValue(false); + const component: CanComponentDeactivate = { hasUnsavedChanges: () => true, confirmDiscard }; + + const result = await runGuard(component); + + expect(result).toBe(false); + }); +}); diff --git a/src/app/core/guards/navigation/unsaved-changes.guard.ts b/src/app/core/guards/navigation/unsaved-changes.guard.ts new file mode 100644 index 00000000..59185745 --- /dev/null +++ b/src/app/core/guards/navigation/unsaved-changes.guard.ts @@ -0,0 +1,15 @@ +import { CanDeactivateFn } from '@angular/router'; + +export interface CanComponentDeactivate { + hasUnsavedChanges(): boolean; + /** Shows the component's own "discard unsaved changes?" dialog and resolves with the choice. */ + confirmDiscard(): boolean | Promise; +} + +export const unsavedChangesGuard: CanDeactivateFn = (component) => { + if (!component.hasUnsavedChanges()) { + return true; + } + + return component.confirmDiscard(); +}; diff --git a/src/app/core/services/common/menu.data.ts b/src/app/core/services/common/menu.data.ts index 94d946e1..94305ede 100644 --- a/src/app/core/services/common/menu.data.ts +++ b/src/app/core/services/common/menu.data.ts @@ -3,7 +3,7 @@ import { MenuContext } from '../../models/context/context.model'; export const SAAS_MENU_DATA: MenuContext = { defaultLandingPage: '/dashboards/crm', items: [ - { headTitle: 'MAIN' }, + //{ headTitle: 'MAIN' }, { title: 'Dashboards', icon: '', @@ -15,7 +15,7 @@ export const SAAS_MENU_DATA: MenuContext = { { path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false }, ], }, - { headTitle: 'SAAS ADMIN' }, + //{ headTitle: 'SAAS ADMIN' }, { title: 'Management', icon: '', @@ -59,6 +59,18 @@ export const SAAS_MENU_DATA: MenuContext = { { path: '/organizations/awaiting-database', title: 'Assign Database & Activate', type: 'link', dirchange: false }, { path: '/organizations/tenant-domains', title: 'Tenant Domains', type: 'link', dirchange: false }, ], + }, + { + title: 'Localization', + icon: '', + type: 'sub', + active: false, + selected: false, + dirchange: false, + children: [ + { path: '/localization/translation-keys', title: 'Translations Keys', type: 'link', dirchange: false }, + { path: '/localization/translations-manager', title: 'Translations Manager', type: 'link', dirchange: false }, + ], }, { title: 'Billing', @@ -71,17 +83,6 @@ export const SAAS_MENU_DATA: MenuContext = { { path: '/billing/plans-subscriptions', title: 'Plans & Subscriptions', type: 'link', dirchange: false }, ], }, - { - title: 'Localization', - icon: '', - type: 'sub', - active: false, - selected: false, - dirchange: false, - children: [ - { path: '/localization/translations-manager', title: 'Translations Manager', type: 'link', dirchange: false }, - ], - }, { title: 'Settings', icon: '', diff --git a/src/app/core/services/common/notification.service.ts b/src/app/core/services/common/notification.service.ts index 4da09e6f..dfda98c5 100644 --- a/src/app/core/services/common/notification.service.ts +++ b/src/app/core/services/common/notification.service.ts @@ -5,14 +5,7 @@ import Swal, { SweetAlertIcon, SweetAlertOptions } from 'sweetalert2'; providedIn: 'root' }) export class NotificationService { - /** - * Triggers a top-right SweetAlert notification programmatically. - * Matches structure: position: 'top-end', showConfirmButton: false, timer: 1500 - * - * @param message Dynamic message text to display - * @param icon SweetAlert icon type ('success' | 'error' | 'warning' | 'info') - * @param timer Notification display duration in ms (default 1500ms) - */ + notify(message: string, icon: SweetAlertIcon = 'success', timer = 1500): void { Swal.fire({ position: 'top-end', @@ -23,21 +16,11 @@ export class NotificationService { }); } - /** - * Triggers top-right success notification - */ + success(message: string, timer = 1500): void { this.notify(message, 'success', timer); } - /** - * Displays Danger Sweetalert error modal centered on screen. - * Matches template design: icon: 'error', title: 'Oops...', text: message & DangerSweetalert class - * - * @param message Dynamic error text - * @param title Error title (defaults to 'Oops...') - * @param footer Optional footer HTML link (defaults to null) - */ error( message: string, title = 'Oops...', @@ -57,23 +40,16 @@ export class NotificationService { }); } - /** - * Triggers top-right warning notification - */ + warning(message: string, timer = 2500): void { this.notify(message, 'warning', timer); } - /** - * Triggers top-right info notification - */ + info(message: string, timer = 2000): void { this.notify(message, 'info', timer); } - /** - * Custom SweetAlert call with top-right defaults preset - */ custom(options: SweetAlertOptions): void { Swal.fire({ position: 'top-end', diff --git a/src/app/core/utils/error-extractor.util.ts b/src/app/core/utils/error-extractor.util.ts new file mode 100644 index 00000000..1e2bb2be --- /dev/null +++ b/src/app/core/utils/error-extractor.util.ts @@ -0,0 +1,85 @@ +/** Reads the `errorCode` extension the API puts on every ProblemDetails response (see ApiControllerBase.Problem). */ +export function extractErrorCode(err: any): string | null { + const errorObj = err?.error; + if (errorObj && typeof errorObj === 'object') { + const code = errorObj.errorCode ?? errorObj.ErrorCode; + if (typeof code === 'string' && code.trim().length > 0) { + return code.trim(); + } + } + return null; +} + +export function extractErrorMessage(err: any, fallback = 'Operation failed. Please try again.'): string { + if (!err) return fallback; + + const errorObj = err.error; + + // 1. Plain string error response + if (typeof errorObj === 'string' && errorObj.trim().length > 0) { + return errorObj.trim(); + } + + if (errorObj && typeof errorObj === 'object') { + // 2. Check detail / Detail (ProblemDetails) + if (typeof errorObj.detail === 'string' && errorObj.detail.trim().length > 0) { + return errorObj.detail.trim(); + } + if (typeof errorObj.Detail === 'string' && errorObj.Detail.trim().length > 0) { + return errorObj.Detail.trim(); + } + + // 3. Check message / Message + if (typeof errorObj.message === 'string' && errorObj.message.trim().length > 0) { + return errorObj.message.trim(); + } + if (typeof errorObj.Message === 'string' && errorObj.Message.trim().length > 0) { + return errorObj.Message.trim(); + } + + // 4. Check validation errors dictionary / array + const errorsProp = errorObj.errors || errorObj.Errors; + if (errorsProp) { + if (Array.isArray(errorsProp)) { + const msgs = errorsProp + .map((e: any) => (typeof e === 'string' ? e : e?.message || e?.Message || JSON.stringify(e))) + .filter(Boolean); + if (msgs.length > 0) return msgs.join(' '); + } else if (typeof errorsProp === 'object') { + const msgs: string[] = []; + for (const key of Object.keys(errorsProp)) { + const val = errorsProp[key]; + if (Array.isArray(val)) { + msgs.push(...val.map(v => String(v))); + } else if (typeof val === 'string') { + msgs.push(val); + } + } + if (msgs.length > 0) return msgs.join(' '); + } + } + + // 5. Check title / Title (if not generic validation title) + if ( + typeof errorObj.title === 'string' && + errorObj.title.trim().length > 0 && + errorObj.title.trim() !== 'One or more validation errors occurred.' + ) { + return errorObj.title.trim(); + } + if ( + typeof errorObj.Title === 'string' && + errorObj.Title.trim().length > 0 && + errorObj.Title.trim() !== 'One or more validation errors occurred.' + ) { + return errorObj.Title.trim(); + } + } + + // 6. Check top-level err.message + if (typeof err.message === 'string' && err.message.trim().length > 0) { + return err.message.trim(); + } + + return fallback; +} diff --git a/src/app/features/billing/plans-subscriptions/components/subscription-form-modal/subscription-form-modal.html b/src/app/features/billing/plans-subscriptions/components/subscription-form-modal/subscription-form-modal.html index c7838160..39df1192 100644 --- a/src/app/features/billing/plans-subscriptions/components/subscription-form-modal/subscription-form-modal.html +++ b/src/app/features/billing/plans-subscriptions/components/subscription-form-modal/subscription-form-modal.html @@ -6,12 +6,14 @@ [submitLabel]="isChangePlanMode() ? 'Change Plan' : 'Save'" [loadingLabel]="isChangePlanMode() ? 'Changing...' : 'Saving...'" [loading]="saving()" + [showSubmitButton]="!isViewMode()" + [cancelLabel]="isViewMode() ? 'Close' : 'Cancel'" (closed)="closeModal()" (submitted)="save()" >
- @if (isCreateMode()) { + @if (isCreateMode() || isViewMode()) {
- @if (isCreateMode()) { + @if (isCreateMode() || isViewMode()) {
} - @if (isCreateMode()) { + @if (isCreateMode() || isViewMode()) {
@@ -98,6 +104,7 @@ variant="floating" label="Ends On" mode="single" + [readonly]="isViewMode()" [submitAttempted]="submitAttempted()" />
diff --git a/src/app/features/billing/plans-subscriptions/components/subscription-form-modal/subscription-form-modal.ts b/src/app/features/billing/plans-subscriptions/components/subscription-form-modal/subscription-form-modal.ts index 497a4976..621dda93 100644 --- a/src/app/features/billing/plans-subscriptions/components/subscription-form-modal/subscription-form-modal.ts +++ b/src/app/features/billing/plans-subscriptions/components/subscription-form-modal/subscription-form-modal.ts @@ -12,7 +12,7 @@ import { import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { HttpErrorResponse } from '@angular/common/http'; -import { of } from 'rxjs'; +import { forkJoin, of } from 'rxjs'; import { catchError, finalize, map, switchMap } from 'rxjs/operators'; import { NotificationService } from '../../../../../core/services/common/notification.service'; @@ -76,6 +76,7 @@ export class SubscriptionFormModalComponent { readonly isCreateMode = computed(() => this.mode() === 'create'); readonly isChangePlanMode = computed(() => this.mode() === 'change-plan'); + readonly isViewMode = computed(() => this.mode() === 'view'); readonly statusOptions = signal[]>([ { value: SubscriptionStatus.Trialing, label: 'Trialing' }, @@ -85,7 +86,13 @@ export class SubscriptionFormModalComponent { { value: SubscriptionStatus.Expired, label: 'Expired' } ]); - readonly modalTitle = computed(() => this.isChangePlanMode() ? 'Change Plan' : 'Add New Subscription'); + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': return 'Add New Subscription'; + case 'change-plan': return 'Change Plan'; + case 'view': return 'View Subscription'; + } + }); readonly searchTenants: AutocompleteSearchFn = (term, limit) => this.tenantApi.autocomplete(term, limit).pipe(catchError(() => of([]))); @@ -128,19 +135,38 @@ export class SubscriptionFormModalComponent { } const subscriptionId = this.subscriptionId(); - if (this.isChangePlanMode() && subscriptionId) { + if ((this.isChangePlanMode() || this.isViewMode()) && subscriptionId) { this.subscriptionApi.getById(subscriptionId).pipe( switchMap(subscription => { - this.subscriptionForm.controls.planId.setValue(subscription.planId); - return this.planApi.getPlanById(subscription.planId).pipe( - catchError(() => of(null)) - ); + const startsDate = subscription.startsOn ? subscription.startsOn.split('T')[0] : null; + const endsDate = subscription.endsOn ? subscription.endsOn.split('T')[0] : null; + + this.subscriptionForm.patchValue({ + tenantId: subscription.tenantId, + planId: subscription.planId, + status: subscription.status, + startsOn: startsDate, + endsOn: endsDate + }); + + const tenant$ = subscription.tenantId + ? this.tenantApi.getTenantById(subscription.tenantId).pipe(catchError(() => of(null))) + : of(null); + + const plan$ = subscription.planId + ? this.planApi.getPlanById(subscription.planId).pipe(catchError(() => of(null))) + : of(null); + + return forkJoin({ tenant: tenant$, plan: plan$ }); }), catchError(() => of(null)), takeUntilDestroyed(this.destroyRef) - ).subscribe(plan => { - if (plan) { - this.selectedPlan.set({ id: plan.id, code: plan.code, name: plan.name }); + ).subscribe(res => { + if (res?.tenant) { + this.selectedTenant.set({ id: res.tenant.id, code: res.tenant.code, name: res.tenant.name }); + } + if (res?.plan) { + this.selectedPlan.set({ id: res.plan.id, code: res.plan.code, name: res.plan.name }); } }); } @@ -165,6 +191,10 @@ export class SubscriptionFormModalComponent { } save(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } this.submitAttempted.set(true); if (this.subscriptionForm.invalid || this.saving()) return; diff --git a/src/app/features/billing/plans-subscriptions/data-access/subscription.endpoints.ts b/src/app/features/billing/plans-subscriptions/data-access/subscription.endpoints.ts index 84506ab7..2dd9d9be 100644 --- a/src/app/features/billing/plans-subscriptions/data-access/subscription.endpoints.ts +++ b/src/app/features/billing/plans-subscriptions/data-access/subscription.endpoints.ts @@ -1,12 +1,7 @@ import { buildApiUrl } from '../../../../core/config/api-url.util'; export const SUBSCRIPTION_ENDPOINTS = { - /** - * Not yet implemented on the backend (SubscriptionsController currently only exposes - * Create/ChangePlan/Cancel/UpdateStatus/Delete/GetById — no list endpoint). This targets - * the DataTableRequest/DataTableResponse convention every other grid in this app uses - * (Plans, Currency, Tenants, ...), so the UI is ready the moment it ships. - */ + dataTable: buildApiUrl('masterAdmin', '/v1/subscriptions/datatable'), create: buildApiUrl('masterAdmin', '/v1/subscriptions'), changePlan: buildApiUrl('masterAdmin', '/v1/subscriptions/change-plan'), @@ -17,11 +12,7 @@ export const SUBSCRIPTION_ENDPOINTS = { buildApiUrl('masterAdmin', `/v1/subscriptions/${encodeURIComponent(id)}`), getById: (id: string) => buildApiUrl('masterAdmin', `/v1/subscriptions/${encodeURIComponent(id)}`), - /** - * Not yet implemented on the backend (SubscriptionsController currently only - * exposes GetById). Requesting this path lets the UI wire up the moment the - * endpoint ships, without another round of client changes. - */ + getByTenantId: (tenantId: string) => buildApiUrl('masterAdmin', `/v1/subscriptions/by-tenant/${encodeURIComponent(tenantId)}`) } as const; diff --git a/src/app/features/billing/plans-subscriptions/models/subscription.model.ts b/src/app/features/billing/plans-subscriptions/models/subscription.model.ts index 69bed97d..96e0837d 100644 --- a/src/app/features/billing/plans-subscriptions/models/subscription.model.ts +++ b/src/app/features/billing/plans-subscriptions/models/subscription.model.ts @@ -56,7 +56,7 @@ export interface UpdateSubscriptionStatusRequest { isActive: boolean; } -export type SubscriptionFormMode = 'create' | 'change-plan'; +export type SubscriptionFormMode = 'create' | 'change-plan' | 'view'; export interface SubscriptionTableRow extends DataTableRecord { readonly id: string; diff --git a/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.html b/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.html index e6c0b22e..1564bc9e 100644 --- a/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.html +++ b/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.html @@ -10,7 +10,7 @@ tableTitle="Tenant Subscriptions" buttonTitle="Add" [showSearch]="true" - [showAddButton]="true" + [showAddButton]="false" searchPlaceholder="Search subscriptions..." [searchDebounceTime]="300" (addClicked)="onAddSubscription()" @@ -23,6 +23,7 @@ + + (null); - readonly cancelConfirmDialog = viewChild(ConfirmDialog); + readonly deletingId = signal(null); + readonly pendingDeleteSubscription = signal(null); + readonly cancelConfirmDialog = viewChild('cancelConfirmDialog'); + readonly deleteConfirmDialog = viewChild('deleteConfirmDialog'); private pendingCancelId: string | null = null; readonly showSubscriptionModal = signal(false); @@ -134,19 +139,7 @@ export class PlansSubscriptions implements OnInit { readonly actions = signal[]>([ { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-primary' }, - { type: 'change-plan', label: 'Change Plan', icon: 'ti ti-replace', className: 'text-primary' }, - { - type: 'deactivate', label: 'Deactivate', icon: 'ti ti-toggle-right', className: 'text-warning', - visible: row => row.isActive, disabled: row => this.statusChangingId() === row.id - }, - { - type: 'activate', label: 'Activate', icon: 'ti ti-toggle-left', className: 'text-success', - visible: row => !row.isActive, disabled: row => this.statusChangingId() === row.id - }, - { - type: 'cancel', label: 'Cancel Subscription', icon: 'ti ti-x', className: 'text-danger', - visible: row => row.status !== SubscriptionStatus.Canceled && row.status !== SubscriptionStatus.Expired - } + { type: 'edit', label: 'Edit', icon: 'ti ti-pencil', className: 'text-primary' } ]); readonly searchTenants: AutocompleteSearchFn = (term, limit) => @@ -194,22 +187,16 @@ export class PlansSubscriptions implements OnInit { this.applyRowAsSelection(row); switch (event.action.type) { - case 'activate': - this.changeSubscriptionStatus(row, true); - break; - case 'deactivate': - this.changeSubscriptionStatus(row, false); - break; - case 'change-plan': - this.subscriptionModalMode.set('change-plan'); + case 'view': + this.subscriptionModalMode.set('view'); this.subscriptionModalSubscriptionId.set(row.id); this.showSubscriptionModal.set(true); break; - case 'cancel': - this.pendingCancelId = row.id; - this.cancelConfirmDialog()?.open(); + case 'edit': + void this.router.navigate(['/organizations/onboarding'], { + queryParams: { id: row.tenantId, step: 'plan-limits' } + }); break; - // 'view' just populates the Subscription detail panel below via applyRowAsSelection. } } @@ -233,7 +220,6 @@ export class PlansSubscriptions implements OnInit { }); } - /** Populates the tenant-scoped detail panel directly from a grid row — no extra API call needed. */ private applyRowAsSelection(row: SubscriptionTableRow): void { this.selectedTenant.set({ id: row.tenantId, name: row.tenantName, code: '' }); this.tenantForm.controls.tenantId.setValue(row.tenantId); @@ -286,19 +272,12 @@ export class PlansSubscriptions implements OnInit { this.subscriptionPlan.set(plan); }, error: () => { - // The tenant-lookup endpoint doesn't exist on the backend yet (see - // SubscriptionService.getByTenantId) — surface that distinctly from a - // real failure so the empty state reads correctly either way. this.subscriptionLookupUnavailable.set(true); } }); } - /** - * Applies a subscription returned directly from a mutation (create/change-plan/cancel/ - * edit-status) instead of re-fetching via getByTenantId — that lookup endpoint doesn't - * exist on the backend yet, so relying on it here would misreport a successful action. - */ + private applySubscription(subscription: SubscriptionDto): void { this.subscription.set(subscription); this.subscriptionLookupUnavailable.set(false); @@ -367,4 +346,40 @@ export class PlansSubscriptions implements OnInit { onCancelDismissed(): void { this.pendingCancelId = null; } + + onDeleteConfirmed(): void { + const sub = this.pendingDeleteSubscription(); + this.pendingDeleteSubscription.set(null); + if (!sub) return; + + this.deletingId.set(sub.id); + this.subscriptionApi.delete(sub.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.notification.success('Subscription deleted successfully.'); + if (this.subscription()?.id === sub.id) { + this.subscription.set(null); + this.subscriptionPlan.set(null); + } + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete subscription.'; + if (err?.status === 409) { + errorMsg = err?.error?.detail || err?.error?.message || 'Cannot delete subscription due to active dependency or conflict.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Subscription not found or has already been deleted.'; + } else if (err?.error?.detail || err?.error?.message || err?.error?.title) { + errorMsg = err.error.detail || err.error.message || err.error.title; + } + this.notification.error(errorMsg); + } + }); + } + + onDeleteDismissed(): void { + this.pendingDeleteSubscription.set(null); + } } diff --git a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html index f19802ab..9c2691eb 100644 --- a/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html +++ b/src/app/features/global-masters/countries/components/country-form-modal/country-form-modal.html @@ -1,7 +1,7 @@ -
- -
-
diff --git a/src/app/features/global-masters/exchange-rates/pages/exchange-rate-list/exchange-rate-list.ts b/src/app/features/global-masters/exchange-rates/pages/exchange-rate-list/exchange-rate-list.ts index c71b1c77..55753b22 100644 --- a/src/app/features/global-masters/exchange-rates/pages/exchange-rate-list/exchange-rate-list.ts +++ b/src/app/features/global-masters/exchange-rates/pages/exchange-rate-list/exchange-rate-list.ts @@ -5,12 +5,10 @@ import { DatePipe, DecimalPipe } from '@angular/common'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { NotificationService } from '../../../../../core/services/common/notification.service'; -import { ExchangeRateDto, ExchangeRateFilterParams } from '../../models/exchange-rate.model'; +import { ExchangeRateDto } from '../../models/exchange-rate.model'; import { ExchangeRateService } from '../../data-access/exchange-rate.service'; import { CurrencyService } from '../../../currencies/data-access/currency.service'; import { CurrencyLookupDto } from '../../../currencies/models/currency.model'; -import { OrganizationService } from '../../../../organizations/pages/organization-list/data-access/organization.service'; -import { OrganizationLookupDto } from '../../../../organizations/pages/organization-list/models/organization.model'; import { DataTable, DataTableToolbarDirective, DataTableCellDirective } from '../../../../../shared/components/data-table/data-table'; import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; @@ -20,12 +18,6 @@ import { DataTableColumn, DataTableRecord } from '../../../../../shared/components/data-table/data-table.types'; -import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; -import { - AutocompleteDisplayFn, - AutocompleteSearchFn, - AutocompleteValueFn -} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; import { Button } from '../../../../../shared/components/button/button'; import { ConfirmDialog } from '../../../../../shared/components/confirm-dialog/confirm-dialog'; @@ -59,7 +51,6 @@ export interface ExchangeRateTableRow extends DataTableRecord { DataTable, DataTableToolbarDirective, DataTableCellDirective, - Autocomplete, FormInput, Button, ConfirmDialog, @@ -76,7 +67,6 @@ export class ExchangeRateList implements OnInit { private readonly formBuilder = inject(FormBuilder); private readonly exchangeRateApi = inject(ExchangeRateService); private readonly currencyService = inject(CurrencyService); - private readonly orgService = inject(OrganizationService); private readonly notification = inject(NotificationService); readonly tableStore = inject(DataTableStore); @@ -89,12 +79,10 @@ export class ExchangeRateList implements OnInit { readonly closeConfirmDialog = viewChild('closeDialog', { read: ConfirmDialog }); readonly showFilters = signal(false); - readonly selectedOrgLookup = signal(null); readonly currencyCodeMap = signal>({}); private readonly pendingCurrencyFetchIds = new Set(); readonly filterForm = this.formBuilder.group({ - organizationId: [''], rateType: [''] }); @@ -127,11 +115,9 @@ export class ExchangeRateList implements OnInit { this.tableStore.initialize({ fetcher: query => { - const orgId = this.filterForm.controls.organizationId.value; const rType = this.filterForm.controls.rateType.value; const fullQuery = { ...query, - ...(orgId ? { organizationId: orgId } : {}), ...(rType ? { rateType: rType } : {}) }; return this.exchangeRateApi.getExchangeRateDataTable(fullQuery); @@ -180,18 +166,6 @@ export class ExchangeRateList implements OnInit { return id ? id.substring(0, 8) + '...' : '-'; } - readonly searchOrganizations: AutocompleteSearchFn = (term, limit) => { - return this.orgService.autocomplete(term, limit || 20); - }; - - readonly displayOrg: AutocompleteDisplayFn = org => org?.name ?? ''; - readonly orgValue: AutocompleteValueFn = org => org?.id ?? ''; - - onOrgSelected(org: OrganizationLookupDto | null): void { - this.selectedOrgLookup.set(org); - this.filterForm.patchValue({ organizationId: org?.id ?? '' }); - } - onToggleFilters(): void { this.showFilters.update(v => !v); } @@ -201,9 +175,7 @@ export class ExchangeRateList implements OnInit { } onResetFilter(): void { - this.selectedOrgLookup.set(null); this.filterForm.reset({ - organizationId: '', rateType: '' }); this.tableStore.refresh(); diff --git a/src/app/features/localization/components/translation-form-modal/translation-form-modal.html b/src/app/features/localization/components/translation-form-modal/translation-form-modal.html deleted file mode 100644 index 559f776e..00000000 --- a/src/app/features/localization/components/translation-form-modal/translation-form-modal.html +++ /dev/null @@ -1,126 +0,0 @@ - - -
- -
- -
- - -
- -
- - -
-
- Translations -
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
- Tenant Override - Check if this is a tenant-specific text override. -
- -
- - @if (form.controls.isTenantOverride.value) { -
- -
- } -
-
- -
diff --git a/src/app/features/localization/components/translation-form-modal/translation-form-modal.ts b/src/app/features/localization/components/translation-form-modal/translation-form-modal.ts deleted file mode 100644 index b1056247..00000000 --- a/src/app/features/localization/components/translation-form-modal/translation-form-modal.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Component, ChangeDetectionStrategy, input, output, effect, inject, signal } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { of } from 'rxjs'; -import { Modal } from '../../../../shared/components/modal/modal'; -import { FormInput } from '../../../../shared/components/form/form-input/form-input'; -import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete'; -import { AutocompleteDisplayFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; -import { CreateTranslationFormValue, TranslationMatrixRow } from '../../models/localized-text.model'; - -export interface ModuleOptionItem { - id: string; - name: string; -} - -@Component({ - selector: 'app-translation-form-modal', - standalone: true, - imports: [ReactiveFormsModule, Modal, FormInput, Autocomplete], - templateUrl: './translation-form-modal.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class TranslationFormModalComponent { - private readonly fb = inject(FormBuilder); - - readonly open = input(false); - readonly mode = input<'add' | 'edit'>('add'); - readonly initialData = input(null); - readonly saving = input(false); - - readonly saved = output(); - readonly closed = output(); - - readonly submitAttempted = signal(false); - - readonly moduleOptions: ModuleOptionItem[] = [ - { id: 'Common', name: 'Common' }, - { id: 'Login', name: 'Login' }, - { id: 'Dashboard', name: 'Dashboard' }, - { id: 'Organizations', name: 'Organizations' }, - { id: 'Billing', name: 'Billing' }, - { id: 'Settings', name: 'Settings' } - ]; - - readonly selectedModuleOption = signal(this.moduleOptions[0]); - - readonly searchModules: AutocompleteSearchFn = (term: string) => { - const query = term.toLowerCase().trim(); - const filtered = query - ? this.moduleOptions.filter(m => m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query)) - : this.moduleOptions; - return of(filtered); - }; - - readonly displayModule: AutocompleteDisplayFn = item => item.name; - readonly moduleValue: AutocompleteValueFn = item => item.id; - - readonly form = this.fb.group({ - key: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_\-\.]+$/)]], - module: ['Common', [Validators.required]], - enUs: ['', [Validators.required]], - arSa: [''], - hiIn: [''], - isTenantOverride: [false], - tenantName: ['TNT One'] - }); - - constructor() { - effect(() => { - if (this.open()) { - this.submitAttempted.set(false); - const row = this.initialData(); - if (this.mode() === 'edit' && row) { - const modValue = row.module || 'Common'; - const matchedModule = this.moduleOptions.find(m => m.id === modValue) || { id: modValue, name: modValue }; - this.selectedModuleOption.set(matchedModule); - - this.form.reset({ - key: row.key, - module: modValue, - enUs: row.translations['en-US'] || '', - arSa: row.translations['ar-SA'] || '', - hiIn: row.translations['hi-IN'] || '', - isTenantOverride: !!row.tenantId || row.overrideStatus !== '—', - tenantName: row.tenantName || (row.overrideStatus !== '—' ? row.overrideStatus : 'TNT One') - }); - } else { - this.selectedModuleOption.set(this.moduleOptions[0]); - this.form.reset({ - key: '', - module: 'Common', - enUs: '', - arSa: '', - hiIn: '', - isTenantOverride: false, - tenantName: 'TNT One' - }); - } - } - }); - } - - onModuleSelected(module: ModuleOptionItem | null): void { - this.selectedModuleOption.set(module); - this.form.controls.module.setValue(module ? module.id : ''); - } - - onSubmit(): void { - this.submitAttempted.set(true); - - if (this.form.invalid || this.saving()) { - this.form.markAllAsTouched(); - return; - } - - const val = this.form.getRawValue(); - this.saved.emit({ - key: val.key ?? '', - module: val.module ?? 'Common', - enUs: val.enUs ?? '', - arSa: val.arSa ?? '', - hiIn: val.hiIn ?? '', - tenantId: val.isTenantOverride ? 'tenant-101' : null, - tenantName: val.isTenantOverride ? val.tenantName : null - }); - } - - onClose(): void { - this.closed.emit(); - } -} diff --git a/src/app/features/localization/components/translation-header-filters/translation-header-filters.html b/src/app/features/localization/components/translation-header-filters/translation-header-filters.html deleted file mode 100644 index 2d01fd90..00000000 --- a/src/app/features/localization/components/translation-header-filters/translation-header-filters.html +++ /dev/null @@ -1,58 +0,0 @@ -
-
- -
-
- -
- -
- - -
- -
- - -
- -
-
- - -
- -
-
diff --git a/src/app/features/localization/components/translation-header-filters/translation-header-filters.ts b/src/app/features/localization/components/translation-header-filters/translation-header-filters.ts deleted file mode 100644 index 6f6573db..00000000 --- a/src/app/features/localization/components/translation-header-filters/translation-header-filters.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -export interface HeaderFilterChange { - searchKey: string; - module: string; - languageId: string; -} - -@Component({ - selector: 'app-translation-header-filters', - standalone: true, - imports: [FormsModule], - templateUrl: './translation-header-filters.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class TranslationHeaderFiltersComponent { - readonly modules = input([ - 'ALL', - 'Common', - 'Login', - 'Dashboard', - 'Organizations', - 'Billing', - 'Settings' - ]); - - readonly languages = input<{ id: string; name: string }[]>([ - { id: 'ALL', name: 'All Languages' }, - { id: 'en-US', name: 'English (en-US)' }, - { id: 'ar-SA', name: 'Arabic (ar-SA)' }, - { id: 'hi-IN', name: 'Hindi (hi-IN)' } - ]); - - readonly filterChanged = output(); - readonly addClicked = output(); - - readonly searchKey = signal(''); - readonly selectedModule = signal('ALL'); - readonly selectedLanguage = signal('ALL'); - - onSearchInput(value: string): void { - this.searchKey.set(value); - this.emitFilterChange(); - } - - onModuleChange(value: string): void { - this.selectedModule.set(value); - this.emitFilterChange(); - } - - onLanguageChange(value: string): void { - this.selectedLanguage.set(value); - this.emitFilterChange(); - } - - onAddTranslation(): void { - this.addClicked.emit(); - } - - private emitFilterChange(): void { - this.filterChanged.emit({ - searchKey: this.searchKey(), - module: this.selectedModule(), - languageId: this.selectedLanguage() - }); - } -} diff --git a/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.html b/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.html deleted file mode 100644 index 78ff19f8..00000000 --- a/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.html +++ /dev/null @@ -1,175 +0,0 @@ -
-
- - - - - - - - - - - - @if (loading()) { - - - - } @else if (rows().length === 0) { - - - - } @else { - @for (row of rows(); track row.id) { - - - - - - - - - - - - - - - - - } - } - -
Keyen-USar-SAhi-INOverride
-
- - Loading translation matrix... -
-
- - No translations found matching current search/filter. -
-
- {{ row.key }} -
-
- - {{ row.module }} - -
-
-
- - @if (row.dirtyFlags['en-US']) { - - } -
-
-
- - @if (row.dirtyFlags['ar-SA']) { - - } -
-
-
- - @if (row.dirtyFlags['hi-IN']) { - - } -
-
- @if (row.overrideStatus === '—' || !row.overrideStatus || row.overrideStatus === 'Global') { - - } @else { - - {{ row.overrideStatus }} - - } -
-
-
- - -
-
- -

- tenant_id NULL = global text; a tenant row overrides just that tenant. Missing-translation report + bulk import/export planned. -

-
-
- - - diff --git a/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.ts b/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.ts deleted file mode 100644 index fcdcfea4..00000000 --- a/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { TranslationMatrixRow } from '../../models/localized-text.model'; - -export interface MatrixCellValueChange { - rowId: string; - key: string; - languageCode: string; - newValue: string; -} - -@Component({ - selector: 'app-translation-matrix-table', - standalone: true, - imports: [FormsModule], - templateUrl: './translation-matrix-table.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class TranslationMatrixTableComponent { - readonly rows = input([]); - readonly loading = input(false); - readonly saving = input(false); - - readonly cellChanged = output(); - readonly importCsvClicked = output(); - readonly exportClicked = output(); - readonly saveChangesClicked = output(); - - readonly hasDirtyChanges = computed(() => { - return this.rows().some(row => - Object.values(row.dirtyFlags || {}).some(dirty => dirty) - ); - }); - - readonly dirtyCount = computed(() => { - let count = 0; - this.rows().forEach(row => { - Object.values(row.dirtyFlags || {}).forEach(dirty => { - if (dirty) count++; - }); - }); - return count; - }); - - onTranslationInput(row: TranslationMatrixRow, langCode: string, value: string): void { - this.cellChanged.emit({ - rowId: row.id, - key: row.key, - languageCode: langCode, - newValue: value - }); - } - - onImportCsv(): void { - this.importCsvClicked.emit(); - } - - onExport(): void { - this.exportClicked.emit(); - } - - onSaveChanges(): void { - if (this.hasDirtyChanges() && !this.saving()) { - this.saveChangesClicked.emit(); - } - } -} diff --git a/src/app/features/localization/data-access/localized-text.endpoints.ts b/src/app/features/localization/data-access/localized-text.endpoints.ts deleted file mode 100644 index 13a1f574..00000000 --- a/src/app/features/localization/data-access/localized-text.endpoints.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { buildApiUrl } from '../../../core/config/api-url.util'; - -export const LOCALIZED_TEXT_ENDPOINTS = { - getSingle: buildApiUrl('masterAdmin', '/v1/localized-texts'), - upsert: buildApiUrl('masterAdmin', '/v1/localized-texts'), - matrixList: buildApiUrl('masterAdmin', '/v1/localized-texts/matrix') -}; diff --git a/src/app/features/localization/data-access/localized-text.service.ts b/src/app/features/localization/data-access/localized-text.service.ts deleted file mode 100644 index 91ad07b5..00000000 --- a/src/app/features/localization/data-access/localized-text.service.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable, catchError, forkJoin, map, of } from 'rxjs'; - -import { LOCALIZED_TEXT_ENDPOINTS } from './localized-text.endpoints'; -import { - LocalizedTextDto, - LocalizedTextKeyRequest, - TranslationFilter, - TranslationMatrixRow, - UpsertLocalizedTextRequest -} from '../models/localized-text.model'; - -@Injectable({ providedIn: 'root' }) -export class LocalizedTextService { - private readonly http = inject(HttpClient); - - /** - * GET /api/v1/localized-texts - * Passing LocalizedTextKeyRequest parameters as query string returning LocalizedTextDto - */ - getLocalizedText(keyRequest: LocalizedTextKeyRequest): Observable { - const params = this.buildQueryParams(keyRequest); - return this.http.get(LOCALIZED_TEXT_ENDPOINTS.getSingle, { params }); - } - - /** - * PUT /api/v1/localized-texts - * Accepting LocalizedTextKeyRequest via query string and UpsertLocalizedTextRequest in request body - */ - upsertLocalizedText( - keyRequest: LocalizedTextKeyRequest, - payload: UpsertLocalizedTextRequest - ): Observable { - const params = this.buildQueryParams(keyRequest); - return this.http.put(LOCALIZED_TEXT_ENDPOINTS.upsert, payload, { params }); - } - - /** - * Query translations matrix list from wireframe-compliant defaults - */ - getTranslationsMatrix(filter?: TranslationFilter): Observable { - return of(this.getInitialDefaultMatrix()); - } - - /** - * Save multiple translation modifications in batch across languages - */ - saveBatchTranslations( - items: { keyRequest: LocalizedTextKeyRequest; payload: UpsertLocalizedTextRequest }[] - ): Observable { - if (items.length === 0) { - return of([]); - } - - const requests = items.map(item => this.upsertLocalizedText(item.keyRequest, item.payload).pipe( - catchError(err => { - console.warn('Individual upsert failed, continuing batch:', err); - return of({ - fieldName: item.keyRequest.fieldName ?? '', - translatedValue: item.payload.translatedValue, - languageCode: item.keyRequest.languageId ?? '' - } as LocalizedTextDto); - }) - )); - - return forkJoin(requests); - } - - /** - * Export translation matrix to CSV format - */ - exportToCsv(rows: TranslationMatrixRow[], filename = 'translations_export.csv'): void { - const headers = ['Key', 'Module', 'en-US', 'ar-SA', 'hi-IN', 'Override']; - const csvLines = [headers.join(',')]; - - rows.forEach(row => { - const line = [ - `"${(row.key || '').replace(/"/g, '""')}"`, - `"${(row.module || '').replace(/"/g, '""')}"`, - `"${(row.translations['en-US'] || '').replace(/"/g, '""')}"`, - `"${(row.translations['ar-SA'] || '').replace(/"/g, '""')}"`, - `"${(row.translations['hi-IN'] || '').replace(/"/g, '""')}"`, - `"${(row.overrideStatus || '—').replace(/"/g, '""')}"` - ]; - csvLines.push(line.join(',')); - }); - - const blob = new Blob([csvLines.join('\n')], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.setAttribute('href', url); - link.setAttribute('download', filename); - link.style.visibility = 'hidden'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } - - /** - * Parse CSV content into structured matrix rows - */ - parseCsv(csvContent: string): Partial[] { - const lines = csvContent.split(/\r\n|\n/); - if (lines.length < 2) return []; - - const parsedRows: Partial[] = []; - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - // Basic CSV splitter handling quotes - const matches = line.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g); - if (matches && matches.length >= 4) { - const clean = (val: string) => val.replace(/^"|"$/g, '').replace(/""/g, '"'); - const key = clean(matches[0]); - const moduleName = matches[1] ? clean(matches[1]) : 'Common'; - const enUs = clean(matches[2]); - const arSa = matches[3] ? clean(matches[3]) : ''; - const hiIn = matches[4] ? clean(matches[4]) : ''; - const overrideStatus = matches[5] ? clean(matches[5]) : '—'; - - parsedRows.push({ - key, - module: moduleName, - translations: { - 'en-US': enUs, - 'ar-SA': arSa, - 'hi-IN': hiIn - }, - overrideStatus - }); - } - } - return parsedRows; - } - - private buildQueryParams(keyRequest: LocalizedTextKeyRequest): HttpParams { - let params = new HttpParams(); - if (keyRequest.tenantId) params = params.set('TenantId', keyRequest.tenantId); - if (keyRequest.entityType) params = params.set('EntityType', keyRequest.entityType); - if (keyRequest.entityId) params = params.set('EntityId', keyRequest.entityId); - if (keyRequest.fieldName) params = params.set('FieldName', keyRequest.fieldName); - if (keyRequest.languageId) params = params.set('LanguageId', keyRequest.languageId); - return params; - } - - private getInitialDefaultMatrix(): TranslationMatrixRow[] { - return [ - { - id: '1', - key: 'k_common_global_btn_save', - module: 'Common', - tenantId: null, - entityType: 'Global', - entityId: null, - overrideStatus: '—', - translations: { - 'en-US': 'Save', - 'ar-SA': 'حفظ', - 'hi-IN': 'सहेजें' - }, - originalTranslations: { - 'en-US': 'Save', - 'ar-SA': 'حفظ', - 'hi-IN': 'सहेजें' - }, - dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } - }, - { - id: '2', - key: 'k_common_global_btn_cancel', - module: 'Common', - tenantId: null, - entityType: 'Global', - entityId: null, - overrideStatus: '—', - translations: { - 'en-US': 'Cancel', - 'ar-SA': 'إلغاء', - 'hi-IN': 'रद्द करें' - }, - originalTranslations: { - 'en-US': 'Cancel', - 'ar-SA': 'إلغاء', - 'hi-IN': 'रद्द करें' - }, - dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } - }, - { - id: '3', - key: 'k_login_signin_title_signin', - module: 'Login', - tenantId: 'tenant-101', - tenantName: 'TNT One', - entityType: 'Tenant', - entityId: 'tenant-101', - overrideStatus: 'TNT One', - translations: { - 'en-US': 'Sign In to Portal', - 'ar-SA': 'تسجيل الدخول إلى البوابة', - 'hi-IN': 'पोर्टल पर साइन इन करें' - }, - originalTranslations: { - 'en-US': 'Sign In to Portal', - 'ar-SA': 'تسجيل الدخول إلى البوابة', - 'hi-IN': 'पोर्टل पर साइन इन करें' - }, - dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } - }, - { - id: '4', - key: 'k_login_label_password', - module: 'Login', - tenantId: null, - entityType: 'Global', - entityId: null, - overrideStatus: '—', - translations: { - 'en-US': 'Password', - 'ar-SA': 'كلمة المرور', - 'hi-IN': 'पासवर्ड' - }, - originalTranslations: { - 'en-US': 'Password', - 'ar-SA': 'كلمة المرور', - 'hi-IN': 'पासवर्ड' - }, - dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } - }, - { - id: '5', - key: 'k_dashboard_widget_total_revenue', - module: 'Dashboard', - tenantId: null, - entityType: 'Global', - entityId: null, - overrideStatus: '—', - translations: { - 'en-US': 'Total Revenue', - 'ar-SA': 'إجمالي الإيرادات', - 'hi-IN': 'कुल राजस्व' - }, - originalTranslations: { - 'en-US': 'Total Revenue', - 'ar-SA': 'إجمالي الإيرادات', - 'hi-IN': 'कुल राजस्व' - }, - dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } - }, - { - id: '6', - key: 'k_org_onboarding_step_basics', - module: 'Organizations', - tenantId: null, - entityType: 'Global', - entityId: null, - overrideStatus: '—', - translations: { - 'en-US': 'Organization Basics', - 'ar-SA': 'أساسيات المنظمة', - 'hi-IN': 'संगठन मूल बातें' - }, - originalTranslations: { - 'en-US': 'Organization Basics', - 'ar-SA': 'أساسيات المنظمة', - 'hi-IN': 'संगठन मूल बातें' - }, - dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } - } - ]; - } -} diff --git a/src/app/features/localization/localization.routes.ts b/src/app/features/localization/localization.routes.ts index 57ff42a3..197f50b6 100644 --- a/src/app/features/localization/localization.routes.ts +++ b/src/app/features/localization/localization.routes.ts @@ -1,18 +1,31 @@ import { Routes } from '@angular/router'; -import { TranslationsManagerComponent } from './pages/translations-manager/translations-manager'; +import { superAdminGuard } from '../../core/guards/auth/super-admin.guard'; export const localizationRoutes: Routes = [ { - path: '', - redirectTo: 'translations-manager', - pathMatch: 'full' + path: 'translations-manager', + canActivate: [superAdminGuard], + loadComponent: () => + import('./translations/pages/translation-list/translation-list').then( + m => m.TranslationList + ), + data: { + childTitle: 'Translations Manager', + parentTitle: 'Localization', + subParentTitle: 'Configuration' + } }, { - path: 'translations-manager', - component: TranslationsManagerComponent, + path: 'translation-keys', + canActivate: [superAdminGuard], + loadComponent: () => + import('./translation-keys/pages/translation-key-list/translation-key-list').then( + m => m.TranslationKeyList + ), data: { + childTitle: 'Translation Keys', parentTitle: 'Localization', - childTitle: 'Translations Manager' + subParentTitle: 'Configuration' } } ]; diff --git a/src/app/features/localization/models/localized-text.model.ts b/src/app/features/localization/models/localized-text.model.ts deleted file mode 100644 index 18cf3cf7..00000000 --- a/src/app/features/localization/models/localized-text.model.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { DataTableRecord } from '../../../shared/components/data-table/data-table.types'; - -export interface LocalizedTextKeyRequest { - tenantId?: string | null; - entityType?: string | null; - entityId?: string | null; - fieldName?: string | null; - languageId?: string | null; -} - -export interface LocalizedTextDto { - id?: string; - tenantId?: string | null; - entityType?: string; - entityId?: string | null; - fieldName?: string; - languageId?: string; - languageCode?: string; - translatedValue: string; - isOverride?: boolean; - tenantName?: string | null; -} - -export interface UpsertLocalizedTextRequest { - translatedValue: string; -} - -export interface TranslationMatrixRow extends DataTableRecord { - id: string; - serialNumber?: number; - key: string; - module: string; - tenantId: string | null; - entityType: string; - entityId: string | null; - overrideStatus: string; - tenantName?: string | null; - translations: Record; - originalTranslations: Record; - dirtyFlags: Record; -} - -export interface TranslationFilter { - searchKey: string; - module: string; - languageId: string; - tenantId?: string; -} - -export interface CreateTranslationFormValue { - key: string; - module: string; - enUs: string; - arSa: string; - hiIn: string; - tenantId?: string | null; - tenantName?: string | null; -} diff --git a/src/app/features/localization/pages/translations-manager/translations-manager.html b/src/app/features/localization/pages/translations-manager/translations-manager.html deleted file mode 100644 index c7b1ae56..00000000 --- a/src/app/features/localization/pages/translations-manager/translations-manager.html +++ /dev/null @@ -1,187 +0,0 @@ -
-
- - - -
- -
- -
- - -
- -
- - -
- - -
-
-
- - - -
- {{ value }} -
-
- - - -
-
- {{ value }} -
-
-
- - - -
- - {{ value || 'Common' }} - -
-
- - - -
- - {{ row.translations['en-US'] || '—' }} - -
-
- - - -
- - {{ row.translations['ar-SA'] || '—' }} - -
-
- - - -
- - {{ row.translations['hi-IN'] || '—' }} - -
-
- - - -
- @if (value === '—' || !value || value === 'Global') { - - } @else { - - {{ value }} - - } -
-
-
-
-
- - - - - - diff --git a/src/app/features/localization/pages/translations-manager/translations-manager.ts b/src/app/features/localization/pages/translations-manager/translations-manager.ts deleted file mode 100644 index 80068e01..00000000 --- a/src/app/features/localization/pages/translations-manager/translations-manager.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { Component, ChangeDetectionStrategy, OnInit, inject, signal, computed, DestroyRef, viewChild } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, FormsModule } from '@angular/forms'; -import { finalize, map, catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; - -import { LocalizedTextService } from '../../data-access/localized-text.service'; -import { LanguageService } from '../../../global-masters/languages/data-access/language.service'; -import { NotificationService } from '../../../../core/services/common/notification.service'; -import { - CreateTranslationFormValue, - LocalizedTextKeyRequest, - TranslationFilter, - TranslationMatrixRow, - UpsertLocalizedTextRequest -} from '../../models/localized-text.model'; - -import { DataTable, DataTableCellDirective, DataTableToolbarDirective } from '../../../../shared/components/data-table/data-table'; -import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent } from '../../../../shared/components/data-table/data-table.types'; -import { Button } from '../../../../shared/components/button/button'; -import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; -import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete'; -import { AutocompleteDisplayFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; -import { TranslationFormModalComponent } from '../../components/translation-form-modal/translation-form-modal'; - -export interface ModuleFilterOptionItem { - id: string; - name: string; -} - -export interface LanguageFilterOptionItem { - id: string; - code: string; - name: string; -} - -@Component({ - selector: 'app-translations-manager', - standalone: true, - imports: [ - FormsModule, - ReactiveFormsModule, - DataTable, - DataTableCellDirective, - DataTableToolbarDirective, - Button, - ConfirmDialog, - Autocomplete, - TranslationFormModalComponent - ], - templateUrl: './translations-manager.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class TranslationsManagerComponent implements OnInit { - private readonly destroyRef = inject(DestroyRef); - private readonly localizationApi = inject(LocalizedTextService); - private readonly languageService = inject(LanguageService); - private readonly notification = inject(NotificationService); - private readonly fb = inject(FormBuilder); - - readonly deleteConfirmDialog = viewChild(ConfirmDialog); - - readonly allRows = signal([]); - readonly loading = signal(true); - readonly saving = signal(false); - readonly showFilters = signal(false); - readonly modalOpen = signal(false); - readonly modalMode = signal<'add' | 'edit'>('add'); - readonly selectedRow = signal(null); - readonly pendingDeleteRow = signal(null); - - readonly searchKey = signal(''); - readonly pageIndex = signal(1); - readonly pageSize = signal(15); - - readonly activeFilter = signal<{ module: string; languageId: string }>({ - module: 'ALL', - languageId: 'ALL' - }); - - readonly moduleFilterOptions: ModuleFilterOptionItem[] = [ - { id: 'ALL', name: 'All Modules' }, - { id: 'Common', name: 'Common' }, - { id: 'Login', name: 'Login' }, - { id: 'Dashboard', name: 'Dashboard' }, - { id: 'Organizations', name: 'Organizations' }, - { id: 'Billing', name: 'Billing' }, - { id: 'Settings', name: 'Settings' } - ]; - - readonly defaultLanguageFilterItem: LanguageFilterOptionItem = { id: 'ALL', code: 'ALL', name: 'All Languages' }; - - readonly selectedModuleFilter = signal(this.moduleFilterOptions[0]); - readonly selectedLanguageFilter = signal(this.defaultLanguageFilterItem); - - readonly searchModuleFilters: AutocompleteSearchFn = (term: string) => { - const query = term.toLowerCase().trim(); - const filtered = query - ? this.moduleFilterOptions.filter(m => m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query)) - : this.moduleFilterOptions; - return of(filtered); - }; - - readonly displayModuleFilter: AutocompleteDisplayFn = item => item.name; - readonly moduleFilterValue: AutocompleteValueFn = item => item.id; - - readonly searchLanguageFilters: AutocompleteSearchFn = (term: string) => { - const queryTerm = term && term.trim() ? term.trim() : null; - - return this.languageService.autocomplete(queryTerm, 50).pipe( - map(languages => { - const backendItems: LanguageFilterOptionItem[] = languages.map(l => ({ - id: l.code || l.id, - code: l.code || l.id, - name: `${l.name} (${l.code})` - })); - - return [ - this.defaultLanguageFilterItem, - ...backendItems - ]; - }), - catchError(err => { - console.error('Failed to fetch languages from backend:', err); - return of([ - this.defaultLanguageFilterItem, - { id: 'en-US', code: 'en-US', name: 'English (en-US)' }, - { id: 'ar-SA', code: 'ar-SA', name: 'Arabic (ar-SA)' }, - { id: 'hi-IN', code: 'hi-IN', name: 'Hindi (hi-IN)' } - ]); - }) - ); - }; - - readonly displayLanguageFilter: AutocompleteDisplayFn = item => item.name; - readonly languageFilterValue: AutocompleteValueFn = item => item.code || item.id; - - readonly filterForm = this.fb.group({ - module: ['ALL'], - languageId: ['ALL'] - }); - - readonly columns = signal[]>([ - { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px', align: 'center' }, - { key: 'key', label: 'Key', header: 'Key', sortable: true, align: 'left', width: '220px' }, - { key: 'module', label: 'Module', header: 'Module', sortable: true, align: 'left', width: '140px' }, - { key: 'en-US', label: 'en-US', header: 'en-US', sortable: false, align: 'left' }, - { key: 'ar-SA', label: 'ar-SA', header: 'ar-SA', sortable: false, align: 'left' }, - { key: 'hi-IN', label: 'hi-IN', header: 'hi-IN', sortable: false, align: 'left' }, - { key: 'overrideStatus', label: 'Override', header: 'Override', sortable: false, align: 'center', width: '120px' } - ]); - - readonly actions = signal[]>([ - { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, - { type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger' } - ]); - - readonly filteredRows = computed(() => { - const rows = this.allRows(); - const query = this.searchKey().toLowerCase().trim(); - const filter = this.activeFilter(); - - return rows.filter(row => { - // Search Key matching - if (query) { - const keyMatch = row.key.toLowerCase().includes(query); - const moduleMatch = (row.module || '').toLowerCase().includes(query); - const enMatch = (row.translations['en-US'] || '').toLowerCase().includes(query); - const arMatch = (row.translations['ar-SA'] || '').toLowerCase().includes(query); - const hiMatch = (row.translations['hi-IN'] || '').toLowerCase().includes(query); - if (!keyMatch && !moduleMatch && !enMatch && !arMatch && !hiMatch) { - return false; - } - } - - // Module filter matching - if (filter.module && filter.module !== 'ALL') { - if (row.module !== filter.module) { - return false; - } - } - - // Language filter matching - if (filter.languageId && filter.languageId !== 'ALL') { - const langVal = row.translations[filter.languageId]; - if (!langVal || !langVal.trim()) { - return false; - } - } - - return true; - }); - }); - - readonly totalRecords = computed(() => this.filteredRows().length); - - readonly paginatedRows = computed(() => { - const start = (this.pageIndex() - 1) * this.pageSize(); - const end = start + this.pageSize(); - const sliced = this.filteredRows().slice(start, end); - - return sliced.map((row, index): TranslationMatrixRow => ({ - ...row, - serialNumber: start + index + 1 - })); - }); - - ngOnInit(): void { - this.filterForm.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(val => { - this.activeFilter.set({ - module: val.module || 'ALL', - languageId: val.languageId || 'ALL' - }); - }); - - this.loadTranslations(); - } - - loadTranslations(): void { - this.loading.set(true); - this.localizationApi.getTranslationsMatrix() - .pipe( - finalize(() => this.loading.set(false)), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: (matrixRows) => { - this.allRows.set(matrixRows); - }, - error: (err) => { - console.error('Failed to load translations matrix:', err); - this.notification.error('Failed to load translation matrix.'); - } - }); - } - - onSearchChanged(term: string): void { - this.searchKey.set(term); - this.pageIndex.set(1); - } - - onPageChanged(event: DataTablePageEvent): void { - this.pageIndex.set(event.pageIndex); - this.pageSize.set(event.pageSize); - } - - onToggleFilters(): void { - this.showFilters.update(v => !v); - } - - onFilterModuleSelected(module: ModuleFilterOptionItem | null): void { - this.selectedModuleFilter.set(module); - const modId = module ? module.id : 'ALL'; - this.filterForm.controls.module.setValue(modId); - this.activeFilter.update(f => ({ ...f, module: modId })); - this.pageIndex.set(1); - } - - onFilterLanguageSelected(language: LanguageFilterOptionItem | null): void { - this.selectedLanguageFilter.set(language); - const langId = language ? (language.code || language.id) : 'ALL'; - this.filterForm.controls.languageId.setValue(langId); - this.activeFilter.update(f => ({ ...f, languageId: langId })); - this.pageIndex.set(1); - } - - applyFilters(): void { - const val = this.filterForm.value; - this.activeFilter.set({ - module: val.module || 'ALL', - languageId: val.languageId || 'ALL' - }); - this.pageIndex.set(1); - } - - resetFilters(): void { - this.filterForm.reset({ - module: 'ALL', - languageId: 'ALL' - }); - this.selectedModuleFilter.set(this.moduleFilterOptions[0]); - this.selectedLanguageFilter.set(this.defaultLanguageFilterItem); - this.activeFilter.set({ - module: 'ALL', - languageId: 'ALL' - }); - this.searchKey.set(''); - this.pageIndex.set(1); - } - - onActionClick(event: DataTableActionEvent): void { - if (event.action.type === 'edit') { - this.modalMode.set('edit'); - this.selectedRow.set(event.row); - this.modalOpen.set(true); - } else if (event.action.type === 'delete') { - this.pendingDeleteRow.set(event.row); - this.deleteConfirmDialog()?.open(); - } - } - - onAddTranslationClicked(): void { - this.modalMode.set('add'); - this.selectedRow.set(null); - this.modalOpen.set(true); - } - - onModalClosed(): void { - this.modalOpen.set(false); - this.selectedRow.set(null); - } - - onTranslationFormSaved(val: CreateTranslationFormValue): void { - this.saving.set(true); - - const isEdit = this.modalMode() === 'edit'; - const existing = this.selectedRow(); - const rowId = isEdit && existing ? existing.id : 'row-' + Date.now(); - const overrideStatus = val.tenantId ? (val.tenantName || 'Tenant Override') : '—'; - - const savedRow: TranslationMatrixRow = { - id: rowId, - key: val.key, - module: val.module, - tenantId: val.tenantId ?? null, - tenantName: val.tenantName ?? null, - entityType: val.tenantId ? 'Tenant' : 'Global', - entityId: val.tenantId ?? null, - overrideStatus, - translations: { - 'en-US': val.enUs, - 'ar-SA': val.arSa, - 'hi-IN': val.hiIn - }, - originalTranslations: { - 'en-US': val.enUs, - 'ar-SA': val.arSa, - 'hi-IN': val.hiIn - }, - dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } - }; - - const upsertRequests: { keyRequest: LocalizedTextKeyRequest; payload: UpsertLocalizedTextRequest }[] = [ - { - keyRequest: { tenantId: val.tenantId, entityType: savedRow.entityType, entityId: savedRow.entityId, fieldName: val.key, languageId: 'en-US' }, - payload: { translatedValue: val.enUs } - } - ]; - if (val.arSa) { - upsertRequests.push({ - keyRequest: { tenantId: val.tenantId, entityType: savedRow.entityType, entityId: savedRow.entityId, fieldName: val.key, languageId: 'ar-SA' }, - payload: { translatedValue: val.arSa } - }); - } - if (val.hiIn) { - upsertRequests.push({ - keyRequest: { tenantId: val.tenantId, entityType: savedRow.entityType, entityId: savedRow.entityId, fieldName: val.key, languageId: 'hi-IN' }, - payload: { translatedValue: val.hiIn } - }); - } - - this.localizationApi.saveBatchTranslations(upsertRequests) - .pipe( - finalize(() => { - this.saving.set(false); - this.modalOpen.set(false); - this.selectedRow.set(null); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe({ - next: () => { - if (isEdit) { - this.allRows.update(rows => rows.map(r => r.id === rowId ? savedRow : r)); - this.notification.success(`Translation key '${val.key}' updated successfully.`); - } else { - this.allRows.update(rows => [savedRow, ...rows]); - this.notification.success(`Translation key '${val.key}' added successfully.`); - } - }, - error: (err) => { - console.error('Failed to save translation key:', err); - this.notification.error('Failed to save translation key.'); - } - }); - } - - onDeleteConfirmed(): void { - const target = this.pendingDeleteRow(); - if (!target) return; - this.pendingDeleteRow.set(null); - - this.allRows.update(rows => rows.filter(r => r.id !== target.id)); - this.notification.success(`Translation key '${target.key}' deleted successfully.`); - } - - onDeleteCancelled(): void { - this.pendingDeleteRow.set(null); - } - - onExportCsv(): void { - this.localizationApi.exportToCsv(this.filteredRows(), 'translations_manager_export.csv'); - this.notification.success('Translations exported to CSV file.'); - } - - onImportCsvClicked(): void { - const fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = '.csv'; - fileInput.onchange = (event: Event) => { - const target = event.target as HTMLInputElement; - if (target.files && target.files.length > 0) { - const file = target.files[0]; - const reader = new FileReader(); - reader.onload = (e: ProgressEvent) => { - const content = e.target?.result as string; - if (content) { - const importedPartialRows = this.localizationApi.parseCsv(content); - if (importedPartialRows.length > 0) { - this.applyImportedRows(importedPartialRows); - this.notification.success(`Imported ${importedPartialRows.length} keys from CSV.`); - } else { - this.notification.error('No valid translation rows found in CSV.'); - } - } - }; - reader.readAsText(file); - } - }; - fileInput.click(); - } - - private applyImportedRows(imported: Partial[]): void { - this.allRows.update(existingRows => { - const existingMap = new Map(existingRows.map(r => [r.key, r])); - - imported.forEach(imp => { - if (!imp.key) return; - - if (existingMap.has(imp.key)) { - const existing = existingMap.get(imp.key)!; - const updatedTranslations = { ...existing.translations, ...(imp.translations || {}) }; - - existingMap.set(imp.key, { - ...existing, - translations: updatedTranslations - }); - } else { - const newId = 'row-' + Date.now() + '-' + Math.random().toString(36).substring(2, 6); - const newRow: TranslationMatrixRow = { - id: newId, - key: imp.key, - module: imp.module || 'Common', - tenantId: null, - entityType: 'Global', - entityId: null, - overrideStatus: imp.overrideStatus || '—', - translations: { - 'en-US': imp.translations?.['en-US'] || '', - 'ar-SA': imp.translations?.['ar-SA'] || '', - 'hi-IN': imp.translations?.['hi-IN'] || '' - }, - originalTranslations: { 'en-US': '', 'ar-SA': '', 'hi-IN': '' }, - dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } - }; - existingMap.set(imp.key, newRow); - } - }); - - return Array.from(existingMap.values()); - }); - } -} diff --git a/src/app/features/localization/public-api.ts b/src/app/features/localization/public-api.ts deleted file mode 100644 index 1aade1f6..00000000 --- a/src/app/features/localization/public-api.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './models/localized-text.model'; -export * from './data-access/localized-text.endpoints'; -export * from './data-access/localized-text.service'; -export * from './pages/translations-manager/translations-manager'; -export * from './localization.routes'; diff --git a/src/app/features/localization/translation-keys/components/translation-key-form-modal/translation-key-form-modal.html b/src/app/features/localization/translation-keys/components/translation-key-form-modal/translation-key-form-modal.html new file mode 100644 index 00000000..4c9afe04 --- /dev/null +++ b/src/app/features/localization/translation-keys/components/translation-key-form-modal/translation-key-form-modal.html @@ -0,0 +1,116 @@ + + @if (modalLoading()) { +
+ + Loading key details... +
+ } @else { +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ } +
diff --git a/src/app/features/localization/translation-keys/components/translation-key-form-modal/translation-key-form-modal.ts b/src/app/features/localization/translation-keys/components/translation-key-form-modal/translation-key-form-modal.ts new file mode 100644 index 00000000..4ece4e62 --- /dev/null +++ b/src/app/features/localization/translation-keys/components/translation-key-form-modal/translation-key-form-modal.ts @@ -0,0 +1,216 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { finalize } from 'rxjs/operators'; + +import { NotificationService } from '../../../../../core/services/common/notification.service'; +import { + CreateTranslationKeyRequest, + TranslationKeyDto, + TranslationKeyModalMode, + UpdateTranslationKeyRequest +} from '../../models/translation-key.model'; +import { TranslationKeyService } from '../../data-access/translation-key.service'; +import { Modal } from '../../../../../shared/components/modal/modal'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; + +@Component({ + selector: 'app-translation-key-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput], + templateUrl: './translation-key-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TranslationKeyFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly keyApi = inject(TranslationKeyService); + private readonly notification = inject(NotificationService); + + readonly open = input(false); + readonly mode = input('create'); + readonly translationKeyId = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedKey = signal(null); + + readonly keyForm = this.formBuilder.nonNullable.group({ + keyName: [ + '', + [ + Validators.required, + Validators.maxLength(200), + Validators.pattern(/^[A-Za-z0-9_.-]+$/) + ] + ], + moduleCode: ['', [Validators.maxLength(50)]], + componentType: ['', [Validators.maxLength(50)]], + defaultText: ['', [Validators.required, Validators.maxLength(4000)]], + description: ['', [Validators.maxLength(1000)]] + }); + + readonly isEditMode = computed(() => this.mode() === 'edit'); + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly isCreateMode = computed(() => this.mode() === 'create'); + + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': + return 'Add Translation Key'; + case 'edit': + return 'Edit Translation Key'; + case 'view': + return 'View Translation Key'; + } + }); + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.translationKeyId()); + } + }); + } + + prepareModal(id: string | null): void { + this.submitAttempted.set(false); + this.keyForm.reset({ + keyName: '', + moduleCode: '', + componentType: '', + defaultText: '', + description: '' + }); + + if (!id || this.isCreateMode()) { + this.selectedKey.set(null); + this.modalLoading.set(false); + this.keyForm.enable(); + return; + } + + this.modalLoading.set(true); + this.keyApi + .getById(id) + .pipe( + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: keyDto => { + this.selectedKey.set(keyDto); + this.keyForm.patchValue({ + keyName: keyDto.keyName, + moduleCode: keyDto.moduleCode ?? '', + componentType: keyDto.componentType ?? '', + defaultText: keyDto.defaultText, + description: keyDto.description ?? '' + }); + + this.keyForm.enable(); + + if (this.isEditMode()) { + // KeyName is immutable after creation + this.keyForm.controls.keyName.disable(); + } + }, + error: () => { + this.notification.error('Unable to load translation key details.'); + this.closeModal(); + } + }); + } + + saveKey(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.submitAttempted.set(true); + if (this.keyForm.invalid || this.saving()) return; + + this.saving.set(true); + + if (this.isCreateMode()) { + const request: CreateTranslationKeyRequest = { + keyName: this.keyForm.controls.keyName.value.trim(), + moduleCode: this.keyForm.controls.moduleCode.value.trim() || null, + componentType: this.keyForm.controls.componentType.value.trim() || null, + defaultText: this.keyForm.controls.defaultText.value.trim(), + description: this.keyForm.controls.description.value.trim() || null + }; + + this.keyApi + .create(request) + .pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.notification.success('Translation key created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.translationKeyId(); + if (!id) return; + + const request: UpdateTranslationKeyRequest = { + keyName: this.keyForm.controls.keyName.value.trim() || (this.selectedKey()?.keyName ?? ''), + moduleCode: this.keyForm.controls.moduleCode.value.trim() || null, + componentType: this.keyForm.controls.componentType.value.trim() || null, + defaultText: this.keyForm.controls.defaultText.value.trim(), + description: this.keyForm.controls.description.value.trim() || null + }; + + this.keyApi + .update(id, request) + .pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.notification.success('Translation key updated successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'update') + }); + } + } + + closeModal(): void { + if (this.saving()) return; + this.closed.emit(); + } + + private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void { + if (error.status === 409) { + this.notification.error('A translation key with this KeyName already exists.'); + return; + } + const message = error.error?.message || error.error?.title || `Unable to ${action} translation key.`; + this.notification.error(message); + } +} diff --git a/src/app/features/localization/translation-keys/data-access/translation-key.endpoints.ts b/src/app/features/localization/translation-keys/data-access/translation-key.endpoints.ts new file mode 100644 index 00000000..9d6ad701 --- /dev/null +++ b/src/app/features/localization/translation-keys/data-access/translation-key.endpoints.ts @@ -0,0 +1,12 @@ +import { buildApiUrl } from '../../../../core/config/api-url.util'; + +export const TRANSLATION_KEY_ENDPOINTS = { + base: buildApiUrl('masterAdmin', '/v1/translation-keys'), + create: buildApiUrl('masterAdmin', '/v1/translation-keys'), + update: (id: string) => + buildApiUrl('masterAdmin', `/v1/translation-keys/${encodeURIComponent(id)}`), + getById: (id: string) => + buildApiUrl('masterAdmin', `/v1/translation-keys/${encodeURIComponent(id)}`), + autocomplete: buildApiUrl('masterAdmin', '/v1/translation-keys/autocomplete'), + dataTable: buildApiUrl('masterAdmin', '/v1/translation-keys/datatable') +} as const; diff --git a/src/app/features/localization/translation-keys/data-access/translation-key.service.ts b/src/app/features/localization/translation-keys/data-access/translation-key.service.ts new file mode 100644 index 00000000..ff1b02ae --- /dev/null +++ b/src/app/features/localization/translation-keys/data-access/translation-key.service.ts @@ -0,0 +1,102 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { TRANSLATION_KEY_ENDPOINTS } from './translation-key.endpoints'; +import { + CreateTranslationKeyRequest, + DataTableRequest, + DataTableResponse, + TranslationKeyDto, + TranslationKeyFilterParams, + TranslationKeyLookupDto, + UpdateTranslationKeyRequest +} from '../models/translation-key.model'; +import { + DataTableQuery, + DataTableResult +} from '../../../../shared/components/data-table/data-table.types'; + +@Injectable({ providedIn: 'root' }) +export class TranslationKeyService { + private readonly http = inject(HttpClient); + + /** + * Create a new translation key. + */ + create(request: CreateTranslationKeyRequest): Observable { + return this.http.post(TRANSLATION_KEY_ENDPOINTS.create, request); + } + + /** + * Update an existing translation key by ID. + */ + update(id: string, request: UpdateTranslationKeyRequest): Observable { + return this.http.put(TRANSLATION_KEY_ENDPOINTS.update(id), request); + } + + /** + * Fetch translation key details by ID. + */ + getById(id: string): Observable { + return this.http.get(TRANSLATION_KEY_ENDPOINTS.getById(id)); + } + + /** + * Autocomplete lookup for translation keys. + */ + autocomplete(term?: string, limit: number = 10): Observable { + let params = new HttpParams().set('limit', limit); + if (term && term.trim()) { + params = params.set('term', term.trim()); + } + + return this.http.get( + TRANSLATION_KEY_ENDPOINTS.autocomplete, + { params } + ); + } + + /** + * Server-side data table query execution. + */ + getDataTable(request: DataTableRequest): Observable> { + return this.http.post>( + TRANSLATION_KEY_ENDPOINTS.dataTable, + request + ); + } + + /** + * Helper method integrated with DataTableStore query structure. + */ + getTranslationKeyDataTable( + query: DataTableQuery, + filters?: TranslationKeyFilterParams + ): Observable> { + let params = new HttpParams(); + + if (filters?.moduleCode) { + params = params.set('moduleCode', filters.moduleCode); + } + if (filters?.componentType) { + params = params.set('componentType', filters.componentType); + } + + return this.http + .post>( + TRANSLATION_KEY_ENDPOINTS.dataTable, + query, + { params } + ) + .pipe( + map(response => ({ + draw: response.draw ?? query.draw, + total: response.total ?? response.recordsTotal ?? 0, + filtered: response.filtered ?? response.recordsFiltered ?? response.total ?? response.recordsTotal ?? 0, + rows: response.rows ?? response.data ?? [] + })) + ); + } +} diff --git a/src/app/features/localization/translation-keys/models/translation-key.model.ts b/src/app/features/localization/translation-keys/models/translation-key.model.ts new file mode 100644 index 00000000..542c7321 --- /dev/null +++ b/src/app/features/localization/translation-keys/models/translation-key.model.ts @@ -0,0 +1,75 @@ +import { DataTableRecord } from '../../../../shared/components/data-table/data-table.types'; + +export interface TranslationKeyDto { + id: string; + keyName: string; + moduleCode?: string | null; + componentType?: string | null; + defaultText: string; + description?: string | null; + createdOn: string; + updatedOn: string; +} + +export interface TranslationKeyLookupDto { + id: string; + keyName: string; + defaultText: string; + moduleCode?: string | null; + componentType?: string | null; +} + +export interface CreateTranslationKeyRequest { + keyName: string; + moduleCode?: string | null; + componentType?: string | null; + defaultText: string; + description?: string | null; +} + +export interface UpdateTranslationKeyRequest { + keyName: string; + moduleCode?: string | null; + componentType?: string | null; + defaultText: string; + description?: string | null; +} + +export interface DataTableRequest { + draw?: number; + start: number; + length: number; + search?: { value: string; regex: boolean } | string; + order?: Array<{ column: number; dir: 'asc' | 'desc' }>; + columns?: Array; +} + +export interface DataTableResponse { + draw?: number; + recordsTotal?: number; + recordsFiltered?: number; + total?: number; + filtered?: number; + data?: T[]; + rows?: T[]; +} + +export type TranslationKeyModalMode = 'create' | 'edit' | 'view'; + +export interface TranslationKeyFilterParams { + moduleCode?: string | null; + componentType?: string | null; + searchTerm?: string | null; +} + +export interface TranslationKeyTableRow extends DataTableRecord { + id: string; + keyName: string; + moduleCode?: string | null; + componentType?: string | null; + defaultText: string; + description?: string | null; + serialNumber: number; + createdOn: string; + updatedOn: string; +} diff --git a/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.html b/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.html new file mode 100644 index 00000000..649be08f --- /dev/null +++ b/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.html @@ -0,0 +1,83 @@ + + +
+ + +
+ +
+ + + + +
+ + +
+
+
+
+ + diff --git a/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.scss b/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.scss new file mode 100644 index 00000000..d4718679 --- /dev/null +++ b/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.scss @@ -0,0 +1 @@ +/* Custom styles for Translation Key List */ diff --git a/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.ts b/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.ts new file mode 100644 index 00000000..a2eefeeb --- /dev/null +++ b/src/app/features/localization/translation-keys/pages/translation-key-list/translation-key-list.ts @@ -0,0 +1,155 @@ +import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { NotificationService } from '../../../../../core/services/common/notification.service'; + +import { + TranslationKeyDto, + TranslationKeyFilterParams, + TranslationKeyLookupDto, + TranslationKeyTableRow +} from '../../models/translation-key.model'; +import { TranslationKeyService } from '../../data-access/translation-key.service'; + +import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; +import { + DataTableAction, + DataTableActionEvent, + DataTableColumn +} from '../../../../../shared/components/data-table/data-table.types'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; +import { Button } from '../../../../../shared/components/button/button'; +import { TranslationKeyFormModalComponent } from '../../components/translation-key-form-modal/translation-key-form-modal'; + +@Component({ + selector: 'app-translation-key-list', + standalone: true, + imports: [ + DataTable, + DataTableToolbarDirective, + ReactiveFormsModule, + Autocomplete, + Button, + TranslationKeyFormModalComponent + ], + providers: [DataTableStore], + templateUrl: './translation-key-list.html', + styleUrl: './translation-key-list.scss' +}) +export class TranslationKeyList implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly keyApi = inject(TranslationKeyService); + private readonly formBuilder = inject(FormBuilder); + private readonly notification = inject(NotificationService); + + readonly tableStore = inject(DataTableStore); + + readonly selectedKeyFilter = signal(null); + readonly showFilters = signal(false); + + readonly filterForm = this.formBuilder.group({ + moduleCode: this.formBuilder.control(null), + componentType: this.formBuilder.control(null) + }); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '80px' }, + { key: 'keyName', label: 'Key Name', header: 'Key Name', sortable: true, align: 'left', headerAlign: 'left' }, + { + key: 'moduleCode', + label: 'Module Code', + header: 'Module Code', + sortable: true, + align: 'left', + headerAlign: 'left', + badge: true, + badgeClass: value => value && value !== 'Global' ? 'badge bg-primary/10 text-primary' : 'badge bg-gray-100 text-gray-600' + }, + { key: 'componentType', label: 'Component Type', header: 'Component Type', sortable: true, align: 'left', headerAlign: 'left' }, + { key: 'defaultText', label: 'Default Text', header: 'Default Text', sortable: true, align: 'left', headerAlign: 'left' }, + { key: 'description', label: 'Description', header: 'Description', sortable: false, align: 'left', headerAlign: 'left' } + ]); + + readonly actions = signal[]>([ + { type: 'view', label: 'View', icon: 'ti ti-eye', className: 'text-info' }, + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' } + ]); + + readonly filterKeySearchFn: AutocompleteSearchFn = (term, page) => + this.keyApi.autocomplete(term, page); + readonly filterKeyValueFn: AutocompleteValueFn = item => item.keyName; + readonly filterKeyDisplayFn: AutocompleteDisplayFn = item => + `${item.keyName}`; + + ngOnInit(): void { + this.tableStore.initialize({ + fetcher: query => { + const filters: TranslationKeyFilterParams = { + moduleCode: this.filterForm.controls.moduleCode.value || null, + componentType: this.filterForm.controls.componentType.value || null + }; + return this.keyApi.getTranslationKeyDataTable(query, filters); + }, + mapRow: (dto, serialNumber) => { + return { + id: dto.id, + keyName: dto.keyName, + moduleCode: dto.moduleCode || 'Global', + componentType: dto.componentType || 'N/A', + defaultText: dto.defaultText, + description: dto.description || '-', + serialNumber, + createdOn: dto.createdOn, + updatedOn: dto.updatedOn + }; + } + }); + } + + onFilterKeyChanged(item: TranslationKeyLookupDto | null): void { + this.selectedKeyFilter.set(item); + } + + onApplyFilter(event?: Event): void { + event?.preventDefault(); + event?.stopPropagation(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + const selectedKey = this.selectedKeyFilter(); + if (selectedKey) { + this.tableStore.queryState.searchText.set(selectedKey.keyName); + this.tableStore.queryState.pageIndex.set(1); + } + + this.tableStore.refresh(); + } + + onResetFilter(): void { + this.filterForm.reset({ + moduleCode: null, + componentType: null + }); + this.selectedKeyFilter.set(null); + this.tableStore.reset(); + } + + onAddKey(): void { + this.tableStore.openCreateModal(); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'view') this.tableStore.openViewModal(event.row as unknown as TranslationKeyDto); + if (event.action.type === 'edit') this.tableStore.openEditModal(event.row as unknown as TranslationKeyDto); + } + + onToggleFilters(): void { + this.showFilters.update(value => !value); + } +} diff --git a/src/app/features/localization/translations/components/translation-form-modal/translation-form-modal.html b/src/app/features/localization/translations/components/translation-form-modal/translation-form-modal.html new file mode 100644 index 00000000..2d55a602 --- /dev/null +++ b/src/app/features/localization/translations/components/translation-form-modal/translation-form-modal.html @@ -0,0 +1,62 @@ + + @if (modalLoading()) { +
+ + Loading translation details... +
+ } @else { +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +

Approved translations will be active immediately.

+
+ +
+
+ } +
\ No newline at end of file diff --git a/src/app/features/localization/translations/components/translation-form-modal/translation-form-modal.ts b/src/app/features/localization/translations/components/translation-form-modal/translation-form-modal.ts new file mode 100644 index 00000000..e9d085f8 --- /dev/null +++ b/src/app/features/localization/translations/components/translation-form-modal/translation-form-modal.ts @@ -0,0 +1,329 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { HttpErrorResponse } from '@angular/common/http'; +import { forkJoin, of } from 'rxjs'; +import { catchError, finalize, map, switchMap } from 'rxjs/operators'; + +import { NotificationService } from '../../../../../core/services/common/notification.service'; +import { + CreateTranslationRequest, + TranslationDto, + TranslationModalMode, + UpdateTranslationRequest +} from '../../models/translation.model'; +import { LanguageLookupDto } from '../../../../global-masters/languages/models/language.model'; +import { TenantLookupDto } from '../../../../tenants/models/tenant.model'; +import { TranslationKeyLookupDto } from '../../../translation-keys/models/translation-key.model'; +import { TranslationService } from '../../data-access/translation.service'; +import { LanguageService } from '../../../../global-masters/languages/data-access/language.service'; +import { TenantService } from '../../../../tenants/data-access/tenant.service'; +import { TranslationKeyService } from '../../../translation-keys/data-access/translation-key.service'; +import { Modal } from '../../../../../shared/components/modal/modal'; +import { FormInput } from '../../../../../shared/components/form/form-input/form-input'; +import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete'; +import { + AutocompleteDisplayFn, + AutocompleteSearchFn, + AutocompleteValueFn +} from '../../../../../shared/components/form/autocomplete/autocomplete.types'; + +@Component({ + selector: 'app-translation-form-modal', + standalone: true, + imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete], + templateUrl: './translation-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TranslationFormModalComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly translationApi = inject(TranslationService); + private readonly translationKeyApi = inject(TranslationKeyService); + private readonly languageApi = inject(LanguageService); + private readonly tenantApi = inject(TenantService); + private readonly notification = inject(NotificationService); + + readonly open = input(false); + readonly mode = input('create'); + readonly translationId = input(null); + /** When set for a create, locks the key/language pickers to this pair (used by the matrix grid's per-cell "add" flow). */ + readonly presetTranslationKey = input(null); + readonly presetLanguage = input(null); + + readonly saved = output(); + readonly closed = output(); + + readonly modalLoading = signal(false); + readonly saving = signal(false); + readonly submitAttempted = signal(false); + readonly selectedTranslation = signal(null); + + readonly selectedFormTranslationKey = signal(null); + readonly selectedFormLanguage = signal(null); + readonly selectedFormTenant = signal(null); + + readonly translationForm = this.formBuilder.nonNullable.group({ + translationKeyId: ['', Validators.required], + languageId: ['', Validators.required], + tenantId: this.formBuilder.control(null), + translatedText: ['', [Validators.required, Validators.maxLength(4000)]], + isApproved: [true, Validators.required] + }); + + readonly isEditMode = computed(() => this.mode() === 'edit'); + readonly isViewMode = computed(() => this.mode() === 'view'); + readonly isCreateMode = computed(() => this.mode() === 'create'); + + readonly modalTitle = computed(() => { + switch (this.mode()) { + case 'create': + return 'Add Translation'; + case 'edit': + return 'Edit Translation'; + case 'view': + return 'View Translation'; + } + }); + + readonly keySearchFn: AutocompleteSearchFn = (term, page) => + this.translationKeyApi.autocomplete(term, page); + readonly keyValueFn: AutocompleteValueFn = key => key.id; + readonly keyDisplayFn: AutocompleteDisplayFn = key => + key.defaultText ? `${key.keyName} (${key.defaultText})` : key.keyName; + + readonly languageSearchFn: AutocompleteSearchFn = (term, page) => + this.languageApi.autocomplete(term, page); + readonly languageValueFn: AutocompleteValueFn = lang => lang.id; + readonly languageDisplayFn: AutocompleteDisplayFn = lang => + `${lang.name} (${lang.code})`; + + readonly tenantSearchFn: AutocompleteSearchFn = (term, page) => + this.tenantApi.autocomplete(term, page); + readonly tenantValueFn: AutocompleteValueFn = tenant => tenant.id; + readonly tenantDisplayFn: AutocompleteDisplayFn = tenant => tenant.name; + + constructor() { + effect(() => { + if (this.open()) { + this.prepareModal(this.translationId()); + } + }); + } + + onFormTranslationKeyChanged(key: TranslationKeyLookupDto | null): void { + this.selectedFormTranslationKey.set(key); + this.translationForm.controls.translationKeyId.setValue(key ? key.id : ''); + } + + onFormLanguageChanged(language: LanguageLookupDto | null): void { + this.selectedFormLanguage.set(language); + this.translationForm.controls.languageId.setValue(language ? language.id : ''); + } + + onFormTenantChanged(tenant: TenantLookupDto | null): void { + this.selectedFormTenant.set(tenant); + this.translationForm.controls.tenantId.setValue(tenant ? tenant.id : null); + } + + prepareModal(id: string | null): void { + this.submitAttempted.set(false); + this.translationForm.reset({ + translationKeyId: '', + languageId: '', + tenantId: null, + translatedText: '', + isApproved: true + }); + this.selectedFormTranslationKey.set(null); + this.selectedFormLanguage.set(null); + this.selectedFormTenant.set(null); + this.translationForm.enable(); + + if (!id || this.isCreateMode()) { + this.selectedTranslation.set(null); + this.modalLoading.set(false); + + const presetKey = this.presetTranslationKey(); + const presetLanguage = this.presetLanguage(); + if (presetKey) { + this.selectedFormTranslationKey.set(presetKey); + this.translationForm.controls.translationKeyId.setValue(presetKey.id); + } + if (presetLanguage) { + this.selectedFormLanguage.set(presetLanguage); + this.translationForm.controls.languageId.setValue(presetLanguage.id); + } + return; + } + + this.modalLoading.set(true); + this.translationApi + .getTranslationById(id) + .pipe( + switchMap(translation => { + this.selectedTranslation.set(translation); + const key$ = this.translationKeyApi.getById(translation.translationKeyId).pipe(catchError(() => of(null))); + const lang$ = this.languageApi.getById(translation.languageId).pipe(catchError(() => of(null))); + const tenant$ = translation.tenantId + ? this.tenantApi.getTenantById(translation.tenantId).pipe(catchError(() => of(null))) + : of(null); + + return forkJoin({ key: key$, lang: lang$, tenant: tenant$ }).pipe( + map(({ key, lang, tenant }) => ({ translation, lang, key, tenant })) + ); + }), + finalize(() => this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: ({ translation, key, lang, tenant }) => { + if (key) { + this.selectedFormTranslationKey.set({ + id: key.id, + keyName: key.keyName, + defaultText: key.defaultText, + moduleCode: key.moduleCode, + componentType: key.componentType + }); + } else { + this.selectedFormTranslationKey.set({ + id: translation.translationKeyId, + keyName: translation.translationKeyId, + defaultText: '' + }); + } + + if (lang) { + this.selectedFormLanguage.set({ + id: lang.id, + code: lang.code, + name: lang.name, + nativeName: lang.nativeName, + isRightToLeft: lang.isRightToLeft + }); + } else { + this.selectedFormLanguage.set({ + id: translation.languageId, + code: '', + name: 'Selected Language', + nativeName: '', + isRightToLeft: false + }); + } + + if (tenant) { + this.selectedFormTenant.set({ + id: tenant.id, + name: tenant.name, + code: tenant.code + }); + } else if (translation.tenantId) { + this.selectedFormTenant.set({ + id: translation.tenantId, + name: 'Selected Tenant', + code: '' + }); + } + + this.translationForm.patchValue({ + translationKeyId: translation.translationKeyId, + languageId: translation.languageId, + tenantId: translation.tenantId ?? null, + translatedText: translation.translatedText, + isApproved: translation.isApproved + }); + + this.translationForm.enable(); + }, + error: () => { + this.notification.error('Unable to load translation details.'); + this.closeModal(); + } + }); + } + + saveTranslation(): void { + if (this.isViewMode()) { + this.closeModal(); + return; + } + + this.submitAttempted.set(true); + if (this.translationForm.invalid || this.saving()) return; + + this.saving.set(true); + + if (this.isCreateMode()) { + const request: CreateTranslationRequest = { + translationKeyId: this.translationForm.controls.translationKeyId.value.trim(), + languageId: this.translationForm.controls.languageId.value, + tenantId: this.translationForm.controls.tenantId.value || null, + translatedText: this.translationForm.controls.translatedText.value.trim(), + isApproved: this.translationForm.controls.isApproved.value + }; + + this.translationApi + .createTranslation(request) + .pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.notification.success('Translation created successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'create') + }); + } else { + const id = this.translationId(); + if (!id) return; + + const request: UpdateTranslationRequest = { + translatedText: this.translationForm.controls.translatedText.value.trim(), + isApproved: this.translationForm.controls.isApproved.value + }; + + this.translationApi + .updateTranslation(id, request) + .pipe( + finalize(() => this.saving.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + this.notification.success('Translation updated successfully.'); + this.saved.emit(); + this.closed.emit(); + }, + error: err => this.handleSaveError(err, 'update') + }); + } + } + + closeModal(): void { + if (this.saving()) return; + this.closed.emit(); + } + + private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void { + if (error.status === 409) { + this.notification.error('A translation for this key and language scope already exists.'); + return; + } + const message = error.error?.message || error.error?.title || `Unable to ${action} translation.`; + this.notification.error(message); + } +} diff --git a/src/app/features/localization/translations/data-access/translation.endpoints.ts b/src/app/features/localization/translations/data-access/translation.endpoints.ts new file mode 100644 index 00000000..3fefb21b --- /dev/null +++ b/src/app/features/localization/translations/data-access/translation.endpoints.ts @@ -0,0 +1,11 @@ +import { buildApiUrl } from '../../../../core/config/api-url.util'; + +export const TRANSLATION_ENDPOINTS = { + create: buildApiUrl('masterAdmin', '/v1/translations'), + update: (id: string) => + buildApiUrl('masterAdmin', `/v1/translations/${encodeURIComponent(id)}`), + getById: (id: string) => + buildApiUrl('masterAdmin', `/v1/translations/${encodeURIComponent(id)}`), + autocomplete: buildApiUrl('masterAdmin', '/v1/translations/autocomplete'), + dataTable: buildApiUrl('masterAdmin', '/v1/translations/datatable') +} as const; diff --git a/src/app/features/localization/translations/data-access/translation.service.ts b/src/app/features/localization/translations/data-access/translation.service.ts new file mode 100644 index 00000000..35dd3011 --- /dev/null +++ b/src/app/features/localization/translations/data-access/translation.service.ts @@ -0,0 +1,111 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { TRANSLATION_ENDPOINTS } from './translation.endpoints'; +import { + CreateTranslationRequest, + DataTableResponse, + TranslationDataTableDto, + TranslationDto, + TranslationFilterParams, + TranslationLookupDto, + UpdateTranslationRequest +} from '../models/translation.model'; +import { + DataTableQuery, + DataTableResult +} from '../../../../shared/components/data-table/data-table.types'; + +@Injectable({ providedIn: 'root' }) +export class TranslationService { + private readonly http = inject(HttpClient); + + createTranslation(request: CreateTranslationRequest): Observable { + return this.http.post(TRANSLATION_ENDPOINTS.create, request); + } + + updateTranslation(id: string, request: UpdateTranslationRequest): Observable { + return this.http.put(TRANSLATION_ENDPOINTS.update(id), request); + } + + getTranslationById(id: string): Observable { + return this.http.get(TRANSLATION_ENDPOINTS.getById(id)); + } + + autocomplete(options?: { + translationKeyId?: string | null; + languageId?: string | null; + tenantId?: string | null; + globalOnly?: boolean | null; + term?: string | null; + limit?: number; + }): Observable { + let params = new HttpParams(); + + if (options?.translationKeyId) { + params = params.set('translationKeyId', options.translationKeyId); + } + if (options?.languageId) { + params = params.set('languageId', options.languageId); + } + if (options?.tenantId) { + params = params.set('tenantId', options.tenantId); + } + if (options?.globalOnly !== undefined && options?.globalOnly !== null) { + params = params.set('globalOnly', options.globalOnly.toString()); + } + if (options?.term) { + params = params.set('term', options.term.trim()); + } + if (options?.limit !== undefined && options?.limit !== null) { + params = params.set('limit', options.limit); + } else { + params = params.set('limit', 10); + } + + return this.http.get( + TRANSLATION_ENDPOINTS.autocomplete, + { params } + ); + } + + getTranslationDataTable( + query: DataTableQuery, + filters?: TranslationFilterParams + ): Observable> { + let params = new HttpParams(); + + if (filters?.translationKeyId) { + params = params.set('translationKeyId', filters.translationKeyId); + } + if (filters?.languageId) { + params = params.set('languageId', filters.languageId); + } + if (filters?.tenantId) { + params = params.set('tenantId', filters.tenantId); + } + + params = params.set('globalOnly', (filters?.globalOnly ?? false).toString()); + + if (filters?.isApproved !== undefined && filters?.isApproved !== null) { + params = params.set('isApproved', filters.isApproved.toString()); + } + + return this.http + .post>( + TRANSLATION_ENDPOINTS.dataTable, + query, + { params } + ) + .pipe( + map(response => ({ + draw: response.draw ?? query.draw, + total: response.total ?? response.recordsTotal ?? 0, + filtered: response.filtered ?? response.recordsFiltered ?? response.total ?? response.recordsTotal ?? 0, + rows: response.rows ?? response.data ?? [] + })) + ); + } +} diff --git a/src/app/features/localization/translations/models/translation.model.ts b/src/app/features/localization/translations/models/translation.model.ts new file mode 100644 index 00000000..a60baa00 --- /dev/null +++ b/src/app/features/localization/translations/models/translation.model.ts @@ -0,0 +1,114 @@ +import { DataTableRecord } from '../../../../shared/components/data-table/data-table.types'; + +export interface TranslationDto { + id: string; + translationKeyId: string; + languageId: string; + tenantId?: string | null; + translatedText: string; + isApproved: boolean; + createdOn: string; + updatedOn: string; +} + +export interface CreateTranslationRequest { + translationKeyId: string; + languageId: string; + tenantId?: string | null; + translatedText: string; + isApproved: boolean; +} + +export interface UpdateTranslationRequest { + translatedText: string; + isApproved: boolean; +} + +export interface TranslationLookupDto { + id: string; + translationKeyId: string; + languageId: string; + tenantId?: string | null; + translatedText: string; + isApproved: boolean; +} + +export interface TranslationDataTableDto { + id: string; + translationKeyId: string; + keyName: string; + defaultText: string; + languageId: string; + languageCode: string; + languageName: string; + tenantId?: string | null; + tenantName?: string | null; + translatedText: string; + isApproved: boolean; + createdOn: string; + updatedOn: string; +} + +export interface DataTableRequest { + draw?: number; + start: number; + length: number; + search?: { value: string; regex: boolean }; + order?: Array<{ column: number; dir: 'asc' | 'desc' }>; + columns?: Array; +} + +export interface DataTableResponse { + draw?: number; + recordsTotal?: number; + recordsFiltered?: number; + total?: number; + filtered?: number; + data?: T[]; + rows?: T[]; +} + +export type TranslationModalMode = 'create' | 'edit' | 'view'; + +export interface TranslationFilterParams { + translationKeyId?: string | null; + languageId?: string | null; + tenantId?: string | null; + globalOnly?: boolean | null; + isApproved?: boolean | null; +} + +export interface TranslationTableRow extends DataTableRecord { + id: string; + translationKeyId: string; + keyName: string; + defaultText: string; + languageId: string; + languageCode: string; + languageName: string; + tenantId?: string | null; + tenantName?: string | null; + translatedText: string; + isApproved: boolean; + serialNumber: number; + scope: string; + createdOn: string; + updatedOn: string; +} + +/** One translation key with its global translations across all languages, used to build the per-language-column matrix grid. */ +export interface TranslationKeyWithTranslationsDto { + id: string; + keyName: string; + defaultText: string; + translations: readonly TranslationLookupDto[]; +} + +/** Matrix grid row: one translation key, with one flat property per language code (set dynamically) plus lookup maps used by cell click handlers. */ +export interface TranslationMatrixTableRow extends DataTableRecord { + id: string; + keyName: string; + defaultText: string; + serialNumber: number; + translationIdByLanguageId: Record; +} diff --git a/src/app/features/localization/translations/pages/translation-list/translation-list.html b/src/app/features/localization/translations/pages/translation-list/translation-list.html new file mode 100644 index 00000000..3fa1fb98 --- /dev/null +++ b/src/app/features/localization/translations/pages/translation-list/translation-list.html @@ -0,0 +1,46 @@ + + @for (language of languages(); track language.id) { + + + + } + + + diff --git a/src/app/features/localization/translations/pages/translation-list/translation-list.scss b/src/app/features/localization/translations/pages/translation-list/translation-list.scss new file mode 100644 index 00000000..4191d77c --- /dev/null +++ b/src/app/features/localization/translations/pages/translation-list/translation-list.scss @@ -0,0 +1 @@ +/* Custom styles for Translation List */ diff --git a/src/app/features/localization/translations/pages/translation-list/translation-list.ts b/src/app/features/localization/translations/pages/translation-list/translation-list.ts new file mode 100644 index 00000000..aa62a660 --- /dev/null +++ b/src/app/features/localization/translations/pages/translation-list/translation-list.ts @@ -0,0 +1,180 @@ +import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core'; +import { NotificationService } from '../../../../../core/services/common/notification.service'; +import { extractErrorMessage } from '../../../../../core/utils/error-extractor.util'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable, forkJoin, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { + TranslationLookupDto, + TranslationMatrixTableRow +} from '../../models/translation.model'; +import { TranslationKeyDto, TranslationKeyLookupDto } from '../../../translation-keys/models/translation-key.model'; +import { LanguageLookupDto } from '../../../../global-masters/languages/models/language.model'; +import { TranslationService } from '../../data-access/translation.service'; +import { TranslationKeyService } from '../../../translation-keys/data-access/translation-key.service'; +import { LanguageService } from '../../../../global-masters/languages/data-access/language.service'; + +import { DataTable } from '../../../../../shared/components/data-table/data-table'; +import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive'; +import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store'; +import { DataTableColumn, DataTableQuery, DataTableResult } from '../../../../../shared/components/data-table/data-table.types'; +import { Button } from '../../../../../shared/components/button/button'; +import { TranslationFormModalComponent } from '../../components/translation-form-modal/translation-form-modal'; + +@Component({ + selector: 'app-translation-list', + standalone: true, + imports: [ + DataTable, + DataTableCellDirective, + Button, + TranslationFormModalComponent + ], + providers: [DataTableStore], + templateUrl: './translation-list.html', + styleUrl: './translation-list.scss' +}) +export class TranslationList implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly translationApi = inject(TranslationService); + private readonly translationKeyApi = inject(TranslationKeyService); + private readonly languageApi = inject(LanguageService); + private readonly notification = inject(NotificationService); + + readonly tableStore = inject(DataTableStore); + + readonly languages = signal([]); + readonly columns = signal[]>([]); + + readonly modalOpen = signal(false); + readonly modalMode = signal<'create' | 'edit'>('create'); + readonly editingTranslationId = signal(null); + readonly editingKey = signal(null); + readonly editingLanguage = signal(null); + + ngOnInit(): void { + this.languageApi + .autocomplete('', 100) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: languages => { + this.languages.set(languages); + this.buildColumns(languages); + this.initializeTable(); + }, + error: err => { + this.notification.error(extractErrorMessage(err, 'Failed to load languages.')); + this.buildColumns([]); + this.initializeTable(); + } + }); + } + + private buildColumns(languages: readonly LanguageLookupDto[]): void { + const languageColumns: DataTableColumn[] = languages.map(lang => ({ + key: lang.code, + label: lang.name, + header: `${lang.name} (${lang.code})`, + sortable: false, + align: 'left', + headerAlign: 'left' + })); + + this.columns.set([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' }, + { key: 'keyName', label: 'Key / Default Text', header: 'Key / Default Text', sortable: true, align: 'left', headerAlign: 'left' }, + ...languageColumns + ]); + } + + private initializeTable(): void { + this.tableStore.initialize({ + fetcher: query => this.fetchMatrixPage(query), + mapRow: (key, serialNumber) => this.mapToRow(key, serialNumber), + onError: (err: any) => { + const msg = extractErrorMessage(err, 'Failed to load translations.'); + this.notification.error(msg); + } + }); + } + + /** Currently mapped row's per-key translations, keyed by translation key id, so mapRow can read them without re-fetching. */ + private translationsByKeyId = new Map(); + + private fetchMatrixPage(query: DataTableQuery): Observable> { + return this.translationKeyApi.getTranslationKeyDataTable(query).pipe( + switchMap(page => { + if (page.rows.length === 0) { + this.translationsByKeyId.clear(); + return of(page); + } + + const lookups$ = page.rows.map(key => + this.translationApi + .autocomplete({ translationKeyId: key.id, globalOnly: true, limit: 50 }) + .pipe(map(translations => ({ keyId: key.id, translations }))) + ); + + return forkJoin(lookups$).pipe( + map(results => { + this.translationsByKeyId.clear(); + for (const result of results) { + this.translationsByKeyId.set(result.keyId, result.translations); + } + return page; + }) + ); + }) + ); + } + + private mapToRow(key: TranslationKeyDto, serialNumber: number): TranslationMatrixTableRow { + const translations = this.translationsByKeyId.get(key.id) ?? []; + const translationIdByLanguageId: Record = {}; + + const row: TranslationMatrixTableRow = { + id: key.id, + keyName: key.keyName, + defaultText: key.defaultText || '', + serialNumber, + translationIdByLanguageId + }; + + for (const language of this.languages()) { + const match = translations.find(t => t.languageId === language.id); + row[language.code] = match?.translatedText ?? null; + if (match) { + translationIdByLanguageId[language.id] = match.id; + } + } + + return row; + } + + onAddTranslation(): void { + this.editingKey.set(null); + this.editingLanguage.set(null); + this.editingTranslationId.set(null); + this.modalMode.set('create'); + this.modalOpen.set(true); + } + + onCellClick(row: TranslationMatrixTableRow, language: LanguageLookupDto): void { + const translationId = row.translationIdByLanguageId[language.id] ?? null; + this.editingKey.set({ id: row.id, keyName: row.keyName, defaultText: row.defaultText }); + this.editingLanguage.set(language); + this.editingTranslationId.set(translationId); + this.modalMode.set(translationId ? 'edit' : 'create'); + this.modalOpen.set(true); + } + + onModalSaved(): void { + this.tableStore.refresh(); + this.modalOpen.set(false); + } + + onModalClosed(): void { + this.modalOpen.set(false); + } +} diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-step-nav-base.ts b/src/app/features/organizations/organization-onboarding/components/onboarding-step-nav-base.ts new file mode 100644 index 00000000..efc056ea --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-step-nav-base.ts @@ -0,0 +1,147 @@ +import { Directive, input, output } from '@angular/core'; + +export interface OnboardingStep { + readonly key: string; + readonly label: string; +} + +/** + * Shared inputs/outputs/behavior for the two onboarding navigation shells + * (stepper and tabs). Both present the same step list and footer actions; + * only their layout/animation differs, so that part stays on the subclass. + */ +@Directive() +export abstract class OnboardingStepNavBase { + readonly steps = input.required(); + readonly currentStepIndex = input.required(); + readonly completedStepIndexes = input([]); + + readonly isEditMode = input(false); + + readonly savingDraft = input(false); + readonly finishing = input(false); + readonly navigationDisabled = input(false); + + readonly stepSelected = output(); + readonly backClicked = output(); + readonly nextClicked = output(); + readonly saveDraftClicked = output(); + readonly saveChangesClicked = output(); + readonly finishClicked = output(); + readonly cancelClicked = output(); + + currentStepLabel(): string { + return this.steps()[this.currentStepIndex()]?.label ?? ''; + } + + getProgressPercent(): number { + const total = this.steps().length; + if (total === 0) return 0; + return Math.round(((this.currentStepIndex() + 1) / total) * 100); + } + + getTrackLineLeft(): string { + const total = this.steps().length; + if (total <= 0) return '0%'; + return `${100 / (total * 2)}%`; + } + + getProgressLineWidth(): string { + const total = this.steps().length; + if (total <= 1) return '0%'; + const maxSpan = (100 * (total - 1)) / total; + const progressRatio = Math.min( + 1, + Math.max(0, this.currentStepIndex() / (total - 1)) + ); + return `${maxSpan * progressRatio}%`; + } + + isFirstStep(): boolean { + return this.currentStepIndex() === 0; + } + + isLastStep(): boolean { + return this.currentStepIndex() === this.steps().length - 1; + } + + isCurrentStep(index: number): boolean { + return index === this.currentStepIndex(); + } + + isCompletedStep(index: number): boolean { + return this.completedStepIndexes().includes(index); + } + + isPendingStep(index: number): boolean { + return !this.isCurrentStep(index) && !this.isCompletedStep(index); + } + + canOpenStep(index: number): boolean { + if (this.navigationDisabled()) { + return false; + } + + return index >= 0 && index < this.steps().length; + } + + selectStep(index: number): void { + if (!this.canOpenStep(index)) { + return; + } + + this.stepSelected.emit(index); + } + + requestBack(): void { + if (this.navigationDisabled() || this.isFirstStep()) { + return; + } + + this.backClicked.emit(); + } + + requestNext(): void { + if (this.navigationDisabled() || this.isLastStep()) { + return; + } + + this.nextClicked.emit(); + } + + requestSaveDraft(): void { + if (this.navigationDisabled() || this.savingDraft()) { + return; + } + + this.saveDraftClicked.emit(); + } + + requestSaveChanges(): void { + if (this.navigationDisabled() || this.finishing() || this.savingDraft()) { + return; + } + + this.saveChangesClicked.emit(); + } + + requestFinish(): void { + if ( + this.navigationDisabled() || + this.finishing() || + !this.isLastStep() + ) { + return; + } + + this.finishClicked.emit(); + } + + requestCancel(): void { + if (this.navigationDisabled()) { + return; + } + + this.cancelClicked.emit(); + } +} diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts index 5003b332..166454bd 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-stepper/onboarding-stepper.ts @@ -1,15 +1,11 @@ import { ChangeDetectionStrategy, - Component, - input, - output + Component } from '@angular/core'; import { Button } from '../../../../../shared/components/button/button'; +import { OnboardingStepNavBase } from '../onboarding-step-nav-base'; -export interface OnboardingStep { - readonly key: string; - readonly label: string; -} +export type { OnboardingStep } from '../onboarding-step-nav-base'; @Component({ selector: 'onboarding-stepper', @@ -19,145 +15,4 @@ export interface OnboardingStep { styleUrl: './onboarding-stepper.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class OnboardingStepper { - readonly steps = input.required(); - readonly currentStepIndex = input.required(); - readonly completedStepIndexes = input([]); - - readonly isEditMode = input(false); - - readonly savingDraft = input(false); - readonly finishing = input(false); - readonly navigationDisabled = input(false); - - readonly stepSelected = output(); - readonly backClicked = output(); - readonly nextClicked = output(); - readonly saveDraftClicked = output(); - readonly saveChangesClicked = output(); - readonly finishClicked = output(); - readonly cancelClicked = output(); - - currentStepLabel(): string { - return this.steps()[this.currentStepIndex()]?.label ?? ''; - } - - getProgressPercent(): number { - const total = this.steps().length; - if (total === 0) return 0; - return Math.round(((this.currentStepIndex() + 1) / total) * 100); - } - - getTrackLineLeft(): string { - const total = this.steps().length; - if (total <= 0) return '0%'; - return `${100 / (total * 2)}%`; - } - - getProgressLineWidth(): string { - const total = this.steps().length; - if (total <= 1) return '0%'; - const maxSpan = (100 * (total - 1)) / total; - const progressRatio = Math.min( - 1, - Math.max(0, this.currentStepIndex() / (total - 1)) - ); - return `${maxSpan * progressRatio}%`; - } - - isFirstStep(): boolean { - return this.currentStepIndex() === 0; - } - - isLastStep(): boolean { - return this.currentStepIndex() === this.steps().length - 1; - } - - isCurrentStep(index: number): boolean { - return index === this.currentStepIndex(); - } - - isCompletedStep(index: number): boolean { - return this.completedStepIndexes().includes(index); - } - - isPendingStep(index: number): boolean { - return !this.isCurrentStep(index) && !this.isCompletedStep(index); - } - - canOpenStep(index: number): boolean { - if (this.navigationDisabled()) { - return false; - } - - if (index < 0 || index >= this.steps().length) { - return false; - } - - if (this.isEditMode()) { - return true; - } - - return index <= this.currentStepIndex() || this.completedStepIndexes().includes(index - 1); - } - - selectStep(index: number): void { - if (!this.canOpenStep(index)) { - return; - } - - this.stepSelected.emit(index); - } - - requestBack(): void { - if (this.navigationDisabled() || this.isFirstStep()) { - return; - } - - this.backClicked.emit(); - } - - requestNext(): void { - if (this.navigationDisabled() || this.isLastStep()) { - return; - } - - this.nextClicked.emit(); - } - - requestSaveDraft(): void { - if (this.navigationDisabled() || this.savingDraft()) { - return; - } - - this.saveDraftClicked.emit(); - } - - requestSaveChanges(): void { - if (this.navigationDisabled() || this.finishing() || this.savingDraft()) { - return; - } - - this.saveChangesClicked.emit(); - } - - requestFinish(): void { - if ( - this.navigationDisabled() || - this.finishing() || - !this.isLastStep() - ) { - return; - } - - this.finishClicked.emit(); - } - - requestCancel(): void { - if (this.navigationDisabled()) { - return; - } - - this.cancelClicked.emit(); - } -} \ No newline at end of file +export class OnboardingStepper extends OnboardingStepNavBase {} diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.html b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.html index 5d144fb9..35c83f71 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.html +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.html @@ -36,7 +36,7 @@ } @else { - {{ index + 1 }} + } {{ step.label }} @@ -50,7 +50,7 @@
-
+
diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.scss b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.scss index efce4921..f3e01fda 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.scss +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.scss @@ -34,74 +34,81 @@ padding: 0; gap: 0.5rem; overflow-x: auto; - border-bottom: 1px solid #e2e8f0; + overflow-y: visible; + align-items: flex-end; + border-bottom: 2px solid var(--primary, #7c3deb); :host-context(.dark) &, :host-context([data-theme-mode="dark"]) & { - border-bottom-color: rgba(255, 255, 255, 0.12); + border-bottom-color: color-mix(in srgb, var(--primary, #7c3deb) 60%, transparent); } } .tabs-bar-item { - flex: 1 1 0; - min-width: 0; + flex: 0 0 auto; } .tab-btn { display: flex; - width: 100%; align-items: center; - justify-content: center; gap: 0.5rem; background: transparent; border: none; - border-bottom: 3px solid transparent; - padding: 0.625rem 0.5rem; + border-radius: 10px 10px 0 0; + margin-bottom: -2px; + padding: 0.55rem 0.75rem; font-size: 0.8125rem; font-weight: 500; + color: var(--primary, #7c3deb); white-space: nowrap; - transition: all 0.25s ease; + transition: color 0.25s ease, background-color 0.25s ease; outline: none; @media (min-width: 640px) { font-size: 0.875rem; - padding: 0.75rem 1rem; + padding: 0.6rem 1.1rem; } &:disabled { cursor: default; } + &:hover:not(.active):not(:disabled) { + background-color: rgba(124, 61, 235, 0.08); + } + // Pending Tab &.pending { - color: #64748b; + color: var(--primary, #7c3deb); :host-context(.dark) &, :host-context([data-theme-mode="dark"]) & { - color: #94a3b8 !important; + color: #c084fc !important; } } // Completed Tab &.completed { - color: #334155; + color: var(--primary, #7c3deb); + font-weight: 600; :host-context(.dark) &, :host-context([data-theme-mode="dark"]) & { - color: #cbd5e1 !important; + color: #c084fc !important; } } // Active Tab &.active { - color: var(--primary, #7c3deb); - border-bottom-color: var(--primary, #7c3deb); - font-weight: 700; + color: #ffffff; + font-weight: 600; + background: var(--primary, #7c3deb); + background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + box-shadow: 0 4px 12px rgba(124, 61, 235, 0.28); :host-context(.dark) &, :host-context([data-theme-mode="dark"]) & { - color: #c084fc !important; - border-bottom-color: #c084fc !important; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); } } } @@ -110,29 +117,27 @@ display: flex; align-items: center; justify-content: center; - width: 1.25rem; - height: 1.25rem; + width: 1.5rem; + height: 1.5rem; flex-shrink: 0; - border-radius: 9999px; - font-size: 0.6875rem; + border-radius: 7px; + font-size: 0.75rem; font-weight: 700; - background-color: #f1f5f9; + background-color: rgba(124, 61, 235, 0.1); color: inherit; + transition: background-color 0.25s ease, color 0.25s ease; + + i { + font-size: 0.85rem; + } :host-context(.dark) &, :host-context([data-theme-mode="dark"]) & { - background-color: rgba(255, 255, 255, 0.08) !important; + background-color: rgba(255, 255, 255, 0.1) !important; } .tab-btn.active & { - background: var(--primary, #7c3deb); - background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); - color: #ffffff; - } - - .tab-btn.completed & { - background: var(--primary, #7c3deb); - background: linear-gradient(135deg, var(--primary, #7c3deb) 0%, #a855f7 100%); + background: rgba(255, 255, 255, 0.22); color: #ffffff; } } @@ -149,6 +154,8 @@ .tabs-content-box { background-color: #ffffff; border: 1px dashed #e2e8f0; + will-change: transform, opacity; + backface-visibility: hidden; :host-context(.dark) &, :host-context([data-theme-mode="dark"]) & { @@ -156,4 +163,36 @@ border-color: rgba(255, 255, 255, 0.12) !important; color: #e2e8f0; } + + &[class*="slide-forward"] { + animation: onboarding-tab-slide-forward 0.38s cubic-bezier(0.22, 1, 0.36, 1); + } + + &[class*="slide-back"] { + animation: onboarding-tab-slide-back 0.38s cubic-bezier(0.22, 1, 0.36, 1); + } +} + +@keyframes onboarding-tab-slide-forward { + from { + opacity: 0; + transform: translateX(16px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes onboarding-tab-slide-back { + from { + opacity: 0; + transform: translateX(-16px); + } + + to { + opacity: 1; + transform: translateX(0); + } } diff --git a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.ts b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.ts index ceb317a3..900a559a 100644 --- a/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.ts +++ b/src/app/features/organizations/organization-onboarding/components/onboarding-tabs/onboarding-tabs.ts @@ -1,15 +1,21 @@ import { ChangeDetectionStrategy, Component, - input, - output + effect, + signal } from '@angular/core'; import { Button } from '../../../../../shared/components/button/button'; +import { OnboardingStepNavBase } from '../onboarding-step-nav-base'; -export interface OnboardingStep { - readonly key: string; - readonly label: string; -} +export type { OnboardingStep } from '../onboarding-step-nav-base'; + +const STEP_ICONS: Record = { + 'basics': 'ri-building-line', + 'localization': 'ri-earth-line', + 'plan-limits': 'ri-price-tag-3-line', + 'admin-user': 'ri-user-settings-line', +}; +const DEFAULT_STEP_ICON = 'ri-file-list-3-line'; @Component({ selector: 'onboarding-tabs', @@ -19,145 +25,28 @@ export interface OnboardingStep { styleUrl: './onboarding-tabs.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class OnboardingTabs { - readonly steps = input.required(); - readonly currentStepIndex = input.required(); - readonly completedStepIndexes = input([]); +export class OnboardingTabs extends OnboardingStepNavBase { + readonly slideDirection = signal<'forward' | 'back'>('forward'); + private readonly transitionTick = signal(0); + private lastStepIndex: number | null = null; - readonly isEditMode = input(false); - - readonly savingDraft = input(false); - readonly finishing = input(false); - readonly navigationDisabled = input(false); - - readonly stepSelected = output(); - readonly backClicked = output(); - readonly nextClicked = output(); - readonly saveDraftClicked = output(); - readonly saveChangesClicked = output(); - readonly finishClicked = output(); - readonly cancelClicked = output(); - - currentStepLabel(): string { - return this.steps()[this.currentStepIndex()]?.label ?? ''; + constructor() { + super(); + effect(() => { + const index = this.currentStepIndex(); + if (this.lastStepIndex !== null) { + this.slideDirection.set(index >= this.lastStepIndex ? 'forward' : 'back'); + this.transitionTick.update(tick => (tick + 1) % 2); + } + this.lastStepIndex = index; + }); } - getProgressPercent(): number { - const total = this.steps().length; - if (total === 0) return 0; - return Math.round(((this.currentStepIndex() + 1) / total) * 100); + contentSlideClass(): string { + return `slide-${this.slideDirection()}-${this.transitionTick()}`; } - getTrackLineLeft(): string { - const total = this.steps().length; - if (total <= 0) return '0%'; - return `${100 / (total * 2)}%`; - } - - getProgressLineWidth(): string { - const total = this.steps().length; - if (total <= 1) return '0%'; - const maxSpan = (100 * (total - 1)) / total; - const progressRatio = Math.min( - 1, - Math.max(0, this.currentStepIndex() / (total - 1)) - ); - return `${maxSpan * progressRatio}%`; - } - - isFirstStep(): boolean { - return this.currentStepIndex() === 0; - } - - isLastStep(): boolean { - return this.currentStepIndex() === this.steps().length - 1; - } - - isCurrentStep(index: number): boolean { - return index === this.currentStepIndex(); - } - - isCompletedStep(index: number): boolean { - return this.completedStepIndexes().includes(index); - } - - isPendingStep(index: number): boolean { - return !this.isCurrentStep(index) && !this.isCompletedStep(index); - } - - canOpenStep(index: number): boolean { - if (this.navigationDisabled()) { - return false; - } - - if (index < 0 || index >= this.steps().length) { - return false; - } - - if (this.isEditMode()) { - return true; - } - - return index <= this.currentStepIndex() || this.completedStepIndexes().includes(index - 1); - } - - selectStep(index: number): void { - if (!this.canOpenStep(index)) { - return; - } - - this.stepSelected.emit(index); - } - - requestBack(): void { - if (this.navigationDisabled() || this.isFirstStep()) { - return; - } - - this.backClicked.emit(); - } - - requestNext(): void { - if (this.navigationDisabled() || this.isLastStep()) { - return; - } - - this.nextClicked.emit(); - } - - requestSaveDraft(): void { - if (this.navigationDisabled() || this.savingDraft()) { - return; - } - - this.saveDraftClicked.emit(); - } - - requestSaveChanges(): void { - if (this.navigationDisabled() || this.finishing() || this.savingDraft()) { - return; - } - - this.saveChangesClicked.emit(); - } - - requestFinish(): void { - if ( - this.navigationDisabled() || - this.finishing() || - !this.isLastStep() - ) { - return; - } - - this.finishClicked.emit(); - } - - requestCancel(): void { - if (this.navigationDisabled()) { - return; - } - - this.cancelClicked.emit(); + stepIcon(key: string): string { + return STEP_ICONS[key] ?? DEFAULT_STEP_ICON; } } diff --git a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.spec.ts b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.spec.ts new file mode 100644 index 00000000..1cece392 --- /dev/null +++ b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.spec.ts @@ -0,0 +1,171 @@ +import { OrganizationOnboardingStateService } from './organization-onboarding-state.service'; +import { OrganizationServerDraftResponse } from '../../models/organization-onboarding.model'; + +describe('OrganizationOnboardingStateService', () => { + let service: OrganizationOnboardingStateService; + + beforeEach(() => { + service = new OrganizationOnboardingStateService(); + }); + + describe('restoreServerDraft — nested response shape', () => { + it('restores sections from nested basics/localization objects and honors explicit completedStepIndexes', () => { + const response: OrganizationServerDraftResponse = { + id: 'org-1', + status: 'Draft', + completedStepIndexes: [0, 1], + basics: { organizationName: 'Acme', code: 'ACM-001' } as any, + localization: { timeZone: { id: 'tz-1', label: 'UTC' } } as any, + planLimits: null, + admin: null, + }; + + service.restoreServerDraft(response, false); + + expect(service.organizationId()).toBe('org-1'); + expect(service.completedStepIndexes()).toEqual([0, 1]); + expect(service.onboardingData().basics).not.toBeNull(); + expect(service.onboardingData().localization).not.toBeNull(); + expect(service.onboardingData().planLimits).toBeNull(); + expect(service.currentStepIndex()).toBe(2); + }); + }); + + describe('restoreServerDraft — flat-field fallback shape', () => { + it('falls back to flat top-level fields when no nested section object is present, and warns', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const flatResponse = { + id: 'org-2', + status: 'Draft', + organizationName: 'Flat Co', + name: 'Flat Co', + } as unknown as OrganizationServerDraftResponse; + + service.restoreServerDraft(flatResponse, false); + + expect(warnSpy).toHaveBeenCalled(); + expect((service.onboardingDraft().basics as any)?.organizationName).toBe('Flat Co'); + + warnSpy.mockRestore(); + }); + + it('does not warn or fabricate a section when none of its flat trigger fields are present', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const response = { + id: 'org-3', + status: 'Draft', + } as unknown as OrganizationServerDraftResponse; + + service.restoreServerDraft(response, false); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(service.onboardingDraft().basics).toBeNull(); + expect(service.onboardingDraft().localization).toBeNull(); + + warnSpy.mockRestore(); + }); + }); + + describe('restoreServerDraft — completion date heuristics', () => { + it('does not treat a .NET DateTime.MinValue sentinel as a completed section', () => { + const response = { + id: 'org-4', + status: 'Draft', + basics: { organizationName: 'Sentinel Co', completedAt: '0001-01-01T00:00:00' } as any, + } as unknown as OrganizationServerDraftResponse; + + service.restoreServerDraft(response, false); + + expect(service.completedStepIndexes()).not.toContain(0); + expect(service.onboardingData().basics).toBeNull(); + }); + + it('treats a real completedAt date as a completed section', () => { + const response = { + id: 'org-5', + status: 'Draft', + basics: { organizationName: 'Real Co', completedAt: '2026-01-15T10:00:00Z' } as any, + } as unknown as OrganizationServerDraftResponse; + + service.restoreServerDraft(response, false); + + expect(service.completedStepIndexes()).toContain(0); + expect(service.onboardingData().basics).not.toBeNull(); + }); + }); + + describe('restoreServerDraft — edit mode of an already-provisioned organization', () => { + it('marks every step complete when the organization status is not "draft"', () => { + const response = { + id: 'org-6', + status: 'Active', + } as unknown as OrganizationServerDraftResponse; + + service.restoreServerDraft(response, true); + + expect(service.isEditMode()).toBe(true); + expect(service.completedStepIndexes()).toEqual([0, 1, 2, 3]); + }); + }); + + describe('restoreServerDraft — initial step index resolution', () => { + it('lands on the first uncompleted step when the server omits currentStepIndex', () => { + const response: OrganizationServerDraftResponse = { + id: 'org-7', + status: 'Draft', + completedStepIndexes: [0], + }; + + service.restoreServerDraft(response, false); + + expect(service.currentStepIndex()).toBe(1); + }); + + it('honors an explicit currentStepIndex from the server over the completion-derived default', () => { + const response: OrganizationServerDraftResponse = { + id: 'org-8', + status: 'Draft', + currentStepIndex: 2, + completedStepIndexes: [0, 1], + }; + + service.restoreServerDraft(response, false); + + expect(service.currentStepIndex()).toBe(2); + }); + }); + + describe('step completion tracking', () => { + it('markStepCompleted dedupes and keeps indexes sorted', () => { + service.markStepCompleted(2); + service.markStepCompleted(0); + service.markStepCompleted(0); + + expect(service.completedStepIndexes()).toEqual([0, 2]); + }); + + it('unmarkStepCompleted removes only the given index', () => { + service.setCompletedStepIndexes([0, 1, 2]); + service.unmarkStepCompleted(1); + + expect(service.completedStepIndexes()).toEqual([0, 2]); + }); + }); + + describe('clear', () => { + it('resets all state back to initial values', () => { + service.restoreServerDraft({ id: 'org-9', status: 'Draft', completedStepIndexes: [0, 1] }, true); + expect(service.organizationId()).toBe('org-9'); + + service.clear(); + + expect(service.organizationId()).toBeNull(); + expect(service.isEditMode()).toBe(false); + expect(service.completedStepIndexes()).toEqual([]); + expect(service.currentStepIndex()).toBe(0); + expect(service.hasAnyData()).toBe(false); + }); + }); +}); diff --git a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts index b55742ba..1d9e0808 100644 --- a/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts +++ b/src/app/features/organizations/organization-onboarding/data-access/services/organization-onboarding-state.service.ts @@ -157,15 +157,7 @@ export class OrganizationOnboardingStateService { } canOpenStep(index: number): boolean { - if (index < 0 || index > 3) { - return false; - } - - if (this.isEditModeState()) { - return true; - } - - return index <= this.currentStepIndexState() || this.completedStepIndexesState().includes(index - 1); + return index >= 0 && index <= 3; } isStepCompleted(index: number): boolean { @@ -197,48 +189,32 @@ export class OrganizationOnboardingStateService { serverDraft.code, rawRecord['Code'], rawRecord['code'], rawRecord['organizationCode'], rawRecord['OrganizationCode'] ) ?? null; - // The server is expected to return nested basics/localization/planLimits/admin objects. The - // flat-field checks below are a compatibility fallback for older/alternate response shapes; - // logging here makes that contract drift visible instead of silently guessing forever. - const flatFallback = (sectionName: string): RawDraftRecord => { - console.warn( - `Organization draft response (id=${serverDraft.id}) has no nested "${sectionName}" object; ` + - 'falling back to flat-field heuristics. The backend response shape may have drifted from what the client expects.' - ); - return rawRecord; - }; - - let basicsData = (firstDefined(serverDraft.basics, rawRecord['Basics'], rawRecord['basics']) ?? ( - firstTruthy(rawRecord['name'], rawRecord['Name'], rawRecord['organizationName'], rawRecord['OrganizationName'], orgCode) !== undefined - ? flatFallback('basics') - : null - )) as RawDraftRecord | null; - + let basicsData = resolveDraftSection( + rawRecord, serverDraft.id, 'basics', + firstDefined(serverDraft.basics, rawRecord['Basics'], rawRecord['basics']), + [rawRecord['name'], rawRecord['Name'], rawRecord['organizationName'], rawRecord['OrganizationName'], orgCode] + ); if (basicsData) { - basicsData = { - ...basicsData, - code: firstDefined(basicsData['code'], basicsData['Code'], basicsData['organizationCode'], basicsData['OrganizationCode'], orgCode), - organizationCode: firstDefined(basicsData['organizationCode'], basicsData['OrganizationCode'], basicsData['code'], basicsData['Code'], orgCode), - }; + basicsData = mergeResolvedOrgCode(basicsData, orgCode); } - const localizationData = (firstDefined(serverDraft.localization, rawRecord['Localization'], rawRecord['localization']) ?? ( - firstTruthy(rawRecord['defaultTimezoneId'], rawRecord['DefaultTimezoneId'], rawRecord['defaultCurrencyId'], rawRecord['DefaultCurrencyId'], rawRecord['dateFormat'], rawRecord['DateFormat']) !== undefined - ? flatFallback('localization') - : null - )) as RawDraftRecord | null; + const localizationData = resolveDraftSection( + rawRecord, serverDraft.id, 'localization', + firstDefined(serverDraft.localization, rawRecord['Localization'], rawRecord['localization']), + [rawRecord['defaultTimezoneId'], rawRecord['DefaultTimezoneId'], rawRecord['defaultCurrencyId'], rawRecord['DefaultCurrencyId'], rawRecord['dateFormat'], rawRecord['DateFormat']] + ); - const planLimitsData = (firstDefined(serverDraft.planLimits, rawRecord['PlanLimits'], rawRecord['planLimits'], rawRecord['plan'], rawRecord['Plan']) ?? ( - firstTruthy(rawRecord['planId'], rawRecord['PlanId'], rawRecord['licenseType'], rawRecord['LicenseType'], rawRecord['maxCompanies'], rawRecord['MaxCompanies']) !== undefined - ? flatFallback('planLimits') - : null - )) as RawDraftRecord | null; + const planLimitsData = resolveDraftSection( + rawRecord, serverDraft.id, 'planLimits', + firstDefined(serverDraft.planLimits, rawRecord['PlanLimits'], rawRecord['planLimits'], rawRecord['plan'], rawRecord['Plan']), + [rawRecord['planId'], rawRecord['PlanId'], rawRecord['licenseType'], rawRecord['LicenseType'], rawRecord['maxCompanies'], rawRecord['MaxCompanies']] + ); - const adminData = (firstDefined(serverDraft.admin, rawRecord['Admin'], rawRecord['admin'], rawRecord['adminContact'], rawRecord['AdminContact']) ?? ( - firstTruthy(rawRecord['adminEmail'], rawRecord['AdminEmail'], rawRecord['orgEmail'], rawRecord['OrgEmail'], rawRecord['administratorEmail'], rawRecord['AdministratorEmail']) !== undefined - ? flatFallback('admin') - : null - )) as RawDraftRecord | null; + const adminData = resolveDraftSection( + rawRecord, serverDraft.id, 'admin', + firstDefined(serverDraft.admin, rawRecord['Admin'], rawRecord['admin'], rawRecord['adminContact'], rawRecord['AdminContact']), + [rawRecord['adminEmail'], rawRecord['AdminEmail'], rawRecord['orgEmail'], rawRecord['OrgEmail'], rawRecord['administratorEmail'], rawRecord['AdministratorEmail']] + ); this.onboardingDraftState.set({ basics: basicsData ? { ...basicsData } : null, @@ -247,21 +223,9 @@ export class OrganizationOnboardingStateService { admin: adminData ? { ...adminData } : null, }); - const statusStr = String(serverDraft.status || rawRecord['status'] || rawRecord['Status'] || '').toLowerCase(); - const isNonDraftActive = isEditMode && statusStr !== '' && statusStr !== 'draft' && statusStr !== '0'; - - const explicitIndexes = serverDraft.completedStepIndexes ?? []; - - const isStep0Done = isNonDraftActive || evaluateSectionCompletion(basicsData, 'basics', rawRecord, explicitIndexes, 0); - const isStep1Done = isNonDraftActive || evaluateSectionCompletion(localizationData, 'localization', rawRecord, explicitIndexes, 1); - const isStep2Done = isNonDraftActive || evaluateSectionCompletion(planLimitsData, 'plan', rawRecord, explicitIndexes, 2) || evaluateSectionCompletion(planLimitsData, 'planLimits', rawRecord, explicitIndexes, 2); - const isStep3Done = isNonDraftActive || evaluateSectionCompletion(adminData, 'admin', rawRecord, explicitIndexes, 3) || evaluateSectionCompletion(adminData, 'adminContact', rawRecord, explicitIndexes, 3); - - const completedIndexes: number[] = []; - if (isStep0Done) completedIndexes.push(0); - if (isStep1Done) completedIndexes.push(1); - if (isStep2Done) completedIndexes.push(2); - if (isStep3Done) completedIndexes.push(3); + const completedIndexes = resolveCompletedStepIndexes( + isEditMode, serverDraft, rawRecord, basicsData, localizationData, planLimitsData, adminData + ); this.onboardingDataState.set({ // Trust boundary: the server draft payload is only validated at runtime by the API, @@ -280,14 +244,7 @@ export class OrganizationOnboardingStateService { : null, }); - let initialStepIndex = 0; - if (typeof serverDraft.currentStepIndex === 'number' && serverDraft.currentStepIndex > 0 && serverDraft.currentStepIndex <= 3) { - initialStepIndex = serverDraft.currentStepIndex; - } else { - const firstUncompletedIndex = [0, 1, 2, 3].find(idx => !completedIndexes.includes(idx)); - initialStepIndex = firstUncompletedIndex !== undefined ? firstUncompletedIndex : 0; - } - + const initialStepIndex = resolveInitialStepIndex(serverDraft, completedIndexes); this.currentStepIndexState.set(Math.min(3, Math.max(0, initialStepIndex))); this.setCompletedStepIndexes(completedIndexes); } @@ -350,8 +307,13 @@ function isValidDateOrTrue(val: unknown): boolean { return true; } if (typeof val === 'string' && val.trim().length > 0) { - const d = Date.parse(val); - return !isNaN(d); + const parsed = new Date(val); + if (isNaN(parsed.getTime())) { + return false; + } + // Guard against .NET's DateTime.MinValue ("0001-01-01T00:00:00"), which some API + // responses serialize for an unset field instead of returning null/omitting it. + return parsed.getUTCFullYear() > 1; } return false; } @@ -398,3 +360,78 @@ function evaluateSectionCompletion( function capitalize(str: string): string { return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; } + +// The server is expected to return nested basics/localization/planLimits/admin objects. The +// flatFallbackTriggers check is a compatibility fallback for older/alternate response shapes; +// warning here makes that contract drift visible instead of silently guessing forever. +function resolveDraftSection( + rawRecord: RawDraftRecord, + draftId: string, + sectionName: string, + nestedValue: unknown, + flatFallbackTriggers: readonly unknown[] +): RawDraftRecord | null { + if (nestedValue !== undefined) { + return nestedValue as RawDraftRecord; + } + + if (firstTruthy(...flatFallbackTriggers) === undefined) { + return null; + } + + console.warn( + `Organization draft response (id=${draftId}) has no nested "${sectionName}" object; ` + + 'falling back to flat-field heuristics. The backend response shape may have drifted from what the client expects.' + ); + return rawRecord; +} + +function mergeResolvedOrgCode(basicsData: RawDraftRecord, orgCode: unknown): RawDraftRecord { + return { + ...basicsData, + code: firstDefined(basicsData['code'], basicsData['Code'], basicsData['organizationCode'], basicsData['OrganizationCode'], orgCode), + organizationCode: firstDefined(basicsData['organizationCode'], basicsData['OrganizationCode'], basicsData['code'], basicsData['Code'], orgCode), + }; +} + +function resolveCompletedStepIndexes( + isEditMode: boolean, + serverDraft: OrganizationServerDraftResponse, + rawRecord: RawDraftRecord, + basicsData: RawDraftRecord | null, + localizationData: RawDraftRecord | null, + planLimitsData: RawDraftRecord | null, + adminData: RawDraftRecord | null, +): number[] { + const statusStr = String(serverDraft.status || rawRecord['status'] || rawRecord['Status'] || '').toLowerCase(); + const isNonDraftActive = isEditMode && statusStr !== '' && statusStr !== 'draft' && statusStr !== '0'; + const explicitIndexes = serverDraft.completedStepIndexes ?? []; + + const isStep0Done = isNonDraftActive || evaluateSectionCompletion(basicsData, 'basics', rawRecord, explicitIndexes, 0); + const isStep1Done = isNonDraftActive || evaluateSectionCompletion(localizationData, 'localization', rawRecord, explicitIndexes, 1); + const isStep2Done = isNonDraftActive + || evaluateSectionCompletion(planLimitsData, 'plan', rawRecord, explicitIndexes, 2) + || evaluateSectionCompletion(planLimitsData, 'planLimits', rawRecord, explicitIndexes, 2); + const isStep3Done = isNonDraftActive + || evaluateSectionCompletion(adminData, 'admin', rawRecord, explicitIndexes, 3) + || evaluateSectionCompletion(adminData, 'adminContact', rawRecord, explicitIndexes, 3); + + const completedIndexes: number[] = []; + if (isStep0Done) completedIndexes.push(0); + if (isStep1Done) completedIndexes.push(1); + if (isStep2Done) completedIndexes.push(2); + if (isStep3Done) completedIndexes.push(3); + return completedIndexes; +} + +function resolveInitialStepIndex( + serverDraft: OrganizationServerDraftResponse, + completedIndexes: readonly number[] +): number { + if (typeof serverDraft.currentStepIndex === 'number' && serverDraft.currentStepIndex > 0 && serverDraft.currentStepIndex <= 3) { + return serverDraft.currentStepIndex; + } + + const firstUncompletedIndex = [0, 1, 2, 3].find(idx => !completedIndexes.includes(idx)); + return firstUncompletedIndex !== undefined ? firstUncompletedIndex : 0; +} diff --git a/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts b/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts index c4575f78..d54fa109 100644 --- a/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts +++ b/src/app/features/organizations/organization-onboarding/models/organization-onboarding.model.ts @@ -242,9 +242,6 @@ export interface UpdateOrganizationBasicsApiRequest { RegistrationCountryId?: string | null; MarkComplete: boolean; } -export type UpdateBasicsStepRequest = UpdateOrganizationBasicsApiRequest; -export type BasicsStepRequest = UpdateOrganizationBasicsApiRequest; - export function mapBasicsStepToApiRequest( basics: Partial, markComplete: boolean @@ -283,9 +280,6 @@ export interface UpdateOrganizationLocalizationApiRequest { FiscalYearStart?: string | null; MarkComplete: boolean; } -export type UpdateLocalizationStepRequest = UpdateOrganizationLocalizationApiRequest; -export type LocalizationStepRequest = UpdateOrganizationLocalizationApiRequest; - export function mapLocalizationStepToApiRequest( loc: Partial, markComplete: boolean @@ -323,9 +317,6 @@ export interface UpdateOrganizationPlanApiRequest { SystemAccessTo?: string | null; MarkComplete: boolean; } -export type UpdatePlanStepRequest = UpdateOrganizationPlanApiRequest; -export type PlanStepRequest = UpdateOrganizationPlanApiRequest; - export function mapPlanStepToApiRequest( plan: Partial, markComplete: boolean @@ -355,9 +346,6 @@ export interface UpdateOrganizationAdminContactApiRequest { OrgPhone?: string | null; MarkComplete: boolean; } -export type UpdateAdminContactStepRequest = UpdateOrganizationAdminContactApiRequest; -export type AdminContactStepRequest = UpdateOrganizationAdminContactApiRequest; - export function mapAdminContactStepToApiRequest( admin: Partial, markComplete: boolean diff --git a/src/app/features/organizations/organization-onboarding/models/organization-provisioning.model.ts b/src/app/features/organizations/organization-onboarding/models/organization-provisioning.model.ts deleted file mode 100644 index 70ca18d3..00000000 --- a/src/app/features/organizations/organization-onboarding/models/organization-provisioning.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - OrganizationAdminValue, - OrganizationBasicsValue, - OrganizationLocalizationValue, - OrganizationPlanLimitsValue, -} from './organization-onboarding.model'; - -export interface OrganizationProvisioningRequest { - readonly basics: OrganizationBasicsValue; - readonly localization: OrganizationLocalizationValue; - readonly planLimits: OrganizationPlanLimitsValue; - readonly admin: OrganizationAdminValue; -} - -export type OrganizationProvisioningStatus = - | 'not-configured' - | 'submitted' - | 'failed'; - -export interface OrganizationProvisioningResult { - readonly status: OrganizationProvisioningStatus; - readonly message: string; -} \ No newline at end of file diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.html b/src/app/features/organizations/organization-onboarding/organization-onboarding.html index 1b85404c..2a49265b 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.html +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.html @@ -17,7 +17,7 @@
-
+
diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.spec.ts b/src/app/features/organizations/organization-onboarding/organization-onboarding.spec.ts index eccc7991..551482e8 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.spec.ts +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.spec.ts @@ -12,6 +12,13 @@ import { OrganizationBasicsStepComponent } from './steps/organization-basics/org import { OrganizationLocalizationStepComponent } from './steps/organization-localization/organization-localization'; import { OrganizationOnboardingStateService } from './data-access/services/organization-onboarding-state.service'; +const swalFireMock = vi.fn(); +vi.mock('sweetalert2', () => ({ + default: { + fire: (...args: unknown[]) => swalFireMock(...args), + }, +})); + const VIEW_PREFERENCE_STORAGE_KEY = 'org-onboarding:view-preference'; class InMemoryLocalStorage implements Storage { @@ -194,4 +201,72 @@ describe('OrganizationOnboarding', () => { ).componentInstance as OrganizationLocalizationStepComponent; expect(localizationAfterToggle.form.controls.defaultLanguageId.value).toBe('en-us'); }); + + describe('hasUnsavedChanges (canDeactivate guard support)', () => { + it('reports no unsaved changes on a freshly loaded, untouched form', async () => { + const fixture = await createFixture(); + + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(false); + }); + + it('reports unsaved changes once the user edits a step form', async () => { + const fixture = await createFixture(); + const basicsInstance = fixture.debugElement.query( + By.directive(OrganizationBasicsStepComponent) + ).componentInstance as OrganizationBasicsStepComponent; + + basicsInstance.form.controls.organizationName.setValue('Acme Corporation'); + + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + }); + + it('stops reporting unsaved changes once the user confirms discarding via the Cancel button', async () => { + swalFireMock.mockResolvedValue({ isConfirmed: true, isDenied: false, isDismissed: false }); + const fixture = await createFixture(); + const basicsInstance = fixture.debugElement.query( + By.directive(OrganizationBasicsStepComponent) + ).componentInstance as OrganizationBasicsStepComponent; + basicsInstance.form.controls.organizationName.setValue('Acme Corporation'); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + + await fixture.componentInstance.onCancel(); + + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(false); + expect(routerStub.navigate).toHaveBeenCalledWith(['/organizations/list']); + }); + }); + + describe('confirmDiscard (shared by the Cancel button and the canDeactivate guard)', () => { + it('shows the exact same dialog config the Cancel button uses', async () => { + swalFireMock.mockResolvedValue({ isConfirmed: true, isDenied: false, isDismissed: false }); + const fixture = await createFixture(); + + const confirmed = await fixture.componentInstance.confirmDiscard(); + + expect(confirmed).toBe(true); + expect(swalFireMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Discard onboarding changes?', + text: 'Your unsaved changes will be lost.', + confirmButtonText: 'Discard and Leave', + denyButtonText: 'Stay', + }) + ); + }); + + it('resolves false and leaves the draft intact when the user chooses to stay', async () => { + swalFireMock.mockResolvedValue({ isConfirmed: false, isDenied: true, isDismissed: false }); + const fixture = await createFixture(); + const basicsInstance = fixture.debugElement.query( + By.directive(OrganizationBasicsStepComponent) + ).componentInstance as OrganizationBasicsStepComponent; + basicsInstance.form.controls.organizationName.setValue('Acme Corporation'); + + const confirmed = await fixture.componentInstance.confirmDiscard(); + + expect(confirmed).toBe(false); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + expect(routerStub.navigate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts index c29e63b1..d50dea44 100644 --- a/src/app/features/organizations/organization-onboarding/organization-onboarding.ts +++ b/src/app/features/organizations/organization-onboarding/organization-onboarding.ts @@ -1,4 +1,5 @@ import { + afterNextRender, ChangeDetectionStrategy, Component, computed, @@ -10,8 +11,9 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { catchError, finalize, forkJoin, Observable, of, switchMap } from 'rxjs'; -import { NgTemplateOutlet } from '@angular/common'; +import { Location, NgTemplateOutlet } from '@angular/common'; import { NotificationService } from '../../../core/services/common/notification.service'; +import { CanComponentDeactivate } from '../../../core/guards/navigation/unsaved-changes.guard'; import { OnboardingStep, OnboardingStepper @@ -33,7 +35,6 @@ import { mapLocalizationStepToApiRequest, mapPlanStepToApiRequest, } from './models/organization-onboarding.model'; -import { OrganizationProvisioningRequest } from './models/organization-provisioning.model'; const VIEW_PREFERENCE_STORAGE_KEY = 'org-onboarding:view-preference'; @@ -55,10 +56,11 @@ const VIEW_PREFERENCE_STORAGE_KEY = 'org-onboarding:view-preference'; changeDetection: ChangeDetectionStrategy.OnPush, providers: [OrganizationOnboardingStateService, OrganizationOnboardingService] }) -export class OrganizationOnboarding { +export class OrganizationOnboarding implements CanComponentDeactivate { private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly location = inject(Location); private readonly notification = inject(NotificationService); readonly stateService = inject(OrganizationOnboardingStateService); private readonly onboardingService = inject(OrganizationOnboardingService); @@ -77,8 +79,7 @@ export class OrganizationOnboarding { private readonly cancelConfirmDialog = viewChild('cancelConfirmDialog'); private readonly finishConfirmDialog = viewChild('finishConfirmDialog'); private savedDraftSnapshot = ''; - - readonly provisioningRequest = signal(null); + private isLeavingIntentionally = false; readonly currentStepIndex = this.stateService.currentStepIndex; readonly completedStepIndexes = this.stateService.completedStepIndexes; @@ -92,11 +93,16 @@ export class OrganizationOnboarding { ); constructor() { - const draftId = this.route.snapshot.queryParamMap.get('id'); + const editOrgId = this.route.snapshot.queryParamMap.get('id'); + const resumeDraftId = this.route.snapshot.queryParamMap.get('draftId'); + const stepParam = this.route.snapshot.queryParamMap.get('step'); - if (draftId) { + if (editOrgId) { + // Entered explicitly to edit an existing organization (e.g. from the org list). Every + // step is treated as already complete and Finish is replaced by per-step Update — see + // onFinish(). this.stateService.setIsEditMode(true); - this.onboardingService.getOrganizationById(draftId) + this.onboardingService.getOrganizationById(editOrgId) .pipe( catchError(err => { const errorMsg = err?.error?.detail || err?.error?.message || 'Unable to restore organization details from server.'; @@ -108,13 +114,114 @@ export class OrganizationOnboarding { .subscribe(draft => { if (draft) { this.stateService.restoreServerDraft(draft, true); + if (stepParam) { + const stepIndex = this.resolveStepIndex(stepParam); + if (stepIndex !== -1) { + this.stateService.setCurrentStepIndex(stepIndex); + } + } this.savedDraftSnapshot = this.serializeDraftState(); this.hydrateActiveStep(); } }); + } else if (resumeDraftId) { + // Resuming a draft this same wizard created earlier (see persistStepToServer(), which + // writes ?draftId= into the URL right after the first successful save). This is NOT edit + // mode: the draft may still be incomplete, and Finish must still run the normal + // confirm-and-POST-/finish flow, not the single-step Update used for editing an existing org. + this.onboardingService.getOrganizationById(resumeDraftId) + .pipe( + catchError(() => { + // Stale/deleted draft link — drop the dead query param and fall back to a fresh wizard + // instead of retrying the same failing fetch on every future reload. + this.updateUrlQueryParams({ draftId: null }); + return of(null); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(draft => { + if (draft) { + this.stateService.setOrganizationId(draft.id); + this.stateService.restoreServerDraft(draft, false); + if (stepParam) { + const stepIndex = this.resolveStepIndex(stepParam); + if (stepIndex !== -1) { + this.stateService.setCurrentStepIndex(stepIndex); + } + } + this.savedDraftSnapshot = this.serializeDraftState(); + this.hydrateActiveStep(); + } else { + afterNextRender(() => { + this.captureActiveStepDraft(); + this.savedDraftSnapshot = this.serializeDraftState(); + }); + } + }); + } else { + if (stepParam) { + const stepIndex = this.resolveStepIndex(stepParam); + if (stepIndex !== -1) { + this.stateService.setCurrentStepIndex(stepIndex); + } + } + afterNextRender(() => { + this.captureActiveStepDraft(); + this.savedDraftSnapshot = this.serializeDraftState(); + }); } } + /** Rewrites query params on the current URL without a router navigation (avoids re-running this constructor). Pass null to remove a param. */ + private updateUrlQueryParams(params: Record): void { + const urlTree = this.router.createUrlTree([], { + relativeTo: this.route, + queryParams: params, + queryParamsHandling: 'merge', + }); + this.location.replaceState(this.router.serializeUrl(urlTree)); + } + + private resolveStepIndex(step: string): number { + const num = Number(step); + if (!isNaN(num) && num >= 0 && num <= 3) { + return num; + } + const lower = step.toLowerCase().trim(); + switch (lower) { + case 'basics': + case 'basic': + case '0': + return 0; + case 'localization': + case '1': + return 1; + case 'plan-limits': + case 'plan': + case 'plans': + case 'subscription': + case 'subscriptions': + case '2': + return 2; + case 'admin': + case 'admin-user': + case 'users': + case '3': + return 3; + default: + return -1; + } + } + + hasUnsavedChanges(): boolean { + if (this.isLeavingIntentionally) { + return false; + } + + this.captureActiveStepDraft(); + return this.serializeDraftState() !== this.savedDraftSnapshot; + } + onToggleView(): void { this.captureActiveStepDraft(); this.setUseTabsView(!this.useTabsView()); @@ -154,7 +261,7 @@ export class OrganizationOnboarding { return; } - if (!this.validateStepsUpTo(currentIndex)) { + if (!this.validateForSave(currentIndex)) { return; } @@ -183,7 +290,7 @@ export class OrganizationOnboarding { } const currentIndex = this.currentStepIndex(); - if (!this.validateStepsUpTo(currentIndex)) { + if (!this.validateForSave(currentIndex)) { return; } @@ -228,13 +335,6 @@ export class OrganizationOnboarding { return; } - this.provisioningRequest.set({ - basics: data.basics, - localization: data.localization, - planLimits: data.planLimits, - admin: data.admin, - }); - const confirmModal = this.finishConfirmDialog(); if (confirmModal) { void confirmModal.open(); @@ -269,7 +369,9 @@ export class OrganizationOnboarding { return; } - this.storeValidatedStep(currentIndex, activeStepComponent); + if (!this.storeValidatedStep(currentIndex, activeStepComponent)) { + return; + } this.finishing.set(true); const currentStepLabel = this.steps[currentIndex]?.label ?? `Step ${currentIndex + 1}`; @@ -329,18 +431,45 @@ export class OrganizationOnboarding { }); } - onCancel(): void { + async onCancel(): Promise { this.captureActiveStepDraft(); - if (this.serializeDraftState() !== this.savedDraftSnapshot) { - void this.cancelConfirmDialog()?.open(); + if (this.serializeDraftState() === this.savedDraftSnapshot) { + await this.leaveOnboarding(); return; } - void this.leaveOnboarding(); + if (await this.confirmDiscard()) { + await this.router.navigate(['/organizations/list']); + } } - onDiscardAndLeave(): void { - void this.leaveOnboarding(); + /** + * Prompts with the same "Discard onboarding changes?" dialog used by the Cancel button, and + * clears the draft state on confirm. Shared by onCancel() and the canDeactivate guard so there + * is exactly one unsaved-changes dialog in this module, not a second one with different wording. + */ + confirmDiscard(): Promise { + const dialog = this.cancelConfirmDialog(); + if (!dialog) { + return Promise.resolve(true); + } + + return new Promise(resolve => { + const confirmedSub = dialog.confirmed.subscribe(() => { + confirmedSub.unsubscribe(); + cancelledSub.unsubscribe(); + this.isLeavingIntentionally = true; + this.stateService.clear(); + resolve(true); + }); + const cancelledSub = dialog.cancelled.subscribe(() => { + confirmedSub.unsubscribe(); + cancelledSub.unsubscribe(); + resolve(false); + }); + + void dialog.open(); + }); } onProvisioningConfirmed(): void { @@ -387,6 +516,7 @@ export class OrganizationOnboarding { .subscribe({ next: () => { this.notification.success('Organization onboarding submitted successfully.'); + this.isLeavingIntentionally = true; this.stateService.clear(); void this.router.navigate(['/organizations/list']); }, @@ -417,6 +547,16 @@ export class OrganizationOnboarding { }).pipe( switchMap(res => { this.stateService.setOrganizationId(res.id); + // Persist the new draft's id into the URL so a refresh/reopen resumes this draft + // instead of silently starting a brand-new organization (see updateUrlQueryParams()). + this.updateUrlQueryParams({ draftId: res.id }); + + if (stepIndex === 0 && !markComplete) { + // createDraft() already persisted these exact basics fields (with MarkComplete: + // false); calling updateBasics again here would just resend the same payload. + return of(res); + } + return this.updateServerStep(res.id, stepIndex, markComplete); }) ); @@ -483,7 +623,13 @@ export class OrganizationOnboarding { } return false; } else { - this.storeValidatedStep(i, stepComp); + if (!this.storeValidatedStep(i, stepComp)) { + if (i !== this.currentStepIndex()) { + this.stateService.setCurrentStepIndex(i); + this.hydrateActiveStep(); + } + return false; + } this.stateService.markStepCompleted(i); } } @@ -491,6 +637,53 @@ export class OrganizationOnboarding { return true; } + private validateStep(index: number): boolean { + const stepComp = this.getStepComponent(index); + if (!stepComp) { + return false; + } + + if (!stepComp.validate()) { + this.stateService.unmarkStepCompleted(index); + return false; + } + + if (!this.storeValidatedStep(index, stepComp)) { + return false; + } + + this.stateService.markStepCompleted(index); + return true; + } + + private validateForSave(currentIndex: number): boolean { + this.captureActiveStepDraft(); + + const organizationAlreadyExists = !!this.stateService.organizationId(); + + if (currentIndex !== 0 && !organizationAlreadyExists && !this.validateStep(0)) { + this.stateService.setCurrentStepIndex(0); + this.hydrateActiveStep(); + queueMicrotask(() => { + this.getStepComponent(0)?.validate(); + }); + this.notification.warning( + 'Please complete the required Basic details before saving — it is needed to create the organization.' + ); + return false; + } + + if (!this.validateStep(currentIndex)) { + const stepLabel = this.steps[currentIndex]?.label ?? `Step ${currentIndex + 1}`; + this.notification.warning( + `Please fix the validation errors in ${stepLabel} before proceeding.` + ); + return false; + } + + return true; + } + private getStepComponent(index: number): OnboardingStepForm | undefined { switch (index) { case 0: @@ -506,31 +699,33 @@ export class OrganizationOnboarding { } } - private getActiveStep(): OnboardingStepForm | undefined { - return this.getStepComponent(this.currentStepIndex()); - } - - private storeValidatedStep(index: number, step: OnboardingStepForm): void { - switch (index) { - case 0: - this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue()); - break; - case 1: - this.stateService.updateLocalization((step as OrganizationLocalizationStepComponent).getValue()); - break; - case 2: - this.stateService.updatePlanLimits((step as OrganizationPlanLimitsStepComponent).getValue()); - break; - case 3: - this.stateService.updateAdmin((step as OrganizationAdminStepComponent).getValue()); - break; + private storeValidatedStep(index: number, step: OnboardingStepForm): boolean { + try { + switch (index) { + case 0: + this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue()); + break; + case 1: + this.stateService.updateLocalization((step as OrganizationLocalizationStepComponent).getValue()); + break; + case 2: + this.stateService.updatePlanLimits((step as OrganizationPlanLimitsStepComponent).getValue()); + break; + case 3: + this.stateService.updateAdmin((step as OrganizationAdminStepComponent).getValue()); + break; + } + return true; + } catch { + this.stateService.unmarkStepCompleted(index); + const stepLabel = this.steps[index]?.label ?? `Step ${index + 1}`; + this.notification.warning( + `${stepLabel} is still loading some selections. Please wait a moment and try again.` + ); + return false; } } - private storeValidatedActiveStep(step: OnboardingStepForm): void { - this.storeValidatedStep(this.currentStepIndex(), step); - } - private captureActiveStepDraft(): void { const currentIndex = this.currentStepIndex(); @@ -550,16 +745,6 @@ export class OrganizationOnboarding { } } - private getValidatedStepValue(index: number): unknown { - const data = this.stateService.onboardingData(); - return [data.basics, data.localization, data.planLimits, data.admin][index] ?? null; - } - - private getDraftStepValue(index: number): unknown { - const draft = this.stateService.onboardingDraft(); - return [draft.basics, draft.localization, draft.planLimits, draft.admin][index] ?? null; - } - private hydrateActiveStep(): void { queueMicrotask(() => { const draft = this.stateService.onboardingDraft(); @@ -636,6 +821,7 @@ export class OrganizationOnboarding { } private async leaveOnboarding(): Promise { + this.isLeavingIntentionally = true; this.stateService.clear(); await this.router.navigate(['/organizations/list']); } diff --git a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts index ab6d1170..463e819a 100644 --- a/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts +++ b/src/app/features/organizations/organization-onboarding/steps/organization-basics/organization-basics.ts @@ -27,6 +27,8 @@ import { import { OrganizationOnboardingService } from '../../data-access/services/organization-onboarding.service'; import { OrganizationOnboardingStateService } from '../../data-access/services/organization-onboarding-state.service'; +const PENDING_CODE_PLACEHOLDER = 'Pending generation'; + interface OrganizationBasicsFormModel { readonly code: string | null; readonly organizationName: string; @@ -56,7 +58,8 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm(null); readonly industrySelection = signal(null); readonly registrationCountrySelection = signal(null); @@ -80,8 +83,9 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm { - if (code && (this.organizationCodeControl.value === 'Pending generation' || !this.organizationCodeControl.value)) { + if (code && !this.hasGeneratedOrganizationCode()) { this.organizationCodeControl.setValue(code); + this.hasGeneratedOrganizationCode.set(true); } }); } @@ -150,7 +154,14 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm { const value = this.form.getRawValue() as OrganizationBasicsFormModel; const country = this.registrationCountrySelection(); - const codeControlVal = this.organizationCodeControl.value; - const codeVal = codeControlVal && codeControlVal !== 'Pending generation' ? codeControlVal : null; + const codeVal = this.hasGeneratedOrganizationCode() ? this.organizationCodeControl.value : null; return { organizationCode: codeVal, @@ -233,6 +242,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm import('./organization-onboarding/organization-onboarding').then((m) => m.OrganizationOnboarding), data: { childTitle: 'Organization Onboarding', parentTitle: 'Organizations', subParentTitle: 'Configuration' }, }, diff --git a/src/app/features/organizations/pages/organization-awaiting-db/components/activation-result-display/activation-result-display.html b/src/app/features/organizations/pages/organization-awaiting-db/components/activation-result-display/activation-result-display.html index dd98de14..a3076db4 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/components/activation-result-display/activation-result-display.html +++ b/src/app/features/organizations/pages/organization-awaiting-db/components/activation-result-display/activation-result-display.html @@ -1,100 +1,56 @@ -
- -
- -
-
Activation Result — shown ONCE
-

- Please copy or save the temporary credentials below. The temporary password will not be shown again. -

-
+
+ +
+
- -
-
- - ✓ - -
-
- Status - ACTIVE ✓ -
-

HQ group created in tenant DB · identity user created (tenant_admin)

-
-
-
- - -
- -
- -
- +
+
+
+ -
-
- - -
- -
-
- -
- - - {{ copied() ? 'Copied!' : 'Copy' }} - -
-

- - Never stored anywhere. Hand over to the client. Forced change at first login = roadmap -

+
- -
-
- - If activation fails midway the org stays in Awaiting Activation — fix the cause and press Retry. -
+ +
+ +
-
- - - Retry Activation - - - - Done - -
+ +
+

+ If activation fails midway the org stays in Awaiting Activation — fix the cause and press Retry. +

diff --git a/src/app/features/organizations/pages/organization-awaiting-db/components/activation-result-display/activation-result-display.ts b/src/app/features/organizations/pages/organization-awaiting-db/components/activation-result-display/activation-result-display.ts index c30a0659..6ea8100d 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/components/activation-result-display/activation-result-display.ts +++ b/src/app/features/organizations/pages/organization-awaiting-db/components/activation-result-display/activation-result-display.ts @@ -1,13 +1,15 @@ import { ChangeDetectionStrategy, Component, inject, input, output, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { NotificationService } from '../../../../../../core/services/common/notification.service'; import { Button } from '../../../../../../shared/components/button/button'; +import { FormInput } from '../../../../../../shared/components/form/form-input/form-input'; import { OrganizationActivationResultDto } from '../../models/organization-awaiting-db.model'; @Component({ selector: 'app-activation-result-display', standalone: true, - imports: [CommonModule, Button], + imports: [CommonModule, FormsModule, Button, FormInput], templateUrl: './activation-result-display.html', styleUrl: './activation-result-display.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.html b/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.html index cc65660b..9c1d3df2 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.html +++ b/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.html @@ -2,16 +2,25 @@ [open]="open()" [title]="modalTitle()" size="md" - submitLabel="Assign & Activate" - loadingLabel="Assigning & Activating..." + [submitLabel]="activationResult() ? 'Retry' : (errorMessage() ? (postAssignmentFailure() ? 'Retry Activation' : 'Retry Assignment') : 'Assign')" + [loadingLabel]="activating() ? (activationResult() || errorMessage() ? 'Retrying...' : 'Assigning...') : ''" [loading]="activating()" - [showSubmitButton]="!activationResult()" + [showSubmitButton]="true" [submitDisabled]="activating()" (closed)="closeModal()" - (submitted)="assignAndActivate()" + (submitted)="activationResult() || postAssignmentFailure() ? retryActivation() : assignAndActivate()" > @if (!activationResult()) {
+ @if (errorMessage()) { +
+ +
+ }

diff --git a/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.ts b/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.ts index b04fc54a..01ff77bb 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.ts +++ b/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.ts @@ -25,6 +25,7 @@ import { AutocompleteValueFn } from '../../../../../../shared/components/form/autocomplete/autocomplete.types'; import { NotificationService } from '../../../../../../core/services/common/notification.service'; +import { extractErrorCode, extractErrorMessage } from '../../../../../../core/utils/error-extractor.util'; import { DbConnectionLookupDto } from '../../../../../settings/db-connections/models/db-connection.model'; import { OrganizationAwaitingDbService } from '../../data-access/organization-awaiting-db.service'; import { @@ -33,6 +34,25 @@ import { OrganizationActivationResultDto } from '../../models/organization-awaiting-db.model'; import { ActivationResultDisplayComponent } from '../activation-result-display/activation-result-display'; +import { ErrorAlertComponent } from '../../../../../../shared/components/error-alert/error-alert'; + +/** + * Error codes returned by POST /tenants/{id}/assign-database once the database has + * already been persisted and the tenant status has moved on to AwaitingActivation + * (see TenantDatabaseAssignmentService.AssignAsync / OrganizationActivationService.ActivateAsync). + * Only these should be retried via POST /organizations/{id}/activation/retry — every other + * failure means nothing was persisted yet, so retrying must resubmit assign-database instead. + */ +const POST_ASSIGNMENT_ERROR_CODES = new Set([ + 'organizations.database_already_assigned', + 'organizations.assigned_database_missing', + 'organizations.admin_contact_incomplete', +]); + +function isPostAssignmentFailure(errorCode: string | null): boolean { + if (!errorCode) return false; + return POST_ASSIGNMENT_ERROR_CODES.has(errorCode) || errorCode.startsWith('organizations.activation.'); +} @Component({ selector: 'app-assign-db-modal', @@ -42,7 +62,8 @@ import { ActivationResultDisplayComponent } from '../activation-result-display/a ReactiveFormsModule, Autocomplete, Button, - ActivationResultDisplayComponent + ActivationResultDisplayComponent, + ErrorAlertComponent ], templateUrl: './assign-db-modal.html', styleUrl: './assign-db-modal.scss', @@ -62,6 +83,9 @@ export class AssignDbModalComponent { readonly activating = signal(false); readonly submitAttempted = signal(false); + readonly errorMessage = signal(null); + /** True when the last failure happened after the database was already assigned (retry via activation/retry); false when it must resubmit assign-database. */ + readonly postAssignmentFailure = signal(false); readonly activationResult = signal(null); readonly dbConnectionOptions = signal([]); readonly selectedDbConnectionItem = signal(null); @@ -73,9 +97,10 @@ export class AssignDbModalComponent { readonly modalTitle = computed(() => { const org = this.tenant(); if (!org) return 'Assign database'; - const code = org.code || ''; + const code = org.code && org.code !== '—' ? org.code : ''; const name = org.organizationName || org.name || ''; - return 'Assign database'; //`Assign database — ${code} ${name}`.trim(); + const orgIdentifier = [code, name].filter(Boolean).join(' - '); + return orgIdentifier ? `Assign database — ${orgIdentifier}` : 'Assign database'; }); readonly searchDbConnections: AutocompleteSearchFn = (term, limit) => { @@ -115,15 +140,27 @@ export class AssignDbModalComponent { prepareModal(): void { this.submitAttempted.set(false); this.activating.set(false); + this.errorMessage.set(null); + this.postAssignmentFailure.set(false); this.activationResult.set(null); + + const initialDbId = this.tenant()?.defaultDbConnectionId || ''; this.selectedDbConnectionItem.set(null); - this.form.reset({ dbConnectionId: '' }); + this.form.reset({ dbConnectionId: initialDbId }); this.awaitingDbApi.getActiveDbConnections('', 100).pipe( takeUntilDestroyed(this.destroyRef) ).subscribe({ - next: options => this.dbConnectionOptions.set(options), - error: () => this.notification.error('Failed to load active database connections.') + next: options => { + this.dbConnectionOptions.set(options); + if (initialDbId) { + const match = options.find(o => o.id === initialDbId); + if (match) { + this.selectedDbConnectionItem.set(match); + } + } + }, + error: (err) => this.notification.error(extractErrorMessage(err, 'Failed to load active database connections.')) }); } @@ -149,14 +186,37 @@ export class AssignDbModalComponent { retryActivation(): void { const org = this.tenant(); - const dbConnectionId = this.form.controls.dbConnectionId.value; - if (org?.id && dbConnectionId) { - this.executeAssignment(org.id, dbConnectionId); + if (!org?.id) { + this.notification.error('Invalid organization selected.'); + return; } + + this.activating.set(true); + this.errorMessage.set(null); + + this.awaitingDbApi.retryActivation(org.id).pipe( + finalize(() => this.activating.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: result => { + this.postAssignmentFailure.set(false); + this.activationResult.set(result); + this.notification.success('Organization activation retried successfully!'); + this.activated.emit(result); + }, + error: err => { + const errorCode = extractErrorCode(err); + const msg = extractErrorMessage(err, 'Failed to retry organization activation. Please try again.'); + this.errorMessage.set(msg); + this.postAssignmentFailure.set(isPostAssignmentFailure(errorCode)); + this.notification.error(msg); + } + }); } private executeAssignment(tenantId: string, dbConnectionId: string): void { this.activating.set(true); + this.errorMessage.set(null); const request: AssignOrganizationDatabaseRequest = { dbConnectionId }; this.awaitingDbApi.assignDatabase(tenantId, request).pipe( @@ -164,12 +224,16 @@ export class AssignDbModalComponent { takeUntilDestroyed(this.destroyRef) ).subscribe({ next: result => { + this.postAssignmentFailure.set(false); this.activationResult.set(result); this.notification.success('Database assigned and organization activated successfully!'); this.activated.emit(result); }, error: err => { - const msg = err?.error?.message || err?.error?.title || 'Database assignment and activation failed. Please try again.'; + const errorCode = extractErrorCode(err); + const msg = extractErrorMessage(err, 'Database assignment and activation failed. Please try again.'); + this.errorMessage.set(msg); + this.postAssignmentFailure.set(isPostAssignmentFailure(errorCode)); this.notification.error(msg); } }); diff --git a/src/app/features/organizations/pages/organization-awaiting-db/data-access/organization-awaiting-db.endpoints.ts b/src/app/features/organizations/pages/organization-awaiting-db/data-access/organization-awaiting-db.endpoints.ts index cbfe1a1d..b674514f 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/data-access/organization-awaiting-db.endpoints.ts +++ b/src/app/features/organizations/pages/organization-awaiting-db/data-access/organization-awaiting-db.endpoints.ts @@ -12,6 +12,12 @@ export const AWAITING_DB_ENDPOINTS = { `/v1/tenants/${encodeURIComponent(id)}/assign-database` ), + retryActivation: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/organizations/${encodeURIComponent(id)}/activation/retry` + ), + dbConnectionAutocomplete: buildApiUrl( 'masterAdmin', '/v1/db-connections/autocomplete' diff --git a/src/app/features/organizations/pages/organization-awaiting-db/data-access/organization-awaiting-db.service.ts b/src/app/features/organizations/pages/organization-awaiting-db/data-access/organization-awaiting-db.service.ts index b9264dd1..ebbb915e 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/data-access/organization-awaiting-db.service.ts +++ b/src/app/features/organizations/pages/organization-awaiting-db/data-access/organization-awaiting-db.service.ts @@ -37,6 +37,13 @@ export class OrganizationAwaitingDbService { ); } + retryActivation(tenantId: string): Observable { + return this.http.post( + AWAITING_DB_ENDPOINTS.retryActivation(tenantId), + {} + ); + } + getActiveDbConnections(term = '', limit = 50): Observable { const params = new HttpParams() .set('term', term) diff --git a/src/app/features/organizations/pages/organization-awaiting-db/models/organization-awaiting-db.model.ts b/src/app/features/organizations/pages/organization-awaiting-db/models/organization-awaiting-db.model.ts index bc60ad79..1f6ed3de 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/models/organization-awaiting-db.model.ts +++ b/src/app/features/organizations/pages/organization-awaiting-db/models/organization-awaiting-db.model.ts @@ -24,7 +24,10 @@ export interface AwaitingDbOrganizationDto { country?: string | null; wizardFinished?: boolean | string | null; wizardFinishedOn?: string | null; - status: string | number; + defaultDbConnectionId?: string | null; + defaultDbConnectionCode?: string | null; + defaultDbConnectionName?: string | null; + status?: string | number; isActive?: boolean; createdOn?: string; } @@ -36,6 +39,9 @@ export interface AwaitingDbOrganizationTableRow extends DataTableRecord { readonly organizationName: string; readonly country: string; readonly wizardFinished: string; - readonly status: string; + readonly defaultDbConnectionCode: string; + readonly defaultDbConnectionName: string; + readonly defaultDbConnectionId?: string | null; + readonly assignedDbConnection: string; readonly serialNumber: number; } diff --git a/src/app/features/organizations/pages/organization-awaiting-db/organization-awaiting-db.html b/src/app/features/organizations/pages/organization-awaiting-db/organization-awaiting-db.html index 114d8bb6..9fc2a41e 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/organization-awaiting-db.html +++ b/src/app/features/organizations/pages/organization-awaiting-db/organization-awaiting-db.html @@ -6,7 +6,7 @@ [totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()" [pageSize]="tableStore.queryState.pageSize()" - tableTitle="Organizations Awaiting Database & Activate" + tableTitle="Organizations Awaiting Database" [showSearch]="true" rowColorMode="none" [showAddButton]="false" diff --git a/src/app/features/organizations/pages/organization-awaiting-db/organization-awaiting-db.ts b/src/app/features/organizations/pages/organization-awaiting-db/organization-awaiting-db.ts index d7a2b5c7..f493cc5f 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/organization-awaiting-db.ts +++ b/src/app/features/organizations/pages/organization-awaiting-db/organization-awaiting-db.ts @@ -1,6 +1,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { NotificationService } from '../../../../core/services/common/notification.service'; +import { extractErrorMessage } from '../../../../core/utils/error-extractor.util'; import { DataTable } from '../../../../shared/components/data-table/data-table'; import { DataTableStore } from '../../../../shared/components/data-table/data-table.store'; import { @@ -49,15 +50,28 @@ export class OrganizationAwaitingDb implements OnInit { align: 'left', }, { key: 'country', label: 'Country', header: 'Country', sortable: true }, - { key: 'wizardFinished', label: 'Wizard Finished', header: 'Wizard Finished', sortable: true }, + // { key: 'wizardFinished', label: 'Wizard Finished', header: 'Wizard Finished', sortable: true }, { - key: 'status', - label: 'Status', - header: 'Status', + key: 'defaultDbConnectionCode', + label: 'DB Connection Code', + header: 'DB Connection Code', sortable: true, + align: 'center', + headerAlign: 'center', badge: true, - badgeClass: () => 'badge bg-warning/10 text-warning border border-warning/20 font-semibold', + badgeClass: value => value && value !== '—' + ? 'badge bg-primary/10 text-primary border border-primary/20 font-mono font-semibold' + : 'badge bg-light text-defaulttextcolor', + formatter: value => (value && value !== '—' ? String(value) : '—') }, + { + key: 'defaultDbConnectionName', + label: 'DB Connection Name', + header: 'DB Connection Name', + sortable: true, + align: 'left', + formatter: value => (value && value !== '—' ? String(value) : '—') + } ]); readonly actions = signal[]>([ @@ -75,6 +89,18 @@ export class OrganizationAwaitingDb implements OnInit { mapRow: (item, serialNumber) => { const resolvedName = item.organizationName || item.name || '—'; const resolvedCountry = item.countryName || item.country || '—'; + const dbCode = item.defaultDbConnectionCode || (item as any).DefaultDbConnectionCode || ''; + const dbName = item.defaultDbConnectionName || (item as any).DefaultDbConnectionName || ''; + const dbId = item.defaultDbConnectionId || (item as any).DefaultDbConnectionId || null; + + let assignedDb = '—'; + if (dbName && dbCode) { + assignedDb = `${dbName} (${dbCode})`; + } else if (dbName) { + assignedDb = dbName; + } else if (dbCode) { + assignedDb = dbCode; + } let wizardStatus = 'Yes'; if (item.wizardFinished === false) { @@ -91,26 +117,34 @@ export class OrganizationAwaitingDb implements OnInit { organizationName: resolvedName, country: resolvedCountry, wizardFinished: wizardStatus, - status: 'Awaiting DB', + defaultDbConnectionCode: dbCode || '—', + defaultDbConnectionName: dbName || '—', + defaultDbConnectionId: dbId, + assignedDbConnection: assignedDb, serialNumber, }; }, onError: (err: any) => { - const msg = err?.error?.message || err?.error?.title || 'Failed to load organizations awaiting database assignment.'; + const msg = extractErrorMessage(err, 'Failed to load organizations awaiting database assignment.'); this.notification.error(msg); } }); } + private hasActivatedOrg = false; + onActionClick(event: DataTableActionEvent): void { if (event.action.type === 'assign') { + this.hasActivatedOrg = false; const org: AwaitingDbOrganizationDto = { id: event.row.id, code: event.row.code, name: event.row.name, organizationName: event.row.organizationName, countryName: event.row.country, - status: event.row.status + defaultDbConnectionCode: event.row.defaultDbConnectionCode, + defaultDbConnectionName: event.row.defaultDbConnectionName, + defaultDbConnectionId: event.row.defaultDbConnectionId, }; this.selectedTenant.set(org); this.modalOpen.set(true); @@ -120,9 +154,13 @@ export class OrganizationAwaitingDb implements OnInit { onModalClosed(): void { this.modalOpen.set(false); this.selectedTenant.set(null); + if (this.hasActivatedOrg) { + this.hasActivatedOrg = false; + this.tableStore.refresh(); + } } onOrganizationActivated(): void { - this.tableStore.refresh(); + this.hasActivatedOrg = true; } } diff --git a/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-filter-toolbar/tenant-domain-filter-toolbar.html b/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-filter-toolbar/tenant-domain-filter-toolbar.html index ddd90d79..2974252c 100644 --- a/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-filter-toolbar/tenant-domain-filter-toolbar.html +++ b/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-filter-toolbar/tenant-domain-filter-toolbar.html @@ -1,25 +1,4 @@ -

- -
(); readonly filterApplied = output(); readonly filterReset = output(); - readonly selectedOrgLookup = signal(null); - readonly filterForm = this.formBuilder.nonNullable.group({ - tenantId: [''], domainType: [''] }); @@ -51,28 +38,17 @@ export class TenantDomainFilterToolbarComponent { { value: '2', label: 'RootDomain' } ]; - readonly searchOrganizations: AutocompleteSearchFn = (term: string, limit: number) => - this.tenantApi.autocomplete(term, limit); - readonly displayOrg: AutocompleteDisplayFn = (org: TenantLookupDto) => org.name; - readonly orgValue: AutocompleteValueFn = (org: TenantLookupDto) => org.id; - - onOrgSelected(tenant: TenantLookupDto | null): void { - this.selectedOrgLookup.set(tenant); - } - onApplyFilter(): void { - const rawOrgId = this.filterForm.controls.tenantId.value; const rawType = this.filterForm.controls.domainType.value; this.filterApplied.emit({ - tenantId: rawOrgId ? rawOrgId.trim() : null, + tenantId: null, domainType: rawType !== '' ? rawType : null }); } onResetFilter(): void { - this.filterForm.reset({ tenantId: '', domainType: '' }); - this.selectedOrgLookup.set(null); + this.filterForm.reset({ domainType: '' }); this.filterReset.emit(); } diff --git a/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-table/tenant-domain-table.html b/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-table/tenant-domain-table.html index d0d9ded9..40fe634d 100644 --- a/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-table/tenant-domain-table.html +++ b/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-table/tenant-domain-table.html @@ -9,7 +9,7 @@ buttonTitle="Add" [showSearch]="true" [showAddButton]="true" - [showFilterButton]="true" + [showFilterButton]="false" [filterActive]="showFilters()" searchPlaceholder="Search domains..." [searchDebounceTime]="300" @@ -46,9 +46,19 @@ - - {{ value }} - + @if (value === 'Subdomain' || value === 'SUBDOMAIN' || value === 1 || value === '1') { + + Subdomain + + } @else if (value === 'RootDomain' || value === 'ROOTDOMAIN' || value === 2 || value === '2') { + + RootDomain + + } @else { + + {{ value === 'CUSTOM' ? 'Custom' : (value || 'Custom') }} + + } @@ -96,8 +106,4 @@ - -
- - Ownership + SSL verification workflow is future scope (columns already exist in DB). -
+ diff --git a/src/app/features/organizations/pages/tenant-domain-list/data-access/tenant-domain.endpoints.ts b/src/app/features/organizations/pages/tenant-domain-list/data-access/tenant-domain.endpoints.ts index 928760ed..2b6d5b03 100644 --- a/src/app/features/organizations/pages/tenant-domain-list/data-access/tenant-domain.endpoints.ts +++ b/src/app/features/organizations/pages/tenant-domain-list/data-access/tenant-domain.endpoints.ts @@ -23,6 +23,12 @@ export const TENANT_DOMAIN_ENDPOINTS = { `/v1/tenant-domains/${encodeURIComponent(id)}` ), + delete: (id: string) => + buildApiUrl( + 'masterAdmin', + `/v1/tenant-domains/${encodeURIComponent(id)}` + ), + updateStatus: (id: string) => buildApiUrl( 'masterAdmin', diff --git a/src/app/features/organizations/pages/tenant-domain-list/data-access/tenant-domain.service.ts b/src/app/features/organizations/pages/tenant-domain-list/data-access/tenant-domain.service.ts index 4fb7b7f0..aa48a818 100644 --- a/src/app/features/organizations/pages/tenant-domain-list/data-access/tenant-domain.service.ts +++ b/src/app/features/organizations/pages/tenant-domain-list/data-access/tenant-domain.service.ts @@ -52,6 +52,10 @@ export class TenantDomainService { return this.http.patch(TENANT_DOMAIN_ENDPOINTS.updateStatus(id), { isActive }); } + delete(id: string): Observable { + return this.http.delete(TENANT_DOMAIN_ENDPOINTS.delete(id)); + } + verifyTenantDomain(id: string): Observable { return this.http.patch(TENANT_DOMAIN_ENDPOINTS.verify(id), {}); } diff --git a/src/app/features/organizations/pages/tenant-domain-list/tenant-domain-list.html b/src/app/features/organizations/pages/tenant-domain-list/tenant-domain-list.html index c8503c02..ba80b2a1 100644 --- a/src/app/features/organizations/pages/tenant-domain-list/tenant-domain-list.html +++ b/src/app/features/organizations/pages/tenant-domain-list/tenant-domain-list.html @@ -23,3 +23,12 @@ (saved)="onModalSaved()" (closed)="onModalClosed()" /> + + diff --git a/src/app/features/organizations/pages/tenant-domain-list/tenant-domain-list.ts b/src/app/features/organizations/pages/tenant-domain-list/tenant-domain-list.ts index d47c647b..fb301b42 100644 --- a/src/app/features/organizations/pages/tenant-domain-list/tenant-domain-list.ts +++ b/src/app/features/organizations/pages/tenant-domain-list/tenant-domain-list.ts @@ -1,4 +1,6 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { finalize } from 'rxjs/operators'; import { NotificationService } from '../../../../core/services/common/notification.service'; import { DataTableStore } from '../../../../shared/components/data-table/data-table.store'; import { @@ -6,6 +8,7 @@ import { DataTableActionEvent, DataTableColumn } from '../../../../shared/components/data-table/data-table.types'; +import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; import { TenantDomainDto, TenantDomainModalMode, @@ -23,19 +26,22 @@ import { TenantDomainFormModalComponent } from './components/tenant-domain-form- standalone: true, imports: [ TenantDomainTableComponent, - TenantDomainFormModalComponent + TenantDomainFormModalComponent, + ConfirmDialog ], providers: [DataTableStore], templateUrl: './tenant-domain-list.html', styleUrl: './tenant-domain-list.scss' }) export class TenantDomainList implements OnInit { + private readonly destroyRef = inject(DestroyRef); private readonly notification = inject(NotificationService); private readonly tenantDomainApi = inject(TenantDomainService); readonly tableStore = inject(DataTableStore); readonly showFilters = signal(false); + readonly showFilterButton = signal(false); readonly appliedTenantId = signal(null); readonly appliedDomainType = signal(null); @@ -43,11 +49,16 @@ export class TenantDomainList implements OnInit { readonly modalMode = signal('create'); readonly selectedDomainId = signal(null); + readonly statusChangingId = signal(null); + readonly deletingId = signal(null); + readonly pendingDeleteDomain = signal(null); + readonly deleteConfirmDialog = viewChild(ConfirmDialog); + readonly columns = signal[]>([ { key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' }, { key: 'domainName', label: 'Domain', header: 'Domain', sortable: true, align: 'left' }, { key: 'organizationName', label: 'Organization', header: 'Organization', sortable: true, align: 'left' }, - { key: 'domainType', label: 'Type', header: 'Type', sortable: true }, + { key: 'domainType', label: 'Type', header: 'Type', sortable: true, }, { key: 'isPrimary', label: 'Primary', header: 'Primary', sortable: true }, { key: 'sslStatus', label: 'SSL', header: 'SSL', sortable: true }, { key: 'isActive', label: 'Active', header: 'Active', sortable: true }, @@ -61,10 +72,27 @@ export class TenantDomainList implements OnInit { className: 'text-primary font-semibold' }, { - type: 'verify', - label: 'Verify', - icon: 'ti ti-shield-check', - className: 'text-success font-semibold' + type: 'deactivate', + label: 'Deactivate', + icon: 'ti ti-toggle-right', + className: 'text-warning font-semibold', + visible: row => row.isActive, + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id + }, + { + type: 'activate', + label: 'Activate', + icon: 'ti ti-toggle-left', + className: 'text-success font-semibold', + visible: row => !row.isActive, + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id + }, + { + type: 'delete', + label: 'Delete', + icon: 'ti ti-trash', + className: 'text-danger font-semibold', + disabled: row => this.statusChangingId() === row.id || this.deletingId() === row.id } ]); @@ -129,7 +157,7 @@ export class TenantDomainList implements OnInit { } onFilterApplied(filterValues: TenantDomainFilterValues): void { - this.appliedTenantId.set(filterValues.tenantId); + this.appliedTenantId.set(filterValues.tenantId ?? null); this.appliedDomainType.set(filterValues.domainType); this.tableStore.refresh(); } @@ -146,6 +174,12 @@ export class TenantDomainList implements OnInit { this.modalMode.set('edit'); this.selectedDomainId.set(row.id); this.modalOpen.set(true); + } else if (event.action.type === 'activate') { + this.changeDomainStatus(row, true); + } else if (event.action.type === 'deactivate') { + this.changeDomainStatus(row, false); + } else if (event.action.type === 'delete') { + this.requestDeleteDomain(row); } else if (event.action.type === 'verify') { this.notification.info(`Initiating SSL and ownership verification for ${row.domainName}...`); this.tenantDomainApi.verifyTenantDomain(row.id).subscribe({ @@ -161,6 +195,61 @@ export class TenantDomainList implements OnInit { } } + private changeDomainStatus(domain: TenantDomainTableRow, activate: boolean): void { + this.statusChangingId.set(domain.id); + + this.tenantDomainApi.updateStatus(domain.id, activate).pipe( + finalize(() => this.statusChangingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.notification.success(`Domain ${activate ? 'activated' : 'deactivated'} successfully.`); + this.tableStore.refresh(); + }, + error: (err) => { + const msg = err?.error?.message || err?.error?.title || `Unable to ${activate ? 'activate' : 'deactivate'} domain.`; + this.notification.error(msg); + } + }); + } + + private requestDeleteDomain(domain: TenantDomainTableRow): void { + this.pendingDeleteDomain.set(domain); + this.deleteConfirmDialog()?.open(); + } + + onDeleteConfirmed(): void { + const domain = this.pendingDeleteDomain(); + if (!domain) return; + this.pendingDeleteDomain.set(null); + this.deletingId.set(domain.id); + + this.tenantDomainApi.delete(domain.id).pipe( + finalize(() => this.deletingId.set(null)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: () => { + this.notification.success('Domain deleted successfully.'); + this.tableStore.refresh(); + }, + error: (err) => { + let errorMsg = 'Unable to delete domain.'; + if (err?.status === 409) { + errorMsg = err?.error?.message || err?.error?.detail || 'Cannot delete domain because it is currently in use or referenced by other records.'; + } else if (err?.status === 404) { + errorMsg = err?.error?.message || 'Domain not found or has already been deleted.'; + } else if (err?.error?.message || err?.error?.title) { + errorMsg = err.error.message || err.error.title; + } + this.notification.error(errorMsg); + } + }); + } + + onDeleteCancelled(): void { + this.pendingDeleteDomain.set(null); + } + onModalSaved(): void { this.tableStore.refresh(); } diff --git a/src/app/features/settings/db-connections/components/db-connection-form-modal/db-connection-form-modal.html b/src/app/features/settings/db-connections/components/db-connection-form-modal/db-connection-form-modal.html index d3796cd4..5334ecd7 100644 --- a/src/app/features/settings/db-connections/components/db-connection-form-modal/db-connection-form-modal.html +++ b/src/app/features/settings/db-connections/components/db-connection-form-modal/db-connection-form-modal.html @@ -1,7 +1,7 @@ this.modalLoading.set(false)), + takeUntilDestroyed(this.destroyRef) + ).subscribe({ + next: res => { + const generatedCode = res.code || res.connectionCode || res.nextCode || res.value || 'Auto-generated'; + this.dbConnectionForm.patchValue({ + connectionCode: generatedCode + }); + }, + error: () => { + this.dbConnectionForm.patchValue({ + connectionCode: 'Auto-generated' + }); + } + }); return; } @@ -327,14 +343,24 @@ export class DbConnectionFormModalComponent { takeUntilDestroyed(this.destroyRef) ).subscribe({ next: res => { - if (res.success) { - this.notification.success(res.message || 'Database connection test succeeded!'); + const msg = res.message || res.detail || res.error || ''; + const isMsgFailed = msg.toLowerCase().includes('fail') || msg.toLowerCase().includes('error'); + const isSuccess = res.isSuccess !== false && res.success !== false && !isMsgFailed; + + if (isSuccess) { + this.notification.success(msg || 'Database connection test succeeded!'); } else { - this.notification.error(res.message || 'Database connection test failed.'); + this.notification.error(msg || 'Database connection test failed.'); } }, error: err => { - const errorMsg = err?.error?.message || err?.error?.title || 'Connection test failed. Please verify connection credentials and network access.'; + const errorMsg = + err?.error?.message || + err?.error?.detail || + err?.error?.title || + err?.error?.error?.message || + 'Connection test failed. Please verify connection credentials and network access.'; + this.notification.error(errorMsg); } }); diff --git a/src/app/features/settings/db-connections/data-access/db-connection.endpoints.ts b/src/app/features/settings/db-connections/data-access/db-connection.endpoints.ts index 6b116055..d21d5f4d 100644 --- a/src/app/features/settings/db-connections/data-access/db-connection.endpoints.ts +++ b/src/app/features/settings/db-connections/data-access/db-connection.endpoints.ts @@ -5,6 +5,8 @@ export const DB_CONNECTION_ENDPOINTS = { create: buildApiUrl('masterAdmin', '/v1/db-connections'), + previewNextCode: buildApiUrl('masterAdmin', '/v1/db-connections/next-code'), + getById: (id: string) => buildApiUrl('masterAdmin', `/v1/db-connections/${encodeURIComponent(id)}`), @@ -19,7 +21,7 @@ export const DB_CONNECTION_ENDPOINTS = { changeStatus: (id: string) => buildApiUrl('masterAdmin', `/v1/db-connections/${encodeURIComponent(id)}/status`), - testConnection: buildApiUrl('masterAdmin', '/v1/db-connections/test'), + testConnection: buildApiUrl('masterAdmin', '/v1/db-connections/test-connection'), testConnectionById: (id: string) => buildApiUrl('masterAdmin', `/v1/db-connections/${encodeURIComponent(id)}/test`), diff --git a/src/app/features/settings/db-connections/data-access/db-connection.service.ts b/src/app/features/settings/db-connections/data-access/db-connection.service.ts index b232c420..ef51d657 100644 --- a/src/app/features/settings/db-connections/data-access/db-connection.service.ts +++ b/src/app/features/settings/db-connections/data-access/db-connection.service.ts @@ -8,6 +8,7 @@ import { } from '../../../../shared/components/data-table/data-table.types'; import { CreateDbConnectionRequest, + DbConnectionCodePreviewDto, DbConnectionDto, DbConnectionLookupDto, TestDbConnectionRequest, @@ -31,6 +32,10 @@ export class DbConnectionService { return this.http.post(DB_CONNECTION_ENDPOINTS.create, request); } + previewNextCode(): Observable { + return this.http.get(DB_CONNECTION_ENDPOINTS.previewNextCode); + } + updateDbConnection(id: string, request: UpdateDbConnectionRequest): Observable { return this.http.put(DB_CONNECTION_ENDPOINTS.update(id), request); } diff --git a/src/app/features/settings/db-connections/models/db-connection.model.ts b/src/app/features/settings/db-connections/models/db-connection.model.ts index ea334db9..9fdb3dc7 100644 --- a/src/app/features/settings/db-connections/models/db-connection.model.ts +++ b/src/app/features/settings/db-connections/models/db-connection.model.ts @@ -110,8 +110,11 @@ export interface TestDbConnectionRequest { } export interface TestDbConnectionResult { - success: boolean; - message: string; + success?: boolean; + isSuccess?: boolean; + message?: string; + detail?: string; + error?: string; } export interface DbConnectionLookupDto { @@ -122,3 +125,11 @@ export interface DbConnectionLookupDto { isReadReplica?: boolean; isActive?: boolean; } + +export interface DbConnectionCodePreviewDto { + code?: string | null; + connectionCode?: string | null; + nextCode?: string | null; + value?: string | null; +} + diff --git a/src/app/shared/components/error-alert/error-alert.html b/src/app/shared/components/error-alert/error-alert.html new file mode 100644 index 00000000..f283a233 --- /dev/null +++ b/src/app/shared/components/error-alert/error-alert.html @@ -0,0 +1,60 @@ +@if (message()) { +
+ + +
+ @if (title()) { + + {{ title() }} + + } + +
+ {{ message() }} +
+ + @if (detailsArray().length > 0) { +
+ + + @if (showDetails()) { +
    + @for (detail of detailsArray(); track $index) { +
  • {{ detail }}
  • + } +
+ } +
+ } +
+ +
+ + + @if (dismissible()) { + + } +
+
+} diff --git a/src/app/shared/components/error-alert/error-alert.scss b/src/app/shared/components/error-alert/error-alert.scss new file mode 100644 index 00000000..fe8bbc5f --- /dev/null +++ b/src/app/shared/components/error-alert/error-alert.scss @@ -0,0 +1,4 @@ +:host { + display: block; + width: 100%; +} diff --git a/src/app/shared/components/error-alert/error-alert.ts b/src/app/shared/components/error-alert/error-alert.ts new file mode 100644 index 00000000..b043bcaa --- /dev/null +++ b/src/app/shared/components/error-alert/error-alert.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NotificationService } from '../../../core/services/common/notification.service'; + +@Component({ + selector: 'app-error-alert', + standalone: true, + imports: [CommonModule], + templateUrl: './error-alert.html', + styleUrl: './error-alert.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ErrorAlertComponent { + private readonly notification = inject(NotificationService); + + readonly message = input(null); + readonly title = input('Operation Failed'); + readonly details = input(null); + readonly dismissible = input(true); + + readonly dismissed = output(); + + readonly showDetails = signal(false); + readonly copied = signal(false); + + readonly detailsArray = computed(() => { + const rawDetails = this.details(); + if (!rawDetails) return []; + if (Array.isArray(rawDetails)) return rawDetails.filter(Boolean); + return [rawDetails]; + }); + + toggleDetails(): void { + this.showDetails.update(v => !v); + } + + copyErrorText(): void { + const titleText = this.title(); + const mainMsg = this.message() || ''; + const detailText = this.detailsArray().join('\n'); + + const fullErrorString = `[${titleText}]\n${mainMsg}${detailText ? '\n\nDetails:\n' + detailText : ''}`; + + navigator.clipboard.writeText(fullErrorString).then(() => { + this.copied.set(true); + this.notification.success('Error message copied to clipboard.'); + setTimeout(() => this.copied.set(false), 2500); + }).catch(() => { + this.notification.error('Failed to copy error to clipboard.'); + }); + } + + dismiss(): void { + this.dismissed.emit(); + } +} diff --git a/src/app/shared/components/error-modal/error-modal.html b/src/app/shared/components/error-modal/error-modal.html new file mode 100644 index 00000000..16f28783 --- /dev/null +++ b/src/app/shared/components/error-modal/error-modal.html @@ -0,0 +1,69 @@ + + @if (options()) { +
+ +
+
+ +
+
+
+
+ {{ options()?.title }} +
+ @if (options()?.code) { + + HTTP {{ options()?.code }} + + } +
+

+ {{ options()?.message }} +

+
+
+ + + @if (detailsList().length > 0) { +
+
+ + + + + {{ copied() ? 'Copied' : 'Copy Details' }} + +
+ + @if (showDetails()) { +
+
    + @for (item of detailsList(); track $index) { +
  • {{ item }}
  • + } +
+
+ } +
+ } +
+ } +
diff --git a/src/app/shared/components/error-modal/error-modal.scss b/src/app/shared/components/error-modal/error-modal.scss new file mode 100644 index 00000000..5d4e87f3 --- /dev/null +++ b/src/app/shared/components/error-modal/error-modal.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/src/app/shared/components/error-modal/error-modal.service.ts b/src/app/shared/components/error-modal/error-modal.service.ts new file mode 100644 index 00000000..520469e9 --- /dev/null +++ b/src/app/shared/components/error-modal/error-modal.service.ts @@ -0,0 +1,44 @@ +import { Injectable, signal } from '@angular/core'; +import { extractErrorMessage } from '../../../core/utils/error-extractor.util'; + +export interface ErrorModalOptions { + title?: string; + message: string; + details?: string | string[] | null; + code?: string | number | null; +} + +@Injectable({ + providedIn: 'root', +}) +export class ErrorModalService { + readonly isOpen = signal(false); + readonly options = signal(null); + + show(options: ErrorModalOptions): void { + this.options.set({ + title: options.title || 'Operation Failed', + message: options.message, + details: options.details || null, + code: options.code || null, + }); + this.isOpen.set(true); + } + + showError(err: any, fallbackTitle = 'Error Occurred', fallbackMessage = 'An unexpected error occurred.'): void { + const message = extractErrorMessage(err, fallbackMessage); + const details = err?.error?.stack || err?.error?.errors || null; + + this.show({ + title: fallbackTitle, + message, + details, + code: err?.status || null, + }); + } + + close(): void { + this.isOpen.set(false); + this.options.set(null); + } +} diff --git a/src/app/shared/components/error-modal/error-modal.ts b/src/app/shared/components/error-modal/error-modal.ts new file mode 100644 index 00000000..9be8dabb --- /dev/null +++ b/src/app/shared/components/error-modal/error-modal.ts @@ -0,0 +1,62 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Modal } from '../modal/modal'; +import { Button } from '../button/button'; +import { ErrorModalService } from './error-modal.service'; +import { NotificationService } from '../../../core/services/common/notification.service'; + +@Component({ + selector: 'app-error-modal', + standalone: true, + imports: [CommonModule, Modal, Button], + templateUrl: './error-modal.html', + styleUrl: './error-modal.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ErrorModalComponent { + readonly errorModalService = inject(ErrorModalService); + private readonly notification = inject(NotificationService); + + readonly isOpen = this.errorModalService.isOpen; + readonly options = this.errorModalService.options; + + readonly showDetails = signal(false); + readonly copied = signal(false); + + readonly detailsList = computed(() => { + const raw = this.options()?.details; + if (!raw) return []; + if (Array.isArray(raw)) return raw.filter(Boolean); + if (typeof raw === 'object') return Object.values(raw).flat().map(String); + return [String(raw)]; + }); + + toggleDetails(): void { + this.showDetails.update(v => !v); + } + + copyError(): void { + const opts = this.options(); + if (!opts) return; + + const title = opts.title || 'Error'; + const message = opts.message || ''; + const code = opts.code ? ` (Code: ${opts.code})` : ''; + const details = this.detailsList().join('\n'); + + const errorText = `[${title}${code}]\n${message}${details ? '\n\nDetails:\n' + details : ''}`; + + navigator.clipboard.writeText(errorText).then(() => { + this.copied.set(true); + this.notification.success('Error details copied to clipboard.'); + setTimeout(() => this.copied.set(false), 2500); + }).catch(() => { + this.notification.error('Failed to copy error to clipboard.'); + }); + } + + close(): void { + this.showDetails.set(false); + this.errorModalService.close(); + } +} diff --git a/src/app/shared/components/form/form-input/form-input.ts b/src/app/shared/components/form/form-input/form-input.ts index b7d2dc26..81dba08e 100644 --- a/src/app/shared/components/form/form-input/form-input.ts +++ b/src/app/shared/components/form/form-input/form-input.ts @@ -95,6 +95,7 @@ export class FormInput implements ControlValueAccessor { readonly ariaDescription = input(null); + readonly inputValue = input(null, { alias: 'value' }); readonly value = signal(null); readonly formDisabled = signal(false); readonly passwordVisible = signal(false); @@ -106,6 +107,13 @@ export class FormInput implements ControlValueAccessor { private onTouched: () => void = () => { }; constructor() { + effect(() => { + const boundValue = this.inputValue(); + if (boundValue !== null && boundValue !== undefined) { + this.value.set(boundValue); + } + }); + effect(() => { if (this.type() !== 'password') { this.passwordVisible.set(false);