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()"
|
[pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
[pageSize]="tableStore.queryState.pageSize()"
|
[pageSize]="tableStore.queryState.pageSize()"
|
||||||
tableTitle="Tenant Subscriptions"
|
tableTitle="Tenant Subscriptions"
|
||||||
buttonTitle="Add New Subscription"
|
buttonTitle="Add"
|
||||||
[showSearch]="true"
|
[showSearch]="true"
|
||||||
[showAddButton]="true"
|
[showAddButton]="true"
|
||||||
searchPlaceholder="Search subscriptions..."
|
searchPlaceholder="Search subscriptions..."
|
||||||
|
|||||||
+1
@@ -112,6 +112,7 @@ export class PlansSubscriptions implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
readonly columns = signal<DataTableColumn<SubscriptionTableRow>[]>([
|
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: 'tenantName', label: 'Tenant / Organization', header: 'Tenant / Organization', sortable: true, align: 'left' },
|
||||||
{
|
{
|
||||||
key: 'planName', label: 'Plan', header: 'Plan', sortable: true, align: 'left',
|
key: 'planName', label: 'Plan', header: 'Plan', sortable: true, align: 'left',
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
[title]="modalTitle()"
|
[title]="modalTitle()"
|
||||||
size="lg"
|
size="lg"
|
||||||
[submitAction]="mode() === 'create' ? 'save' : 'update'"
|
[submitAction]="mode() === 'create' ? 'save' : 'update'"
|
||||||
[submitLabel]="mode() === 'create' ? 'Save Rate' : 'Update Rate'"
|
[submitLabel]="mode() === 'create' ? 'Save' : 'Update'"
|
||||||
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
|
[loadingLabel]="mode() === 'create' ? 'Saving...' : 'Updating...'"
|
||||||
[loading]="saving() || modalLoading()"
|
[loading]="saving() || modalLoading()"
|
||||||
[showSubmitButton]="!isViewMode()"
|
[showSubmitButton]="!isViewMode()"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
|
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
|
||||||
[totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
|
[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()"
|
[showSearch]="true" [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()"
|
||||||
searchPlaceholder="Search pair..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()"
|
searchPlaceholder="Search pair..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()"
|
||||||
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
|
(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
|
<modal
|
||||||
[open]="open()"
|
[open]="open()"
|
||||||
[title]="modalTitle()"
|
[title]="modalTitle()"
|
||||||
[size]="'md'"
|
size="md"
|
||||||
[showFooter]="!activationResult()"
|
submitLabel="Assign & Activate"
|
||||||
[showSubmitButton]="!activationResult()"
|
loadingLabel="Assigning & Activating..."
|
||||||
[submitLabel]="'Assign & Activate'"
|
|
||||||
[loadingLabel]="'Assigning & Activating...'"
|
|
||||||
[loading]="activating()"
|
[loading]="activating()"
|
||||||
[submitDisabled]="form.invalid || activating()"
|
[showSubmitButton]="!activationResult()"
|
||||||
|
[submitDisabled]="activating()"
|
||||||
(closed)="closeModal()"
|
(closed)="closeModal()"
|
||||||
(submitted)="assignAndActivate()"
|
(submitted)="assignAndActivate()"
|
||||||
>
|
>
|
||||||
@if (!activationResult()) {
|
@if (!activationResult()) {
|
||||||
<form [formGroup]="form" (ngSubmit)="assignAndActivate()" class="space-y-4">
|
<form [formGroup]="form" (ngSubmit)="assignAndActivate()" autocomplete="off" class="grid grid-cols-12 gap-x-4 gap-y-4 py-1 pb-16">
|
||||||
<div class="space-y-1.5">
|
<div class="col-span-12">
|
||||||
<app-autocomplete
|
<app-autocomplete
|
||||||
formControlName="dbConnectionId"
|
formControlName="dbConnectionId"
|
||||||
label="Database Connection *"
|
inputId="assign-db-connection-id"
|
||||||
placeholder="Select an active database connection..."
|
variant="floating"
|
||||||
|
size="sm"
|
||||||
|
label="Database Connection"
|
||||||
|
placeholder="Select an active database connection"
|
||||||
|
[required]="true"
|
||||||
[searchFn]="searchDbConnections"
|
[searchFn]="searchDbConnections"
|
||||||
[displayWith]="displayDbConnection"
|
[displayWith]="displayDbConnection"
|
||||||
[valueWith]="dbConnectionValue"
|
[valueWith]="dbConnectionValue"
|
||||||
|
[resolveValueFn]="resolveDbConnection"
|
||||||
|
[selectedItem]="selectedDbConnectionItem()"
|
||||||
|
[minSearchLength]="0"
|
||||||
|
[debounceTime]="100"
|
||||||
|
[limit]="50"
|
||||||
|
[clearable]="true"
|
||||||
[submitAttempted]="submitAttempted()"
|
[submitAttempted]="submitAttempted()"
|
||||||
></app-autocomplete>
|
[validationMessages]="{ required: 'Database connection selection is required.' }"
|
||||||
<p class="text-xs text-muted flex items-center gap-1">
|
wrapperClass="w-full"
|
||||||
<i class="ti ti-info-circle text-info"></i>
|
(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>
|
<span>Only Active, non-replica connections</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,6 +47,6 @@
|
|||||||
[retrying]="activating()"
|
[retrying]="activating()"
|
||||||
(retryClicked)="retryActivation()"
|
(retryClicked)="retryActivation()"
|
||||||
(doneClicked)="onActivationDone()"
|
(doneClicked)="onActivationDone()"
|
||||||
></app-activation-result-display>
|
/>
|
||||||
}
|
}
|
||||||
</modal>
|
</modal>
|
||||||
|
|||||||
+7
-6
@@ -64,6 +64,7 @@ export class AssignDbModalComponent {
|
|||||||
readonly submitAttempted = signal(false);
|
readonly submitAttempted = signal(false);
|
||||||
readonly activationResult = signal<OrganizationActivationResultDto | null>(null);
|
readonly activationResult = signal<OrganizationActivationResultDto | null>(null);
|
||||||
readonly dbConnectionOptions = signal<DbConnectionLookupDto[]>([]);
|
readonly dbConnectionOptions = signal<DbConnectionLookupDto[]>([]);
|
||||||
|
readonly selectedDbConnectionItem = signal<DbConnectionLookupDto | null>(null);
|
||||||
|
|
||||||
readonly form = this.formBuilder.group({
|
readonly form = this.formBuilder.group({
|
||||||
dbConnectionId: ['', [Validators.required]]
|
dbConnectionId: ['', [Validators.required]]
|
||||||
@@ -74,7 +75,7 @@ export class AssignDbModalComponent {
|
|||||||
if (!org) return 'Assign database';
|
if (!org) return 'Assign database';
|
||||||
const code = org.code || '';
|
const code = org.code || '';
|
||||||
const name = org.organizationName || org.name || '';
|
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) => {
|
readonly searchDbConnections: AutocompleteSearchFn<DbConnectionLookupDto> = (term, limit) => {
|
||||||
@@ -115,6 +116,7 @@ export class AssignDbModalComponent {
|
|||||||
this.submitAttempted.set(false);
|
this.submitAttempted.set(false);
|
||||||
this.activating.set(false);
|
this.activating.set(false);
|
||||||
this.activationResult.set(null);
|
this.activationResult.set(null);
|
||||||
|
this.selectedDbConnectionItem.set(null);
|
||||||
this.form.reset({ dbConnectionId: '' });
|
this.form.reset({ dbConnectionId: '' });
|
||||||
|
|
||||||
this.awaitingDbApi.getActiveDbConnections('', 100).pipe(
|
this.awaitingDbApi.getActiveDbConnections('', 100).pipe(
|
||||||
@@ -127,7 +129,9 @@ export class AssignDbModalComponent {
|
|||||||
|
|
||||||
assignAndActivate(): void {
|
assignAndActivate(): void {
|
||||||
this.submitAttempted.set(true);
|
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();
|
const org = this.tenant();
|
||||||
if (!org?.id) {
|
if (!org?.id) {
|
||||||
@@ -138,10 +142,7 @@ export class AssignDbModalComponent {
|
|||||||
const rawVal = this.form.getRawValue();
|
const rawVal = this.form.getRawValue();
|
||||||
const dbConnectionId = (rawVal.dbConnectionId || '').trim();
|
const dbConnectionId = (rawVal.dbConnectionId || '').trim();
|
||||||
|
|
||||||
if (!dbConnectionId) {
|
if (!dbConnectionId) return;
|
||||||
this.notification.error('Please select a Database Connection.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.executeAssignment(org.id, dbConnectionId);
|
this.executeAssignment(org.id, dbConnectionId);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
[pageIndex]="pageIndex()"
|
[pageIndex]="pageIndex()"
|
||||||
[pageSize]="pageSize()"
|
[pageSize]="pageSize()"
|
||||||
tableTitle="Tenant Domains"
|
tableTitle="Tenant Domains"
|
||||||
buttonTitle="Add Domain"
|
buttonTitle="Add"
|
||||||
[showSearch]="true"
|
[showSearch]="true"
|
||||||
[showAddButton]="true"
|
[showAddButton]="true"
|
||||||
[showFilterButton]="true"
|
[showFilterButton]="true"
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
className="!rounded-full shadow-sm !whitespace-nowrap !mb-0" (buttonClicked)="onFilterClick($event)" />
|
className="!rounded-full shadow-sm !whitespace-nowrap !mb-0" (buttonClicked)="onFilterClick($event)" />
|
||||||
</div>
|
</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()){
|
@if(showAddButton()){
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
|
<app-button action="add" [label]="buttonTitle()" size="sm" iconClass="!text-[1rem]"
|
||||||
@@ -214,20 +222,22 @@
|
|||||||
|
|
||||||
@if (pageSizeOptions().length > 0) {
|
@if (pageSizeOptions().length > 0) {
|
||||||
<div class="flex items-center gap-2">
|
<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
|
Show
|
||||||
</label>
|
</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)">
|
(change)="onPageSizeChange($event)">
|
||||||
@for (size of pageSizeOptions(); track size) {
|
@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 }}
|
{{ size }}
|
||||||
</option>
|
</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<span class="text-sm whitespace-nowrap">
|
<span class="text-sm whitespace-nowrap text-defaulttextcolor">
|
||||||
entries
|
entries
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +259,7 @@
|
|||||||
|
|
||||||
<!-- Page numbers -->
|
<!-- Page numbers -->
|
||||||
@for (page of visiblePages(); track page) {
|
@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()"
|
<button type="button" class="page-link px-3 py-[0.375rem]" [class.active]="page === pageIndex()"
|
||||||
[attr.aria-current]="
|
[attr.aria-current]="
|
||||||
page === pageIndex() ? 'page' : null
|
page === pageIndex() ? 'page' : null
|
||||||
|
|||||||
@@ -205,12 +205,12 @@
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table-modern .ti-pagination li.active .page-link,
|
.data-table-modern .ti-pagination li.active .page-link,
|
||||||
.data-table-modern .ti-pagination li .page-link.active {
|
.data-table-modern .ti-pagination li .page-link.active {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary) !important;
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
box-shadow: 0 2px 6px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table-modern .ti-pagination li.disabled .page-link {
|
.data-table-modern .ti-pagination li.disabled .page-link {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -227,10 +227,11 @@
|
|||||||
color: var(--color-primary);
|
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.active .page-link,
|
||||||
color: var(--color-white);
|
:host-context(.dark) .data-table-modern .ti-pagination li .page-link.active {
|
||||||
background-color: var(--color-primary);
|
color: var(--color-white);
|
||||||
}
|
background-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive scroll */
|
/* Responsive scroll */
|
||||||
.table-responsive {
|
.table-responsive {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
|
|||||||
emptyDescription = input('There is currently no data to display.');
|
emptyDescription = input('There is currently no data to display.');
|
||||||
showFilterButton = input<boolean>(false);
|
showFilterButton = input<boolean>(false);
|
||||||
filterActive = input<boolean>(false);
|
filterActive = input<boolean>(false);
|
||||||
|
showImportExportButtons = input<boolean>(false);
|
||||||
/*---------------------------*/
|
/*---------------------------*/
|
||||||
|
|
||||||
/* --------- Permission inputs ---- */
|
/* --------- Permission inputs ---- */
|
||||||
@@ -153,6 +154,8 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
|
|||||||
sortChanged = output<DataTableSortEvent>();
|
sortChanged = output<DataTableSortEvent>();
|
||||||
actionClicked = output<DataTableActionEvent<T>>();
|
actionClicked = output<DataTableActionEvent<T>>();
|
||||||
rowClicked = output<T>();
|
rowClicked = output<T>();
|
||||||
|
importClicked = output<void>();
|
||||||
|
exportClicked = output<void>();
|
||||||
|
|
||||||
sortColumn = signal('');
|
sortColumn = signal('');
|
||||||
sortDirection = signal<'asc' | 'desc'>('asc');
|
sortDirection = signal<'asc' | 'desc'>('asc');
|
||||||
@@ -331,6 +334,22 @@ export class DataTable<T extends DataTableRecord = DataTableRecord> {
|
|||||||
this.addClicked.emit();
|
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 {
|
toggleActionMenu(event: MouseEvent, row: T): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-control--floating.is-focused,
|
.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);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
color: #8c9097 !important;
|
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])) {
|
.date-picker-floating .floating-label:focus-within:not(.disabled):not(:has(.form-control:disabled)):not(:has(.form-control[readonly])) {
|
||||||
& > label {
|
& > label {
|
||||||
color: var(--color-primary, #7c3deb) !important;
|
color: var(--color-primary, #7c3deb) !important;
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
color: #94a3b8 !important;
|
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])) {
|
.date-picker-floating .floating-label:focus-within:not(.disabled):not(:has(.form-control:disabled)):not(:has(.form-control[readonly])) {
|
||||||
& > label {
|
& > label {
|
||||||
color: #c084fc !important;
|
color: #c084fc !important;
|
||||||
|
|||||||
@@ -42,13 +42,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-within > label,
|
||||||
&.floating-label--float > label {
|
&.floating-label--float > label {
|
||||||
top: 0;
|
|
||||||
transform: translateY(-50%) scale(0.85);
|
|
||||||
color: #8c9097 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-within > label {
|
|
||||||
top: 0;
|
top: 0;
|
||||||
transform: translateY(-50%) scale(0.85);
|
transform: translateY(-50%) scale(0.85);
|
||||||
color: var(--color-primary) !important;
|
color: var(--color-primary) !important;
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
.shared-form-input.has-suffix { padding-inline-end: 2.5rem; }
|
.shared-form-input.has-suffix { padding-inline-end: 2.5rem; }
|
||||||
.shared-form-input::placeholder { color: transparent; }
|
.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);
|
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;
|
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 {
|
.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;
|
top: 0;
|
||||||
transform: translateY(-50%) scale(0.9);
|
transform: translateY(-50%) scale(0.9);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
|||||||
@@ -121,7 +121,9 @@
|
|||||||
top: auto;
|
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 .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-opened .ng-select-container,
|
||||||
:host ::ng-deep .form-select-floating .ng-select.ng-select-focused .ng-select-container {
|
:host ::ng-deep .form-select-floating .ng-select.ng-select-focused .ng-select-container {
|
||||||
border-color: var(--color-primary) !important;
|
border-color: var(--color-primary) !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user