Files
vac-optimizer/src/app/components/rules-panel/rules-panel.html
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

510 lines
16 KiB
HTML

<mat-expansion-panel class="rules-panel-container mat-elevation-z2">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon aria-hidden="true">rule</mat-icon>
<span>{{ tr().rules.title }}</span>
<span class="rule-count">{{ activeRuleCount }} {{ tr().rules.active }}</span>
</mat-panel-title>
<mat-panel-description> {{ tr().rules.subtitle }} </mat-panel-description>
</mat-expansion-panel-header>
<div class="priority-statement">
<mat-icon aria-hidden="true">info_outline</mat-icon>
<span>{{ tr().rules.priorityInfo }}</span>
</div>
<div class="rules-list" cdkDropList (cdkDropListDropped)="drop($event)">
@for (rule of rules; track rule.id; let i = $index) {
<div
class="rule-item"
cdkDrag
cdkDragLockAxis="y"
[cdkDragStartDelay]="{ touch: 180, mouse: 0 }"
[cdkDragDisabled]="editingRuleId() === rule.id"
[class.editing]="editingRuleId() === rule.id"
>
@if (editingRuleId() !== rule.id) {
<div class="rule-display">
<div class="rule-header">
<div
class="rule-drag-grip"
cdkDragHandle
[title]="tr().rules.dragRule"
aria-hidden="true"
>
<mat-icon>drag_indicator</mat-icon>
</div>
<mat-slide-toggle
[checked]="rule.enabled"
(change)="toggleRule(rule.id)"
color="primary"
>
<span class="rule-name"
><span class="rule-number">{{ i + 1 }}.</span>
{{ rule.name }}</span
>
</mat-slide-toggle>
</div>
<div class="rule-summary">
<span
class="rule-force"
[class.obligation]="rule.force === 'obligation'"
>
{{
rule.force === 'obligation'
? tr().rules.obligation
: tr().rules.preference
}}
</span>
<span [innerHTML]="getRuleSummary(rule)"></span>
</div>
<div class="rule-actions">
<button
mat-icon-button
[disabled]="i === 0"
[aria-label]="tr().rules.moveRuleUp"
(click)="moveRule(i, -1, $event)"
>
<mat-icon aria-hidden="true">keyboard_arrow_up</mat-icon>
</button>
<button
mat-icon-button
[disabled]="i === rules.length - 1"
[aria-label]="tr().rules.moveRuleDown"
(click)="moveRule(i, 1, $event)"
>
<mat-icon aria-hidden="true">keyboard_arrow_down</mat-icon>
</button>
<button
mat-icon-button
[aria-label]="tr().rules.editRule"
(click)="startEdit(rule, $event)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
[aria-label]="tr().rules.deleteRule"
(click)="deleteRule(rule.id, $event)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
} @else {
<div class="rule-editor">
<h4>{{ tr().rules.editRule }}</h4>
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.ruleName }}</mat-label>
<input matInput [(ngModel)]="$any(editBuffer).name" required />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.force }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).force">
@for (option of forceOptions; track option.value) {
<mat-option [value]="option.value">{{
option.label()
}}</mat-option>
}
</mat-select>
</mat-form-field>
@if ($any(editBuffer).type === RuleType.IF_THEN) {
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.holidayFalls }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).dayOfWeek">
@for (day of daysOfWeek; track day.value) {
<mat-option [value]="day.value">{{
day.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">{{ tr().rules.place }}</span>
<mat-form-field class="small-input">
<mat-label>{{ tr().rules.days }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).placeDays"
min="1"
/>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.type }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placeType">
@for (type of placeDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.position }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placement">
@for (opt of placementOptions; track opt.value) {
<mat-option [value]="opt.value">{{
opt.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">{{ tr().rules.getBonus }}</span>
<mat-form-field class="small-input">
<mat-label>{{ tr().rules.days }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).rewardDays"
min="0"
/>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.type }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).rewardType">
@for (type of rewardDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
} @else if ($any(editBuffer).type === RuleType.OPTIMIZATION) {
<ng-container [ngTemplateOutlet]="optimizationEditor" />
} @else {
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.startDate }}</mat-label>
<input
matInput
[matDatepicker]="startPicker"
[(ngModel)]="$any(editBuffer).startDate"
/>
<mat-datepicker-toggle
matSuffix
[for]="startPicker"
></mat-datepicker-toggle>
<mat-datepicker #startPicker></mat-datepicker>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.endDate }}</mat-label>
<input
matInput
[matDatepicker]="endPicker"
[(ngModel)]="$any(editBuffer).endDate"
/>
<mat-datepicker-toggle
matSuffix
[for]="endPicker"
></mat-datepicker-toggle>
<mat-datepicker #endPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">{{ tr().rules.place }}</span>
<mat-form-field class="small-input">
<mat-label>{{ tr().rules.days }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).placeDays"
min="1"
/>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.type }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placeType">
@for (type of placeDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
@if (validationError()) {
<p role="alert">{{ validationError() }}</p>
}
<div class="editor-actions">
<button mat-button (click)="cancelEdit()">
{{ tr().rules.cancel }}
</button>
<button mat-flat-button color="primary" (click)="saveEdit()">
{{ tr().rules.save }}
</button>
</div>
</div>
}
</div>
}
@if (isAddingNew()) {
<div class="rule-item editing">
<div class="rule-editor">
<h4>{{ tr().rules.addRule }}</h4>
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.ruleName }}</mat-label>
<input matInput [(ngModel)]="$any(editBuffer).name" required />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.force }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).force">
@for (option of forceOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label() }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (newRuleType() === RuleType.IF_THEN) {
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.holidayFalls }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).dayOfWeek">
@for (day of daysOfWeek; track day.value) {
<mat-option [value]="day.value">{{ day.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">{{ tr().rules.place }}</span>
<mat-form-field class="small-input">
<mat-label>{{ tr().rules.days }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).placeDays"
min="1"
/>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.type }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placeType">
@for (type of placeDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.position }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placement">
@for (opt of placementOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">{{ tr().rules.getBonus }}</span>
<mat-form-field class="small-input">
<mat-label>{{ tr().rules.days }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).rewardDays"
min="0"
/>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.type }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).rewardType">
@for (type of rewardDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
} @else if ($any(editBuffer).type === RuleType.OPTIMIZATION) {
<ng-container [ngTemplateOutlet]="optimizationEditor" />
} @else {
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.startDate }}</mat-label>
<input
matInput
[matDatepicker]="startPicker"
[(ngModel)]="$any(editBuffer).startDate"
/>
<mat-datepicker-toggle
matSuffix
[for]="startPicker"
></mat-datepicker-toggle>
<mat-datepicker #startPicker></mat-datepicker>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.endDate }}</mat-label>
<input
matInput
[matDatepicker]="endPicker"
[(ngModel)]="$any(editBuffer).endDate"
/>
<mat-datepicker-toggle
matSuffix
[for]="endPicker"
></mat-datepicker-toggle>
<mat-datepicker #endPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="editor-row">
<span class="action-text">{{ tr().rules.place }}</span>
<mat-form-field class="small-input">
<mat-label>{{ tr().rules.days }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="$any(editBuffer).placeDays"
min="1"
/>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.type }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).placeType">
@for (type of placeDayTypes; track type) {
<mat-option [value]="type">{{
getDayTypeLabel(type)
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
@if (validationError()) {
<p role="alert">{{ validationError() }}</p>
}
<div class="editor-actions">
<button mat-button (click)="cancelEdit()">{{ tr().rules.cancel }}</button>
<button mat-flat-button color="primary" (click)="saveEdit()">
{{ tr().rules.add }}
</button>
</div>
</div>
</div>
}
<div class="add-buttons" [class.hidden]="isAddingNew() || editingRuleId()">
<button mat-stroked-button (click)="startAddNew(RuleType.IF_THEN)">
<mat-icon>add</mat-icon> {{ tr().rules.newIfThenRule }}
</button>
<button mat-stroked-button (click)="startAddNew(RuleType.TIME_WINDOW)">
<mat-icon>add</mat-icon> {{ tr().rules.newTimeWindowRule }}
</button>
<button mat-stroked-button (click)="startAddNew(RuleType.OPTIMIZATION)">
<mat-icon aria-hidden="true">add</mat-icon> {{ tr().rules.optimization.newRule }}
</button>
</div>
</div>
</mat-expansion-panel>
<ng-template #optimizationEditor>
@if (optimizationConfiguration; as configuration) {
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.optimization.elementsLabel }}</mat-label>
<mat-select
[ngModel]="configuration.elements"
(ngModelChange)="changeOptimizationElements($event)"
>
@for (elements of optimizationElements; track elements) {
<mat-option [value]="elements">{{
tr().rules.optimization.elements[elements]
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.optimization.measureLabel }}</mat-label>
<mat-select
[ngModel]="configuration.measure"
(ngModelChange)="changeOptimizationMeasure($event)"
>
@for (row of optimizationMeasures; track row.measure) {
<mat-option [value]="row.measure">{{
tr().rules.optimization.measures[row.measure]
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (optimizationConditions.length) {
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.optimization.conditionsLabel }}</mat-label>
<mat-select multiple [(ngModel)]="configuration.conditions">
@for (condition of optimizationConditions; track condition) {
<mat-option [value]="condition">{{
tr().rules.optimization.conditions[condition]
}}</mat-option>
}
</mat-select>
</mat-form-field>
}
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.optimization.aggregationLabel }}</mat-label>
<mat-select [(ngModel)]="configuration.aggregation">
@for (aggregation of optimizationAggregations; track aggregation) {
<mat-option [value]="aggregation">{{
tr().rules.optimization.aggregations[aggregation]
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.optimization.directionLabel }}</mat-label>
<mat-select [(ngModel)]="configuration.direction">
@for (direction of optimizationDirections; track direction) {
<mat-option [value]="direction">{{
tr().rules.optimization.directions[direction]
}}</mat-option>
}
</mat-select>
</mat-form-field>
@if (configuration.measure === 'work-sequence-deficit') {
<mat-form-field>
<mat-label>{{ tr().rules.optimization.thresholdLabel }}</mat-label>
<input
matInput
type="number"
min="1"
step="1"
required
[(ngModel)]="configuration.thresholdDays"
/>
</mat-form-field>
}
</div>
}
</ng-template>