buttons labels and fix grid of translation manager, import and export functionality in data-table
This commit is contained in:
+1
-1
@@ -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..."
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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)"
|
||||
|
||||
+126
@@ -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>
|
||||
+130
@@ -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();
|
||||
}
|
||||
}
|
||||
+58
@@ -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>
|
||||
+68
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
+175
@@ -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>
|
||||
+67
@@ -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';
|
||||
+26
-14
@@ -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>
|
||||
|
||||
+7
-6
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
@@ -407,4 +408,4 @@
|
||||
|
||||
.filter-close-btn:active {
|
||||
transform: rotate(90deg) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user