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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user