buttons labels and fix grid of translation manager, import and export functionality in data-table

This commit is contained in:
Gagan7900
2026-08-04 16:47:01 +05:30
parent c7c361cc0b
commit c683f6fea9
28 changed files with 1742 additions and 55 deletions
@@ -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..."
@@ -112,6 +112,7 @@ export class PlansSubscriptions implements OnInit {
});
readonly columns = signal<DataTableColumn<SubscriptionTableRow>[]>([
{ 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',
@@ -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()"
@@ -1,6 +1,6 @@
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
[totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()" tableTitle="Exchange Rates (ROE)" buttonTitle="Add Exchange Rate"
[pageSize]="tableStore.queryState.pageSize()" tableTitle="Exchange Rates (ROE)" buttonTitle="Add"
[showSearch]="true" [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()"
searchPlaceholder="Search pair..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()"
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
@@ -0,0 +1,126 @@
<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>
@@ -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<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();
}
}
@@ -0,0 +1,58 @@
<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>
@@ -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<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()
});
}
}
@@ -0,0 +1,175 @@
<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>
@@ -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<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();
}
}
}
@@ -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')
};
@@ -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<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 }
}
];
}
}
@@ -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'
}
}
];
@@ -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<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;
}
@@ -0,0 +1,187 @@
<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()"
/>
@@ -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<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());
});
}
}
@@ -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';
@@ -1,30 +1,42 @@
<modal
[open]="open()"
[title]="modalTitle()"
[size]="'md'"
[showFooter]="!activationResult()"
[showSubmitButton]="!activationResult()"
[submitLabel]="'Assign & Activate'"
[loadingLabel]="'Assigning & Activating...'"
size="md"
submitLabel="Assign & Activate"
loadingLabel="Assigning & Activating..."
[loading]="activating()"
[submitDisabled]="form.invalid || activating()"
[showSubmitButton]="!activationResult()"
[submitDisabled]="activating()"
(closed)="closeModal()"
(submitted)="assignAndActivate()"
>
@if (!activationResult()) {
<form [formGroup]="form" (ngSubmit)="assignAndActivate()" class="space-y-4">
<div class="space-y-1.5">
<form [formGroup]="form" (ngSubmit)="assignAndActivate()" autocomplete="off" class="grid grid-cols-12 gap-x-4 gap-y-4 py-1 pb-16">
<div class="col-span-12">
<app-autocomplete
formControlName="dbConnectionId"
label="Database Connection *"
placeholder="Select an active database connection..."
inputId="assign-db-connection-id"
variant="floating"
size="sm"
label="Database Connection"
placeholder="Select an active database connection"
[required]="true"
[searchFn]="searchDbConnections"
[displayWith]="displayDbConnection"
[valueWith]="dbConnectionValue"
[resolveValueFn]="resolveDbConnection"
[selectedItem]="selectedDbConnectionItem()"
[minSearchLength]="0"
[debounceTime]="100"
[limit]="50"
[clearable]="true"
[submitAttempted]="submitAttempted()"
></app-autocomplete>
<p class="text-xs text-muted flex items-center gap-1">
<i class="ti ti-info-circle text-info"></i>
[validationMessages]="{ required: 'Database connection selection is required.' }"
wrapperClass="w-full"
(itemSelected)="selectedDbConnectionItem.set($event)"
/>
<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>
<span>Only Active, non-replica connections</span>
</p>
</div>
@@ -35,6 +47,6 @@
[retrying]="activating()"
(retryClicked)="retryActivation()"
(doneClicked)="onActivationDone()"
></app-activation-result-display>
/>
}
</modal>
@@ -64,6 +64,7 @@ export class AssignDbModalComponent {
readonly submitAttempted = signal(false);
readonly activationResult = signal<OrganizationActivationResultDto | null>(null);
readonly dbConnectionOptions = signal<DbConnectionLookupDto[]>([]);
readonly selectedDbConnectionItem = signal<DbConnectionLookupDto | null>(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<DbConnectionLookupDto> = (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);
}
@@ -6,7 +6,7 @@
[pageIndex]="pageIndex()"
[pageSize]="pageSize()"
tableTitle="Tenant Domains"
buttonTitle="Add Domain"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
[showFilterButton]="true"
@@ -14,6 +14,14 @@
className="!rounded-full shadow-sm !whitespace-nowrap !mb-0" (buttonClicked)="onFilterClick($event)" />
</div>
}
@if(showImportExportButtons()){
<div class="flex items-center gap-2">
<app-button action="custom" icon="ri-upload-2-line" label="Import CSV" variant="outline-primary" size="sm"
className="!rounded-full shadow-sm !mb-0" (buttonClicked)="onImportClick($event)" />
<app-button action="custom" icon="ri-download-2-line" label="Export" variant="secondary" size="sm"
className="!rounded-full shadow-sm !mb-0" (buttonClicked)="onExportClick($event)" />
</div>
}
@if(showAddButton()){
<div class="flex items-center">
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
@@ -214,20 +222,22 @@
@if (pageSizeOptions().length > 0) {
<div class="flex items-center gap-2">
<label for="dataTablePageSize" class="text-sm whitespace-nowrap">
<label for="dataTablePageSize" class="text-sm whitespace-nowrap text-defaulttextcolor">
Show
</label>
<select id="dataTablePageSize" class="ti-form-select form-select-sm !w-[75px] !py-1"
<select id="dataTablePageSize"
class="form-select form-select-sm !w-[80px] !py-1 !ps-2.5 !pe-6 text-defaulttextcolor bg-white dark:bg-bodybg border border-defaultborder rounded-md"
[value]="pageSize()"
(change)="onPageSizeChange($event)">
@for (size of pageSizeOptions(); track size) {
<option [value]="size" [selected]="size === pageSize()">
<option [value]="size" [selected]="size === pageSize()" class="text-defaulttextcolor bg-white dark:bg-bodybg">
{{ size }}
</option>
}
</select>
<span class="text-sm whitespace-nowrap">
<span class="text-sm whitespace-nowrap text-defaulttextcolor">
entries
</span>
</div>
@@ -249,7 +259,7 @@
<!-- Page numbers -->
@for (page of visiblePages(); track page) {
<li class="page-item">
<li class="page-item" [class.active]="page === pageIndex()">
<button type="button" class="page-link px-3 py-[0.375rem]" [class.active]="page === pageIndex()"
[attr.aria-current]="
page === pageIndex() ? 'page' : null
@@ -205,12 +205,12 @@
color: var(--color-primary);
}
.data-table-modern .ti-pagination li.active .page-link,
.data-table-modern .ti-pagination li .page-link.active {
background-color: var(--color-primary);
color: var(--color-white);
box-shadow: 0 2px 6px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.data-table-modern .ti-pagination li.active .page-link,
.data-table-modern .ti-pagination li .page-link.active {
background-color: var(--color-primary) !important;
color: var(--color-white);
box-shadow: 0 2px 6px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.data-table-modern .ti-pagination li.disabled .page-link {
cursor: not-allowed;
@@ -227,10 +227,11 @@
color: var(--color-primary);
}
:host-context(.dark) .data-table-modern .ti-pagination li.active .page-link {
color: var(--color-white);
background-color: var(--color-primary);
}
:host-context(.dark) .data-table-modern .ti-pagination li.active .page-link,
:host-context(.dark) .data-table-modern .ti-pagination li .page-link.active {
color: var(--color-white);
background-color: var(--color-primary) !important;
}
/* Responsive scroll */
.table-responsive {
@@ -140,6 +140,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
emptyDescription = input('There is currently no data to display.');
showFilterButton = input<boolean>(false);
filterActive = input<boolean>(false);
showImportExportButtons = input<boolean>(false);
/*---------------------------*/
/* --------- Permission inputs ---- */
@@ -153,6 +154,8 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
sortChanged = output<DataTableSortEvent>();
actionClicked = output<DataTableActionEvent<T>>();
rowClicked = output<T>();
importClicked = output<void>();
exportClicked = output<void>();
sortColumn = signal('');
sortDirection = signal<'asc' | 'desc'>('asc');
@@ -331,6 +334,22 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
this.addClicked.emit();
}
onImportClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.closeActionMenu();
this.importClicked.emit();
}
onExportClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.closeActionMenu();
this.exportClicked.emit();
}
toggleActionMenu(event: MouseEvent, row: T): void {
event.stopPropagation();
@@ -50,7 +50,8 @@
}
.autocomplete-control--floating.is-focused,
.autocomplete-control--floating.is-open {
.autocomplete-control--floating.is-open,
.autocomplete-control--floating.has-value {
border-color: var(--color-primary);
}
@@ -49,7 +49,8 @@
color: #8c9097 !important;
}
/* Active focus highlight ONLY on focus-within when not disabled/readonly */
/* Active focus & float highlight when not disabled/readonly */
.date-picker-floating .floating-label--float:not(.disabled):not(:has(.form-control:disabled)):not(:has(.form-control[readonly])),
.date-picker-floating .floating-label:focus-within:not(.disabled):not(:has(.form-control:disabled)):not(:has(.form-control[readonly])) {
& > label {
color: var(--color-primary, #7c3deb) !important;
@@ -95,6 +96,7 @@
color: #94a3b8 !important;
}
.date-picker-floating .floating-label--float:not(.disabled):not(:has(.form-control:disabled)):not(:has(.form-control[readonly])),
.date-picker-floating .floating-label:focus-within:not(.disabled):not(:has(.form-control:disabled)):not(:has(.form-control[readonly])) {
& > label {
color: #c084fc !important;
@@ -42,13 +42,8 @@
white-space: nowrap;
}
&:focus-within > label,
&.floating-label--float > label {
top: 0;
transform: translateY(-50%) scale(0.85);
color: #8c9097 !important;
}
&:focus-within > label {
top: 0;
transform: translateY(-50%) scale(0.85);
color: var(--color-primary) !important;
@@ -35,7 +35,8 @@
.shared-form-input.has-suffix { padding-inline-end: 2.5rem; }
.shared-form-input::placeholder { color: transparent; }
.shared-form-control.is-focused .shared-form-input {
.shared-form-control.is-focused .shared-form-input,
.shared-form-control.has-value .shared-form-input {
border-color: var(--color-primary);
}
@@ -59,13 +60,8 @@
transition: top 0.2s ease-out, transform 0.2s ease-out, color 0.2s ease-out;
}
.shared-form-control.is-focused .shared-floating-label,
.shared-form-control.has-value .shared-floating-label {
top: 0;
transform: translateY(-50%) scale(0.9);
color: #8c9097;
}
.shared-form-control.is-focused .shared-floating-label {
top: 0;
transform: translateY(-50%) scale(0.9);
color: var(--color-primary);
@@ -121,7 +121,9 @@
top: auto;
}
:host ::ng-deep .form-select-floating .floating-label--float .ng-select-container,
:host ::ng-deep .form-select-floating .floating-label:focus-within .ng-select-container,
:host ::ng-deep .form-select-floating .ng-select.ng-select-has-value .ng-select-container,
:host ::ng-deep .form-select-floating .ng-select.ng-select-opened .ng-select-container,
:host ::ng-deep .form-select-floating .ng-select.ng-select-focused .ng-select-container {
border-color: var(--color-primary) !important;