localization, assign data-base, tenant domain, plan and subscription and data-base connection screen added
This commit is contained in:
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
+10
-3
@@ -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>
|
||||
|
||||
+40
-10
@@ -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;
|
||||
|
||||
+12
-1
@@ -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()"
|
||||
|
||||
+50
-35
@@ -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
-1
@@ -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...'"
|
||||
|
||||
-8
@@ -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" />
|
||||
|
||||
+1
-29
@@ -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();
|
||||
|
||||
-126
@@ -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>
|
||||
-130
@@ -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();
|
||||
}
|
||||
}
|
||||
-58
@@ -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>
|
||||
-68
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
-175
@@ -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>
|
||||
-67
@@ -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';
|
||||
+116
@@ -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>
|
||||
+216
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
+102
@@ -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;
|
||||
}
|
||||
+83
@@ -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()"
|
||||
/>
|
||||
+1
@@ -0,0 +1 @@
|
||||
/* Custom styles for Translation Key List */
|
||||
+155
@@ -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);
|
||||
}
|
||||
}
|
||||
+62
@@ -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>
|
||||
+329
@@ -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>;
|
||||
}
|
||||
+46
@@ -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()"
|
||||
/>
|
||||
+1
@@ -0,0 +1 @@
|
||||
/* Custom styles for Translation List */
|
||||
+180
@@ -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);
|
||||
}
|
||||
}
|
||||
+147
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-149
@@ -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 {}
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
|
||||
+72
-33
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-141
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+171
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+108
-71
@@ -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;
|
||||
}
|
||||
|
||||
-12
@@ -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
|
||||
|
||||
-23
@@ -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>
|
||||
|
||||
+75
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+243
-57
@@ -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']);
|
||||
}
|
||||
|
||||
+17
-7
@@ -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'] ?? '';
|
||||
|
||||
+8
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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' },
|
||||
},
|
||||
|
||||
+46
-90
@@ -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>
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+14
-5
@@ -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>
|
||||
|
||||
+74
-10
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
+6
@@ -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'
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+8
-2
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+47
-9
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-21
@@ -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
|
||||
|
||||
+4
-28
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+15
-9
@@ -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>
|
||||
|
||||
|
||||
+6
@@ -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',
|
||||
|
||||
+4
@@ -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
-1
@@ -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...'"
|
||||
|
||||
+31
-5
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user