feat: implement rules engine and management panel for custom holiday optimization
This commit is contained in:
1 parent
c0549de4dd
commit
08ee84e5c9
20 files changed
+3951
-16847
No files matched your search
Generated
-16599
File diff suppressed because it is too large.
Load diff
+24
-24
@@ -27,46 +27,46 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "21.2.10",
|
||||
"@angular/cdk": "^21.2.8",
|
||||
"@angular/common": "21.2.10",
|
||||
"@angular/compiler": "21.2.10",
|
||||
"@angular/core": "21.2.10",
|
||||
"@angular/forms": "21.2.10",
|
||||
"@angular/material": "^21.2.8",
|
||||
"@angular/platform-browser": "21.2.10",
|
||||
"@angular/platform-browser-dynamic": "21.2.10",
|
||||
"@angular/platform-server": "21.2.10",
|
||||
"@angular/router": "21.2.10",
|
||||
"@angular/ssr": "21.2.8",
|
||||
"date-holidays": "^3.27.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"@angular/animations": "21.2.15",
|
||||
"@angular/cdk": "^21.2.13",
|
||||
"@angular/common": "21.2.15",
|
||||
"@angular/compiler": "21.2.15",
|
||||
"@angular/core": "21.2.15",
|
||||
"@angular/forms": "21.2.15",
|
||||
"@angular/material": "^21.2.13",
|
||||
"@angular/platform-browser": "21.2.15",
|
||||
"@angular/platform-browser-dynamic": "21.2.15",
|
||||
"@angular/platform-server": "21.2.15",
|
||||
"@angular/router": "21.2.15",
|
||||
"@angular/ssr": "21.2.13",
|
||||
"date-holidays": "^3.30.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"express": "^5.2.1",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "21.2.8",
|
||||
"@angular/cli": "21.2.8",
|
||||
"@angular/compiler-cli": "21.2.10",
|
||||
"@angular/build": "21.2.13",
|
||||
"@angular/cli": "21.2.13",
|
||||
"@angular/compiler-cli": "21.2.15",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jasmine": "~6.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"eslint": "^10.2.1",
|
||||
"@types/node": "^25.9.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"jasmine-core": "~6.2.0",
|
||||
"jest": "^30.3.0",
|
||||
"jest-preset-angular": "^16.1.4",
|
||||
"jest": "^30.4.2",
|
||||
"jest-preset-angular": "^16.1.5",
|
||||
"karma": "~6.4.4",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.1",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.2.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.59.1"
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.60.0"
|
||||
}
|
||||
}
|
||||
+41
-13
@@ -9,14 +9,52 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
app-user-input {
|
||||
display: block;
|
||||
flex: 3;
|
||||
min-width: 350px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
app-calendar-year {
|
||||
display: block;
|
||||
flex: 7;
|
||||
min-width: 700px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
app-user-input,
|
||||
app-calendar-year {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
app-user-input {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mat-divider-vertical {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-divider-horizontal {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +65,6 @@
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mat-divider-vertical {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-divider-horizontal {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-divider-vertical {
|
||||
height: 80vh;
|
||||
width: 1px;
|
||||
|
||||
@@ -5,16 +5,11 @@
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
max-width: 65vw;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.calendar-year {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -5,8 +5,8 @@ import { CalendarMonth } from '../calendar-month/calendar-month';
|
||||
import { CalendarService, SelectedDates } from '../../services/calendar-service';
|
||||
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { DateCacheService } from '../../services/date-cache.service';
|
||||
import { RuleService } from '../../services/rule-service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-year',
|
||||
@@ -18,7 +18,7 @@ export class CalendarYear {
|
||||
private dateCache = inject(DateCacheService);
|
||||
private userInput = inject(UserInputService);
|
||||
private calendarService = inject(CalendarService);
|
||||
private calendarSettingsService = inject(CalendarSettingsService);
|
||||
private ruleService = inject(RuleService);
|
||||
|
||||
protected isLoading = signal(true);
|
||||
protected loadingProgress = signal(0);
|
||||
@@ -38,9 +38,11 @@ export class CalendarYear {
|
||||
|
||||
this.selectedDates = this.calendarService.getSelectedDatesForYear();
|
||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||
const c = untracked(() => this.calendarSettingsService.get('samediMalin')());
|
||||
console.log('Samedi Malin setting:', c);
|
||||
const currentRules = untracked(() => this.ruleService.rules);
|
||||
|
||||
console.log('Optimizing vacations with data:', vacationData);
|
||||
console.log('Using rules:', currentRules);
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
// Defer execution to allow UI to update
|
||||
@@ -51,7 +53,7 @@ export class CalendarYear {
|
||||
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
|
||||
const remainingDays = await this.selectedDates.optimizeVacations(
|
||||
vacationDaysCopy,
|
||||
this.calendarSettingsService,
|
||||
currentRules,
|
||||
this.calendarService,
|
||||
(progress: number) => {
|
||||
this.loadingProgress.set(progress);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<mat-expansion-panel class="rules-panel-container mat-elevation-z2">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>rule</mat-icon>
|
||||
Règles Personnalisées
|
||||
</mat-panel-title>
|
||||
<mat-panel-description> Gérer les règles d'optimisation </mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="priority-statement">
|
||||
<mat-icon color="primary">info</mat-icon>
|
||||
<span>L'ordre des règles définit leur priorité d'exécution (de haut en bas). Glissez-déposez pour réorganiser.</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 [cdkDragDisabled]="editingRuleId() === rule.id" [class.editing]="editingRuleId() === rule.id">
|
||||
@if (editingRuleId() !== rule.id) {
|
||||
<div class="rule-drag-grip">
|
||||
<mat-icon class="drag-handle-icon">drag_handle</mat-icon>
|
||||
</div>
|
||||
<div class="rule-display">
|
||||
<div class="rule-header">
|
||||
<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 class="rule-actions">
|
||||
<button mat-icon-button (click)="startEdit(rule, $event)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="deleteRule(rule.id, $event)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-summary">
|
||||
@if (rule.type === RuleType.IF_THEN) {
|
||||
<span>
|
||||
Si un jour férié tombe un
|
||||
<strong>{{
|
||||
daysOfWeek[rule.dayOfWeek > 0 ? rule.dayOfWeek - 1 : 6]
|
||||
.label
|
||||
}}</strong
|
||||
>, placer <strong>{{ rule.placeDays }}</strong> jour(s)
|
||||
<strong>{{ getDayTypeLabel(rule.placeType) }}</strong>
|
||||
<strong>
|
||||
{{
|
||||
placementOptions.find((p) => p.value === rule.placement)
|
||||
?.label | lowercase
|
||||
}}</strong
|
||||
>
|
||||
et obtenir <strong>{{ rule.rewardDays }}</strong> jour(s)
|
||||
<strong>{{ getDayTypeLabel(rule.rewardType) }}</strong> en
|
||||
bonus.
|
||||
</span>
|
||||
} @else if (rule.type === RuleType.TIME_WINDOW) {
|
||||
<span>
|
||||
Placer <strong>{{ rule.placeDays }}</strong> jour(s)
|
||||
<strong>{{ getDayTypeLabel(rule.placeType) }}</strong> entre
|
||||
le <strong>{{ rule.startDate | date: 'dd/MM/yyyy' }}</strong> et
|
||||
le <strong>{{ rule.endDate | date: 'dd/MM/yyyy' }}</strong
|
||||
>.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rule-editor">
|
||||
<h4>Modifier la règle</h4>
|
||||
|
||||
<mat-form-field class="full-width">
|
||||
<mat-label>Nom de la règle</mat-label>
|
||||
<input matInput [(ngModel)]="$any(editBuffer).name" required />
|
||||
</mat-form-field>
|
||||
|
||||
@if ($any(editBuffer).type === RuleType.IF_THEN) {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>Jour férié tombe un</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">Placer</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>Jours</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).placeDays"
|
||||
min="1"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>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>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">Obtenir en bonus</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>Jours</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).rewardDays"
|
||||
min="0"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>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 {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>Date de début</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>Date de fin</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">Placer</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>Jours</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).placeDays"
|
||||
min="1"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>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>
|
||||
}
|
||||
|
||||
<div class="editor-actions">
|
||||
<button mat-button (click)="cancelEdit()">Annuler</button>
|
||||
<button mat-flat-button color="primary" (click)="saveEdit()">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isAddingNew()) {
|
||||
<div class="rule-item editing">
|
||||
<div class="rule-editor">
|
||||
<h4>Ajouter une règle</h4>
|
||||
|
||||
<mat-form-field class="full-width">
|
||||
<mat-label>Nom de la règle</mat-label>
|
||||
<input matInput [(ngModel)]="$any(editBuffer).name" required />
|
||||
</mat-form-field>
|
||||
|
||||
@if (newRuleType() === RuleType.IF_THEN) {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>Jour férié tombe un</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">Placer</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>Jours</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).placeDays"
|
||||
min="1"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>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>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">Obtenir en bonus</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>Jours</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).rewardDays"
|
||||
min="0"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>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 {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>Date de début</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>Date de fin</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">Placer</span>
|
||||
<mat-form-field class="small-input">
|
||||
<mat-label>Jours</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[(ngModel)]="$any(editBuffer).placeDays"
|
||||
min="1"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>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>
|
||||
}
|
||||
|
||||
<div class="editor-actions">
|
||||
<button mat-button (click)="cancelEdit()">Annuler</button>
|
||||
<button mat-flat-button color="primary" (click)="saveEdit()">
|
||||
Ajouter
|
||||
</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> Nouvelle Règle "Si ... Alors"
|
||||
</button>
|
||||
<button mat-stroked-button (click)="startAddNew(RuleType.TIME_WINDOW)">
|
||||
<mat-icon>add</mat-icon> Nouvelle Période Imposée
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
@@ -0,0 +1,217 @@
|
||||
.rules-panel-container {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
|
||||
mat-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@container (max-width: 480px) {
|
||||
mat-panel-description {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--mat-sys-surface-container);
|
||||
|
||||
&:not(.editing) {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.editing) {
|
||||
border-color: var(--mat-sys-outline);
|
||||
}
|
||||
|
||||
&.editing {
|
||||
border: 1px solid var(--mat-sys-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
|
||||
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.rules-list.cdk-drop-list-dragging .rule-item:not(.cdk-drag-placeholder) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.rule-display {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.priority-statement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-drag-grip {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
margin-bottom: -8px;
|
||||
color: var(--mat-sys-outline);
|
||||
opacity: 0.6;
|
||||
cursor: grab;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.rule-item:active & {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.rule-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.rule-number {
|
||||
color: var(--mat-sys-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.rule-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rule-summary {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
padding-left: 48px; /* Align with toggle switch text */
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-editor {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
border-radius: 8px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.action-text {
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.small-input {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { RulesPanel } from './rules-panel';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { RuleService, RuleType } from '../../services/rule-service';
|
||||
|
||||
describe('RulesPanel', () => {
|
||||
let component: RulesPanel;
|
||||
let fixture: ComponentFixture<RulesPanel>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear localStorage before testing
|
||||
localStorage.clear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RulesPanel, BrowserAnimationsModule],
|
||||
providers: [provideZonelessChangeDetection(), RuleService],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RulesPanel);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display initial rules from service', () => {
|
||||
expect(component.rules.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should start adding new If-Then rule', () => {
|
||||
component.startAddNew(RuleType.IF_THEN);
|
||||
|
||||
expect(component['isAddingNew']()).toBeTrue();
|
||||
expect(component['newRuleType']()).toBe(RuleType.IF_THEN);
|
||||
expect(component['editBuffer'].type).toBe(RuleType.IF_THEN);
|
||||
expect(component['editBuffer'].name).toBe('Nouvelle Règle');
|
||||
});
|
||||
|
||||
it('should start adding new Time Window rule', () => {
|
||||
component.startAddNew(RuleType.TIME_WINDOW);
|
||||
|
||||
expect(component['isAddingNew']()).toBeTrue();
|
||||
expect(component['newRuleType']()).toBe(RuleType.TIME_WINDOW);
|
||||
expect(component['editBuffer'].type).toBe(RuleType.TIME_WINDOW);
|
||||
expect(component['editBuffer'].name).toBe('Nouvelle Période');
|
||||
});
|
||||
|
||||
it('should cancel edit', () => {
|
||||
component.startAddNew(RuleType.IF_THEN);
|
||||
component.cancelEdit();
|
||||
|
||||
expect(component['isAddingNew']()).toBeFalse();
|
||||
expect(component['editingRuleId']()).toBeNull();
|
||||
expect(Object.keys(component['editBuffer']).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should save a new rule', () => {
|
||||
const initialCount = component.rules.length;
|
||||
|
||||
component.startAddNew(RuleType.IF_THEN);
|
||||
component['editBuffer'].name = 'Custom Test Rule';
|
||||
component.saveEdit();
|
||||
|
||||
expect(component.rules.length).toBe(initialCount + 1);
|
||||
expect(component.rules.some((r) => r.name === 'Custom Test Rule')).toBeTrue();
|
||||
});
|
||||
|
||||
it('should update an existing rule', () => {
|
||||
const targetRule = component.rules[0];
|
||||
|
||||
// Create a mock event with stopPropagation
|
||||
const mockEvent = { stopPropagation: () => {} } as Event;
|
||||
|
||||
component.startEdit(targetRule, mockEvent);
|
||||
component['editBuffer'].name = 'Updated Name';
|
||||
component.saveEdit();
|
||||
|
||||
expect(component.rules.find((r) => r.id === targetRule.id)?.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should delete a rule', () => {
|
||||
const targetId = component.rules[0].id;
|
||||
const initialCount = component.rules.length;
|
||||
|
||||
const mockEvent = { stopPropagation: () => {} } as Event;
|
||||
component.deleteRule(targetId, mockEvent);
|
||||
|
||||
expect(component.rules.length).toBe(initialCount - 1);
|
||||
expect(component.rules.some((r) => r.id === targetId)).toBeFalse();
|
||||
});
|
||||
|
||||
it('should toggle a rule', () => {
|
||||
const targetRule = component.rules[0];
|
||||
const initialState = targetRule.enabled;
|
||||
|
||||
component.toggleRule(targetRule.id);
|
||||
|
||||
expect(component.rules.find((r) => r.id === targetRule.id)?.enabled).toBe(!initialState);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
|
||||
import {
|
||||
RuleService,
|
||||
RuleType,
|
||||
IfThenRule,
|
||||
TimeWindowRule,
|
||||
VacationRule,
|
||||
PlacementStrategy,
|
||||
} from '../../services/rule-service';
|
||||
import { SelectableDayType, tooltipTypeMapping, DayType } from '../../services/calendar-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 { DatePipe, LowerCasePipe } from '@angular/common';
|
||||
import {
|
||||
DateAdapter,
|
||||
MAT_DATE_FORMATS,
|
||||
MAT_DATE_LOCALE,
|
||||
NativeDateAdapter,
|
||||
} from '@angular/material/core';
|
||||
import { CdkDragDrop, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
|
||||
|
||||
class CustomDateAdapter extends NativeDateAdapter {
|
||||
override format(date: Date, displayFormat: unknown): string {
|
||||
if (displayFormat === 'input') {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
return super.format(date, displayFormat as object);
|
||||
}
|
||||
}
|
||||
|
||||
const MY_DATE_FORMATS = {
|
||||
parse: { dateInput: 'input' },
|
||||
display: {
|
||||
dateInput: 'input',
|
||||
monthYearLabel: { year: 'numeric', month: 'short' },
|
||||
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||
monthYearA11yLabel: { year: 'numeric', month: 'long' },
|
||||
},
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-rules-panel',
|
||||
imports: [
|
||||
MatExpansionModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatSlideToggleModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
FormsModule,
|
||||
DatePipe,
|
||||
LowerCasePipe,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
],
|
||||
templateUrl: './rules-panel.html',
|
||||
styleUrl: './rules-panel.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{ provide: DateAdapter, useClass: CustomDateAdapter },
|
||||
{ provide: MAT_DATE_LOCALE, useValue: 'en-GB' },
|
||||
{ provide: MAT_DATE_FORMATS, useValue: MY_DATE_FORMATS },
|
||||
],
|
||||
})
|
||||
export class RulesPanel {
|
||||
public ruleService = inject(RuleService);
|
||||
|
||||
protected readonly RuleType = RuleType;
|
||||
protected readonly placeDayTypes = Object.values(SelectableDayType);
|
||||
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
|
||||
(t) => t !== SelectableDayType.ANY,
|
||||
);
|
||||
protected readonly tooltipTypeMapping = tooltipTypeMapping;
|
||||
|
||||
protected readonly daysOfWeek = [
|
||||
{ value: 1, label: 'Lundi' },
|
||||
{ value: 2, label: 'Mardi' },
|
||||
{ value: 3, label: 'Mercredi' },
|
||||
{ value: 4, label: 'Jeudi' },
|
||||
{ value: 5, label: 'Vendredi' },
|
||||
{ value: 6, label: 'Samedi' },
|
||||
{ value: 0, label: 'Dimanche' },
|
||||
];
|
||||
|
||||
protected readonly placementOptions: { value: PlacementStrategy; label: string }[] = [
|
||||
{ value: 'before', label: ' Avant' },
|
||||
{ value: 'after', label: ' Après' },
|
||||
{ value: 'around', label: ' Autour' },
|
||||
{ value: 'auto', label: ' Auto (Meilleur choix)' },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public drop(event: CdkDragDrop<VacationRule[]>) {
|
||||
if (event.previousIndex !== event.currentIndex) {
|
||||
this.ruleService.reorderRules(event.previousIndex, event.currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
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 = { ...rule };
|
||||
}
|
||||
|
||||
public cancelEdit() {
|
||||
this.editingRuleId.set(null);
|
||||
this.isAddingNew.set(false);
|
||||
this.editBuffer = {};
|
||||
}
|
||||
|
||||
public saveEdit() {
|
||||
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);
|
||||
|
||||
if (type === RuleType.IF_THEN) {
|
||||
this.editBuffer = {
|
||||
name: 'Nouvelle Règle',
|
||||
type: RuleType.IF_THEN,
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: SelectableDayType.CP,
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
} as Partial<IfThenRule>;
|
||||
} else {
|
||||
this.editBuffer = {
|
||||
name: 'Nouvelle Période',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
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 tooltipTypeMapping[type as unknown as DayType] || String(type);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
</button>
|
||||
<app-calendar-settings></app-calendar-settings>
|
||||
</div>
|
||||
<app-rules-panel></app-rules-panel>
|
||||
@if (hasRemainingDays()) {
|
||||
<mat-card class="mat-elevation-z2 warning-card">
|
||||
<mat-card-content class="warning-content">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/c
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettings } from '../calendar-settings/calendar-settings';
|
||||
import { RulesPanel } from '../rules-panel/rules-panel';
|
||||
import { MatCard, MatCardContent } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { DayInput } from '../userInput/day-input/day-input';
|
||||
@@ -13,6 +14,7 @@ import { tooltipTypeMapping, DayType, VacDay } from '../../services/calendar-ser
|
||||
imports: [
|
||||
MatInputModule,
|
||||
CalendarSettings,
|
||||
RulesPanel,
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
MatIcon,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
:host {
|
||||
display: block;
|
||||
container-type: inline-size;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -18,3 +24,49 @@
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (max-width: 480px) {
|
||||
.card {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
width: 70px;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.dayTypeInput {
|
||||
flex-grow: 1;
|
||||
width: 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.dateInput {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
flex-basis: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
button[matFab] {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
line-height: 40px;
|
||||
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,9 @@ export class DayInput {
|
||||
public dayChange = output<VacDay>();
|
||||
public deleteEntry = output<void>();
|
||||
|
||||
protected readonly selectableDayTypes = Object.values(SelectableDayType);
|
||||
protected readonly selectableDayTypes = Object.values(SelectableDayType).filter(
|
||||
(t) => t !== SelectableDayType.ANY,
|
||||
);
|
||||
protected readonly tooltipTypeMapping = tooltipTypeMapping;
|
||||
|
||||
onInput(event: Event) {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service';
|
||||
import {
|
||||
CalendarService,
|
||||
DayType,
|
||||
SelectedDate,
|
||||
SelectedDates,
|
||||
SelectableDayType,
|
||||
VacDay,
|
||||
} from './calendar-service';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
import { IfThenRule, TimeWindowRule, RuleType } from './rule-service';
|
||||
|
||||
describe('CalendarService', () => {
|
||||
let service: CalendarService;
|
||||
@@ -202,4 +210,254 @@ describe('SelectedDates', () => {
|
||||
const tooltip = selectedDates.getTooltip(date);
|
||||
expect(tooltip).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('optimizeVacations and rules engine', () => {
|
||||
let calendarService: CalendarService;
|
||||
let selectedDates: SelectedDates;
|
||||
|
||||
beforeEach(() => {
|
||||
calendarService = TestBed.inject(CalendarService);
|
||||
selectedDates = new SelectedDates();
|
||||
});
|
||||
|
||||
it('should execute rules sequentially in order', async () => {
|
||||
const vacationDays: VacDay[] = [];
|
||||
const rules: (TimeWindowRule | IfThenRule)[] = [
|
||||
{ id: '1', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
|
||||
{ id: '2', type: RuleType.TIME_WINDOW, enabled: true } as TimeWindowRule,
|
||||
{ id: '3', type: RuleType.IF_THEN, enabled: true } as IfThenRule,
|
||||
];
|
||||
|
||||
const applyTimeWindowRulesSpy = spyOn(selectedDates, 'applyTimeWindowRules');
|
||||
const applyIfThenRulesSpy = spyOn(selectedDates, 'applyIfThenRules');
|
||||
|
||||
await selectedDates.optimizeVacations(vacationDays, rules, calendarService);
|
||||
|
||||
expect(applyIfThenRulesSpy.calls.count()).toBe(2);
|
||||
expect(applyTimeWindowRulesSpy.calls.count()).toBe(1);
|
||||
|
||||
// First rule is IF_THEN
|
||||
expect(applyIfThenRulesSpy.calls.argsFor(0)[1][0].id).toBe('1');
|
||||
// Second rule is TIME_WINDOW
|
||||
expect(applyTimeWindowRulesSpy.calls.argsFor(0)[1][0].id).toBe('2');
|
||||
// Third rule is IF_THEN
|
||||
expect(applyIfThenRulesSpy.calls.argsFor(1)[1][0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('should apply time window rules', () => {
|
||||
const futureYear = new Date().getFullYear() + 2;
|
||||
const vacationDays: VacDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 5,
|
||||
expiryDate: new Date(futureYear + 1, 11, 31),
|
||||
},
|
||||
];
|
||||
const rules: TimeWindowRule[] = [
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Time Window',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
enabled: true,
|
||||
startDate: new Date(futureYear, 11, 20),
|
||||
endDate: new Date(futureYear, 11, 25),
|
||||
placeDays: 3,
|
||||
placeType: SelectableDayType.CP,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock monthsInAYearFromNow to return a controlled range of days
|
||||
spyOn(calendarService, 'monthsInAYearFromNow').and.returnValue([
|
||||
[
|
||||
[
|
||||
new Date(futureYear, 11, 20),
|
||||
new Date(futureYear, 11, 21),
|
||||
new Date(futureYear, 11, 22),
|
||||
new Date(futureYear, 11, 23),
|
||||
new Date(futureYear, 11, 24),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService);
|
||||
|
||||
// Should have placed 3 days
|
||||
const placedCount = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.CP,
|
||||
).length;
|
||||
expect(placedCount).toBe(3);
|
||||
// Should have consumed 3 days from the pool
|
||||
expect(vacationDays[0].numberOfDays).toBe(2);
|
||||
});
|
||||
|
||||
it('should apply if-then rules', () => {
|
||||
const futureYear = new Date().getFullYear() + 2;
|
||||
const vacationDays: VacDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 5,
|
||||
expiryDate: new Date(futureYear + 1, 11, 31),
|
||||
},
|
||||
];
|
||||
const rules: IfThenRule[] = [
|
||||
{
|
||||
id: 'i1',
|
||||
name: 'Samedi Malin',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 6, // Saturday
|
||||
placeDays: 2,
|
||||
placeType: SelectableDayType.CP,
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
},
|
||||
];
|
||||
|
||||
// Setup a mock holiday on a Saturday
|
||||
// Use a date far in the future so it doesn't fail the "past date" check
|
||||
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5); // Dec 5 is usually Saturday or close, let's just make sure we get a Saturday
|
||||
// To guarantee it's a Saturday for the test:
|
||||
while (saturdayHoliday.getDay() !== 6) {
|
||||
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
|
||||
}
|
||||
selectedDates._datesSelected.push(
|
||||
new SelectedDate(
|
||||
DayType.CLOSED_DAY,
|
||||
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
|
||||
),
|
||||
);
|
||||
|
||||
selectedDates.applyIfThenRules(vacationDays, rules);
|
||||
|
||||
// Should have placed 2 CP days (e.g. Thu/Fri, Mon/Tue, or Fri/Mon)
|
||||
const placedCP = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.CP,
|
||||
).length;
|
||||
expect(placedCP).toBe(2);
|
||||
|
||||
// Should have consumed 2 CP days
|
||||
expect(vacationDays[0].numberOfDays).toBe(3);
|
||||
|
||||
// Should have added 1 OTHER reward day
|
||||
const rewardDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER);
|
||||
expect(rewardDay).toBeDefined();
|
||||
expect(rewardDay?.numberOfDays).toBe(1);
|
||||
});
|
||||
|
||||
it('should apply time window rules with ANY type', () => {
|
||||
const futureYear = new Date().getFullYear() + 2;
|
||||
const vacationDays: VacDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 2,
|
||||
expiryDate: new Date(futureYear, 11, 31),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: SelectableDayType.RTT,
|
||||
numberOfDays: 2,
|
||||
expiryDate: new Date(futureYear - 1, 11, 31), // Expires earlier
|
||||
},
|
||||
];
|
||||
const rules: TimeWindowRule[] = [
|
||||
{
|
||||
id: 'tw1',
|
||||
name: 'Vacances',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
enabled: true,
|
||||
startDate: new Date(futureYear, 6, 1),
|
||||
endDate: new Date(futureYear, 6, 15),
|
||||
placeDays: 3,
|
||||
placeType: SelectableDayType.ANY,
|
||||
},
|
||||
];
|
||||
|
||||
spyOn(calendarService, 'monthsInAYearFromNow').and.returnValue([
|
||||
[
|
||||
[
|
||||
new Date(futureYear, 6, 1),
|
||||
new Date(futureYear, 6, 2),
|
||||
new Date(futureYear, 6, 3),
|
||||
new Date(futureYear, 6, 4),
|
||||
new Date(futureYear, 6, 5),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService);
|
||||
|
||||
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
|
||||
const placedRTT = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.RTT,
|
||||
).length;
|
||||
const placedCP = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.CP,
|
||||
).length;
|
||||
expect(placedRTT).toBe(2);
|
||||
expect(placedCP).toBe(1);
|
||||
|
||||
// Should have consumed 2 RTT and 1 CP
|
||||
expect(vacationDays[0].numberOfDays).toBe(1); // CP remaining
|
||||
expect(vacationDays[1].numberOfDays).toBe(0); // RTT remaining
|
||||
});
|
||||
|
||||
it('should apply if-then rules with ANY type', () => {
|
||||
const futureYear = new Date().getFullYear() + 2;
|
||||
const vacationDays: VacDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 2,
|
||||
expiryDate: new Date(futureYear + 2, 11, 31),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: SelectableDayType.RTT,
|
||||
numberOfDays: 2,
|
||||
expiryDate: new Date(futureYear + 1, 11, 31), // Expires earlier
|
||||
},
|
||||
];
|
||||
const rules: IfThenRule[] = [
|
||||
{
|
||||
id: 'i1',
|
||||
name: 'Rule',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 6, // Saturday
|
||||
placeDays: 3,
|
||||
placeType: SelectableDayType.ANY,
|
||||
placement: 'auto',
|
||||
rewardDays: 0,
|
||||
rewardType: SelectableDayType.CP,
|
||||
},
|
||||
];
|
||||
|
||||
const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5);
|
||||
while (saturdayHoliday.getDay() !== 6) {
|
||||
saturdayHoliday.setDate(saturdayHoliday.getDate() + 1);
|
||||
}
|
||||
selectedDates._datesSelected.push(
|
||||
new SelectedDate(
|
||||
DayType.CLOSED_DAY,
|
||||
new DateRange<Date>(saturdayHoliday, saturdayHoliday),
|
||||
),
|
||||
);
|
||||
|
||||
selectedDates.applyIfThenRules(vacationDays, rules);
|
||||
|
||||
// Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP
|
||||
const placedRTT = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.RTT,
|
||||
).length;
|
||||
const placedCP = selectedDates._datesSelected.filter(
|
||||
(d) => d.type === DayType.CP,
|
||||
).length;
|
||||
expect(placedRTT).toBe(2);
|
||||
expect(placedCP).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import Holidays, { HolidaysTypes } from 'date-holidays';
|
||||
import dayjs from 'dayjs';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import { CalendarSettingsService } from './calendar-settings-service';
|
||||
import { DateCacheService } from './date-cache.service';
|
||||
import { VacationRule, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
|
||||
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.extend(isoWeek);
|
||||
@@ -208,15 +208,17 @@ export enum SelectableDayType {
|
||||
CP = DayType.CP,
|
||||
RTT = DayType.RTT,
|
||||
OTHER = DayType.OTHER,
|
||||
ANY = 'any',
|
||||
}
|
||||
|
||||
export const tooltipTypeMapping: { [key in DayType]: string } = {
|
||||
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
|
||||
[DayType.WEEKEND]: 'Weekend',
|
||||
[DayType.CLOSED_DAY]: 'Jour Férié',
|
||||
[DayType.RTT]: 'RTT',
|
||||
[DayType.CP]: 'Congé Payé',
|
||||
[DayType.OTHER]: 'Autre',
|
||||
[DayType.TODAY]: "Aujourd'hui",
|
||||
[SelectableDayType.ANY]: "N'importe",
|
||||
};
|
||||
|
||||
export const monthNames: string[] = [
|
||||
@@ -545,16 +547,25 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
|
||||
async optimizeVacations(
|
||||
vacationDays: VacDay[],
|
||||
calendarSettingsService: CalendarSettingsService,
|
||||
rules: VacationRule[],
|
||||
calendarService: CalendarService,
|
||||
progressCallback?: (progress: number) => void,
|
||||
): Promise<VacDay[]> {
|
||||
if (calendarSettingsService.get('samediMalin')()) this.samediMalin(vacationDays);
|
||||
|
||||
// Calculate total days to process
|
||||
const totalDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
let processedDays = 0;
|
||||
|
||||
// Execute custom rules sequentially in the exact order they appear in the list
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
if (rule.type === RuleType.TIME_WINDOW) {
|
||||
this.applyTimeWindowRules(vacationDays, [rule as TimeWindowRule], calendarService);
|
||||
} else if (rule.type === RuleType.IF_THEN) {
|
||||
this.applyIfThenRules(vacationDays, [rule as IfThenRule]);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to update progress, throttled to every 5%
|
||||
let lastReportedProgress = 0;
|
||||
const PROGRESS_STEP = 5; // percent
|
||||
@@ -617,209 +628,306 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
return vacationDays;
|
||||
}
|
||||
|
||||
samediMalin(vacationDays: VacDay[]): void {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const nowTime = now.getTime();
|
||||
applyTimeWindowRules(
|
||||
vacationDays: VacDay[],
|
||||
rules: TimeWindowRule[],
|
||||
calendarService: CalendarService,
|
||||
): void {
|
||||
for (const rule of rules) {
|
||||
let daysToPlace = rule.placeDays;
|
||||
|
||||
const samedi_ferie = this._datesSelected.filter(
|
||||
(date) => date.type === DayType.CLOSED_DAY && date.range.start?.getDay() === 6,
|
||||
);
|
||||
const consumeDays = (count: number): boolean => {
|
||||
let needed = count;
|
||||
const availableDays = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
(rule.placeType === SelectableDayType.ANY ||
|
||||
vd.type === rule.placeType) &&
|
||||
vd.numberOfDays > 0,
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
|
||||
// Helper function to get available CP days that don't expire before a given date (ordered by expiry date)
|
||||
const getAvailableCpDaysForDate = (useByDate: Date): VacDay[] => {
|
||||
for (const vd of availableDays) {
|
||||
if (needed === 0) break;
|
||||
const take = Math.min(vd.numberOfDays, needed);
|
||||
vd.numberOfDays -= take;
|
||||
needed -= take;
|
||||
}
|
||||
return needed === 0;
|
||||
};
|
||||
|
||||
const totalAvailable = vacationDays
|
||||
.filter(
|
||||
(vd) => rule.placeType === SelectableDayType.ANY || vd.type === rule.placeType,
|
||||
)
|
||||
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
|
||||
if (totalAvailable < daysToPlace) {
|
||||
console.warn(
|
||||
`Insufficient ${rule.placeType} days for Time Window Rule "${rule.name}". Needs ${daysToPlace}, has ${totalAvailable}. Placing ${totalAvailable} days instead.`,
|
||||
);
|
||||
daysToPlace = totalAvailable;
|
||||
}
|
||||
|
||||
if (daysToPlace <= 0) continue;
|
||||
|
||||
const getNextAvailableType = (): DayType => {
|
||||
const sorted = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
(rule.placeType === SelectableDayType.ANY ||
|
||||
vd.type === rule.placeType) &&
|
||||
vd.numberOfDays > 0,
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
return sorted.length > 0
|
||||
? (sorted[0].type as unknown as DayType)
|
||||
: ((rule.placeType === SelectableDayType.ANY
|
||||
? SelectableDayType.CP
|
||||
: rule.placeType) as unknown as DayType);
|
||||
};
|
||||
|
||||
const candidates: Date[] = [];
|
||||
const daysOfYear = calendarService.monthsInAYearFromNow();
|
||||
const start = new Date(rule.startDate).getTime();
|
||||
const end = new Date(rule.endDate).getTime();
|
||||
|
||||
for (const month of daysOfYear) {
|
||||
for (const week of month) {
|
||||
for (const day of week) {
|
||||
if (day) {
|
||||
const dayTime = day.getTime();
|
||||
if (dayTime >= start && dayTime <= end && !this.isSelected(day)) {
|
||||
candidates.push(day);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < daysToPlace; i++) {
|
||||
if (candidates.length === 0) break;
|
||||
|
||||
const currentPlaceType = getNextAvailableType();
|
||||
let bestCandidate: Date | null = null;
|
||||
let bestHeuristic = -Infinity;
|
||||
let bestCandidateIndex = -1;
|
||||
|
||||
for (let j = 0; j < candidates.length; j++) {
|
||||
const day = candidates[j];
|
||||
if (this.isSelected(day)) continue;
|
||||
|
||||
const tempDatesBackup = [...this._datesSelected];
|
||||
this._datesSelected.push(
|
||||
new SelectedDate(currentPlaceType, new DateRange<Date>(day, day)),
|
||||
);
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
|
||||
const heuristic = this.computeHeuristics();
|
||||
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
|
||||
if (heuristic > bestHeuristic) {
|
||||
bestHeuristic = heuristic;
|
||||
bestCandidate = day;
|
||||
bestCandidateIndex = j;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCandidate) {
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
currentPlaceType,
|
||||
new DateRange<Date>(bestCandidate, bestCandidate),
|
||||
),
|
||||
);
|
||||
consumeDays(1);
|
||||
candidates.splice(bestCandidateIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyIfThenRules(vacationDays: VacDay[], rules: IfThenRule[]): void {
|
||||
const nowTime = new Date().setHours(0, 0, 0, 0);
|
||||
|
||||
const consumeDays = (count: number, type: SelectableDayType, useByDate: Date): boolean => {
|
||||
let daysToConsume = count;
|
||||
const useByTime = useByDate.getTime();
|
||||
return vacationDays
|
||||
const availableDays = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
vd.type === SelectableDayType.CP &&
|
||||
(type === SelectableDayType.ANY || vd.type === type) &&
|
||||
vd.numberOfDays > 0 &&
|
||||
vd.expiryDate.getTime() >= useByTime,
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
};
|
||||
|
||||
// Helper function to get total available CP days that don't expire before a given date
|
||||
const getTotalCpDaysForDate = (useByDate: Date): number => {
|
||||
const useByTime = useByDate.getTime();
|
||||
return vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
vd.type === SelectableDayType.CP && vd.expiryDate.getTime() >= useByTime,
|
||||
)
|
||||
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
};
|
||||
|
||||
// Helper function to consume 2 CP days from the available pool for a specific date
|
||||
const consume2CpDaysForDate = (useByDate: Date): boolean => {
|
||||
let daysToConsume = 2;
|
||||
const cpDays = getAvailableCpDaysForDate(useByDate);
|
||||
for (const cpDay of cpDays) {
|
||||
for (const vd of availableDays) {
|
||||
if (daysToConsume === 0) break;
|
||||
const toConsume = Math.min(cpDay.numberOfDays, daysToConsume);
|
||||
cpDay.numberOfDays -= toConsume;
|
||||
daysToConsume -= toConsume;
|
||||
const take = Math.min(vd.numberOfDays, daysToConsume);
|
||||
vd.numberOfDays -= take;
|
||||
daysToConsume -= take;
|
||||
}
|
||||
return daysToConsume === 0;
|
||||
};
|
||||
|
||||
// Helper function to add 1 OTHER day as reward
|
||||
const addOtherDayReward = (): void => {
|
||||
// Find an existing OTHER VacDay or create a new one
|
||||
const otherDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER);
|
||||
if (otherDay) {
|
||||
otherDay.numberOfDays += 1;
|
||||
const checkAvailableDays = (
|
||||
count: number,
|
||||
type: SelectableDayType,
|
||||
useByDate: Date,
|
||||
): boolean => {
|
||||
const useByTime = useByDate.getTime();
|
||||
const total = vacationDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
(type === SelectableDayType.ANY || vd.type === type) &&
|
||||
vd.expiryDate.getTime() >= useByTime,
|
||||
)
|
||||
.reduce((sum, vd) => sum + vd.numberOfDays, 0);
|
||||
return total >= count;
|
||||
};
|
||||
|
||||
const addRewardDays = (count: number, type: SelectableDayType): void => {
|
||||
if (count <= 0) return;
|
||||
const existing = vacationDays.find((vd) => vd.type === type);
|
||||
if (existing) {
|
||||
existing.numberOfDays += count;
|
||||
} else {
|
||||
// Create a new OTHER VacDay with a far future expiry date
|
||||
vacationDays.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: SelectableDayType.OTHER,
|
||||
numberOfDays: 1,
|
||||
expiryDate: new Date(now.getFullYear() + 10, 11, 31),
|
||||
type: type,
|
||||
numberOfDays: count,
|
||||
expiryDate: new Date(new Date().getFullYear() + 10, 11, 31),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const holiday of samedi_ferie) {
|
||||
if (!holiday.range.start) continue;
|
||||
|
||||
// Get the Saturday date (holiday) and the following Sunday
|
||||
const saturdayDate = new Date(holiday.range.start);
|
||||
const sundayDate = new Date(saturdayDate);
|
||||
sundayDate.setDate(saturdayDate.getDate() + 1);
|
||||
|
||||
// Calculate dates for all three strategies (2 CP days total each)
|
||||
// Strategy 1: 2 days before (Thursday + Friday)
|
||||
const twoDaysBefore1 = new Date(saturdayDate);
|
||||
twoDaysBefore1.setDate(saturdayDate.getDate() - 2);
|
||||
const twoDaysBefore2 = new Date(saturdayDate);
|
||||
twoDaysBefore2.setDate(saturdayDate.getDate() - 1);
|
||||
|
||||
// Strategy 2: 2 days after (Monday + Tuesday)
|
||||
const twoDaysAfter1 = new Date(sundayDate);
|
||||
twoDaysAfter1.setDate(sundayDate.getDate() + 1);
|
||||
const twoDaysAfter2 = new Date(sundayDate);
|
||||
twoDaysAfter2.setDate(sundayDate.getDate() + 2);
|
||||
|
||||
// Strategy 3: 1 day before + 1 day after (Friday + Monday)
|
||||
const oneDayBefore = new Date(saturdayDate);
|
||||
oneDayBefore.setDate(saturdayDate.getDate() - 1);
|
||||
const oneDayAfter = new Date(sundayDate);
|
||||
oneDayAfter.setDate(sundayDate.getDate() + 1);
|
||||
|
||||
// Check if we can apply each strategy (not in the past, not already selected, and have non-expired CP days)
|
||||
const canApplyTwoBeforeStrategy =
|
||||
twoDaysBefore1.getTime() >= nowTime &&
|
||||
!this.isSelected(twoDaysBefore1) &&
|
||||
!this.isSelected(twoDaysBefore2) &&
|
||||
getTotalCpDaysForDate(twoDaysBefore2) >= 2; // Check expiry against the latest date used
|
||||
|
||||
const canApplyTwoAfterStrategy =
|
||||
twoDaysAfter1.getTime() >= nowTime &&
|
||||
!this.isSelected(twoDaysAfter1) &&
|
||||
!this.isSelected(twoDaysAfter2) &&
|
||||
getTotalCpDaysForDate(twoDaysAfter2) >= 2; // Check expiry against the latest date used
|
||||
|
||||
const canApplyMixedStrategy =
|
||||
oneDayBefore.getTime() >= nowTime &&
|
||||
!this.isSelected(oneDayBefore) &&
|
||||
!this.isSelected(oneDayAfter) &&
|
||||
getTotalCpDaysForDate(oneDayAfter) >= 2; // Check expiry against the latest date used
|
||||
|
||||
// Test all applicable strategies and choose the one with the best heuristic score
|
||||
const strategies: { apply: () => void; heuristic: number }[] = [];
|
||||
const tempDatesBackup = [...this._datesSelected];
|
||||
|
||||
if (canApplyTwoBeforeStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
|
||||
),
|
||||
const getWorkingDays = (startDate: Date, direction: 1 | -1, count: number): Date[] => {
|
||||
const result: Date[] = [];
|
||||
const current = new Date(startDate);
|
||||
while (result.length < count) {
|
||||
current.setDate(current.getDate() + direction);
|
||||
const isWeekend = current.getDay() === 0 || current.getDay() === 6;
|
||||
const isHoliday = this._datesSelected.some(
|
||||
(d) =>
|
||||
d.type === DayType.CLOSED_DAY &&
|
||||
d.range.start?.getTime() === current.getTime(),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysBefore1, twoDaysBefore2),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(twoDaysBefore2);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
if (!isWeekend && !isHoliday) {
|
||||
result.push(new Date(current));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
if (canApplyTwoAfterStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(twoDaysAfter1, twoDaysAfter2)),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(twoDaysAfter1, twoDaysAfter2),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(twoDaysAfter2);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
}
|
||||
for (const rule of rules) {
|
||||
const matchingHolidays = this._datesSelected.filter(
|
||||
(date) =>
|
||||
date.type === DayType.CLOSED_DAY &&
|
||||
date.range.start?.getDay() === rule.dayOfWeek,
|
||||
);
|
||||
|
||||
if (canApplyMixedStrategy) {
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(oneDayBefore, oneDayBefore)),
|
||||
);
|
||||
this.push(
|
||||
new SelectedDate(DayType.CP, new DateRange<Date>(oneDayAfter, oneDayAfter)),
|
||||
);
|
||||
const heuristic = this.computeHeuristics();
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(oneDayBefore, oneDayBefore),
|
||||
),
|
||||
);
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
DayType.CP,
|
||||
new DateRange<Date>(oneDayAfter, oneDayAfter),
|
||||
),
|
||||
);
|
||||
consume2CpDaysForDate(oneDayAfter);
|
||||
addOtherDayReward();
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
}
|
||||
for (const holiday of matchingHolidays) {
|
||||
if (!holiday.range.start) continue;
|
||||
const holidayDate = new Date(holiday.range.start);
|
||||
|
||||
// Apply the strategy with the best heuristic score
|
||||
if (strategies.length > 0) {
|
||||
const bestStrategy = strategies.reduce((best, current) =>
|
||||
current.heuristic > best.heuristic ? current : best,
|
||||
);
|
||||
bestStrategy.apply();
|
||||
const N = rule.placeDays;
|
||||
if (N <= 0) continue;
|
||||
|
||||
const placementOptions: Date[][] = [];
|
||||
|
||||
if (rule.placement === 'before' || rule.placement === 'auto') {
|
||||
placementOptions.push(getWorkingDays(holidayDate, -1, N));
|
||||
}
|
||||
if (rule.placement === 'after' || rule.placement === 'auto') {
|
||||
placementOptions.push(getWorkingDays(holidayDate, 1, N));
|
||||
}
|
||||
if (rule.placement === 'around' || rule.placement === 'auto') {
|
||||
const beforeCount = Math.ceil(N / 2);
|
||||
const afterCount = Math.floor(N / 2);
|
||||
const aroundDates = [
|
||||
...getWorkingDays(holidayDate, -1, beforeCount),
|
||||
...getWorkingDays(holidayDate, 1, afterCount),
|
||||
];
|
||||
placementOptions.push(aroundDates);
|
||||
}
|
||||
|
||||
const strategies: { apply: () => void; heuristic: number }[] = [];
|
||||
const tempDatesBackup = [...this._datesSelected];
|
||||
|
||||
for (const dates of placementOptions) {
|
||||
const allValid = dates.every(
|
||||
(d) => d.getTime() >= nowTime && !this.isSelected(d),
|
||||
);
|
||||
if (!allValid) continue;
|
||||
|
||||
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
|
||||
if (!checkAvailableDays(N, rule.placeType, maxDate)) continue;
|
||||
|
||||
const simulatedDays = vacationDays.map((vd) => ({ ...vd }));
|
||||
const datesWithTypes: { date: Date; type: DayType }[] = [];
|
||||
for (const d of dates) {
|
||||
const sorted = simulatedDays
|
||||
.filter(
|
||||
(vd) =>
|
||||
(rule.placeType === SelectableDayType.ANY ||
|
||||
vd.type === rule.placeType) &&
|
||||
vd.numberOfDays > 0 &&
|
||||
vd.expiryDate.getTime() >= maxDate.getTime(),
|
||||
)
|
||||
.sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime());
|
||||
|
||||
let chosenType = (rule.placeType === SelectableDayType.ANY
|
||||
? SelectableDayType.CP
|
||||
: rule.placeType) as unknown as DayType;
|
||||
if (sorted.length > 0) {
|
||||
sorted[0].numberOfDays--;
|
||||
chosenType = sorted[0].type as unknown as DayType;
|
||||
}
|
||||
datesWithTypes.push({ date: d, type: chosenType });
|
||||
}
|
||||
|
||||
for (const dt of datesWithTypes) {
|
||||
this.push(new SelectedDate(dt.type, new DateRange<Date>(dt.date, dt.date)));
|
||||
}
|
||||
const heuristic = this.computeHeuristics();
|
||||
|
||||
strategies.push({
|
||||
apply: () => {
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
for (const dt of datesWithTypes) {
|
||||
this.push(
|
||||
new SelectedDate(
|
||||
dt.type,
|
||||
new DateRange<Date>(dt.date, dt.date),
|
||||
),
|
||||
);
|
||||
}
|
||||
consumeDays(N, rule.placeType, maxDate);
|
||||
addRewardDays(rule.rewardDays, rule.rewardType);
|
||||
},
|
||||
heuristic: heuristic,
|
||||
});
|
||||
|
||||
this._datesSelected = [...tempDatesBackup];
|
||||
this.datesSelected = this._datesSelected.slice();
|
||||
this.grouping();
|
||||
}
|
||||
|
||||
if (strategies.length > 0) {
|
||||
const bestStrategy = strategies.reduce((best, current) =>
|
||||
current.heuristic > best.heuristic ? current : best,
|
||||
);
|
||||
bestStrategy.apply();
|
||||
} else {
|
||||
console.warn(
|
||||
`Could not apply If-Then Rule "${rule.name}" for holiday on ${holidayDate.toLocaleDateString()}. Candidates invalid or insufficient days.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ describe('CalendarSettingsService', () => {
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
expect(service.get('showWeekNumbers')()).toBe(false);
|
||||
expect(service.get('samediMalin')()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow updating showWeekNumbers', () => {
|
||||
@@ -26,20 +25,13 @@ describe('CalendarSettingsService', () => {
|
||||
expect(service.get('showWeekNumbers')()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow updating samediMalin', () => {
|
||||
service.get('samediMalin').set(false);
|
||||
expect(service.get('samediMalin')()).toBe(false);
|
||||
});
|
||||
|
||||
it('should provide settings array for UI iteration', () => {
|
||||
const settingsArray = service.settingsArray;
|
||||
expect(settingsArray.length).toBe(2);
|
||||
expect(settingsArray.length).toBe(1);
|
||||
expect(settingsArray[0].id).toBe('showWeekNumbers');
|
||||
expect(settingsArray[1].id).toBe('samediMalin');
|
||||
});
|
||||
|
||||
it('should return correct descriptions', () => {
|
||||
expect(service.getDescription('showWeekNumbers')).toBe('Afficher les numéros de semaine');
|
||||
expect(service.getDescription('samediMalin')).toBe('Activer le Samedi Malin');
|
||||
});
|
||||
});
|
||||
@@ -20,13 +20,6 @@ export class CalendarSettingsService {
|
||||
description: 'Afficher les numéros de semaine',
|
||||
},
|
||||
],
|
||||
[
|
||||
'samediMalin',
|
||||
{
|
||||
defaultValue: true,
|
||||
description: 'Activer le Samedi Malin',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
private readonly signals = new Map<string, WritableSignal<boolean>>();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RuleService, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
|
||||
describe('RuleService', () => {
|
||||
let service: RuleService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test to ensure a clean state
|
||||
localStorage.clear();
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(RuleService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with default rules', () => {
|
||||
const rules = service.rules;
|
||||
expect(rules.length).toBe(2);
|
||||
expect(rules[0].id).toBe('default-samedi-malin');
|
||||
expect(rules[1].id).toBe('default-christmas');
|
||||
});
|
||||
|
||||
it('should parse dates correctly for TimeWindowRules', () => {
|
||||
const rules = service.rules;
|
||||
const christmasRule = rules.find((r) => r.type === RuleType.TIME_WINDOW) as TimeWindowRule;
|
||||
expect(christmasRule).toBeDefined();
|
||||
expect(christmasRule.startDate instanceof Date).toBeTrue();
|
||||
expect(christmasRule.endDate instanceof Date).toBeTrue();
|
||||
});
|
||||
|
||||
it('should add a new rule', () => {
|
||||
const newRule: IfThenRule = {
|
||||
id: 'test-rule',
|
||||
name: 'Test Rule',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 1, // Monday
|
||||
placeDays: 1,
|
||||
placeType: SelectableDayType.RTT,
|
||||
placement: 'after',
|
||||
rewardDays: 0,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
};
|
||||
|
||||
service.addRule(newRule);
|
||||
const rules = service.rules;
|
||||
expect(rules.length).toBe(3);
|
||||
expect(rules.find((r) => r.id === 'test-rule')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update an existing rule', () => {
|
||||
const rules = service.rules;
|
||||
const targetRule = { ...rules[0] } as IfThenRule;
|
||||
targetRule.name = 'Updated Name';
|
||||
|
||||
service.updateRule(targetRule);
|
||||
|
||||
const updatedRules = service.rules;
|
||||
const updated = updatedRules.find((r) => r.id === targetRule.id);
|
||||
expect(updated?.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should delete a rule', () => {
|
||||
const rules = service.rules;
|
||||
const idToDelete = rules[0].id;
|
||||
|
||||
service.deleteRule(idToDelete);
|
||||
|
||||
const updatedRules = service.rules;
|
||||
expect(updatedRules.length).toBe(1);
|
||||
expect(updatedRules.find((r) => r.id === idToDelete)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should toggle rule enabled state', () => {
|
||||
const targetId = service.rules[0].id;
|
||||
const initialState = service.rules[0].enabled;
|
||||
|
||||
service.toggleRule(targetId);
|
||||
|
||||
expect(service.rules.find((r) => r.id === targetId)?.enabled).toBe(!initialState);
|
||||
});
|
||||
|
||||
it('should reorder rules correctly', () => {
|
||||
const rulesBefore = service.rules;
|
||||
const firstRuleId = rulesBefore[0].id;
|
||||
const secondRuleId = rulesBefore[1].id;
|
||||
|
||||
// Move first rule to second position
|
||||
service.reorderRules(0, 1);
|
||||
|
||||
const rulesAfter = service.rules;
|
||||
expect(rulesAfter[0].id).toBe(secondRuleId);
|
||||
expect(rulesAfter[1].id).toBe(firstRuleId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Injectable, WritableSignal, Injector, inject } from '@angular/core';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
export enum RuleType {
|
||||
IF_THEN = 'if_then',
|
||||
TIME_WINDOW = 'time_window',
|
||||
}
|
||||
|
||||
export interface BaseRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type PlacementStrategy = 'before' | 'after' | 'around' | 'auto';
|
||||
|
||||
export interface IfThenRule extends BaseRule {
|
||||
type: RuleType.IF_THEN;
|
||||
dayOfWeek: number; // 0-6, where 0 is Sunday, 6 is Saturday
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
placement: PlacementStrategy;
|
||||
rewardDays: number;
|
||||
rewardType: SelectableDayType;
|
||||
}
|
||||
|
||||
export interface TimeWindowRule extends BaseRule {
|
||||
type: RuleType.TIME_WINDOW;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
}
|
||||
|
||||
export type VacationRule = IfThenRule | TimeWindowRule;
|
||||
|
||||
interface SerializedRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
enabled: boolean;
|
||||
dayOfWeek?: number;
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
placement?: PlacementStrategy;
|
||||
rewardDays?: number;
|
||||
rewardType?: SelectableDayType;
|
||||
startDate?: string | Date;
|
||||
endDate?: string | Date;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RuleService {
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
private readonly defaultRules: VacationRule[] = [
|
||||
{
|
||||
id: 'default-samedi-malin',
|
||||
name: 'Samedi Malin',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: true,
|
||||
dayOfWeek: 6, // Saturday
|
||||
placeDays: 2,
|
||||
placeType: SelectableDayType.CP,
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
},
|
||||
{
|
||||
id: 'default-christmas',
|
||||
name: 'Vacances de Noël',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
enabled: true,
|
||||
startDate: new Date(new Date().getFullYear(), 11, 20), // Dec 20
|
||||
endDate: new Date(new Date().getFullYear() + 1, 0, 3), // Jan 3 next year
|
||||
placeDays: 5,
|
||||
placeType: SelectableDayType.ANY,
|
||||
},
|
||||
];
|
||||
|
||||
private rawRulesSignal: WritableSignal<SerializedRule[]>;
|
||||
|
||||
constructor() {
|
||||
this.rawRulesSignal = storedSignal<SerializedRule[]>(
|
||||
'vacationRules',
|
||||
this.defaultRules as unknown as SerializedRule[],
|
||||
this.injector,
|
||||
);
|
||||
}
|
||||
|
||||
public get rules(): VacationRule[] {
|
||||
const raw = this.rawRulesSignal();
|
||||
return raw.map((rule) => {
|
||||
if (rule.type === RuleType.TIME_WINDOW) {
|
||||
return {
|
||||
...rule,
|
||||
startDate: new Date(rule.startDate as string),
|
||||
endDate: new Date(rule.endDate as string),
|
||||
} as TimeWindowRule;
|
||||
}
|
||||
return rule as IfThenRule;
|
||||
});
|
||||
}
|
||||
|
||||
public addRule(rule: VacationRule): void {
|
||||
this.rawRulesSignal.update((rules) => [...rules, rule as unknown as SerializedRule]);
|
||||
}
|
||||
|
||||
public updateRule(updatedRule: VacationRule): void {
|
||||
this.rawRulesSignal.update((rules) =>
|
||||
rules.map((rule) =>
|
||||
rule.id === updatedRule.id ? (updatedRule as unknown as SerializedRule) : rule,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public deleteRule(id: string): void {
|
||||
this.rawRulesSignal.update((rules) => rules.filter((rule) => rule.id !== id));
|
||||
}
|
||||
|
||||
public toggleRule(id: string): void {
|
||||
this.rawRulesSignal.update((rules) =>
|
||||
rules.map((rule) => (rule.id === id ? { ...rule, enabled: !rule.enabled } : rule)),
|
||||
);
|
||||
}
|
||||
|
||||
public reorderRules(previousIndex: number, currentIndex: number): void {
|
||||
this.rawRulesSignal.update((rules) => {
|
||||
const updatedRules = [...rules];
|
||||
const [movedRule] = updatedRules.splice(previousIndex, 1);
|
||||
updatedRules.splice(currentIndex, 0, movedRule);
|
||||
return updatedRules;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user