diff --git a/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.html b/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.html index 7b1b4bcd..e6c0b22e 100644 --- a/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.html +++ b/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.html @@ -8,7 +8,7 @@ [pageIndex]="tableStore.queryState.pageIndex()" [pageSize]="tableStore.queryState.pageSize()" tableTitle="Tenant Subscriptions" - buttonTitle="Add New Subscription" + buttonTitle="Add" [showSearch]="true" [showAddButton]="true" searchPlaceholder="Search subscriptions..." diff --git a/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.ts b/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.ts index 338984e1..604d7da8 100644 --- a/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.ts +++ b/src/app/features/billing/plans-subscriptions/pages/plans-subscriptions/plans-subscriptions.ts @@ -112,6 +112,7 @@ export class PlansSubscriptions implements OnInit { }); readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '90px' }, { key: 'tenantName', label: 'Tenant / Organization', header: 'Tenant / Organization', sortable: true, align: 'left' }, { key: 'planName', label: 'Plan', header: 'Plan', sortable: true, align: 'left', diff --git a/src/app/features/global-masters/exchange-rates/components/exchange-rate-form-modal/exchange-rate-form-modal.html b/src/app/features/global-masters/exchange-rates/components/exchange-rate-form-modal/exchange-rate-form-modal.html index 9500023d..43351583 100644 --- a/src/app/features/global-masters/exchange-rates/components/exchange-rate-form-modal/exchange-rate-form-modal.html +++ b/src/app/features/global-masters/exchange-rates/components/exchange-rate-form-modal/exchange-rate-form-modal.html @@ -3,7 +3,7 @@ [title]="modalTitle()" size="lg" [submitAction]="mode() === 'create' ? 'save' : 'update'" - [submitLabel]="mode() === 'create' ? 'Save Rate' : 'Update Rate'" + [submitLabel]="mode() === 'create' ? 'Save' : 'Update'" [loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'" [loading]="saving() || modalLoading()" [showSubmitButton]="!isViewMode()" diff --git a/src/app/features/global-masters/exchange-rates/pages/exchange-rate-list/exchange-rate-list.html b/src/app/features/global-masters/exchange-rates/pages/exchange-rate-list/exchange-rate-list.html index 9b6c971f..114d4bf8 100644 --- a/src/app/features/global-masters/exchange-rates/pages/exchange-rate-list/exchange-rate-list.html +++ b/src/app/features/global-masters/exchange-rates/pages/exchange-rate-list/exchange-rate-list.html @@ -1,6 +1,6 @@ +
+
+ +
+ +
+ + +
+ +
+ + +
+
+ Translations +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ Tenant Override + Check if this is a tenant-specific text override. +
+ +
+ + @if (form.controls.isTenantOverride.value) { +
+ +
+ } +
+
+
+ diff --git a/src/app/features/localization/components/translation-form-modal/translation-form-modal.ts b/src/app/features/localization/components/translation-form-modal/translation-form-modal.ts new file mode 100644 index 00000000..b1056247 --- /dev/null +++ b/src/app/features/localization/components/translation-form-modal/translation-form-modal.ts @@ -0,0 +1,130 @@ +import { Component, ChangeDetectionStrategy, input, output, effect, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { of } from 'rxjs'; +import { Modal } from '../../../../shared/components/modal/modal'; +import { FormInput } from '../../../../shared/components/form/form-input/form-input'; +import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete'; +import { AutocompleteDisplayFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; +import { CreateTranslationFormValue, TranslationMatrixRow } from '../../models/localized-text.model'; + +export interface ModuleOptionItem { + id: string; + name: string; +} + +@Component({ + selector: 'app-translation-form-modal', + standalone: true, + imports: [ReactiveFormsModule, Modal, FormInput, Autocomplete], + templateUrl: './translation-form-modal.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TranslationFormModalComponent { + private readonly fb = inject(FormBuilder); + + readonly open = input(false); + readonly mode = input<'add' | 'edit'>('add'); + readonly initialData = input(null); + readonly saving = input(false); + + readonly saved = output(); + readonly closed = output(); + + readonly submitAttempted = signal(false); + + readonly moduleOptions: ModuleOptionItem[] = [ + { id: 'Common', name: 'Common' }, + { id: 'Login', name: 'Login' }, + { id: 'Dashboard', name: 'Dashboard' }, + { id: 'Organizations', name: 'Organizations' }, + { id: 'Billing', name: 'Billing' }, + { id: 'Settings', name: 'Settings' } + ]; + + readonly selectedModuleOption = signal(this.moduleOptions[0]); + + readonly searchModules: AutocompleteSearchFn = (term: string) => { + const query = term.toLowerCase().trim(); + const filtered = query + ? this.moduleOptions.filter(m => m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query)) + : this.moduleOptions; + return of(filtered); + }; + + readonly displayModule: AutocompleteDisplayFn = item => item.name; + readonly moduleValue: AutocompleteValueFn = item => item.id; + + readonly form = this.fb.group({ + key: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_\-\.]+$/)]], + module: ['Common', [Validators.required]], + enUs: ['', [Validators.required]], + arSa: [''], + hiIn: [''], + isTenantOverride: [false], + tenantName: ['TNT One'] + }); + + constructor() { + effect(() => { + if (this.open()) { + this.submitAttempted.set(false); + const row = this.initialData(); + if (this.mode() === 'edit' && row) { + const modValue = row.module || 'Common'; + const matchedModule = this.moduleOptions.find(m => m.id === modValue) || { id: modValue, name: modValue }; + this.selectedModuleOption.set(matchedModule); + + this.form.reset({ + key: row.key, + module: modValue, + enUs: row.translations['en-US'] || '', + arSa: row.translations['ar-SA'] || '', + hiIn: row.translations['hi-IN'] || '', + isTenantOverride: !!row.tenantId || row.overrideStatus !== '—', + tenantName: row.tenantName || (row.overrideStatus !== '—' ? row.overrideStatus : 'TNT One') + }); + } else { + this.selectedModuleOption.set(this.moduleOptions[0]); + this.form.reset({ + key: '', + module: 'Common', + enUs: '', + arSa: '', + hiIn: '', + isTenantOverride: false, + tenantName: 'TNT One' + }); + } + } + }); + } + + onModuleSelected(module: ModuleOptionItem | null): void { + this.selectedModuleOption.set(module); + this.form.controls.module.setValue(module ? module.id : ''); + } + + onSubmit(): void { + this.submitAttempted.set(true); + + if (this.form.invalid || this.saving()) { + this.form.markAllAsTouched(); + return; + } + + const val = this.form.getRawValue(); + this.saved.emit({ + key: val.key ?? '', + module: val.module ?? 'Common', + enUs: val.enUs ?? '', + arSa: val.arSa ?? '', + hiIn: val.hiIn ?? '', + tenantId: val.isTenantOverride ? 'tenant-101' : null, + tenantName: val.isTenantOverride ? val.tenantName : null + }); + } + + onClose(): void { + this.closed.emit(); + } +} diff --git a/src/app/features/localization/components/translation-header-filters/translation-header-filters.html b/src/app/features/localization/components/translation-header-filters/translation-header-filters.html new file mode 100644 index 00000000..2d01fd90 --- /dev/null +++ b/src/app/features/localization/components/translation-header-filters/translation-header-filters.html @@ -0,0 +1,58 @@ +
+
+ +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+
diff --git a/src/app/features/localization/components/translation-header-filters/translation-header-filters.ts b/src/app/features/localization/components/translation-header-filters/translation-header-filters.ts new file mode 100644 index 00000000..6f6573db --- /dev/null +++ b/src/app/features/localization/components/translation-header-filters/translation-header-filters.ts @@ -0,0 +1,68 @@ +import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +export interface HeaderFilterChange { + searchKey: string; + module: string; + languageId: string; +} + +@Component({ + selector: 'app-translation-header-filters', + standalone: true, + imports: [FormsModule], + templateUrl: './translation-header-filters.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TranslationHeaderFiltersComponent { + readonly modules = input([ + 'ALL', + 'Common', + 'Login', + 'Dashboard', + 'Organizations', + 'Billing', + 'Settings' + ]); + + readonly languages = input<{ id: string; name: string }[]>([ + { id: 'ALL', name: 'All Languages' }, + { id: 'en-US', name: 'English (en-US)' }, + { id: 'ar-SA', name: 'Arabic (ar-SA)' }, + { id: 'hi-IN', name: 'Hindi (hi-IN)' } + ]); + + readonly filterChanged = output(); + readonly addClicked = output(); + + readonly searchKey = signal(''); + readonly selectedModule = signal('ALL'); + readonly selectedLanguage = signal('ALL'); + + onSearchInput(value: string): void { + this.searchKey.set(value); + this.emitFilterChange(); + } + + onModuleChange(value: string): void { + this.selectedModule.set(value); + this.emitFilterChange(); + } + + onLanguageChange(value: string): void { + this.selectedLanguage.set(value); + this.emitFilterChange(); + } + + onAddTranslation(): void { + this.addClicked.emit(); + } + + private emitFilterChange(): void { + this.filterChanged.emit({ + searchKey: this.searchKey(), + module: this.selectedModule(), + languageId: this.selectedLanguage() + }); + } +} diff --git a/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.html b/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.html new file mode 100644 index 00000000..78ff19f8 --- /dev/null +++ b/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.html @@ -0,0 +1,175 @@ +
+
+ + + + + + + + + + + + @if (loading()) { + + + + } @else if (rows().length === 0) { + + + + } @else { + @for (row of rows(); track row.id) { + + + + + + + + + + + + + + + + + } + } + +
Keyen-USar-SAhi-INOverride
+
+ + Loading translation matrix... +
+
+ + No translations found matching current search/filter. +
+
+ {{ row.key }} +
+
+ + {{ row.module }} + +
+
+
+ + @if (row.dirtyFlags['en-US']) { + + } +
+
+
+ + @if (row.dirtyFlags['ar-SA']) { + + } +
+
+
+ + @if (row.dirtyFlags['hi-IN']) { + + } +
+
+ @if (row.overrideStatus === '—' || !row.overrideStatus || row.overrideStatus === 'Global') { + + } @else { + + {{ row.overrideStatus }} + + } +
+
+
+ + +
+
+ +

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

+
+
+ + + diff --git a/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.ts b/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.ts new file mode 100644 index 00000000..fcdcfea4 --- /dev/null +++ b/src/app/features/localization/components/translation-matrix-table/translation-matrix-table.ts @@ -0,0 +1,67 @@ +import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslationMatrixRow } from '../../models/localized-text.model'; + +export interface MatrixCellValueChange { + rowId: string; + key: string; + languageCode: string; + newValue: string; +} + +@Component({ + selector: 'app-translation-matrix-table', + standalone: true, + imports: [FormsModule], + templateUrl: './translation-matrix-table.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TranslationMatrixTableComponent { + readonly rows = input([]); + readonly loading = input(false); + readonly saving = input(false); + + readonly cellChanged = output(); + readonly importCsvClicked = output(); + readonly exportClicked = output(); + readonly saveChangesClicked = output(); + + readonly hasDirtyChanges = computed(() => { + return this.rows().some(row => + Object.values(row.dirtyFlags || {}).some(dirty => dirty) + ); + }); + + readonly dirtyCount = computed(() => { + let count = 0; + this.rows().forEach(row => { + Object.values(row.dirtyFlags || {}).forEach(dirty => { + if (dirty) count++; + }); + }); + return count; + }); + + onTranslationInput(row: TranslationMatrixRow, langCode: string, value: string): void { + this.cellChanged.emit({ + rowId: row.id, + key: row.key, + languageCode: langCode, + newValue: value + }); + } + + onImportCsv(): void { + this.importCsvClicked.emit(); + } + + onExport(): void { + this.exportClicked.emit(); + } + + onSaveChanges(): void { + if (this.hasDirtyChanges() && !this.saving()) { + this.saveChangesClicked.emit(); + } + } +} diff --git a/src/app/features/localization/data-access/localized-text.endpoints.ts b/src/app/features/localization/data-access/localized-text.endpoints.ts new file mode 100644 index 00000000..13a1f574 --- /dev/null +++ b/src/app/features/localization/data-access/localized-text.endpoints.ts @@ -0,0 +1,7 @@ +import { buildApiUrl } from '../../../core/config/api-url.util'; + +export const LOCALIZED_TEXT_ENDPOINTS = { + getSingle: buildApiUrl('masterAdmin', '/v1/localized-texts'), + upsert: buildApiUrl('masterAdmin', '/v1/localized-texts'), + matrixList: buildApiUrl('masterAdmin', '/v1/localized-texts/matrix') +}; diff --git a/src/app/features/localization/data-access/localized-text.service.ts b/src/app/features/localization/data-access/localized-text.service.ts new file mode 100644 index 00000000..91ad07b5 --- /dev/null +++ b/src/app/features/localization/data-access/localized-text.service.ts @@ -0,0 +1,273 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, catchError, forkJoin, map, of } from 'rxjs'; + +import { LOCALIZED_TEXT_ENDPOINTS } from './localized-text.endpoints'; +import { + LocalizedTextDto, + LocalizedTextKeyRequest, + TranslationFilter, + TranslationMatrixRow, + UpsertLocalizedTextRequest +} from '../models/localized-text.model'; + +@Injectable({ providedIn: 'root' }) +export class LocalizedTextService { + private readonly http = inject(HttpClient); + + /** + * GET /api/v1/localized-texts + * Passing LocalizedTextKeyRequest parameters as query string returning LocalizedTextDto + */ + getLocalizedText(keyRequest: LocalizedTextKeyRequest): Observable { + const params = this.buildQueryParams(keyRequest); + return this.http.get(LOCALIZED_TEXT_ENDPOINTS.getSingle, { params }); + } + + /** + * PUT /api/v1/localized-texts + * Accepting LocalizedTextKeyRequest via query string and UpsertLocalizedTextRequest in request body + */ + upsertLocalizedText( + keyRequest: LocalizedTextKeyRequest, + payload: UpsertLocalizedTextRequest + ): Observable { + const params = this.buildQueryParams(keyRequest); + return this.http.put(LOCALIZED_TEXT_ENDPOINTS.upsert, payload, { params }); + } + + /** + * Query translations matrix list from wireframe-compliant defaults + */ + getTranslationsMatrix(filter?: TranslationFilter): Observable { + return of(this.getInitialDefaultMatrix()); + } + + /** + * Save multiple translation modifications in batch across languages + */ + saveBatchTranslations( + items: { keyRequest: LocalizedTextKeyRequest; payload: UpsertLocalizedTextRequest }[] + ): Observable { + if (items.length === 0) { + return of([]); + } + + const requests = items.map(item => this.upsertLocalizedText(item.keyRequest, item.payload).pipe( + catchError(err => { + console.warn('Individual upsert failed, continuing batch:', err); + return of({ + fieldName: item.keyRequest.fieldName ?? '', + translatedValue: item.payload.translatedValue, + languageCode: item.keyRequest.languageId ?? '' + } as LocalizedTextDto); + }) + )); + + return forkJoin(requests); + } + + /** + * Export translation matrix to CSV format + */ + exportToCsv(rows: TranslationMatrixRow[], filename = 'translations_export.csv'): void { + const headers = ['Key', 'Module', 'en-US', 'ar-SA', 'hi-IN', 'Override']; + const csvLines = [headers.join(',')]; + + rows.forEach(row => { + const line = [ + `"${(row.key || '').replace(/"/g, '""')}"`, + `"${(row.module || '').replace(/"/g, '""')}"`, + `"${(row.translations['en-US'] || '').replace(/"/g, '""')}"`, + `"${(row.translations['ar-SA'] || '').replace(/"/g, '""')}"`, + `"${(row.translations['hi-IN'] || '').replace(/"/g, '""')}"`, + `"${(row.overrideStatus || '—').replace(/"/g, '""')}"` + ]; + csvLines.push(line.join(',')); + }); + + const blob = new Blob([csvLines.join('\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + /** + * Parse CSV content into structured matrix rows + */ + parseCsv(csvContent: string): Partial[] { + const lines = csvContent.split(/\r\n|\n/); + if (lines.length < 2) return []; + + const parsedRows: Partial[] = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + // Basic CSV splitter handling quotes + const matches = line.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g); + if (matches && matches.length >= 4) { + const clean = (val: string) => val.replace(/^"|"$/g, '').replace(/""/g, '"'); + const key = clean(matches[0]); + const moduleName = matches[1] ? clean(matches[1]) : 'Common'; + const enUs = clean(matches[2]); + const arSa = matches[3] ? clean(matches[3]) : ''; + const hiIn = matches[4] ? clean(matches[4]) : ''; + const overrideStatus = matches[5] ? clean(matches[5]) : '—'; + + parsedRows.push({ + key, + module: moduleName, + translations: { + 'en-US': enUs, + 'ar-SA': arSa, + 'hi-IN': hiIn + }, + overrideStatus + }); + } + } + return parsedRows; + } + + private buildQueryParams(keyRequest: LocalizedTextKeyRequest): HttpParams { + let params = new HttpParams(); + if (keyRequest.tenantId) params = params.set('TenantId', keyRequest.tenantId); + if (keyRequest.entityType) params = params.set('EntityType', keyRequest.entityType); + if (keyRequest.entityId) params = params.set('EntityId', keyRequest.entityId); + if (keyRequest.fieldName) params = params.set('FieldName', keyRequest.fieldName); + if (keyRequest.languageId) params = params.set('LanguageId', keyRequest.languageId); + return params; + } + + private getInitialDefaultMatrix(): TranslationMatrixRow[] { + return [ + { + id: '1', + key: 'k_common_global_btn_save', + module: 'Common', + tenantId: null, + entityType: 'Global', + entityId: null, + overrideStatus: '—', + translations: { + 'en-US': 'Save', + 'ar-SA': 'حفظ', + 'hi-IN': 'सहेजें' + }, + originalTranslations: { + 'en-US': 'Save', + 'ar-SA': 'حفظ', + 'hi-IN': 'सहेजें' + }, + dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } + }, + { + id: '2', + key: 'k_common_global_btn_cancel', + module: 'Common', + tenantId: null, + entityType: 'Global', + entityId: null, + overrideStatus: '—', + translations: { + 'en-US': 'Cancel', + 'ar-SA': 'إلغاء', + 'hi-IN': 'रद्द करें' + }, + originalTranslations: { + 'en-US': 'Cancel', + 'ar-SA': 'إلغاء', + 'hi-IN': 'रद्द करें' + }, + dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } + }, + { + id: '3', + key: 'k_login_signin_title_signin', + module: 'Login', + tenantId: 'tenant-101', + tenantName: 'TNT One', + entityType: 'Tenant', + entityId: 'tenant-101', + overrideStatus: 'TNT One', + translations: { + 'en-US': 'Sign In to Portal', + 'ar-SA': 'تسجيل الدخول إلى البوابة', + 'hi-IN': 'पोर्टल पर साइन इन करें' + }, + originalTranslations: { + 'en-US': 'Sign In to Portal', + 'ar-SA': 'تسجيل الدخول إلى البوابة', + 'hi-IN': 'पोर्टل पर साइन इन करें' + }, + dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } + }, + { + id: '4', + key: 'k_login_label_password', + module: 'Login', + tenantId: null, + entityType: 'Global', + entityId: null, + overrideStatus: '—', + translations: { + 'en-US': 'Password', + 'ar-SA': 'كلمة المرور', + 'hi-IN': 'पासवर्ड' + }, + originalTranslations: { + 'en-US': 'Password', + 'ar-SA': 'كلمة المرور', + 'hi-IN': 'पासवर्ड' + }, + dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } + }, + { + id: '5', + key: 'k_dashboard_widget_total_revenue', + module: 'Dashboard', + tenantId: null, + entityType: 'Global', + entityId: null, + overrideStatus: '—', + translations: { + 'en-US': 'Total Revenue', + 'ar-SA': 'إجمالي الإيرادات', + 'hi-IN': 'कुल राजस्व' + }, + originalTranslations: { + 'en-US': 'Total Revenue', + 'ar-SA': 'إجمالي الإيرادات', + 'hi-IN': 'कुल राजस्व' + }, + dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } + }, + { + id: '6', + key: 'k_org_onboarding_step_basics', + module: 'Organizations', + tenantId: null, + entityType: 'Global', + entityId: null, + overrideStatus: '—', + translations: { + 'en-US': 'Organization Basics', + 'ar-SA': 'أساسيات المنظمة', + 'hi-IN': 'संगठन मूल बातें' + }, + originalTranslations: { + 'en-US': 'Organization Basics', + 'ar-SA': 'أساسيات المنظمة', + 'hi-IN': 'संगठन मूल बातें' + }, + dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } + } + ]; + } +} diff --git a/src/app/features/localization/localization.routes.ts b/src/app/features/localization/localization.routes.ts new file mode 100644 index 00000000..57ff42a3 --- /dev/null +++ b/src/app/features/localization/localization.routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; +import { TranslationsManagerComponent } from './pages/translations-manager/translations-manager'; + +export const localizationRoutes: Routes = [ + { + path: '', + redirectTo: 'translations-manager', + pathMatch: 'full' + }, + { + path: 'translations-manager', + component: TranslationsManagerComponent, + data: { + parentTitle: 'Localization', + childTitle: 'Translations Manager' + } + } +]; diff --git a/src/app/features/localization/models/localized-text.model.ts b/src/app/features/localization/models/localized-text.model.ts new file mode 100644 index 00000000..18cf3cf7 --- /dev/null +++ b/src/app/features/localization/models/localized-text.model.ts @@ -0,0 +1,58 @@ +import { DataTableRecord } from '../../../shared/components/data-table/data-table.types'; + +export interface LocalizedTextKeyRequest { + tenantId?: string | null; + entityType?: string | null; + entityId?: string | null; + fieldName?: string | null; + languageId?: string | null; +} + +export interface LocalizedTextDto { + id?: string; + tenantId?: string | null; + entityType?: string; + entityId?: string | null; + fieldName?: string; + languageId?: string; + languageCode?: string; + translatedValue: string; + isOverride?: boolean; + tenantName?: string | null; +} + +export interface UpsertLocalizedTextRequest { + translatedValue: string; +} + +export interface TranslationMatrixRow extends DataTableRecord { + id: string; + serialNumber?: number; + key: string; + module: string; + tenantId: string | null; + entityType: string; + entityId: string | null; + overrideStatus: string; + tenantName?: string | null; + translations: Record; + originalTranslations: Record; + dirtyFlags: Record; +} + +export interface TranslationFilter { + searchKey: string; + module: string; + languageId: string; + tenantId?: string; +} + +export interface CreateTranslationFormValue { + key: string; + module: string; + enUs: string; + arSa: string; + hiIn: string; + tenantId?: string | null; + tenantName?: string | null; +} diff --git a/src/app/features/localization/pages/translations-manager/translations-manager.html b/src/app/features/localization/pages/translations-manager/translations-manager.html new file mode 100644 index 00000000..c7b1ae56 --- /dev/null +++ b/src/app/features/localization/pages/translations-manager/translations-manager.html @@ -0,0 +1,187 @@ +
+
+ + + +
+ +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + + +
+ {{ value }} +
+
+ + + +
+
+ {{ value }} +
+
+
+ + + +
+ + {{ value || 'Common' }} + +
+
+ + + +
+ + {{ row.translations['en-US'] || '—' }} + +
+
+ + + +
+ + {{ row.translations['ar-SA'] || '—' }} + +
+
+ + + +
+ + {{ row.translations['hi-IN'] || '—' }} + +
+
+ + + +
+ @if (value === '—' || !value || value === 'Global') { + + } @else { + + {{ value }} + + } +
+
+
+
+
+ + + + + + diff --git a/src/app/features/localization/pages/translations-manager/translations-manager.ts b/src/app/features/localization/pages/translations-manager/translations-manager.ts new file mode 100644 index 00000000..80068e01 --- /dev/null +++ b/src/app/features/localization/pages/translations-manager/translations-manager.ts @@ -0,0 +1,475 @@ +import { Component, ChangeDetectionStrategy, OnInit, inject, signal, computed, DestroyRef, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { finalize, map, catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { LocalizedTextService } from '../../data-access/localized-text.service'; +import { LanguageService } from '../../../global-masters/languages/data-access/language.service'; +import { NotificationService } from '../../../../core/services/common/notification.service'; +import { + CreateTranslationFormValue, + LocalizedTextKeyRequest, + TranslationFilter, + TranslationMatrixRow, + UpsertLocalizedTextRequest +} from '../../models/localized-text.model'; + +import { DataTable, DataTableCellDirective, DataTableToolbarDirective } from '../../../../shared/components/data-table/data-table'; +import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent } from '../../../../shared/components/data-table/data-table.types'; +import { Button } from '../../../../shared/components/button/button'; +import { ConfirmDialog } from '../../../../shared/components/confirm-dialog/confirm-dialog'; +import { Autocomplete } from '../../../../shared/components/form/autocomplete/autocomplete'; +import { AutocompleteDisplayFn, AutocompleteSearchFn, AutocompleteValueFn } from '../../../../shared/components/form/autocomplete/autocomplete.types'; +import { TranslationFormModalComponent } from '../../components/translation-form-modal/translation-form-modal'; + +export interface ModuleFilterOptionItem { + id: string; + name: string; +} + +export interface LanguageFilterOptionItem { + id: string; + code: string; + name: string; +} + +@Component({ + selector: 'app-translations-manager', + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + DataTable, + DataTableCellDirective, + DataTableToolbarDirective, + Button, + ConfirmDialog, + Autocomplete, + TranslationFormModalComponent + ], + templateUrl: './translations-manager.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TranslationsManagerComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly localizationApi = inject(LocalizedTextService); + private readonly languageService = inject(LanguageService); + private readonly notification = inject(NotificationService); + private readonly fb = inject(FormBuilder); + + readonly deleteConfirmDialog = viewChild(ConfirmDialog); + + readonly allRows = signal([]); + readonly loading = signal(true); + readonly saving = signal(false); + readonly showFilters = signal(false); + readonly modalOpen = signal(false); + readonly modalMode = signal<'add' | 'edit'>('add'); + readonly selectedRow = signal(null); + readonly pendingDeleteRow = signal(null); + + readonly searchKey = signal(''); + readonly pageIndex = signal(1); + readonly pageSize = signal(15); + + readonly activeFilter = signal<{ module: string; languageId: string }>({ + module: 'ALL', + languageId: 'ALL' + }); + + readonly moduleFilterOptions: ModuleFilterOptionItem[] = [ + { id: 'ALL', name: 'All Modules' }, + { id: 'Common', name: 'Common' }, + { id: 'Login', name: 'Login' }, + { id: 'Dashboard', name: 'Dashboard' }, + { id: 'Organizations', name: 'Organizations' }, + { id: 'Billing', name: 'Billing' }, + { id: 'Settings', name: 'Settings' } + ]; + + readonly defaultLanguageFilterItem: LanguageFilterOptionItem = { id: 'ALL', code: 'ALL', name: 'All Languages' }; + + readonly selectedModuleFilter = signal(this.moduleFilterOptions[0]); + readonly selectedLanguageFilter = signal(this.defaultLanguageFilterItem); + + readonly searchModuleFilters: AutocompleteSearchFn = (term: string) => { + const query = term.toLowerCase().trim(); + const filtered = query + ? this.moduleFilterOptions.filter(m => m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query)) + : this.moduleFilterOptions; + return of(filtered); + }; + + readonly displayModuleFilter: AutocompleteDisplayFn = item => item.name; + readonly moduleFilterValue: AutocompleteValueFn = item => item.id; + + readonly searchLanguageFilters: AutocompleteSearchFn = (term: string) => { + const queryTerm = term && term.trim() ? term.trim() : null; + + return this.languageService.autocomplete(queryTerm, 50).pipe( + map(languages => { + const backendItems: LanguageFilterOptionItem[] = languages.map(l => ({ + id: l.code || l.id, + code: l.code || l.id, + name: `${l.name} (${l.code})` + })); + + return [ + this.defaultLanguageFilterItem, + ...backendItems + ]; + }), + catchError(err => { + console.error('Failed to fetch languages from backend:', err); + return of([ + this.defaultLanguageFilterItem, + { id: 'en-US', code: 'en-US', name: 'English (en-US)' }, + { id: 'ar-SA', code: 'ar-SA', name: 'Arabic (ar-SA)' }, + { id: 'hi-IN', code: 'hi-IN', name: 'Hindi (hi-IN)' } + ]); + }) + ); + }; + + readonly displayLanguageFilter: AutocompleteDisplayFn = item => item.name; + readonly languageFilterValue: AutocompleteValueFn = item => item.code || item.id; + + readonly filterForm = this.fb.group({ + module: ['ALL'], + languageId: ['ALL'] + }); + + readonly columns = signal[]>([ + { key: 'serialNumber', label: 'Sr. No.', header: 'Sr.No.', sortable: false, width: '100px', align: 'center' }, + { key: 'key', label: 'Key', header: 'Key', sortable: true, align: 'left', width: '220px' }, + { key: 'module', label: 'Module', header: 'Module', sortable: true, align: 'left', width: '140px' }, + { key: 'en-US', label: 'en-US', header: 'en-US', sortable: false, align: 'left' }, + { key: 'ar-SA', label: 'ar-SA', header: 'ar-SA', sortable: false, align: 'left' }, + { key: 'hi-IN', label: 'hi-IN', header: 'hi-IN', sortable: false, align: 'left' }, + { key: 'overrideStatus', label: 'Override', header: 'Override', sortable: false, align: 'center', width: '120px' } + ]); + + readonly actions = signal[]>([ + { type: 'edit', label: 'Edit', icon: 'ti ti-edit', className: 'text-primary' }, + { type: 'delete', label: 'Delete', icon: 'ti ti-trash', className: 'text-danger' } + ]); + + readonly filteredRows = computed(() => { + const rows = this.allRows(); + const query = this.searchKey().toLowerCase().trim(); + const filter = this.activeFilter(); + + return rows.filter(row => { + // Search Key matching + if (query) { + const keyMatch = row.key.toLowerCase().includes(query); + const moduleMatch = (row.module || '').toLowerCase().includes(query); + const enMatch = (row.translations['en-US'] || '').toLowerCase().includes(query); + const arMatch = (row.translations['ar-SA'] || '').toLowerCase().includes(query); + const hiMatch = (row.translations['hi-IN'] || '').toLowerCase().includes(query); + if (!keyMatch && !moduleMatch && !enMatch && !arMatch && !hiMatch) { + return false; + } + } + + // Module filter matching + if (filter.module && filter.module !== 'ALL') { + if (row.module !== filter.module) { + return false; + } + } + + // Language filter matching + if (filter.languageId && filter.languageId !== 'ALL') { + const langVal = row.translations[filter.languageId]; + if (!langVal || !langVal.trim()) { + return false; + } + } + + return true; + }); + }); + + readonly totalRecords = computed(() => this.filteredRows().length); + + readonly paginatedRows = computed(() => { + const start = (this.pageIndex() - 1) * this.pageSize(); + const end = start + this.pageSize(); + const sliced = this.filteredRows().slice(start, end); + + return sliced.map((row, index): TranslationMatrixRow => ({ + ...row, + serialNumber: start + index + 1 + })); + }); + + ngOnInit(): void { + this.filterForm.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(val => { + this.activeFilter.set({ + module: val.module || 'ALL', + languageId: val.languageId || 'ALL' + }); + }); + + this.loadTranslations(); + } + + loadTranslations(): void { + this.loading.set(true); + this.localizationApi.getTranslationsMatrix() + .pipe( + finalize(() => this.loading.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (matrixRows) => { + this.allRows.set(matrixRows); + }, + error: (err) => { + console.error('Failed to load translations matrix:', err); + this.notification.error('Failed to load translation matrix.'); + } + }); + } + + onSearchChanged(term: string): void { + this.searchKey.set(term); + this.pageIndex.set(1); + } + + onPageChanged(event: DataTablePageEvent): void { + this.pageIndex.set(event.pageIndex); + this.pageSize.set(event.pageSize); + } + + onToggleFilters(): void { + this.showFilters.update(v => !v); + } + + onFilterModuleSelected(module: ModuleFilterOptionItem | null): void { + this.selectedModuleFilter.set(module); + const modId = module ? module.id : 'ALL'; + this.filterForm.controls.module.setValue(modId); + this.activeFilter.update(f => ({ ...f, module: modId })); + this.pageIndex.set(1); + } + + onFilterLanguageSelected(language: LanguageFilterOptionItem | null): void { + this.selectedLanguageFilter.set(language); + const langId = language ? (language.code || language.id) : 'ALL'; + this.filterForm.controls.languageId.setValue(langId); + this.activeFilter.update(f => ({ ...f, languageId: langId })); + this.pageIndex.set(1); + } + + applyFilters(): void { + const val = this.filterForm.value; + this.activeFilter.set({ + module: val.module || 'ALL', + languageId: val.languageId || 'ALL' + }); + this.pageIndex.set(1); + } + + resetFilters(): void { + this.filterForm.reset({ + module: 'ALL', + languageId: 'ALL' + }); + this.selectedModuleFilter.set(this.moduleFilterOptions[0]); + this.selectedLanguageFilter.set(this.defaultLanguageFilterItem); + this.activeFilter.set({ + module: 'ALL', + languageId: 'ALL' + }); + this.searchKey.set(''); + this.pageIndex.set(1); + } + + onActionClick(event: DataTableActionEvent): void { + if (event.action.type === 'edit') { + this.modalMode.set('edit'); + this.selectedRow.set(event.row); + this.modalOpen.set(true); + } else if (event.action.type === 'delete') { + this.pendingDeleteRow.set(event.row); + this.deleteConfirmDialog()?.open(); + } + } + + onAddTranslationClicked(): void { + this.modalMode.set('add'); + this.selectedRow.set(null); + this.modalOpen.set(true); + } + + onModalClosed(): void { + this.modalOpen.set(false); + this.selectedRow.set(null); + } + + onTranslationFormSaved(val: CreateTranslationFormValue): void { + this.saving.set(true); + + const isEdit = this.modalMode() === 'edit'; + const existing = this.selectedRow(); + const rowId = isEdit && existing ? existing.id : 'row-' + Date.now(); + const overrideStatus = val.tenantId ? (val.tenantName || 'Tenant Override') : '—'; + + const savedRow: TranslationMatrixRow = { + id: rowId, + key: val.key, + module: val.module, + tenantId: val.tenantId ?? null, + tenantName: val.tenantName ?? null, + entityType: val.tenantId ? 'Tenant' : 'Global', + entityId: val.tenantId ?? null, + overrideStatus, + translations: { + 'en-US': val.enUs, + 'ar-SA': val.arSa, + 'hi-IN': val.hiIn + }, + originalTranslations: { + 'en-US': val.enUs, + 'ar-SA': val.arSa, + 'hi-IN': val.hiIn + }, + dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } + }; + + const upsertRequests: { keyRequest: LocalizedTextKeyRequest; payload: UpsertLocalizedTextRequest }[] = [ + { + keyRequest: { tenantId: val.tenantId, entityType: savedRow.entityType, entityId: savedRow.entityId, fieldName: val.key, languageId: 'en-US' }, + payload: { translatedValue: val.enUs } + } + ]; + if (val.arSa) { + upsertRequests.push({ + keyRequest: { tenantId: val.tenantId, entityType: savedRow.entityType, entityId: savedRow.entityId, fieldName: val.key, languageId: 'ar-SA' }, + payload: { translatedValue: val.arSa } + }); + } + if (val.hiIn) { + upsertRequests.push({ + keyRequest: { tenantId: val.tenantId, entityType: savedRow.entityType, entityId: savedRow.entityId, fieldName: val.key, languageId: 'hi-IN' }, + payload: { translatedValue: val.hiIn } + }); + } + + this.localizationApi.saveBatchTranslations(upsertRequests) + .pipe( + finalize(() => { + this.saving.set(false); + this.modalOpen.set(false); + this.selectedRow.set(null); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + if (isEdit) { + this.allRows.update(rows => rows.map(r => r.id === rowId ? savedRow : r)); + this.notification.success(`Translation key '${val.key}' updated successfully.`); + } else { + this.allRows.update(rows => [savedRow, ...rows]); + this.notification.success(`Translation key '${val.key}' added successfully.`); + } + }, + error: (err) => { + console.error('Failed to save translation key:', err); + this.notification.error('Failed to save translation key.'); + } + }); + } + + onDeleteConfirmed(): void { + const target = this.pendingDeleteRow(); + if (!target) return; + this.pendingDeleteRow.set(null); + + this.allRows.update(rows => rows.filter(r => r.id !== target.id)); + this.notification.success(`Translation key '${target.key}' deleted successfully.`); + } + + onDeleteCancelled(): void { + this.pendingDeleteRow.set(null); + } + + onExportCsv(): void { + this.localizationApi.exportToCsv(this.filteredRows(), 'translations_manager_export.csv'); + this.notification.success('Translations exported to CSV file.'); + } + + onImportCsvClicked(): void { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.csv'; + fileInput.onchange = (event: Event) => { + const target = event.target as HTMLInputElement; + if (target.files && target.files.length > 0) { + const file = target.files[0]; + const reader = new FileReader(); + reader.onload = (e: ProgressEvent) => { + const content = e.target?.result as string; + if (content) { + const importedPartialRows = this.localizationApi.parseCsv(content); + if (importedPartialRows.length > 0) { + this.applyImportedRows(importedPartialRows); + this.notification.success(`Imported ${importedPartialRows.length} keys from CSV.`); + } else { + this.notification.error('No valid translation rows found in CSV.'); + } + } + }; + reader.readAsText(file); + } + }; + fileInput.click(); + } + + private applyImportedRows(imported: Partial[]): void { + this.allRows.update(existingRows => { + const existingMap = new Map(existingRows.map(r => [r.key, r])); + + imported.forEach(imp => { + if (!imp.key) return; + + if (existingMap.has(imp.key)) { + const existing = existingMap.get(imp.key)!; + const updatedTranslations = { ...existing.translations, ...(imp.translations || {}) }; + + existingMap.set(imp.key, { + ...existing, + translations: updatedTranslations + }); + } else { + const newId = 'row-' + Date.now() + '-' + Math.random().toString(36).substring(2, 6); + const newRow: TranslationMatrixRow = { + id: newId, + key: imp.key, + module: imp.module || 'Common', + tenantId: null, + entityType: 'Global', + entityId: null, + overrideStatus: imp.overrideStatus || '—', + translations: { + 'en-US': imp.translations?.['en-US'] || '', + 'ar-SA': imp.translations?.['ar-SA'] || '', + 'hi-IN': imp.translations?.['hi-IN'] || '' + }, + originalTranslations: { 'en-US': '', 'ar-SA': '', 'hi-IN': '' }, + dirtyFlags: { 'en-US': false, 'ar-SA': false, 'hi-IN': false } + }; + existingMap.set(imp.key, newRow); + } + }); + + return Array.from(existingMap.values()); + }); + } +} diff --git a/src/app/features/localization/public-api.ts b/src/app/features/localization/public-api.ts new file mode 100644 index 00000000..1aade1f6 --- /dev/null +++ b/src/app/features/localization/public-api.ts @@ -0,0 +1,5 @@ +export * from './models/localized-text.model'; +export * from './data-access/localized-text.endpoints'; +export * from './data-access/localized-text.service'; +export * from './pages/translations-manager/translations-manager'; +export * from './localization.routes'; diff --git a/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.html b/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.html index 4f558e7c..cc65660b 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.html +++ b/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.html @@ -1,30 +1,42 @@ @if (!activationResult()) { -
-
+ +
-

- + [validationMessages]="{ required: 'Database connection selection is required.' }" + wrapperClass="w-full" + (itemSelected)="selectedDbConnectionItem.set($event)" + /> +

+ Only Active, non-replica connections

@@ -35,6 +47,6 @@ [retrying]="activating()" (retryClicked)="retryActivation()" (doneClicked)="onActivationDone()" - > + /> } diff --git a/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.ts b/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.ts index 9e6d5ce0..b04fc54a 100644 --- a/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.ts +++ b/src/app/features/organizations/pages/organization-awaiting-db/components/assign-db-modal/assign-db-modal.ts @@ -64,6 +64,7 @@ export class AssignDbModalComponent { readonly submitAttempted = signal(false); readonly activationResult = signal(null); readonly dbConnectionOptions = signal([]); + readonly selectedDbConnectionItem = signal(null); readonly form = this.formBuilder.group({ dbConnectionId: ['', [Validators.required]] @@ -74,7 +75,7 @@ export class AssignDbModalComponent { if (!org) return 'Assign database'; const code = org.code || ''; const name = org.organizationName || org.name || ''; - return `Assign database — ${code} ${name}`.trim(); + return 'Assign database'; //`Assign database — ${code} ${name}`.trim(); }); readonly searchDbConnections: AutocompleteSearchFn = (term, limit) => { @@ -115,6 +116,7 @@ export class AssignDbModalComponent { this.submitAttempted.set(false); this.activating.set(false); this.activationResult.set(null); + this.selectedDbConnectionItem.set(null); this.form.reset({ dbConnectionId: '' }); this.awaitingDbApi.getActiveDbConnections('', 100).pipe( @@ -127,7 +129,9 @@ export class AssignDbModalComponent { assignAndActivate(): void { this.submitAttempted.set(true); - if (this.form.invalid || this.activating()) return; + this.form.markAllAsTouched(); + + if (this.activating() || this.form.invalid) return; const org = this.tenant(); if (!org?.id) { @@ -138,10 +142,7 @@ export class AssignDbModalComponent { const rawVal = this.form.getRawValue(); const dbConnectionId = (rawVal.dbConnectionId || '').trim(); - if (!dbConnectionId) { - this.notification.error('Please select a Database Connection.'); - return; - } + if (!dbConnectionId) return; this.executeAssignment(org.id, dbConnectionId); } diff --git a/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-table/tenant-domain-table.html b/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-table/tenant-domain-table.html index b258171c..d0d9ded9 100644 --- a/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-table/tenant-domain-table.html +++ b/src/app/features/organizations/pages/tenant-domain-list/components/tenant-domain-table/tenant-domain-table.html @@ -6,7 +6,7 @@ [pageIndex]="pageIndex()" [pageSize]="pageSize()" tableTitle="Tenant Domains" - buttonTitle="Add Domain" + buttonTitle="Add" [showSearch]="true" [showAddButton]="true" [showFilterButton]="true" diff --git a/src/app/shared/components/data-table/data-table.html b/src/app/shared/components/data-table/data-table.html index ee1bceb1..1b575386 100644 --- a/src/app/shared/components/data-table/data-table.html +++ b/src/app/shared/components/data-table/data-table.html @@ -14,6 +14,14 @@ className="!rounded-full shadow-sm !whitespace-nowrap !mb-0" (buttonClicked)="onFilterClick($event)" />
} + @if(showImportExportButtons()){ +
+ + +
+ } @if(showAddButton()){
0) {
-
@@ -249,7 +259,7 @@ @for (page of visiblePages(); track page) { -
  • +