import { Component, ChangeDetectionStrategy, ChangeDetectorRef, inject, signal, effect, } from '@angular/core'; import { RuleService, RuleType, IfThenRule, TimeWindowRule, OptimizationRule, VacationRule, PlacementStrategy, } from '../../services/rule-service'; import { SelectableDayType } from '../../services/calendar-service'; import { TranslationService } from '../../services/translation-service'; import { MatExpansionModule } from '@angular/material/expansion'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { FormsModule } from '@angular/forms'; import { NgTemplateOutlet } from '@angular/common'; import { LiveAnnouncer } from '@angular/cdk/a11y'; import { OPTIMIZATION_CATALOG, OPTIMIZATION_PRESETS, validateOptimizationConfiguration, type OptimizationElements, type OptimizationMeasure, } from '../../services/planning-objectives'; import { DateAdapter } from '@angular/material/core'; import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter'; import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop'; @Component({ selector: 'app-rules-panel', imports: [ MatExpansionModule, MatIconModule, MatButtonModule, MatSlideToggleModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatDatepickerModule, FormsModule, NgTemplateOutlet, CdkDropList, CdkDrag, CdkDragHandle, ], templateUrl: './rules-panel.html', styleUrl: './rules-panel.scss', changeDetection: ChangeDetectionStrategy.OnPush, providers: DAY_FIRST_DATE_PROVIDERS, }) export class RulesPanel { public ruleService = inject(RuleService); protected readonly translationService = inject(TranslationService); private readonly dateAdapter = inject(DateAdapter); private readonly liveAnnouncer = inject(LiveAnnouncer); private readonly changeDetector = inject(ChangeDetectorRef); get tr() { return this.translationService.tr; } constructor() { effect(() => { this.dateAdapter.setLocale(this.translationService.locale()); }); } protected readonly RuleType = RuleType; protected readonly placeDayTypes = Object.values(SelectableDayType); protected readonly rewardDayTypes = Object.values(SelectableDayType).filter( (t) => t !== SelectableDayType.ANY, ); protected get forceOptions() { const preference = { value: 'preference' as const, label: () => this.tr().rules.preference, }; return this.editBuffer.type === RuleType.OPTIMIZATION ? [preference] : [ { value: 'obligation' as const, label: () => this.tr().rules.obligation }, preference, ]; } protected readonly optimizationElements = [ ...new Set(OPTIMIZATION_CATALOG.map((row) => row.elements)), ]; protected get optimizationConfiguration() { return this.editBuffer.type === RuleType.OPTIMIZATION ? this.editBuffer.configuration : undefined; } protected get optimizationMeasures() { return OPTIMIZATION_CATALOG.filter( (row) => row.elements === this.optimizationConfiguration?.elements, ); } protected get optimizationConditions() { return ( this.optimizationMeasures.find( (row) => row.measure === this.optimizationConfiguration?.measure, )?.conditions ?? [] ); } protected readonly optimizationAggregations = ['sum', 'count', 'maximum'] as const; protected readonly optimizationDirections = ['minimize', 'maximize'] as const; protected validationError = signal(''); public changeOptimizationElements(elements: OptimizationElements) { this.changeOptimizationMeasure( OPTIMIZATION_CATALOG.find((row) => row.elements === elements)!.measure, ); } public changeOptimizationMeasure(measure: OptimizationMeasure) { if (this.editBuffer.type !== RuleType.OPTIMIZATION) return; const row = OPTIMIZATION_CATALOG.find((item) => item.measure === measure)!; const previous = this.editBuffer.configuration; const conditions: readonly string[] = row.conditions; this.editBuffer.configuration = { elements: row.elements, conditions: previous?.conditions.filter((condition) => conditions.includes(condition)) ?? [], aggregation: previous?.aggregation ?? 'sum', direction: previous?.direction ?? 'minimize', ...(measure === 'work-sequence-deficit' ? { measure, thresholdDays: previous?.measure === measure ? previous.thresholdDays : 5, } : { measure }), }; } protected get daysOfWeek() { const tr = this.tr(); return [ { value: 1, label: tr.rules.daysOfWeek[1] }, { value: 2, label: tr.rules.daysOfWeek[2] }, { value: 3, label: tr.rules.daysOfWeek[3] }, { value: 4, label: tr.rules.daysOfWeek[4] }, { value: 5, label: tr.rules.daysOfWeek[5] }, { value: 6, label: tr.rules.daysOfWeek[6] }, { value: 0, label: tr.rules.daysOfWeek[0] }, ]; } protected get placementOptions(): { value: PlacementStrategy; label: string }[] { const tr = this.tr(); return [ { value: 'before', label: tr.rules.placement.before }, { value: 'after', label: tr.rules.placement.after }, { value: 'around', label: tr.rules.placement.around }, { value: 'auto', label: tr.rules.placement.auto }, ]; } protected editingRuleId = signal(null); protected isAddingNew = signal(false); protected newRuleType = signal(RuleType.IF_THEN); protected editBuffer: Partial = {}; public get rules() { return this.ruleService.rules; } protected get activeRuleCount(): number { return this.rules.filter((rule) => rule.enabled).length; } public drop(event: CdkDragDrop) { if (event.isPointerOverContainer && event.previousIndex !== event.currentIndex) { this.reorderRule(event.previousIndex, event.currentIndex); } // CDK restores the original DOM before this callback. Commit the final order // before the next paint so zoneless rendering doesn't flash the old position. this.changeDetector.detectChanges(); } public moveRule(index: number, offset: -1 | 1, event: Event) { event.stopPropagation(); const trigger = event.currentTarget; const destination = index + offset; if (destination >= 0 && destination < this.rules.length) { this.reorderRule(index, destination); this.changeDetector.detectChanges(); if (trigger instanceof HTMLButtonElement) { const next = trigger.disabled ? trigger.parentElement?.querySelector( 'button:not([disabled])', ) : trigger; next?.focus(); } } } private reorderRule(from: number, to: number) { const rule = this.rules[from]; this.ruleService.reorderRules(from, to); void this.liveAnnouncer.announce( this.tr() .rules.ruleMoved.replace('{name}', rule.name) .replace('{position}', String(to + 1)) .replace('{count}', String(this.rules.length)), ); } public toggleRule(id: string) { this.ruleService.toggleRule(id); } public startEdit(rule: VacationRule, event: Event) { event.stopPropagation(); this.editingRuleId.set(rule.id); this.isAddingNew.set(false); this.editBuffer = structuredClone(rule); this.validationError.set(''); } public cancelEdit() { this.editingRuleId.set(null); this.isAddingNew.set(false); this.editBuffer = {}; this.validationError.set(''); } public saveEdit() { if (!this.editBuffer.name?.trim()) { this.validationError.set(this.tr().rules.optimization.nameRequired); return; } if (this.editBuffer.type === RuleType.OPTIMIZATION) { if (!validateOptimizationConfiguration(this.editBuffer.configuration).valid) { this.validationError.set(this.tr().rules.optimization.invalidConfiguration); return; } this.editBuffer.force = 'preference'; } if (this.editBuffer.id) { this.ruleService.updateRule(this.editBuffer as VacationRule); } else { this.ruleService.addRule({ ...this.editBuffer, id: crypto.randomUUID(), enabled: true, } as VacationRule); } this.cancelEdit(); } public deleteRule(id: string, event: Event) { event.stopPropagation(); this.ruleService.deleteRule(id); } public startAddNew(type: RuleType) { this.isAddingNew.set(true); this.editingRuleId.set(null); this.newRuleType.set(type); this.validationError.set(''); if (type === RuleType.IF_THEN) { this.editBuffer = { name: this.tr().rules.defaultNewRuleName, type: RuleType.IF_THEN, force: 'preference', dayOfWeek: 6, placeDays: 2, placeType: SelectableDayType.CP, placement: 'auto', rewardDays: 1, rewardType: SelectableDayType.OTHER, } as Partial; } else if (type === RuleType.OPTIMIZATION) { this.editBuffer = { name: this.tr().rules.optimization.defaultName, type, force: 'preference', configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk), } satisfies Partial; } else { this.editBuffer = { name: this.tr().rules.defaultNewPeriodName, type: RuleType.TIME_WINDOW, force: 'preference', startDate: new Date(), endDate: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000), placeDays: 5, placeType: SelectableDayType.CP, } as Partial; } } protected getDayTypeLabel(type: string | undefined | null): string { if (!type) return ''; return this.translationService.getDayTypeLabel(type); } public getRuleSummary(rule: VacationRule): string { const tr = this.tr(); if (rule.type === RuleType.OPTIMIZATION) return this.translationService.optimizationSummary(rule.configuration); if (rule.type === RuleType.IF_THEN) { const ifThen = rule as IfThenRule; const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? ''; const placeTypeLabel = this.getDayTypeLabel(ifThen.placeType); const placementLabel = ( this.placementOptions.find((p) => p.value === ifThen.placement)?.label ?? '' ) .trim() .toLowerCase(); const rewardTypeLabel = this.getDayTypeLabel(ifThen.rewardType); return tr.rules.ifThenTemplate .replace('{day}', dayLabel) .replace('{placeDays}', String(ifThen.placeDays)) .replace('{placeType}', placeTypeLabel) .replace('{placement}', placementLabel) .replace('{rewardDays}', String(ifThen.rewardDays)) .replace('{rewardType}', rewardTypeLabel); } else { const timeWindow = rule as TimeWindowRule; const placeTypeLabel = this.getDayTypeLabel(timeWindow.placeType); const startDateStr = new Date(timeWindow.startDate).toLocaleDateString( this.translationService.locale(), ); const endDateStr = new Date(timeWindow.endDate).toLocaleDateString( this.translationService.locale(), ); return tr.rules.timeWindowTemplate .replace('{placeDays}', String(timeWindow.placeDays)) .replace('{placeType}', placeTypeLabel) .replace('{startDate}', startDateStr) .replace('{endDate}', endDateStr); } } }