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
356 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|