localization, assign data-base, tenant domain, plan and subscription and data-base connection screen added

This commit is contained in:
Gagan7900
2026-08-07 14:01:25 +05:30
parent 0ed581abc9
commit c29d9450ec
88 changed files with 3489 additions and 2469 deletions
@@ -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);
});
});
@@ -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<boolean>;
}
export const unsavedChangesGuard: CanDeactivateFn<CanComponentDeactivate> = (component) => {
if (!component.hasUnsavedChanges()) {
return true;
}
return component.confirmDiscard();
};
+14 -13
View File
@@ -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: '<i class="bx bx-home side-menu__icon"></i>',
@@ -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: '<i class="bx bx-buildings side-menu__icon"></i>',
@@ -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: '<i class="bx bx-globe side-menu__icon"></i>',
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: '<i class="bx bx-globe side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/localization/translations-manager', title: 'Translations Manager', type: 'link', dirchange: false },
],
},
{
title: 'Settings',
icon: '<i class="bx bx-cog side-menu__icon"></i>',
@@ -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',
@@ -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;
}
@@ -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()"
>
<form [formGroup]="subscriptionForm" (ngSubmit)="save()" autocomplete="off" class="py-1">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
@if (isCreateMode()) {
@if (isCreateMode() || isViewMode()) {
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="tenantId"
@@ -21,6 +23,7 @@
label="Tenant"
placeholder="Select tenant"
[required]="true"
[readonly]="isViewMode()"
[minSearchLength]="1"
[debounceTime]="300"
[limit]="50"
@@ -45,6 +48,7 @@
label="Plan"
placeholder="Select plan"
[required]="true"
[readonly]="isViewMode()"
[minSearchLength]="0"
[submitAttempted]="submitAttempted()"
[searchFn]="searchPlans"
@@ -57,7 +61,7 @@
/>
</div>
@if (isCreateMode()) {
@if (isCreateMode() || isViewMode()) {
<div class="col-span-12 md:col-span-6">
<app-form-select
formControlName="status"
@@ -67,6 +71,7 @@
placeholder="Select status"
[options]="statusOptions()"
[required]="true"
[readonly]="isViewMode()"
[searchable]="false"
[clearable]="false"
dropdownPosition="auto"
@@ -78,7 +83,7 @@
</div>
}
@if (isCreateMode()) {
@if (isCreateMode() || isViewMode()) {
<div class="col-span-12 md:col-span-6">
<app-form-date-picker
formControlName="startsOn"
@@ -87,6 +92,7 @@
label="Starts On"
mode="single"
[required]="true"
[readonly]="isViewMode()"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Start date is required.' }"
/>
@@ -98,6 +104,7 @@
variant="floating"
label="Ends On"
mode="single"
[readonly]="isViewMode()"
[submitAttempted]="submitAttempted()"
/>
</div>
@@ -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<FormSelectOption<SubscriptionStatus>[]>([
{ 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<TenantLookupDto> = (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;
@@ -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;
@@ -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;
@@ -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 @@
<app-confirm-dialog
#cancelConfirmDialog
title="Cancel Subscription"
text="Do you really want to cancel this subscription?"
confirmButtonText="Cancel Subscription"
@@ -31,6 +32,16 @@
(cancelled)="onCancelDismissed()"
/>
<app-confirm-dialog
#deleteConfirmDialog
title="Delete Subscription"
text="Are you sure you want to delete this subscription? This action will soft-delete the subscription."
confirmButtonText="Yes, Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteDismissed()"
/>
<app-subscription-form-modal
[open]="showSubscriptionModal()"
[mode]="subscriptionModalMode()"
@@ -1,6 +1,7 @@
import { Component, DestroyRef, OnInit, computed, inject, signal, viewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { catchError, of, switchMap } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
@@ -61,6 +62,7 @@ function formatDate(value: string | null): string {
export class PlansSubscriptions implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly planApi = inject(PlanService);
private readonly tenantApi = inject(TenantService);
private readonly subscriptionApi = inject(SubscriptionService);
@@ -80,7 +82,10 @@ export class PlansSubscriptions implements OnInit {
readonly subscriptionLookupUnavailable = signal(false);
readonly cancelling = signal(false);
readonly statusChangingId = signal<string | null>(null);
readonly cancelConfirmDialog = viewChild(ConfirmDialog);
readonly deletingId = signal<string | null>(null);
readonly pendingDeleteSubscription = signal<SubscriptionTableRow | null>(null);
readonly cancelConfirmDialog = viewChild<ConfirmDialog>('cancelConfirmDialog');
readonly deleteConfirmDialog = viewChild<ConfirmDialog>('deleteConfirmDialog');
private pendingCancelId: string | null = null;
readonly showSubscriptionModal = signal(false);
@@ -134,19 +139,7 @@ export class PlansSubscriptions implements OnInit {
readonly actions = signal<DataTableAction<SubscriptionTableRow>[]>([
{ 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<TenantLookupDto> = (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);
}
}
@@ -1,7 +1,7 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="md"
size="sm"
[submitAction]="mode() === 'create' ? 'save' : 'update'"
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
@@ -10,14 +10,6 @@
<ng-template appDataTableToolbar>
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter()" autocomplete="off"
class="flex flex-wrap items-end gap-3 w-full">
<div class="w-64 min-w-[200px]">
<app-autocomplete formControlName="organizationId" inputId="exchange-rate-org-filter" variant="floating"
size="sm" label="Organization" placeholder="Search organization" [searchFn]="searchOrganizations"
[displayWith]="displayOrg" [valueWith]="orgValue" [selectedItem]="selectedOrgLookup()" [minSearchLength]="0"
[debounceTime]="300" [limit]="20" [clearable]="true" [hideValidation]="true" wrapperClass="!mb-0 w-full"
(itemSelected)="onOrgSelected($event)" />
</div>
<div class="w-64 min-w-[200px]">
<app-form-input formControlName="rateType" inputId="exchange-rate-type-filter" variant="floating" type="text"
label="Rate Type" placeholder="e.g. spot, forward" [hideValidation]="true" />
@@ -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<ExchangeRateDto, ExchangeRateTableRow>);
@@ -89,12 +79,10 @@ export class ExchangeRateList implements OnInit {
readonly closeConfirmDialog = viewChild('closeDialog', { read: ConfirmDialog });
readonly showFilters = signal(false);
readonly selectedOrgLookup = signal<OrganizationLookupDto | null>(null);
readonly currencyCodeMap = signal<Record<string, string>>({});
private readonly pendingCurrencyFetchIds = new Set<string>();
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<OrganizationLookupDto> = (term, limit) => {
return this.orgService.autocomplete(term, limit || 20);
};
readonly displayOrg: AutocompleteDisplayFn<OrganizationLookupDto> = org => org?.name ?? '';
readonly orgValue: AutocompleteValueFn<OrganizationLookupDto, string> = 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();
@@ -1,126 +0,0 @@
<modal
[open]="open()"
[title]="mode() === 'edit' ? 'Edit Translation' : 'Add New Translation Key'"
size="md"
[submitAction]="mode() === 'edit' ? 'update' : 'save'"
[submitLabel]="mode() === 'edit' ? 'Update' : 'Save'"
[loadingLabel]="mode() === 'edit' ? 'Updating...' : 'Saving...'"
[loading]="saving()"
(closed)="onClose()"
(submitted)="onSubmit()"
>
<form [formGroup]="form" (ngSubmit)="onSubmit()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<!-- Key Field -->
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="key"
inputId="translation-key"
variant="floating"
label="Key Identifier"
placeholder="e.g. k_common_btn_save"
[required]="true"
[readonly]="mode() === 'edit'"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Key identifier is required.', pattern: 'Must contain letters, numbers, underscores, or hyphens.' }"
/>
</div>
<!-- Module Autocomplete -->
<div class="col-span-12 md:col-span-6">
<app-autocomplete
formControlName="module"
inputId="translation-module"
variant="floating"
label="Module"
placeholder="Search module..."
[searchFn]="searchModules"
[displayWith]="displayModule"
[valueWith]="moduleValue"
[selectedItem]="selectedModuleOption()"
[minSearchLength]="0"
[debounceTime]="100"
[limit]="20"
[required]="true"
[submitAttempted]="submitAttempted()"
(itemSelected)="onModuleSelected($event)"
/>
</div>
<!-- Translations Sub-Header -->
<div class="col-span-12">
<div class="border-t border-defaultborder pt-2">
<span class="text-xs font-semibold uppercase text-textmuted tracking-wider block mb-1">Translations</span>
</div>
</div>
<!-- English (en-US) -->
<div class="col-span-12">
<app-form-input
formControlName="enUs"
inputId="translation-en-us"
variant="floating"
label="English (en-US)"
placeholder="e.g. Save Changes"
[required]="true"
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'English translation is required.' }"
/>
</div>
<!-- Arabic (ar-SA) -->
<div class="col-span-12">
<app-form-input
formControlName="arSa"
inputId="translation-ar-sa"
variant="floating"
label="Arabic (ar-SA)"
placeholder="مثال: حفظ التغييرات"
[submitAttempted]="submitAttempted()"
/>
</div>
<!-- Hindi (hi-IN) -->
<div class="col-span-12">
<app-form-input
formControlName="hiIn"
inputId="translation-hi-in"
variant="floating"
label="Hindi (hi-IN)"
placeholder="उदाहरण: परिवर्तन सहेजें"
[submitAttempted]="submitAttempted()"
/>
</div>
<!-- Tenant Override Section -->
<div class="col-span-12 border-t border-defaultborder pt-3">
<div class="flex items-center justify-between">
<div>
<span class="text-xs font-semibold text-defaulttextcolor block">Tenant Override</span>
<span class="text-[11px] text-textmuted block">Check if this is a tenant-specific text override.</span>
</div>
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
formControlName="isTenantOverride"
class="form-check-input"
/>
</label>
</div>
@if (form.controls.isTenantOverride.value) {
<div class="mt-3">
<app-form-input
formControlName="tenantName"
inputId="translation-tenant-name"
variant="floating"
label="Tenant Name"
placeholder="e.g. TNT One"
[submitAttempted]="submitAttempted()"
/>
</div>
}
</div>
</div>
</form>
</modal>
@@ -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<boolean>(false);
readonly mode = input<'add' | 'edit'>('add');
readonly initialData = input<TranslationMatrixRow | null>(null);
readonly saving = input<boolean>(false);
readonly saved = output<CreateTranslationFormValue>();
readonly closed = output<void>();
readonly submitAttempted = signal<boolean>(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<ModuleOptionItem | null>(this.moduleOptions[0]);
readonly searchModules: AutocompleteSearchFn<ModuleOptionItem> = (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<ModuleOptionItem> = item => item.name;
readonly moduleValue: AutocompleteValueFn<ModuleOptionItem, string> = 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();
}
}
@@ -1,58 +0,0 @@
<div class="box-header justify-between items-center flex-wrap gap-3 p-4 bg-white dark:bg-bodybg border-b border-defaultborder">
<div class="flex flex-wrap items-center gap-3 flex-1 min-w-[280px]">
<!-- Search Input -->
<div class="relative flex-1 min-w-[220px] max-w-md">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none text-textmuted">
<i class="ri-search-line"></i>
</div>
<input
type="text"
class="form-control form-control-sm ps-9 rounded-md w-full"
placeholder="Search key..."
[ngModel]="searchKey()"
(ngModelChange)="onSearchInput($event)"
/>
</div>
<!-- Module Selector Filter -->
<div class="w-44">
<select
class="form-select form-select-sm rounded-md w-full"
[ngModel]="selectedModule()"
(ngModelChange)="onModuleChange($event)"
>
<option value="ALL">All Modules</option>
@for (mod of modules(); track mod) {
@if (mod !== 'ALL') {
<option [value]="mod">{{ mod }}</option>
}
}
</select>
</div>
<!-- Language Selector Filter -->
<div class="w-44">
<select
class="form-select form-select-sm rounded-md w-full"
[ngModel]="selectedLanguage()"
(ngModelChange)="onLanguageChange($event)"
>
@for (lang of languages(); track lang.id) {
<option [value]="lang.id">{{ lang.name }}</option>
}
</select>
</div>
</div>
<!-- Primary Action Button: Add Translation -->
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
class="ti-btn bg-primary text-white btn-wave !font-medium !text-[0.85rem] !rounded-md !py-2 !px-3 shadow-sm inline-flex items-center gap-1.5"
(click)="onAddTranslation()"
>
<i class="ri-add-line text-base"></i>
<span>Add Translation</span>
</button>
</div>
</div>
@@ -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<string[]>([
'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<HeaderFilterChange>();
readonly addClicked = output<void>();
readonly searchKey = signal<string>('');
readonly selectedModule = signal<string>('ALL');
readonly selectedLanguage = signal<string>('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()
});
}
}
@@ -1,175 +0,0 @@
<div class="box-body !p-0">
<div class="table-responsive overflow-x-auto">
<table class="table whitespace-nowrap min-w-full table-hover table-bordered align-middle">
<thead class="bg-gray-50 dark:bg-black/20 text-xs font-semibold uppercase text-textmuted border-b border-defaultborder">
<tr>
<th scope="col" class="py-3 px-4 text-start min-w-[220px]">Key</th>
<th scope="col" class="py-3 px-4 text-start min-w-[240px]">en-US</th>
<th scope="col" class="py-3 px-4 text-start min-w-[240px]">ar-SA</th>
<th scope="col" class="py-3 px-4 text-start min-w-[240px]">hi-IN</th>
<th scope="col" class="py-3 px-4 text-center min-w-[130px]">Override</th>
</tr>
</thead>
<tbody class="divide-y divide-defaultborder text-sm">
@if (loading()) {
<tr>
<td colspan="5" class="text-center py-8 text-textmuted">
<div class="inline-flex items-center gap-2">
<span class="animate-spin inline-block w-4 h-4 border-[2px] border-current border-t-transparent text-primary rounded-full" role="status" aria-label="loading"></span>
<span>Loading translation matrix...</span>
</div>
</td>
</tr>
} @else if (rows().length === 0) {
<tr>
<td colspan="5" class="text-center py-8 text-textmuted">
<i class="ri-inbox-line text-2xl block mb-1"></i>
<span>No translations found matching current search/filter.</span>
</td>
</tr>
} @else {
@for (row of rows(); track row.id) {
<tr class="hover:bg-gray-50/50 dark:hover:bg-black/10 transition-colors">
<!-- Key Column -->
<td class="py-3 px-4">
<div class="font-mono text-xs font-semibold text-primary break-all">
{{ row.key }}
</div>
<div class="flex items-center gap-1.5 mt-1">
<span class="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 dark:bg-black/30 text-textmuted">
{{ row.module }}
</span>
</div>
</td>
<!-- en-US Translation Input -->
<td class="py-2.5 px-4">
<div class="relative">
<input
type="text"
class="form-control form-control-sm rounded-md w-full transition-all"
[class.border-warning]="row.dirtyFlags['en-US']"
[class.bg-warning/5]="row.dirtyFlags['en-US']"
[ngModel]="row.translations['en-US']"
(ngModelChange)="onTranslationInput(row, 'en-US', $event)"
placeholder="Enter English text..."
/>
@if (row.dirtyFlags['en-US']) {
<span class="absolute end-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-warning" title="Modified"></span>
}
</div>
</td>
<!-- ar-SA Translation Input (RTL) -->
<td class="py-2.5 px-4">
<div class="relative">
<input
type="text"
dir="rtl"
class="form-control form-control-sm rounded-md w-full font-serif transition-all"
[class.border-warning]="row.dirtyFlags['ar-SA']"
[class.bg-warning/5]="row.dirtyFlags['ar-SA']"
[ngModel]="row.translations['ar-SA']"
(ngModelChange)="onTranslationInput(row, 'ar-SA', $event)"
placeholder="أدخل النص العربي..."
/>
@if (row.dirtyFlags['ar-SA']) {
<span class="absolute start-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-warning" title="Modified"></span>
}
</div>
</td>
<!-- hi-IN Translation Input -->
<td class="py-2.5 px-4">
<div class="relative">
<input
type="text"
class="form-control form-control-sm rounded-md w-full transition-all"
[class.border-warning]="row.dirtyFlags['hi-IN']"
[class.bg-warning/5]="row.dirtyFlags['hi-IN']"
[ngModel]="row.translations['hi-IN']"
(ngModelChange)="onTranslationInput(row, 'hi-IN', $event)"
placeholder="हिंदी पाठ दर्ज करें..."
/>
@if (row.dirtyFlags['hi-IN']) {
<span class="absolute end-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-warning" title="Modified"></span>
}
</div>
</td>
<!-- Override Status Badge Column -->
<td class="py-3 px-4 text-center">
@if (row.overrideStatus === '—' || !row.overrideStatus || row.overrideStatus === 'Global') {
<span class="text-textmuted text-sm font-medium"></span>
} @else {
<span class="badge bg-primary/10 text-primary border border-primary/20 font-semibold px-2 py-1 rounded">
{{ row.overrideStatus }}
</span>
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
<!-- Information Footnote -->
<div class="p-4 bg-gray-50/70 dark:bg-black/10 border-t border-b border-defaultborder">
<div class="flex items-center gap-2 text-xs text-textmuted">
<i class="ri-information-line text-info text-base shrink-0"></i>
<p class="mb-0">
tenant_id NULL = global text; a tenant row overrides just that tenant. Missing-translation report + bulk import/export planned.
</p>
</div>
</div>
<!-- Footer Action Buttons -->
<div class="box-footer p-4 bg-white dark:bg-bodybg flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<!-- Import CSV Button -->
<button
type="button"
class="ti-btn ti-btn-outline-primary btn-wave !font-medium !text-[0.85rem] !rounded-md !py-2 !px-3 shadow-none inline-flex items-center gap-1.5"
(click)="onImportCsv()"
>
<i class="ri-upload-2-line"></i>
<span>Import CSV</span>
</button>
<!-- Export Button -->
<button
type="button"
class="ti-btn ti-btn-outline-secondary btn-wave !font-medium !text-[0.85rem] !rounded-md !py-2 !px-3 shadow-none inline-flex items-center gap-1.5"
(click)="onExport()"
>
<i class="ri-download-2-line"></i>
<span>Export</span>
</button>
</div>
<!-- Save Changes Primary Dark Blue Button -->
<div class="flex items-center gap-3">
@if (hasDirtyChanges()) {
<span class="text-xs font-medium text-warning flex items-center gap-1">
<i class="ri-alert-line"></i>
<span>{{ dirtyCount() }} unsaved change(s)</span>
</span>
}
<button
type="button"
class="ti-btn bg-[#1e293b] hover:bg-[#0f172a] text-white btn-wave !font-semibold !text-[0.875rem] !rounded-md !py-2 !px-4 shadow-sm inline-flex items-center gap-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
[disabled]="!hasDirtyChanges() || saving()"
(click)="onSaveChanges()"
>
@if (saving()) {
<span class="animate-spin inline-block w-4 h-4 border-[2px] border-current border-t-transparent text-white rounded-full" role="status" aria-label="loading"></span>
<span>Saving Changes...</span>
} @else {
<i class="ri-save-line text-base"></i>
<span>Save Changes</span>
}
</button>
</div>
</div>
@@ -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<TranslationMatrixRow[]>([]);
readonly loading = input<boolean>(false);
readonly saving = input<boolean>(false);
readonly cellChanged = output<MatrixCellValueChange>();
readonly importCsvClicked = output<void>();
readonly exportClicked = output<void>();
readonly saveChangesClicked = output<void>();
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();
}
}
}
@@ -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')
};
@@ -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<LocalizedTextDto> {
const params = this.buildQueryParams(keyRequest);
return this.http.get<LocalizedTextDto>(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<LocalizedTextDto> {
const params = this.buildQueryParams(keyRequest);
return this.http.put<LocalizedTextDto>(LOCALIZED_TEXT_ENDPOINTS.upsert, payload, { params });
}
/**
* Query translations matrix list from wireframe-compliant defaults
*/
getTranslationsMatrix(filter?: TranslationFilter): Observable<TranslationMatrixRow[]> {
return of(this.getInitialDefaultMatrix());
}
/**
* Save multiple translation modifications in batch across languages
*/
saveBatchTranslations(
items: { keyRequest: LocalizedTextKeyRequest; payload: UpsertLocalizedTextRequest }[]
): Observable<LocalizedTextDto[]> {
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<TranslationMatrixRow>[] {
const lines = csvContent.split(/\r\n|\n/);
if (lines.length < 2) return [];
const parsedRows: Partial<TranslationMatrixRow>[] = [];
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 }
}
];
}
}
@@ -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'
}
}
];
@@ -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<string, string>;
originalTranslations: Record<string, string>;
dirtyFlags: Record<string, boolean>;
}
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;
}
@@ -1,187 +0,0 @@
<div class="grid grid-cols-12 gap-x-6 my-4">
<div class="col-span-12">
<app-data-table
[columns]="columns()"
[rows]="paginatedRows()"
[actions]="actions()"
[totalRecords]="totalRecords()"
[pageIndex]="pageIndex()"
[pageSize]="pageSize()"
tableTitle="Translations Manager"
[showSearch]="true"
searchPlaceholder="Search key..."
[searchDebounceTime]="300"
[showFilterButton]="true"
[filterActive]="showFilters()"
[showAddButton]="true"
buttonTitle="Add"
[showImportExportButtons]="true"
(addClicked)="onAddTranslationClicked()"
(importClicked)="onImportCsvClicked()"
(exportClicked)="onExportCsv()"
(filterClicked)="onToggleFilters()"
(searchChanged)="onSearchChanged($event)"
(pageChanged)="onPageChanged($event)"
(actionClicked)="onActionClick($event)"
>
<!-- Data Table Filter Toolbar Template -->
<ng-template appDataTableToolbar>
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()" class="flex flex-wrap items-end gap-3 w-full">
<!-- Module Filter Autocomplete with Floating Label -->
<div class="w-64 min-w-[200px]">
<app-autocomplete
formControlName="module"
inputId="translation-filter-module"
variant="floating"
size="sm"
label="Module"
placeholder="Search module..."
[searchFn]="searchModuleFilters"
[displayWith]="displayModuleFilter"
[valueWith]="moduleFilterValue"
[selectedItem]="selectedModuleFilter()"
[minSearchLength]="0"
[debounceTime]="100"
[limit]="20"
[clearable]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onFilterModuleSelected($event)"
/>
</div>
<!-- Language Filter Autocomplete with Floating Label -->
<div class="w-64 min-w-[200px]">
<app-autocomplete
formControlName="languageId"
inputId="translation-filter-language"
variant="floating"
size="sm"
label="Language"
placeholder="Search language..."
[searchFn]="searchLanguageFilters"
[displayWith]="displayLanguageFilter"
[valueWith]="languageFilterValue"
[selectedItem]="selectedLanguageFilter()"
[minSearchLength]="0"
[debounceTime]="100"
[limit]="20"
[clearable]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onFilterLanguageSelected($event)"
/>
</div>
<!-- Filter Actions -->
<div class="flex items-center gap-2">
<app-button
action="custom"
label="Apply"
icon="ti ti-filter"
variant="primary-full"
type="submit"
size="sm"
className="!rounded-full shadow-sm !mb-0 min-h-8"
/>
<app-button
action="custom"
label="Reset"
icon="ti ti-refresh"
variant="light"
type="button"
size="sm"
className="!rounded-full shadow-sm !mb-0 min-h-8"
(buttonClicked)="resetFilters()"
/>
</div>
</form>
</ng-template>
<!-- Custom Cell Template: Sr. No. -->
<ng-template appDataTableCell="serialNumber" let-row let-value="value">
<div class="text-center py-1">
<span class="font-medium text-textmuted text-xs">{{ value }}</span>
</div>
</ng-template>
<!-- Custom Cell Template: Key -->
<ng-template appDataTableCell="key" let-row let-value="value">
<div class="py-1">
<div class="font-mono text-xs font-semibold text-primary break-all">
{{ value }}
</div>
</div>
</ng-template>
<!-- Custom Cell Template: Module -->
<ng-template appDataTableCell="module" let-value="value">
<div class="py-1">
<span class="badge bg-primary/10 text-primary font-medium px-2 py-1 rounded text-xs">
{{ value || 'Common' }}
</span>
</div>
</ng-template>
<!-- Custom Cell Template: en-US (Display Text) -->
<ng-template appDataTableCell="en-US" let-row>
<div class="py-1">
<span class="text-sm font-normal text-defaulttextcolor">
{{ row.translations['en-US'] || '—' }}
</span>
</div>
</ng-template>
<!-- Custom Cell Template: ar-SA (Display Text) -->
<ng-template appDataTableCell="ar-SA" let-row>
<div class="py-1">
<span dir="rtl" class="text-sm font-normal font-serif text-defaulttextcolor">
{{ row.translations['ar-SA'] || '—' }}
</span>
</div>
</ng-template>
<!-- Custom Cell Template: hi-IN (Display Text) -->
<ng-template appDataTableCell="hi-IN" let-row>
<div class="py-1">
<span class="text-sm font-normal text-defaulttextcolor">
{{ row.translations['hi-IN'] || '—' }}
</span>
</div>
</ng-template>
<!-- Custom Cell Template: Override -->
<ng-template appDataTableCell="overrideStatus" let-row let-value="value">
<div class="text-center py-1">
@if (value === '—' || !value || value === 'Global') {
<span class="text-textmuted text-sm font-medium"></span>
} @else {
<span class="badge bg-primary/10 text-primary border border-primary/20 font-semibold px-2 py-1 rounded">
{{ value }}
</span>
}
</div>
</ng-template>
</app-data-table>
</div>
</div>
<!-- Add/Edit Translation Form Modal Component -->
<app-translation-form-modal
[open]="modalOpen()"
[mode]="modalMode()"
[initialData]="selectedRow()"
[saving]="saving()"
(saved)="onTranslationFormSaved($event)"
(closed)="onModalClosed()"
/>
<!-- Delete Confirm Dialog Component -->
<app-confirm-dialog
title="Delete Translation Key"
text="Do you really want to delete this translation key?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
@@ -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<TranslationMatrixRow[]>([]);
readonly loading = signal<boolean>(true);
readonly saving = signal<boolean>(false);
readonly showFilters = signal<boolean>(false);
readonly modalOpen = signal<boolean>(false);
readonly modalMode = signal<'add' | 'edit'>('add');
readonly selectedRow = signal<TranslationMatrixRow | null>(null);
readonly pendingDeleteRow = signal<TranslationMatrixRow | null>(null);
readonly searchKey = signal<string>('');
readonly pageIndex = signal<number>(1);
readonly pageSize = signal<number>(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<ModuleFilterOptionItem | null>(this.moduleFilterOptions[0]);
readonly selectedLanguageFilter = signal<LanguageFilterOptionItem | null>(this.defaultLanguageFilterItem);
readonly searchModuleFilters: AutocompleteSearchFn<ModuleFilterOptionItem> = (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<ModuleFilterOptionItem> = item => item.name;
readonly moduleFilterValue: AutocompleteValueFn<ModuleFilterOptionItem, string> = item => item.id;
readonly searchLanguageFilters: AutocompleteSearchFn<LanguageFilterOptionItem> = (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<LanguageFilterOptionItem> = item => item.name;
readonly languageFilterValue: AutocompleteValueFn<LanguageFilterOptionItem, string> = item => item.code || item.id;
readonly filterForm = this.fb.group({
module: ['ALL'],
languageId: ['ALL']
});
readonly columns = signal<DataTableColumn<TranslationMatrixRow>[]>([
{ 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<DataTableAction<TranslationMatrixRow>[]>([
{ 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<TranslationMatrixRow[]>(() => {
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<TranslationMatrixRow>): 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<FileReader>) => {
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<TranslationMatrixRow>[]): 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());
});
}
}
@@ -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';
@@ -0,0 +1,116 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="md"
[submitAction]="isCreateMode() ? 'save' : 'update'"
[submitLabel]="isCreateMode() ? 'Save' : 'Update'"
[loadingLabel]="isCreateMode() ? 'Saving...' : 'Updating...'"
[loading]="saving() || modalLoading()"
[showSubmitButton]="!isViewMode()"
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'"
(closed)="closeModal()"
(submitted)="saveKey()"
>
@if (modalLoading()) {
<div class="flex min-h-32 items-center justify-center">
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
<span class="ms-2 text-defaulttextcolor">Loading key details...</span>
</div>
} @else {
<form [formGroup]="keyForm" (ngSubmit)="saveKey()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5">
<!-- Key Name -->
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="keyName"
inputId="key-name-input"
variant="floating"
label="Key Name"
placeholder="e.g. Common.Buttons.Save"
[required]="true"
[readonly]="isEditMode() || isViewMode()"
[maxLength]="200"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'Key Name is required.',
maxLength: 'Key Name cannot exceed 200 characters.',
pattern: 'Key Name can only contain letters, numbers, underscores, dots, and hyphens.'
}"
/>
</div>
<!-- Module Code -->
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="moduleCode"
inputId="module-code-input"
variant="floating"
label="Module Code"
placeholder="e.g. HRMS, FINANCE, GLOBAL"
[readonly]="isViewMode()"
[maxLength]="50"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
maxLength: 'Module Code cannot exceed 50 characters.'
}"
/>
</div>
<!-- Component Type -->
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="componentType"
inputId="component-type-input"
variant="floating"
label="Component Type"
placeholder="e.g. Button, Label, Message"
[readonly]="isViewMode()"
[maxLength]="50"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
maxLength: 'Component Type cannot exceed 50 characters.'
}"
/>
</div>
<!-- Default Text -->
<div class="col-span-12 md:col-span-6">
<app-form-input
formControlName="defaultText"
inputId="default-text-input"
variant="floating"
label="Default Text"
placeholder="Default fallback text..."
[required]="true"
[readonly]="isViewMode()"
[maxLength]="4000"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
required: 'Default Text is required.',
maxLength: 'Default Text cannot exceed 4000 characters.'
}"
/>
</div>
<!-- Description -->
<div class="col-span-12">
<app-form-input
formControlName="description"
inputId="key-description-input"
variant="floating"
label="Description (Optional)"
placeholder="Brief explanation of where and how this key is used..."
[readonly]="isViewMode()"
[maxLength]="1000"
[submitAttempted]="submitAttempted()"
[validationMessages]="{
maxLength: 'Description cannot exceed 1000 characters.'
}"
/>
</div>
</div>
</form>
}
</modal>
@@ -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<boolean>(false);
readonly mode = input<TranslationKeyModalMode>('create');
readonly translationKeyId = input<string | null>(null);
readonly saved = output<void>();
readonly closed = output<void>();
readonly modalLoading = signal(false);
readonly saving = signal(false);
readonly submitAttempted = signal(false);
readonly selectedKey = signal<TranslationKeyDto | null>(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);
}
}
@@ -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;
@@ -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<TranslationKeyDto> {
return this.http.post<TranslationKeyDto>(TRANSLATION_KEY_ENDPOINTS.create, request);
}
/**
* Update an existing translation key by ID.
*/
update(id: string, request: UpdateTranslationKeyRequest): Observable<TranslationKeyDto> {
return this.http.put<TranslationKeyDto>(TRANSLATION_KEY_ENDPOINTS.update(id), request);
}
/**
* Fetch translation key details by ID.
*/
getById(id: string): Observable<TranslationKeyDto> {
return this.http.get<TranslationKeyDto>(TRANSLATION_KEY_ENDPOINTS.getById(id));
}
/**
* Autocomplete lookup for translation keys.
*/
autocomplete(term?: string, limit: number = 10): Observable<readonly TranslationKeyLookupDto[]> {
let params = new HttpParams().set('limit', limit);
if (term && term.trim()) {
params = params.set('term', term.trim());
}
return this.http.get<readonly TranslationKeyLookupDto[]>(
TRANSLATION_KEY_ENDPOINTS.autocomplete,
{ params }
);
}
/**
* Server-side data table query execution.
*/
getDataTable(request: DataTableRequest): Observable<DataTableResponse<TranslationKeyDto>> {
return this.http.post<DataTableResponse<TranslationKeyDto>>(
TRANSLATION_KEY_ENDPOINTS.dataTable,
request
);
}
/**
* Helper method integrated with DataTableStore query structure.
*/
getTranslationKeyDataTable(
query: DataTableQuery,
filters?: TranslationKeyFilterParams
): Observable<DataTableResult<TranslationKeyDto>> {
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<DataTableResponse<TranslationKeyDto>>(
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 ?? []
}))
);
}
}
@@ -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<any>;
}
export interface DataTableResponse<T> {
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;
}
@@ -0,0 +1,83 @@
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[actions]="actions()"
[totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Translation Keys"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
[showFilterButton]="true"
[filterActive]="showFilters()"
searchPlaceholder="Search translation keys..."
[searchDebounceTime]="300"
toolTip="Add Translation Key"
(addClicked)="onAddKey()"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)"
(actionClicked)="onActionClick($event)"
(filterClicked)="onToggleFilters()">
<ng-template appDataTableToolbar>
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter($event)" autocomplete="off"
class="flex flex-wrap items-center gap-3 w-full">
<!-- Key Lookup Autocomplete Filter -->
<div class="w-64 min-w-[200px]">
<app-autocomplete
inputId="translation-key-filter"
variant="floating"
size="sm"
label="Key Lookup"
placeholder="Lookup key by name"
[searchFn]="filterKeySearchFn"
[valueWith]="filterKeyValueFn"
[displayWith]="filterKeyDisplayFn"
[selectedItem]="selectedKeyFilter()"
[minSearchLength]="0"
[debounceTime]="300"
[limit]="20"
[clearable]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onFilterKeyChanged($event)"
/>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2">
<app-button
action="custom"
label="Apply"
icon="ti ti-filter"
variant="primary-full"
type="submit"
size="sm"
className="!rounded-full shadow-sm !mb-0 min-h-8"
/>
<app-button
action="custom"
label="Reset"
icon="ti ti-refresh"
variant="outline-primary"
type="button"
size="sm"
className="!rounded-full shadow-sm !mb-0 min-h-8"
(buttonClicked)="onResetFilter()"
/>
</div>
</form>
</ng-template>
</app-data-table>
<app-translation-key-form-modal
[open]="tableStore.showModal()"
[mode]="tableStore.modalMode()"
[translationKeyId]="tableStore.selectedItem()?.id ?? null"
(saved)="tableStore.refresh(); tableStore.closeModal()"
(closed)="tableStore.closeModal()"
/>
@@ -0,0 +1 @@
/* Custom styles for Translation Key List */
@@ -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<TranslationKeyDto, TranslationKeyTableRow>);
readonly selectedKeyFilter = signal<TranslationKeyLookupDto | null>(null);
readonly showFilters = signal(false);
readonly filterForm = this.formBuilder.group({
moduleCode: this.formBuilder.control<string | null>(null),
componentType: this.formBuilder.control<string | null>(null)
});
readonly columns = signal<DataTableColumn<TranslationKeyTableRow>[]>([
{ 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<DataTableAction<TranslationKeyTableRow>[]>([
{ 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<TranslationKeyLookupDto> = (term, page) =>
this.keyApi.autocomplete(term, page);
readonly filterKeyValueFn: AutocompleteValueFn<TranslationKeyLookupDto, string> = item => item.keyName;
readonly filterKeyDisplayFn: AutocompleteDisplayFn<TranslationKeyLookupDto> = 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<TranslationKeyTableRow>): 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);
}
}
@@ -0,0 +1,62 @@
<modal [open]="open()" [title]="modalTitle()" size="md" [submitAction]="isCreateMode() ? 'save' : 'update'"
[submitLabel]="isCreateMode() ? 'Save' : 'Update'" [loadingLabel]="isCreateMode() ? 'Saving...' : 'Updating...'"
[loading]="saving() || modalLoading()" [showSubmitButton]="!isViewMode()"
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'" (closed)="closeModal()" (submitted)="saveTranslation()">
@if (modalLoading()) {
<div class="flex min-h-32 items-center justify-center">
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
<span class="ms-2 text-defaulttextcolor">Loading translation details...</span>
</div>
} @else {
<form [formGroup]="translationForm" (ngSubmit)="saveTranslation()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5 items-center">
<!-- Translation Key Autocomplete (col-6) -->
<div class="col-span-12 md:col-span-6">
<app-autocomplete formControlName="translationKeyId" inputId="translation-key-id" variant="floating" size="sm"
label="Translation Key" placeholder="Select translation key" [required]="true"
[readonly]="isEditMode() || isViewMode() || !!presetTranslationKey()" [submitAttempted]="submitAttempted()" [searchFn]="keySearchFn"
[valueWith]="keyValueFn" [displayWith]="keyDisplayFn" [selectedItem]="selectedFormTranslationKey()"
[minSearchLength]="0" (itemSelected)="onFormTranslationKeyChanged($event)"
[validationMessages]="{ required: 'Translation key is required.' }" />
</div>
<!-- Language Autocomplete (col-6) -->
<div class="col-span-12 md:col-span-6">
<app-autocomplete formControlName="languageId" inputId="translation-language-id" variant="floating" size="sm"
label="Language" placeholder="Select language" [required]="true" [readonly]="isEditMode() || isViewMode() || !!presetLanguage()"
[submitAttempted]="submitAttempted()" [searchFn]="languageSearchFn" [valueWith]="languageValueFn"
[displayWith]="languageDisplayFn" [selectedItem]="selectedFormLanguage()" [minSearchLength]="0"
(itemSelected)="onFormLanguageChanged($event)" [validationMessages]="{ required: 'Language is required.' }" />
</div>
<!-- Tenant Autocomplete (col-6) -->
<div class="col-span-12 md:col-span-6">
<app-autocomplete formControlName="tenantId" inputId="translation-tenant-id" variant="floating" size="sm"
label="Tenant" placeholder="Select tenant" [required]="false" [readonly]="isEditMode() || isViewMode()"
[submitAttempted]="submitAttempted()" [searchFn]="tenantSearchFn" [valueWith]="tenantValueFn"
[displayWith]="tenantDisplayFn" [selectedItem]="selectedFormTenant()" [minSearchLength]="0"
(itemSelected)="onFormTenantChanged($event)" />
</div>
<!-- Translated Text (col-6) -->
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="translatedText" inputId="translation-text" variant="floating"
label="Translated Text" placeholder="Enter translated text..." [required]="true" [readonly]="isViewMode()"
[maxLength]="4000" [submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Translated text is required.' }" />
</div>
<!-- Approved Status (col-6) -->
<div class="col-span-12 md:col-span-6 flex flex-col justify-center">
<label for="translation-is-approved" class="inline-flex cursor-pointer items-center gap-2" [class.pointer-events-none]="isViewMode()">
<input id="translation-is-approved" type="checkbox" formControlName="isApproved" class="form-check-input" />
<span class="text-sm font-medium text-defaulttextcolor">Is Approved</span>
</label>
<p class="text-xs text-gray-500 mt-1">Approved translations will be active immediately.</p>
</div>
</div>
</form>
}
</modal>
@@ -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<boolean>(false);
readonly mode = input<TranslationModalMode>('create');
readonly translationId = input<string | null>(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<TranslationKeyLookupDto | null>(null);
readonly presetLanguage = input<LanguageLookupDto | null>(null);
readonly saved = output<void>();
readonly closed = output<void>();
readonly modalLoading = signal(false);
readonly saving = signal(false);
readonly submitAttempted = signal(false);
readonly selectedTranslation = signal<TranslationDto | null>(null);
readonly selectedFormTranslationKey = signal<TranslationKeyLookupDto | null>(null);
readonly selectedFormLanguage = signal<LanguageLookupDto | null>(null);
readonly selectedFormTenant = signal<TenantLookupDto | null>(null);
readonly translationForm = this.formBuilder.nonNullable.group({
translationKeyId: ['', Validators.required],
languageId: ['', Validators.required],
tenantId: this.formBuilder.control<string | null>(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<TranslationKeyLookupDto> = (term, page) =>
this.translationKeyApi.autocomplete(term, page);
readonly keyValueFn: AutocompleteValueFn<TranslationKeyLookupDto, string> = key => key.id;
readonly keyDisplayFn: AutocompleteDisplayFn<TranslationKeyLookupDto> = key =>
key.defaultText ? `${key.keyName} (${key.defaultText})` : key.keyName;
readonly languageSearchFn: AutocompleteSearchFn<LanguageLookupDto> = (term, page) =>
this.languageApi.autocomplete(term, page);
readonly languageValueFn: AutocompleteValueFn<LanguageLookupDto, string> = lang => lang.id;
readonly languageDisplayFn: AutocompleteDisplayFn<LanguageLookupDto> = lang =>
`${lang.name} (${lang.code})`;
readonly tenantSearchFn: AutocompleteSearchFn<TenantLookupDto> = (term, page) =>
this.tenantApi.autocomplete(term, page);
readonly tenantValueFn: AutocompleteValueFn<TenantLookupDto, string> = tenant => tenant.id;
readonly tenantDisplayFn: AutocompleteDisplayFn<TenantLookupDto> = 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);
}
}
@@ -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;
@@ -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<TranslationDto> {
return this.http.post<TranslationDto>(TRANSLATION_ENDPOINTS.create, request);
}
updateTranslation(id: string, request: UpdateTranslationRequest): Observable<TranslationDto> {
return this.http.put<TranslationDto>(TRANSLATION_ENDPOINTS.update(id), request);
}
getTranslationById(id: string): Observable<TranslationDto> {
return this.http.get<TranslationDto>(TRANSLATION_ENDPOINTS.getById(id));
}
autocomplete(options?: {
translationKeyId?: string | null;
languageId?: string | null;
tenantId?: string | null;
globalOnly?: boolean | null;
term?: string | null;
limit?: number;
}): Observable<readonly TranslationLookupDto[]> {
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<readonly TranslationLookupDto[]>(
TRANSLATION_ENDPOINTS.autocomplete,
{ params }
);
}
getTranslationDataTable(
query: DataTableQuery,
filters?: TranslationFilterParams
): Observable<DataTableResult<TranslationDataTableDto>> {
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<DataTableResponse<TranslationDataTableDto>>(
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 ?? []
}))
);
}
}
@@ -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<any>;
}
export interface DataTableResponse<T> {
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<string, string>;
}
@@ -0,0 +1,46 @@
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Translation Manager"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
[showFilterButton]="false"
searchPlaceholder="Search translations..."
[searchDebounceTime]="300"
toolTip="Add Translation"
(addClicked)="onAddTranslation()"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)">
@for (language of languages(); track language.id) {
<ng-template [appDataTableCell]="language.code" let-row let-value="value">
<button
type="button"
class="w-full text-left rounded px-2 py-1 -mx-2 -my-1 hover:bg-primary/10 transition-colors"
[class.text-textmuted]="!value"
[attr.aria-label]="value ? null : 'Add translation'"
(click)="onCellClick(row, language)"
>
@if (value) {
{{ value }}
} @else {
<i class="ti ti-plus text-sm"></i>
}
</button>
</ng-template>
}
</app-data-table>
<app-translation-form-modal
[open]="modalOpen()"
[mode]="modalMode()"
[translationId]="editingTranslationId()"
[presetTranslationKey]="editingKey()"
[presetLanguage]="editingLanguage()"
(saved)="onModalSaved()"
(closed)="onModalClosed()"
/>
@@ -0,0 +1 @@
/* Custom styles for Translation List */
@@ -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<TranslationKeyDto, TranslationMatrixTableRow>);
readonly languages = signal<readonly LanguageLookupDto[]>([]);
readonly columns = signal<DataTableColumn<TranslationMatrixTableRow>[]>([]);
readonly modalOpen = signal(false);
readonly modalMode = signal<'create' | 'edit'>('create');
readonly editingTranslationId = signal<string | null>(null);
readonly editingKey = signal<TranslationKeyLookupDto | null>(null);
readonly editingLanguage = signal<LanguageLookupDto | null>(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<TranslationMatrixTableRow>[] = 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<string, readonly TranslationLookupDto[]>();
private fetchMatrixPage(query: DataTableQuery): Observable<DataTableResult<TranslationKeyDto>> {
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<string, string> = {};
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);
}
}
@@ -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 OnboardingStep[]>();
readonly currentStepIndex = input.required<number>();
readonly completedStepIndexes = input<readonly number[]>([]);
readonly isEditMode = input(false);
readonly savingDraft = input(false);
readonly finishing = input(false);
readonly navigationDisabled = input(false);
readonly stepSelected = output<number>();
readonly backClicked = output<void>();
readonly nextClicked = output<void>();
readonly saveDraftClicked = output<void>();
readonly saveChangesClicked = output<void>();
readonly finishClicked = output<void>();
readonly cancelClicked = output<void>();
currentStepLabel(): string {
return this.steps()[this.currentStepIndex()]?.label ?? '';
}
getProgressPercent(): number {
const total = this.steps().length;
if (total === 0) return 0;
return Math.round(((this.currentStepIndex() + 1) / total) * 100);
}
getTrackLineLeft(): string {
const total = this.steps().length;
if (total <= 0) return '0%';
return `${100 / (total * 2)}%`;
}
getProgressLineWidth(): string {
const total = this.steps().length;
if (total <= 1) return '0%';
const maxSpan = (100 * (total - 1)) / total;
const progressRatio = Math.min(
1,
Math.max(0, this.currentStepIndex() / (total - 1))
);
return `${maxSpan * progressRatio}%`;
}
isFirstStep(): boolean {
return this.currentStepIndex() === 0;
}
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();
}
}
@@ -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 OnboardingStep[]>();
readonly currentStepIndex = input.required<number>();
readonly completedStepIndexes = input<readonly number[]>([]);
readonly isEditMode = input(false);
readonly savingDraft = input(false);
readonly finishing = input(false);
readonly navigationDisabled = input(false);
readonly stepSelected = output<number>();
readonly backClicked = output<void>();
readonly nextClicked = output<void>();
readonly saveDraftClicked = output<void>();
readonly saveChangesClicked = output<void>();
readonly finishClicked = output<void>();
readonly cancelClicked = output<void>();
currentStepLabel(): string {
return this.steps()[this.currentStepIndex()]?.label ?? '';
}
getProgressPercent(): number {
const total = this.steps().length;
if (total === 0) return 0;
return Math.round(((this.currentStepIndex() + 1) / total) * 100);
}
getTrackLineLeft(): string {
const total = this.steps().length;
if (total <= 0) return '0%';
return `${100 / (total * 2)}%`;
}
getProgressLineWidth(): string {
const total = this.steps().length;
if (total <= 1) return '0%';
const maxSpan = (100 * (total - 1)) / total;
const progressRatio = Math.min(
1,
Math.max(0, this.currentStepIndex() / (total - 1))
);
return `${maxSpan * progressRatio}%`;
}
isFirstStep(): boolean {
return this.currentStepIndex() === 0;
}
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();
}
}
export class OnboardingStepper extends OnboardingStepNavBase {}
@@ -36,7 +36,7 @@
<path d="M5 13l4 4L19 7" />
</svg>
} @else {
<span>{{ index + 1 }}</span>
<i [class]="stepIcon(step.key)"></i>
}
</span>
<span class="tab-label">{{ step.label }}</span>
@@ -50,7 +50,7 @@
<!-- Tab Form Content -->
<div>
<div class="tabs-content-box rounded-xl p-3.5 sm:p-6">
<div class="tabs-content-box rounded-xl p-3.5 sm:p-6" [class]="contentSlideClass()">
<ng-content />
</div>
@@ -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);
}
}
@@ -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<string, string> = {
'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 OnboardingStep[]>();
readonly currentStepIndex = input.required<number>();
readonly completedStepIndexes = input<readonly number[]>([]);
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<number>();
readonly backClicked = output<void>();
readonly nextClicked = output<void>();
readonly saveDraftClicked = output<void>();
readonly saveChangesClicked = output<void>();
readonly finishClicked = output<void>();
readonly cancelClicked = output<void>();
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;
}
}
@@ -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);
});
});
});
@@ -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;
}
@@ -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<OrganizationBasicsValue>,
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<OrganizationLocalizationValue>,
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<OrganizationPlanLimitsValue>,
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<OrganizationAdminValue>,
markComplete: boolean
@@ -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;
}
@@ -17,7 +17,7 @@
</div>
</ng-template>
<div class="onboarding-header mb-3 flex items-center justify-end gap-2 sm:mb-4">
<!-- <div class="onboarding-header mb-3 flex items-center justify-end gap-2 sm:mb-4">
<span class="view-toggle-label hidden text-xs font-medium text-[#334155] dark:text-[#cbd5e1] sm:inline sm:text-sm">
Tabs view
</span>
@@ -32,7 +32,7 @@
>
<span class="view-toggle-thumb" aria-hidden="true"></span>
</button>
</div>
</div> -->
<section
class="rounded-xl border border-defaultborder bg-white
@@ -85,7 +85,6 @@
text="Your unsaved changes will be lost."
confirmButtonText="Discard and Leave"
cancelButtonText="Stay"
(confirmed)="onDiscardAndLeave()"
>
<span class="hidden"></span>
</app-confirm-dialog>
@@ -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();
});
});
});
@@ -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<ConfirmDialog>('cancelConfirmDialog');
private readonly finishConfirmDialog = viewChild<ConfirmDialog>('finishConfirmDialog');
private savedDraftSnapshot = '';
readonly provisioningRequest = signal<OrganizationProvisioningRequest | null>(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<string, string | null>): 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<void> {
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<boolean> {
const dialog = this.cancelConfirmDialog();
if (!dialog) {
return Promise.resolve(true);
}
return new Promise<boolean>(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<unknown> | undefined {
switch (index) {
case 0:
@@ -506,31 +699,33 @@ export class OrganizationOnboarding {
}
}
private getActiveStep(): OnboardingStepForm<unknown> | undefined {
return this.getStepComponent(this.currentStepIndex());
}
private storeValidatedStep(index: number, step: OnboardingStepForm<unknown>): void {
switch (index) {
case 0:
this.stateService.updateBasics((step as OrganizationBasicsStepComponent).getValue());
break;
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<unknown>): 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<unknown>): 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<void> {
this.isLeavingIntentionally = true;
this.stateService.clear();
await this.router.navigate(['/organizations/list']);
}
@@ -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<Organ
private readonly stateService = inject(OrganizationOnboardingStateService, { optional: true });
readonly submitAttempted = signal(false);
readonly organizationCodeControl = this.formBuilder.nonNullable.control('Pending generation');
readonly organizationCodeControl = this.formBuilder.nonNullable.control(PENDING_CODE_PLACEHOLDER);
readonly hasGeneratedOrganizationCode = signal(false);
readonly organizationTypeSelection = signal<OnboardingLookupValue | null>(null);
readonly industrySelection = signal<OnboardingLookupValue | null>(null);
readonly registrationCountrySelection = signal<CountryLookupDto | null>(null);
@@ -80,8 +83,9 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
this.onboardingService.getNextOrganizationCode()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(code => {
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<Organ
validate(): boolean {
this.submitAttempted.set(true);
if (this.form.valid) {
// The form controls only track the selected id; the label/lookup data resolves separately
// (async on restore). Require both so getValue() never runs while a selection is still pending.
const selectionsResolved =
!!this.organizationTypeSelection() &&
!!this.industrySelection() &&
!!this.registrationCountrySelection();
if (this.form.valid && selectionsResolved) {
return true;
}
@@ -174,8 +185,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
throw new Error('Organization basics selections are incomplete.');
}
const codeControlVal = this.organizationCodeControl.value;
const codeVal = codeControlVal && codeControlVal !== 'Pending generation' ? codeControlVal : null;
const codeVal = this.hasGeneratedOrganizationCode() ? this.organizationCodeControl.value : null;
return {
organizationCode: codeVal,
@@ -195,8 +205,7 @@ export class OrganizationBasicsStepComponent implements OnboardingStepForm<Organ
getDraftValue(): Partial<OrganizationBasicsValue> {
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<Organ
if (orgCode) {
this.organizationCodeControl.setValue(String(orgCode));
this.hasGeneratedOrganizationCode.set(true);
}
const orgName = value.organizationName ?? value['OrganizationName'] ?? value['name'] ?? value['Name'] ?? '';
@@ -209,7 +209,14 @@ export class OrganizationLocalizationStepComponent implements OnboardingStepForm
validate(): boolean {
this.submitAttempted.set(true);
if (this.form.valid) {
// The form controls only track the selected id; the label/lookup data resolves separately
// (async on restore). Require both so getValue() never runs while a selection is still pending.
const selectionsResolved =
!!this.timeZoneSelection() &&
!!this.currencySelection() &&
!!this.defaultLanguageSelection();
if (this.form.valid && selectionsResolved) {
return true;
}
@@ -170,7 +170,10 @@ export class OrganizationPlanLimitsStepComponent implements OnboardingStepForm<O
validate(): boolean {
this.submitAttempted.set(true);
if (this.form.valid) {
// The form control only tracks the selected plan id; the resolved plan lookup data
// arrives separately (async on restore). Require both so getValue() never runs while
// the plan selection is still pending.
if (this.form.valid && !!this.planSelection()) {
return true;
}
@@ -1,5 +1,6 @@
import { Routes } from '@angular/router';
import { superAdminGuard } from '../../core/guards/auth/super-admin.guard';
import { unsavedChangesGuard } from '../../core/guards/navigation/unsaved-changes.guard';
export const organizationsRoutes: Routes = [
{
@@ -17,6 +18,7 @@ export const organizationsRoutes: Routes = [
{
path:'onboarding',
canActivate: [superAdminGuard],
canDeactivate: [unsavedChangesGuard],
loadComponent: () => import('./organization-onboarding/organization-onboarding').then((m) => m.OrganizationOnboarding),
data: { childTitle: 'Organization Onboarding', parentTitle: 'Organizations', subParentTitle: 'Configuration' },
},
@@ -1,100 +1,56 @@
<div class="activation-result-container space-y-5">
<!-- Warning Banner -->
<div class="p-4 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400 flex items-start gap-3">
<i class="ti ti-alert-triangle text-xl shrink-0 mt-0.5"></i>
<div class="text-sm">
<h6 class="font-semibold text-amber-800 dark:text-amber-300">Activation Result — shown ONCE</h6>
<p class="mt-0.5 opacity-90">
Please copy or save the temporary credentials below. The temporary password will not be shown again.
</p>
</div>
<div class="activation-result-container space-y-4 py-1">
<!-- Admin Email -->
<div>
<app-form-input
inputId="activation-admin-email"
label="Admin Email"
variant="floating"
[readonly]="true"
[value]="result().adminEmail"
/>
</div>
<!-- Status Card -->
<div class="p-4 rounded-lg bg-success/10 border border-success/20 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-success text-white font-bold text-sm">
</span>
<div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium uppercase tracking-wider text-muted">Status</span>
<span class="badge bg-success text-white font-semibold px-2.5 py-1 text-xs rounded-full">ACTIVE ✓</span>
</div>
<p class="text-xs text-muted mt-1">HQ group created in tenant DB · identity user created (tenant_admin)</p>
</div>
</div>
</div>
<!-- Admin Email & Password Fields -->
<div class="space-y-4 pt-1">
<!-- Admin Email -->
<div>
<label class="block text-xs font-medium text-defaulttextcolor mb-1.5">Admin Email</label>
<div class="relative">
<input
type="text"
readonly
[value]="result().adminEmail"
class="form-control !bg-light/60 dark:!bg-black/20 text-defaulttextcolor font-medium pr-10"
<!-- One-time Password -->
<div>
<div class="flex items-center gap-2">
<div class="flex-grow">
<app-form-input
inputId="activation-temp-password"
label="One-time Password"
variant="floating"
[readonly]="true"
[value]="result().temporaryPassword"
[showPasswordToggle]="false"
inputClass="font-mono font-bold"
/>
<i class="ti ti-mail absolute right-3 top-1/2 -translate-y-1/2 text-muted"></i>
</div>
</div>
<!-- One-time Password -->
<div>
<label class="block text-xs font-medium text-defaulttextcolor mb-1.5">One-time Password</label>
<div class="flex gap-2">
<div class="relative flex-grow">
<input
type="text"
readonly
[value]="result().temporaryPassword"
class="form-control !bg-light/60 dark:!bg-black/20 font-mono text-defaulttextcolor font-bold tracking-wide"
/>
</div>
<app-button
type="button"
variant="outline-primary"
(clicked)="copyPassword()"
>
<i class="ti" [class.ti-check]="copied()" [class.ti-copy]="!copied()"></i>
<span class="ms-1.5">{{ copied() ? 'Copied!' : 'Copy' }}</span>
</app-button>
</div>
<p class="text-[11px] text-muted mt-1.5 flex items-center gap-1">
<i class="ti ti-info-circle text-info"></i>
<span>Never stored anywhere. Hand over to the client. Forced change at first login = roadmap</span>
</p>
<button
type="button"
class="ti-btn ti-btn-outline-primary h-[44px] px-3.5 whitespace-nowrap shrink-0 flex items-center gap-1.5"
(click)="copyPassword()"
>
<i class="ti text-base" [class.ti-check]="copied()" [class.ti-copy]="!copied()"></i>
<span class="text-xs font-medium">{{ copied() ? 'Copied!' : 'Copy' }}</span>
</button>
</div>
</div>
<!-- Footer Help & Action Buttons -->
<div class="pt-4 border-t border-defaultborder/50 space-y-3">
<div class="p-3 rounded bg-light/50 dark:bg-black/10 border border-defaultborder/40 text-xs text-muted flex items-start gap-2">
<i class="ti ti-help-circle text-muted shrink-0 mt-0.5"></i>
<span>If activation fails midway the org stays in Awaiting Activation — fix the cause and press Retry.</span>
</div>
<!-- Organization Status -->
<div>
<app-form-input
inputId="activation-org-status"
label="Organization Status"
variant="floating"
[readonly]="true"
[value]="(result().status || 'ACTIVE') + ' ✓'"
hint="HQ group created in tenant DB · identity user created (tenant_admin)"
/>
</div>
<div class="flex items-center justify-between gap-3 pt-2">
<app-button
type="button"
variant="outline-primary"
[loading]="retrying()"
(clicked)="onRetry()"
>
<i class="ti ti-refresh me-1.5"></i>
<span>Retry Activation</span>
</app-button>
<app-button
type="button"
variant="primary"
(clicked)="onDone()"
>
<span>Done</span>
</app-button>
</div>
<!-- Helper Note -->
<div class="pt-2">
<p class="text-xs text-muted">
If activation fails midway the org stays in Awaiting Activation — fix the cause and press Retry.
</p>
</div>
</div>
@@ -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,
@@ -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()) {
<form [formGroup]="form" (ngSubmit)="assignAndActivate()" autocomplete="off" class="grid grid-cols-12 gap-x-4 gap-y-4 py-1 pb-16">
@if (errorMessage()) {
<div class="col-span-12 space-y-2">
<app-error-alert
[message]="errorMessage()"
title="Assignment / Activation Failed"
(dismissed)="errorMessage.set(null); postAssignmentFailure.set(false)"
/>
</div>
}
<div class="col-span-12">
<app-autocomplete
formControlName="dbConnectionId"
@@ -33,7 +42,7 @@
[submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Database connection selection is required.' }"
wrapperClass="w-full"
(itemSelected)="selectedDbConnectionItem.set($event)"
(itemSelected)="selectedDbConnectionItem.set($event); errorMessage.set(null); postAssignmentFailure.set(false)"
/>
<p class="mt-2 text-xs text-textmuted flex items-center gap-1.5">
<i class="ti ti-info-circle text-primary text-sm"></i>
@@ -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<string | null>(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<OrganizationActivationResultDto | null>(null);
readonly dbConnectionOptions = signal<DbConnectionLookupDto[]>([]);
readonly selectedDbConnectionItem = signal<DbConnectionLookupDto | null>(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<DbConnectionLookupDto> = (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);
}
});
@@ -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'
@@ -37,6 +37,13 @@ export class OrganizationAwaitingDbService {
);
}
retryActivation(tenantId: string): Observable<OrganizationActivationResultDto> {
return this.http.post<OrganizationActivationResultDto>(
AWAITING_DB_ENDPOINTS.retryActivation(tenantId),
{}
);
}
getActiveDbConnections(term = '', limit = 50): Observable<DbConnectionLookupDto[]> {
const params = new HttpParams()
.set('term', term)
@@ -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;
}
@@ -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"
@@ -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<DataTableAction<AwaitingDbOrganizationTableRow>[]>([
@@ -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<AwaitingDbOrganizationTableRow>): 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;
}
}
@@ -1,25 +1,4 @@
<form [formGroup]="filterForm" (ngSubmit)="onApplyFilter()" autocomplete="off" class="flex flex-wrap items-end gap-3 w-full">
<div class="w-64 min-w-[200px]">
<app-autocomplete
formControlName="tenantId"
inputId="tenant-domain-org-filter"
variant="floating"
size="sm"
label="Organization"
placeholder="Search organization..."
[searchFn]="searchOrganizations"
[displayWith]="displayOrg"
[valueWith]="orgValue"
[selectedItem]="selectedOrgLookup()"
[minSearchLength]="0"
[debounceTime]="300"
[limit]="20"
[clearable]="true"
[hideValidation]="true"
wrapperClass="!mb-0 w-full"
(itemSelected)="onOrgSelected($event)"
/>
</div>
<div class="w-64 min-w-[200px]">
<app-form-select
@@ -1,19 +1,11 @@
import { ChangeDetectionStrategy, Component, inject, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { TenantService } from '../../../../../tenants/data-access/tenant.service';
import { TenantLookupDto } from '../../../../../tenants/models/tenant.model';
import { Autocomplete } from '../../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../../shared/components/form/autocomplete/autocomplete.types';
import { FormSelect } from '../../../../../../shared/components/form/form-select/form-select';
import { FormSelectOption } from '../../../../../../shared/components/form/models/form-select.models';
import { Button } from '../../../../../../shared/components/button/button';
export interface TenantDomainFilterValues {
tenantId: string | null;
tenantId?: string | null;
domainType: string | null;
}
@@ -22,7 +14,6 @@ export interface TenantDomainFilterValues {
standalone: true,
imports: [
ReactiveFormsModule,
Autocomplete,
FormSelect,
Button
],
@@ -32,16 +23,12 @@ export interface TenantDomainFilterValues {
})
export class TenantDomainFilterToolbarComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly tenantApi = inject(TenantService);
readonly addDomainClicked = output<void>();
readonly filterApplied = output<TenantDomainFilterValues>();
readonly filterReset = output<void>();
readonly selectedOrgLookup = signal<TenantLookupDto | null>(null);
readonly filterForm = this.formBuilder.nonNullable.group({
tenantId: [''],
domainType: ['']
});
@@ -51,28 +38,17 @@ export class TenantDomainFilterToolbarComponent {
{ value: '2', label: 'RootDomain' }
];
readonly searchOrganizations: AutocompleteSearchFn<TenantLookupDto> = (term: string, limit: number) =>
this.tenantApi.autocomplete(term, limit);
readonly displayOrg: AutocompleteDisplayFn<TenantLookupDto> = (org: TenantLookupDto) => org.name;
readonly orgValue: AutocompleteValueFn<TenantLookupDto, string> = (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();
}
@@ -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 @@
<!-- Custom Type Cell -->
<ng-template appDataTableCell="domainType" let-row let-value="value">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wider bg-gray-100 dark:bg-black/30 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/10">
{{ value }}
</span>
@if (value === 'Subdomain' || value === 'SUBDOMAIN' || value === 1 || value === '1') {
<span class="badge bg-info/10 text-info border border-info/20 font-medium px-2 py-1 rounded">
Subdomain
</span>
} @else if (value === 'RootDomain' || value === 'ROOTDOMAIN' || value === 2 || value === '2') {
<span class="badge bg-primary/10 text-primary border border-primary/20 font-medium px-2 py-1 rounded">
RootDomain
</span>
} @else {
<span class="badge bg-secondary/10 text-secondary border border-secondary/20 font-medium px-2 py-1 rounded">
{{ value === 'CUSTOM' ? 'Custom' : (value || 'Custom') }}
</span>
}
</ng-template>
<!-- Custom Primary Indicator Cell -->
@@ -96,8 +106,4 @@
</ng-template>
</app-data-table>
<!-- Footnote Instruction Note -->
<div class="mt-3 px-1 text-xs text-gray-500 dark:text-gray-400 italic flex items-center gap-1.5">
<i class="ti ti-info-circle text-sm text-primary"></i>
Ownership + SSL verification workflow is future scope (columns already exist in DB).
</div>
@@ -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',
@@ -52,6 +52,10 @@ export class TenantDomainService {
return this.http.patch<void>(TENANT_DOMAIN_ENDPOINTS.updateStatus(id), { isActive });
}
delete(id: string): Observable<void> {
return this.http.delete<void>(TENANT_DOMAIN_ENDPOINTS.delete(id));
}
verifyTenantDomain(id: string): Observable<TenantDomainDto> {
return this.http.patch<TenantDomainDto>(TENANT_DOMAIN_ENDPOINTS.verify(id), {});
}
@@ -23,3 +23,12 @@
(saved)="onModalSaved()"
(closed)="onModalClosed()"
/>
<app-confirm-dialog
title="Delete Tenant Domain"
text="Do you really want to delete this domain?"
confirmButtonText="Delete"
cancelButtonText="Cancel"
(confirmed)="onDeleteConfirmed()"
(cancelled)="onDeleteCancelled()"
/>
@@ -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<TenantDomainDto, TenantDomainTableRow>);
readonly showFilters = signal(false);
readonly showFilterButton = signal(false);
readonly appliedTenantId = signal<string | null>(null);
readonly appliedDomainType = signal<string | null>(null);
@@ -43,11 +49,16 @@ export class TenantDomainList implements OnInit {
readonly modalMode = signal<TenantDomainModalMode>('create');
readonly selectedDomainId = signal<string | null>(null);
readonly statusChangingId = signal<string | null>(null);
readonly deletingId = signal<string | null>(null);
readonly pendingDeleteDomain = signal<TenantDomainTableRow | null>(null);
readonly deleteConfirmDialog = viewChild(ConfirmDialog);
readonly columns = signal<DataTableColumn<TenantDomainTableRow>[]>([
{ 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();
}
@@ -1,7 +1,7 @@
<modal
[open]="open()"
[title]="modalTitle()"
size="lg"
size="md"
[submitAction]="mode() === 'create' ? 'save' : 'update'"
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
@@ -239,7 +239,23 @@ export class DbConnectionFormModalComponent {
if (!id || this.mode() === 'create') {
this.selectedConnection.set(null);
this.modalLoading.set(false);
this.modalLoading.set(true);
this.dbConnectionApi.previewNextCode().pipe(
finalize(() => 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);
}
});
@@ -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`),
@@ -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<DbConnectionDto>(DB_CONNECTION_ENDPOINTS.create, request);
}
previewNextCode(): Observable<DbConnectionCodePreviewDto> {
return this.http.get<DbConnectionCodePreviewDto>(DB_CONNECTION_ENDPOINTS.previewNextCode);
}
updateDbConnection(id: string, request: UpdateDbConnectionRequest): Observable<DbConnectionDto> {
return this.http.put<DbConnectionDto>(DB_CONNECTION_ENDPOINTS.update(id), request);
}
@@ -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;
}
@@ -0,0 +1,60 @@
@if (message()) {
<div class="app-error-alert-wrapper p-3.5 rounded-lg bg-danger/10 border border-danger/20 text-danger dark:text-danger-light flex items-start gap-3 text-xs font-medium relative mb-4 transition-all duration-200">
<i class="ti ti-alert-circle text-lg shrink-0 mt-0.5 text-danger"></i>
<div class="flex-grow min-w-0 break-words whitespace-pre-wrap">
@if (title()) {
<strong class="font-bold text-sm block mb-1 text-danger dark:text-danger-light leading-snug">
{{ title() }}
</strong>
}
<div class="text-xs text-danger/90 dark:text-danger-light/90 leading-relaxed">
{{ message() }}
</div>
@if (detailsArray().length > 0) {
<div class="mt-2 pt-2 border-t border-danger/15">
<button
type="button"
class="text-[11px] font-semibold text-danger dark:text-danger-light hover:underline inline-flex items-center gap-1 focus:outline-none"
(click)="toggleDetails()"
>
<i class="ti" [class.ti-chevron-down]="!showDetails()" [class.ti-chevron-up]="showDetails()"></i>
<span>{{ showDetails() ? 'Hide Technical Details' : 'View Technical Details (' + detailsArray().length + ')' }}</span>
</button>
@if (showDetails()) {
<ul class="mt-2 space-y-1 pl-4 list-disc text-[11px] font-mono opacity-90 max-h-48 overflow-y-auto bg-black/5 dark:bg-black/20 p-2.5 rounded border border-danger/20">
@for (detail of detailsArray(); track $index) {
<li>{{ detail }}</li>
}
</ul>
}
</div>
}
</div>
<div class="flex items-center gap-1 shrink-0 ms-2">
<button
type="button"
class="p-1 rounded hover:bg-danger/20 text-danger/70 hover:text-danger dark:text-danger-light/70 dark:hover:text-danger-light transition-colors text-sm"
title="Copy Error Message"
(click)="copyErrorText()"
>
<i class="ti" [class.ti-check]="copied()" [class.ti-copy]="!copied()"></i>
</button>
@if (dismissible()) {
<button
type="button"
class="p-1 rounded hover:bg-danger/20 text-danger/70 hover:text-danger dark:text-danger-light/70 dark:hover:text-danger-light transition-colors text-sm"
title="Dismiss"
(click)="dismiss()"
>
<i class="ti ti-x"></i>
</button>
}
</div>
</div>
}
@@ -0,0 +1,4 @@
:host {
display: block;
width: 100%;
}
@@ -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<string | null>(null);
readonly title = input<string>('Operation Failed');
readonly details = input<string | string[] | null>(null);
readonly dismissible = input<boolean>(true);
readonly dismissed = output<void>();
readonly showDetails = signal(false);
readonly copied = signal(false);
readonly detailsArray = computed<string[]>(() => {
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();
}
}
@@ -0,0 +1,69 @@
<modal
[open]="isOpen()"
[title]="options()?.title || 'Operation Error'"
size="md"
[showSubmitButton]="false"
cancelLabel="Close"
(closed)="close()"
>
@if (options()) {
<div class="space-y-4 py-2">
<!-- Header Warning Card -->
<div class="p-4 rounded-lg bg-danger/10 border border-danger/20 text-danger dark:text-danger-light flex items-start gap-3">
<div class="w-9 h-9 rounded-full bg-danger/20 text-danger flex items-center justify-center shrink-0 mt-0.5">
<i class="ti ti-alert-triangle text-xl"></i>
</div>
<div class="min-w-0 flex-grow">
<div class="flex items-center justify-between gap-2">
<h6 class="font-bold text-sm text-danger dark:text-danger-light">
{{ options()?.title }}
</h6>
@if (options()?.code) {
<span class="badge bg-danger/20 text-danger dark:text-danger-light font-mono text-[11px] px-2 py-0.5 rounded">
HTTP {{ options()?.code }}
</span>
}
</div>
<p class="text-xs text-danger/90 dark:text-danger-light/90 mt-1 leading-relaxed break-words whitespace-pre-wrap">
{{ options()?.message }}
</p>
</div>
</div>
<!-- Technical Details Section -->
@if (detailsList().length > 0) {
<div class="pt-1">
<div class="flex items-center justify-between mb-2">
<button
type="button"
class="text-xs font-semibold text-primary hover:underline flex items-center gap-1 focus:outline-none"
(click)="toggleDetails()"
>
<i class="ti" [class.ti-chevron-down]="!showDetails()" [class.ti-chevron-up]="showDetails()"></i>
<span>{{ showDetails() ? 'Hide Technical Details' : 'Show Technical Details (' + detailsList().length + ')' }}</span>
</button>
<app-button
type="button"
variant="outline-primary"
(clicked)="copyError()"
>
<i class="ti" [class.ti-check]="copied()" [class.ti-copy]="!copied()"></i>
<span class="ms-1.5">{{ copied() ? 'Copied' : 'Copy Details' }}</span>
</app-button>
</div>
@if (showDetails()) {
<div class="bg-black/5 dark:bg-black/30 p-3 rounded-lg border border-defaultborder/60 max-h-52 overflow-y-auto">
<ul class="space-y-1 pl-4 list-disc text-xs font-mono text-defaulttextcolor/80">
@for (item of detailsList(); track $index) {
<li class="break-words whitespace-pre-wrap">{{ item }}</li>
}
</ul>
</div>
}
</div>
}
</div>
}
</modal>
@@ -0,0 +1,3 @@
:host {
display: block;
}
@@ -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<ErrorModalOptions | null>(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);
}
}
@@ -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<string[]>(() => {
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();
}
}
@@ -95,6 +95,7 @@ export class FormInput implements ControlValueAccessor {
readonly ariaDescription = input<string | null>(null);
readonly inputValue = input<string | number | null>(null, { alias: 'value' });
readonly value = signal<string | number | null>(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);