Files
vac-optimizer/src/app/components/rules-panel/rules-panel.ts
T
nessar 6811142299 feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
2026-10-10 16:07:34 +02:00

356 lines
11 KiB
TypeScript

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<string | null>(null);
protected isAddingNew = signal(false);
protected newRuleType = signal<RuleType>(RuleType.IF_THEN);
protected editBuffer: Partial<VacationRule> = {};
public get rules() {
return this.ruleService.rules;
}
protected get activeRuleCount(): number {
return this.rules.filter((rule) => rule.enabled).length;
}
public drop(event: CdkDragDrop<VacationRule[]>) {
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<HTMLButtonElement>(
'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<IfThenRule>;
} else if (type === RuleType.OPTIMIZATION) {
this.editBuffer = {
name: this.tr().rules.optimization.defaultName,
type,
force: 'preference',
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
} satisfies Partial<OptimizationRule>;
} 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<TimeWindowRule>;
}
}
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);
}
}
}