WEB-604: Add hour/minute time selection for partial day cashier allocation (#3108)

- Added hour (00-23) and minute (00-59) dropdowns to Create Cashier form
- Added hour/minute dropdowns to Edit Cashier form with existing time parsing
- Time fields shown conditionally when Full Day is unchecked
- Added end-time-after-start-time validation
- Clear stale invalidTimeRange error before re-validating
- Renamed setEditChargeForm to setEditCashierForm
- Added Hour and Minute i18n keys to all 13 locale files
This commit is contained in:
Anvay
2026-02-08 16:15:50 -06:00
committed by GitHub
parent 77e95c6146
commit 2f73e9a9eb
17 changed files with 244 additions and 13 deletions
@@ -83,7 +83,63 @@
}
</mat-form-field>
<div class="space flex-48">
@if (!createCashierForm.value.isFullDay) {
<div class="flex-48 layout-row gap-10px">
<mat-form-field class="flex-50">
<mat-label
>{{ 'labels.inputs.Start Time' | translate }} ({{ 'labels.inputs.Hour' | translate }})</mat-label
>
<mat-select formControlName="hourStartTime">
@for (hour of hours; track hour) {
<mat-option [value]="hour">{{ hour }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-50">
<mat-label
>{{ 'labels.inputs.Start Time' | translate }} ({{ 'labels.inputs.Minute' | translate }})</mat-label
>
<mat-select formControlName="minStartTime">
@for (minute of minutes; track minute) {
<mat-option [value]="minute">{{ minute }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="flex-48 layout-row gap-10px">
<mat-form-field class="flex-50">
<mat-label
>{{ 'labels.inputs.End Time' | translate }} ({{ 'labels.inputs.Hour' | translate }})</mat-label
>
<mat-select formControlName="hourEndTime">
@for (hour of hours; track hour) {
<mat-option [value]="hour">{{ hour }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-50">
<mat-label
>{{ 'labels.inputs.End Time' | translate }} ({{ 'labels.inputs.Minute' | translate }})</mat-label
>
<mat-select formControlName="minEndTime">
@for (minute of minutes; track minute) {
<mat-option [value]="minute">{{ minute }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (createCashierForm.hasError('invalidTimeRange')) {
<div class="flex-48">
<mat-error>
{{ 'labels.inputs.End Time' | translate }} {{ 'labels.commons.must' | translate }}
{{ 'labels.commons.be' | translate }} {{ 'labels.commons.greater than' | translate }}
{{ 'labels.inputs.Start Time' | translate }}
</mat-error>
</div>
}
}
<div class="space flex-100">
<mat-checkbox labelPosition="before" formControlName="isFullDay">
{{ 'labels.inputs.Full Day' | translate }}?
</mat-checkbox>
@@ -46,6 +46,10 @@ export class CreateCashierComponent implements OnInit {
cashierTemplate: any;
/** Create cashier form. */
createCashierForm: UntypedFormGroup;
/** Hours options for time selection (00-23). */
hours: string[] = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
/** Minutes options for time selection (00-59). */
minutes: string[] = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
/**
* Fetches cashier template from `resolve`
@@ -85,7 +89,11 @@ export class CreateCashierComponent implements OnInit {
'',
Validators.required
],
isFullDay: [false]
isFullDay: [true],
hourStartTime: ['00'],
minStartTime: ['00'],
hourEndTime: ['00'],
minEndTime: ['00']
});
}
@@ -104,11 +112,40 @@ export class CreateCashierComponent implements OnInit {
if (createCashierFormData.endDate instanceof Date) {
createCashierFormData.endDate = this.dateUtils.formatDate(prevEndDate, dateFormat);
}
const data = {
...createCashierFormData,
const data: any = {
staffId: createCashierFormData.staffId,
description: createCashierFormData.description,
startDate: createCashierFormData.startDate,
endDate: createCashierFormData.endDate,
isFullDay: createCashierFormData.isFullDay,
dateFormat,
locale
};
// Clear stale time-range errors before re-validating
if (this.createCashierForm.hasError('invalidTimeRange')) {
const { invalidTimeRange, ...rest } = this.createCashierForm.errors ?? {};
this.createCashierForm.setErrors(Object.keys(rest).length ? rest : null);
}
// Add time fields only when not full day
if (!createCashierFormData.isFullDay) {
const hourStart = createCashierFormData.hourStartTime;
const minStart = createCashierFormData.minStartTime;
const hourEnd = createCashierFormData.hourEndTime;
const minEnd = createCashierFormData.minEndTime;
// Validate that end time is after start time
const startMinutes = Number(hourStart) * 60 + Number(minStart);
const endMinutes = Number(hourEnd) * 60 + Number(minEnd);
if (Number.isNaN(startMinutes) || Number.isNaN(endMinutes) || endMinutes <= startMinutes) {
this.createCashierForm.setErrors({ invalidTimeRange: true });
return;
}
data.hourStartTime = hourStart;
data.minStartTime = minStart;
data.hourEndTime = hourEnd;
data.minEndTime = minEnd;
data.startTime = `${hourStart}:${minStart}`;
data.endTime = `${hourEnd}:${minEnd}`;
}
this.organizationService.createCashier(this.cashierTemplate.tellerId, data).subscribe((response: any) => {
this.router.navigate(['../'], { relativeTo: this.route });
});
@@ -92,7 +92,63 @@
}
</mat-form-field>
<div class="space flex-48">
@if (!editCashierForm.value.isFullDay) {
<div class="flex-48 layout-row gap-10px">
<mat-form-field class="flex-50">
<mat-label
>{{ 'labels.inputs.Start Time' | translate }} ({{ 'labels.inputs.Hour' | translate }})</mat-label
>
<mat-select formControlName="hourStartTime">
@for (hour of hours; track hour) {
<mat-option [value]="hour">{{ hour }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-50">
<mat-label
>{{ 'labels.inputs.Start Time' | translate }} ({{ 'labels.inputs.Minute' | translate }})</mat-label
>
<mat-select formControlName="minStartTime">
@for (minute of minutes; track minute) {
<mat-option [value]="minute">{{ minute }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="flex-48 layout-row gap-10px">
<mat-form-field class="flex-50">
<mat-label
>{{ 'labels.inputs.End Time' | translate }} ({{ 'labels.inputs.Hour' | translate }})</mat-label
>
<mat-select formControlName="hourEndTime">
@for (hour of hours; track hour) {
<mat-option [value]="hour">{{ hour }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-50">
<mat-label
>{{ 'labels.inputs.End Time' | translate }} ({{ 'labels.inputs.Minute' | translate }})</mat-label
>
<mat-select formControlName="minEndTime">
@for (minute of minutes; track minute) {
<mat-option [value]="minute">{{ minute }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (editCashierForm.hasError('invalidTimeRange')) {
<div class="flex-48">
<mat-error>
{{ 'labels.inputs.End Time' | translate }} {{ 'labels.commons.must' | translate }}
{{ 'labels.commons.be' | translate }} {{ 'labels.commons.greater than' | translate }}
{{ 'labels.inputs.Start Time' | translate }}
</mat-error>
</div>
}
}
<div class="space flex-100">
<mat-checkbox labelPosition="before" formControlName="isFullDay">
{{ 'labels.inputs.Full Day' | translate }}?
</mat-checkbox>
@@ -48,6 +48,10 @@ export class EditCashierComponent implements OnInit {
minDate = new Date(2000, 0, 1);
/** Maximum Date allowed. */
maxDate = new Date();
/** Hours options for time selection (00-23). */
hours: string[] = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
/** Minutes options for time selection (00-59). */
minutes: string[] = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
/**
*
@@ -70,28 +74,49 @@ export class EditCashierComponent implements OnInit {
ngOnInit() {
this.maxDate = this.settingsService.maxFutureDate;
this.setEditChargeForm();
this.setEditCashierForm();
}
/**
* Sets Edit Charge Form.
* Sets Edit Cashier Form.
*/
setEditChargeForm() {
setEditCashierForm() {
// Extract hours and minutes from existing time data with safe defaults
const startTime = this.cashierData.data.startTime || '09:00';
const endTime = this.cashierData.data.endTime || '17:00';
const [
hourStart = '09',
minStart = '00'
] = startTime.includes(':') ? startTime.split(':') : [
'09',
'00'
];
const [
hourEnd = '17',
minEnd = '00'
] = endTime.includes(':') ? endTime.split(':') : [
'17',
'00'
];
this.editCashierForm = this.formBuilder.group({
staffId: [{ value: this.cashierData.data.staffId, disabled: true }],
description: [this.cashierData.data.description],
startDate: [
this.cashierData.data.startDate && new Date(this.cashierData.data.startDate),
this.cashierData.data.startDate ? new Date(this.cashierData.data.startDate) : null,
Validators.required
],
endDate: [
this.cashierData.data.endDate && new Date(this.cashierData.data.endDate),
this.cashierData.data.endDate ? new Date(this.cashierData.data.endDate) : null,
Validators.required
],
isFullDay: [
this.cashierData.data.isFullDay,
Validators.required
]
],
hourStartTime: [hourStart.padStart(2, '0')],
minStartTime: [minStart.padStart(2, '0')],
hourEndTime: [hourEnd.padStart(2, '0')],
minEndTime: [minEnd.padStart(2, '0')]
});
}
@@ -110,12 +135,40 @@ export class EditCashierComponent implements OnInit {
if (editCashierFormData.endDate instanceof Date) {
editCashierFormData.endDate = this.dateUtils.formatDate(prevEndDate, dateFormat);
}
const data = {
...editCashierFormData,
const data: any = {
staffId: this.cashierData.data.staffId,
description: editCashierFormData.description,
startDate: editCashierFormData.startDate,
endDate: editCashierFormData.endDate,
isFullDay: editCashierFormData.isFullDay,
dateFormat,
locale
};
// Clear stale time-range errors before re-validating
if (this.editCashierForm.hasError('invalidTimeRange')) {
const { invalidTimeRange, ...rest } = this.editCashierForm.errors ?? {};
this.editCashierForm.setErrors(Object.keys(rest).length ? rest : null);
}
// Add time fields only when not full day
if (!editCashierFormData.isFullDay) {
const hourStart = editCashierFormData.hourStartTime;
const minStart = editCashierFormData.minStartTime;
const hourEnd = editCashierFormData.hourEndTime;
const minEnd = editCashierFormData.minEndTime;
// Validate that end time is after start time
const startMinutes = Number(hourStart) * 60 + Number(minStart);
const endMinutes = Number(hourEnd) * 60 + Number(minEnd);
if (Number.isNaN(startMinutes) || Number.isNaN(endMinutes) || endMinutes <= startMinutes) {
this.editCashierForm.setErrors({ invalidTimeRange: true });
return;
}
data.hourStartTime = hourStart;
data.minStartTime = minStart;
data.hourEndTime = hourEnd;
data.minEndTime = minEnd;
data.startTime = `${hourStart}:${minStart}`;
data.endTime = `${hourEnd}:${minEnd}`;
}
this.organizationService
.updateCashier(this.cashierData.data.tellerId, this.cashierData.data.id, data)
.subscribe((response: any) => {
+2
View File
@@ -1848,6 +1848,7 @@
"Hook Template": "Šablona háčku",
"Host": "Hostitel",
"Host Name": "Název hostitele",
"Hour": "Hodina",
"ID": "ID",
"INTEREST RATE CHART": "TABULKA ÚROKOVÝCH SAZEB",
"Id": "Id",
@@ -2082,6 +2083,7 @@
"Minimum Value": "Minimální hodnota",
"Minimum days between disbursal and first repayment date": "Minimální dny mezi výplatou a datem první splátky",
"Minimum gap between Installments": "Minimální mezera mezi splátkami",
"Minute": "Minuta",
"Mobile": "mobilní, pohybliví",
"Mobile Banking App": "Aplikace pro mobilní bankovnictví",
"Mobile No": "Mobil č",
+2
View File
@@ -1849,6 +1849,7 @@
"Hook Template": "Hakenvorlage",
"Host": "Gastgeber",
"Host Name": "Hostname",
"Hour": "Stunde",
"ID": "AUSWEIS",
"INTEREST RATE CHART": "ZINSTABELLE",
"Id": "Ausweis",
@@ -2083,6 +2084,7 @@
"Minimum Value": "Mindestwert",
"Minimum days between disbursal and first repayment date": "Mindesttage zwischen Auszahlung und erstem Rückzahlungsdatum",
"Minimum gap between Installments": "Mindestabstand zwischen den Raten",
"Minute": "Minute",
"Mobile": "Handy, Mobiltelefon",
"Mobile Banking App": "Mobile-Banking-App",
"Mobile No": "Mobil-Nr",
+2
View File
@@ -1857,6 +1857,7 @@
"Hook Template": "Hook Template",
"Host": "Host",
"Host Name": "Host Name",
"Hour": "Hour",
"ID": "ID",
"INTEREST RATE CHART": "INTEREST RATE CHART",
"Id": "Id",
@@ -2092,6 +2093,7 @@
"Minimum Value": "Minimum Value",
"Minimum days between disbursal and first repayment date": "Minimum days between disbursal and first repayment date",
"Minimum gap between Installments": "Minimum gap between Installments",
"Minute": "Minute",
"Mobile": "Mobile",
"Mobile Banking App": "Mobile Banking App",
"Mobile No": "Mobile No",
+2
View File
@@ -1849,6 +1849,7 @@
"Hook Template": "Plantilla de gancho",
"Host": "Anfitrión",
"Host Name": "Nombre del anfitrión",
"Hour": "Hora",
"ID": "IDENTIFICACIÓN",
"INTEREST RATE CHART": "TABLA DE TIPOS DE INTERÉS",
"Id": "Id",
@@ -2083,6 +2084,7 @@
"Minimum Value": "Valor mínimo",
"Minimum days between disbursal and first repayment date": "Días mínimos entre el curso y la primera fecha de amortización",
"Minimum gap between Installments": "Brecha mínima entre Cuotas",
"Minute": "Minuto",
"Mobile": "Móvil",
"Mobile Banking App": "Aplicación de banca móvil",
"Mobile No": "Teléfono móvil",
+2
View File
@@ -1849,6 +1849,7 @@
"Hook Template": "Plantilla de gancho",
"Host": "Anfitrión",
"Host Name": "Nombre del anfitrión",
"Hour": "Hora",
"ID": "IDENTIFICACIÓN",
"INTEREST RATE CHART": "TABLA DE TIPOS DE INTERÉS",
"Id": "Id",
@@ -2083,6 +2084,7 @@
"Minimum Value": "Valor mínimo",
"Minimum days between disbursal and first repayment date": "Días mínimos entre el desembolso y la primera fecha de amortización",
"Minimum gap between Installments": "Brecha mínima entre Cuotas",
"Minute": "Minuto",
"Mobile": "Móvil",
"Mobile Banking App": "Aplicación de banca móvil",
"Mobile No": "Teléfono móvil",
+5
View File
@@ -846,6 +846,7 @@
"and at": "et à",
"and must be greater than": "et doit être supérieur à",
"are": "sont",
"be": "être",
"be larger than 0 and at most 100": "être supérieur à 0 et au maximum 100",
"begin with a special character or number": "commencer par un caractère spécial ou un chiffre",
"cannot begin with a special character or number": "ne peut pas commencer par un caractère spécial ou un chiffre",
@@ -853,12 +854,14 @@
"do not match": "ne correspondent pas",
"does not match": "ne correspond pas",
"greater equal to than 0": "supérieur égal à 0",
"greater than": "supérieur à",
"invalid": "invalide",
"is": "est",
"Is": "Est",
"is required": "est requis",
"least one uppercase": "au moins une majuscule",
"lowercase and special character": "minuscules et caractères spéciaux",
"must": "doit",
"must be": "doit être",
"must be a positive integer": "doit être un entier positif",
"must be an integer between -9999 and 9999": "doit être un entier compris entre -9999 et 9999",
@@ -1849,6 +1852,7 @@
"Hook Template": "Modèle de crochet",
"Host": "Hôte",
"Host Name": "Nom d'hôte",
"Hour": "Heure",
"ID": "IDENTIFIANT",
"INTEREST RATE CHART": "TABLEAU DES TAUX D'INTÉRÊT",
"Id": "Identifiant",
@@ -2083,6 +2087,7 @@
"Minimum Value": "Valeur minimum",
"Minimum days between disbursal and first repayment date": "Jours minimum entre le décaissement et la première date de remboursement",
"Minimum gap between Installments": "Écart minimum entre les versements",
"Minute": "Minute",
"Mobile": "Mobile",
"Mobile Banking App": "Application bancaire mobile",
"Mobile No": "Non de portable",
+2
View File
@@ -1848,6 +1848,7 @@
"Hook Template": "Modello di gancio",
"Host": "Ospite",
"Host Name": "Nome host",
"Hour": "Ora",
"ID": "ID",
"INTEREST RATE CHART": "GRAFICO DEL TASSO DI INTERESSE",
"Id": "Id",
@@ -2082,6 +2083,7 @@
"Minimum Value": "Valore minimo",
"Minimum days between disbursal and first repayment date": "Giorni minimi tra la data di erogazione e la data del primo rimborso",
"Minimum gap between Installments": "Intervallo minimo tra le rate",
"Minute": "Minuto",
"Mobile": "Mobile",
"Mobile Banking App": "Applicazione bancaria mobile",
"Mobile No": "Cellulare n",
+2
View File
@@ -1850,6 +1850,7 @@
"Hook Template": "후크 템플릿",
"Host": "주인",
"Host Name": "호스트 이름",
"Hour": "시",
"ID": "ID",
"INTEREST RATE CHART": "금리 차트",
"Id": "ID",
@@ -2084,6 +2085,7 @@
"Minimum Value": "최소값",
"Minimum days between disbursal and first repayment date": "지불 날짜와 첫 번째 상환 날짜 사이의 최소 일수",
"Minimum gap between Installments": "할부 간 최소 간격",
"Minute": "분",
"Mobile": "이동하는",
"Mobile Banking App": "모바일 뱅킹 앱",
"Mobile No": "모바일 번호",
+2
View File
@@ -1847,6 +1847,7 @@
"Hook Template": "Kabliuko šablonas",
"Host": "Šeimininkas",
"Host Name": "Pagrindinio kompiuterio pavadinimas",
"Hour": "Valanda",
"ID": "ID",
"INTEREST RATE CHART": "PALŪKANŲ NORMOS SCHEMA",
"Id": "Id",
@@ -2081,6 +2082,7 @@
"Minimum Value": "Minimali vertė",
"Minimum days between disbursal and first repayment date": "Minimalios dienos nuo išmokėjimo iki pirmojo grąžinimo datos",
"Minimum gap between Installments": "Minimalus tarpas tarp įmokų",
"Minute": "Minutė",
"Mobile": "Mobilusis",
"Mobile Banking App": "Mobiliosios bankininkystės programa",
"Mobile No": "Mobiliojo Nr",
+2
View File
@@ -1849,6 +1849,7 @@
"Hook Template": "Āķa veidne",
"Host": "Uzņēmēja",
"Host Name": "Saimniekdatora nosaukums",
"Hour": "Stunda",
"ID": "ID",
"INTEREST RATE CHART": "PROCENTU LIKMJU grafiks",
"Id": "Id",
@@ -2083,6 +2084,7 @@
"Minimum Value": "Minimālā vērtība",
"Minimum days between disbursal and first repayment date": "Minimālās dienas starp izmaksu un pirmo atmaksas datumu",
"Minimum gap between Installments": "Minimālā starpība starp iemaksām",
"Minute": "Minūte",
"Mobile": "Mobilais",
"Mobile Banking App": "Mobilās bankas lietotne",
"Mobile No": "Mobilais Nr",
+2
View File
@@ -1847,6 +1847,7 @@
"Hook Template": "हुक टेम्प्लेट",
"Host": "होस्ट",
"Host Name": "होस्ट नाम",
"Hour": "घण्टा",
"ID": "ID",
"INTEREST RATE CHART": "ब्याज दर चार्ट",
"Id": "आईडी",
@@ -2081,6 +2082,7 @@
"Minimum Value": "न्यूनतम मान",
"Minimum days between disbursal and first repayment date": "भुक्तानी र पहिलो भुक्तानी मिति बीचको न्यूनतम दिन",
"Minimum gap between Installments": "किस्ताहरू बीचको न्यूनतम अन्तर",
"Minute": "मिनेट",
"Mobile": "मोबाइल",
"Mobile Banking App": "मोबाइल बैंकिङ एप",
"Mobile No": "मोबाइल नम्बर",
+2
View File
@@ -1848,6 +1848,7 @@
"Hook Template": "Modelo de gancho",
"Host": "Hospedar",
"Host Name": "Nome de anfitrião",
"Hour": "Hora",
"ID": "EU IA",
"INTEREST RATE CHART": "TABELA DE TAXAS DE JUROS",
"Id": "Eu ia",
@@ -2082,6 +2083,7 @@
"Minimum Value": "Valor Mínimo",
"Minimum days between disbursal and first repayment date": "Dias mínimos entre o desembolso e a data do primeiro reembolso",
"Minimum gap between Installments": "Intervalo mínimo entre parcelas",
"Minute": "Minuto",
"Mobile": "Móvel",
"Mobile Banking App": "Aplicativo de banco móvel",
"Mobile No": "Celular Não",
+2
View File
@@ -1846,6 +1846,7 @@
"Hook Template": "Kigezo cha ndoano",
"Host": "Mwenyeji",
"Host Name": "Jina la mwenyeji",
"Hour": "Saa",
"ID": "ID",
"INTEREST RATE CHART": "CHATI YA KIWANGO CHA RIBA",
"Id": "Kitambulisho",
@@ -2080,6 +2081,7 @@
"Minimum Value": "Thamani ya chini",
"Minimum days between disbursal and first repayment date": "Siku chache kati ya malipo na tarehe ya malipo ya kwanza",
"Minimum gap between Installments": "Pengo la chini kati ya Mikopo",
"Minute": "Dakika",
"Mobile": "Rununu",
"Mobile Banking App": "Programu ya Benki ya Simu ya Mkononi",
"Mobile No": "Simu No",