feat: enhance user input and rules panel components

- Updated dark-light mode button styles for improved responsiveness.
- Enhanced rules panel with additional buttons for moving rules up and down, and updated styles for better layout.
- Added active rule count display in the rules panel header.
- Improved user input component layout with section headings and updated button styles.
- Refactored day input component to use grid layout for better alignment and responsiveness.
- Added new translations for user input and rules panel features.
- Implemented tests for new functionality in rules panel and day input components.
- Added application routes test to ensure correct routing configuration.
This commit is contained in:
nessar committed 2026-08-30 19:16:36 +02:00
1 parent 31314f3969
commit 609b3dece9
30 files changed
+870 -382

No files matched your search

@@ -32,15 +32,30 @@ const incomplete: PlanningIncompleteOutcome = {
const plan: PlanOutcome = {
kind: 'Plan',
horizon: { start: '2024-01-01', end: '2025-01-01' },
placements: [],
placements: [
{
date: '2024-05-03',
balanceId: 'cp',
type: 'cp',
ruleIds: [],
supporting: false,
},
{
date: '2024-05-06',
balanceId: 'cp',
type: 'cp',
ruleIds: [],
supporting: false,
},
],
timeOffPeriods: [],
preferences: [],
remainingBalances: [],
metrics: {
unplacedAtRisk: 0,
holidayWeekValue: 0,
nonWorkingDayGain: 0,
longestWorkSequence: 0,
nonWorkingDayGain: 4,
longestWorkSequence: 6,
shortSequenceDeficit: 0,
supportingPlacements: 0,
preferenceSatisfaction: [],
@@ -84,7 +99,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
it('keeps a returned incumbent/result visible only when an outcome supplies one', async () => {
it('keeps and summarizes a returned result', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
@@ -103,7 +118,13 @@ describe('CalendarYear planning outcome boundary', () => {
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
const summaryValues = Array.from(
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
).map((element) => element.textContent?.trim());
expect(summaryValues).toEqual(['2', '4', '6']);
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
@@ -3,33 +3,107 @@
<div class="loading-overlay">
<div class="loading-spinner">
<div class="spinner-container">
<mat-spinner [mode]="'determinate'" [value]="loadingProgress()"></mat-spinner>
<mat-spinner
[diameter]="72"
[mode]="'determinate'"
[value]="loadingProgress()"
></mat-spinner>
<div class="spinner-text">{{ loadingProgress() }}%</div>
</div>
<p>{{ tr().loading }}</p>
</div>
</div>
}
@if (planningOutcome()?.kind === 'InvalidInput') {
<p class="planning-status" role="alert">{{ tr().planning.invalidInput }}</p>
} @else if (planningOutcome()?.kind === 'PlanningConflict') {
<p class="planning-status" role="alert">{{ tr().planning.conflict }}</p>
} @else if (planningOutcome()?.kind === 'PlanningIncomplete') {
<p class="planning-status" role="status">
{{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }}
</p>
} @else if (planningOutcome()?.kind === 'Plan') {
<p class="planning-status" role="status">{{ tr().planning.complete }}</p>
}
@for (month of months; track $index) {
<mat-card class="card">
<mat-card-content>
<app-calendar-month
[monthIndex]="month"
[year]="getYear(month)"
[selectedDates]="selectedDates"
></app-calendar-month>
</mat-card-content>
</mat-card>
<header class="results-header">
<div class="results-heading">
<h2 id="planning-results-title">{{ tr().planning.title }}</h2>
@if (planningOutcome()?.kind === 'InvalidInput') {
<p class="planning-status error" role="alert">
<mat-icon aria-hidden="true">error</mat-icon>
{{ tr().planning.invalidInput }}
</p>
} @else if (planningOutcome()?.kind === 'PlanningConflict') {
<p class="planning-status error" role="alert">
<mat-icon aria-hidden="true">error</mat-icon>
{{ tr().planning.conflict }}
</p>
} @else if (planningOutcome()?.kind === 'PlanningIncomplete') {
<p
class="planning-status"
[class.error]="hasPlannerError()"
[class.warning]="!hasPlannerError()"
[attr.role]="hasPlannerError() ? 'alert' : 'status'"
>
<mat-icon aria-hidden="true">{{
hasPlannerError() ? 'error' : 'warning'
}}</mat-icon>
{{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }}
</p>
} @else if (planningOutcome()?.kind === 'Plan') {
<p class="planning-status success" role="status">
<mat-icon aria-hidden="true">check_circle</mat-icon>
{{ tr().planning.complete }}
</p>
}
</div>
<app-calendar-settings></app-calendar-settings>
</header>
@if (displayedPlan(); as plan) {
<dl class="plan-summary">
<div>
<dt>{{ tr().planning.daysPlaced }}</dt>
<dd>{{ plan.placements.length }}</dd>
</div>
<div>
<dt>{{ tr().planning.daysGained }}</dt>
<dd>{{ plan.metrics.nonWorkingDayGain }}</dd>
</div>
<div>
<dt>{{ tr().planning.longestWorkSequence }}</dt>
<dd>{{ plan.metrics.longestWorkSequence }}</dd>
</div>
</dl>
}
<div class="calendar-legend" role="group" [attr.aria-label]="tr().planning.legend">
<strong>{{ tr().planning.legend }}</strong>
<ul>
<li>
<span class="legend-swatch weekend" aria-hidden="true"></span
>{{ tr().dayTypes.weekend }}
</li>
<li>
<span class="legend-swatch closed-day" aria-hidden="true"></span
>{{ tr().dayTypes.closedDay }}
</li>
<li><span class="legend-swatch cp" aria-hidden="true"></span>{{ tr().dayTypes.cp }}</li>
<li>
<span class="legend-swatch rtt" aria-hidden="true"></span>{{ tr().dayTypes.rtt }}
</li>
<li>
<span class="legend-swatch other" aria-hidden="true"></span
>{{ tr().dayTypes.other }}
</li>
<li>
<span class="legend-swatch today" aria-hidden="true"></span
>{{ tr().dayTypes.today }}
</li>
</ul>
</div>
<div class="month-grid">
@for (month of months; track $index) {
<mat-card class="month-card">
<mat-card-content>
<app-calendar-month
[monthIndex]="month"
[year]="getYear(month)"
[selectedDates]="selectedDates"
></app-calendar-month>
</mat-card-content>
</mat-card>
}
</div>
</div>
@@ -1,40 +1,41 @@
@use '@angular/material' as mat;
:host {
display: block;
min-width: 0;
}
.calendar-year {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
display: grid;
container: calendar-year / inline-size;
gap: 1.25rem;
width: 100%;
max-width: 100%;
min-width: 0;
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.9);
inset: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
border-radius: 4px;
z-index: 1000;
background-color: var(--loading-overlay-bg);
min-height: 18rem;
border-radius: var(--ui-radius-lg, 1rem);
z-index: 1;
background: var(--loading-overlay-bg, rgba(255, 255, 255, 0.92));
}
.loading-spinner {
display: flex;
flex-wrap: wrap;
flex-direction: column;
align-items: center;
gap: 16px;
gap: 1rem;
p {
font-size: 16px;
font-size: 1rem;
font-weight: 500;
color: var(--loading-text-color);
color: var(--loading-text-color, #333);
margin: 0;
}
}
@@ -43,24 +44,183 @@
position: relative;
display: inline-block;
::ng-deep .mat-mdc-progress-spinner {
// Override Material spinner size
width: 100px;
height: 100px;
}
.spinner-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 20px;
font-size: 1.125rem;
font-weight: bold;
color: var(--loading-text-color);
color: var(--loading-text-color, #333);
pointer-events: none;
}
}
.card {
.results-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-block-end: 1rem;
border-block-end: 1px solid var(--ui-border, #c7c7d0);
}
.results-heading {
display: grid;
gap: 0.5rem;
min-width: 0;
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.2;
text-wrap: balance;
}
}
.planning-status {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
width: fit-content;
max-width: 65ch;
margin: 0;
padding: 0.5rem 0.75rem;
border-radius: var(--ui-radius-sm, 0.5rem);
font-weight: 600;
line-height: 1.4;
mat-icon {
flex-shrink: 0;
}
&.success {
background: var(--ui-success-bg, #d8f5df);
color: var(--ui-success-text, #174d26);
}
&.warning {
background: var(--ui-warning-bg, #fff4d6);
color: var(--ui-warning-text, #4f3800);
}
&.error {
background: var(--ui-error-bg, #ffe0e0);
color: var(--ui-error-text, #751b1b);
}
}
.plan-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
gap: 0.75rem;
margin: 0;
div {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding: 0.875rem 1rem;
background: var(--ui-panel-bg, #f7f7fa);
border-radius: var(--ui-radius-md, 0.75rem);
}
dt {
color: var(--ui-text-secondary, #45464f);
font-size: 0.875rem;
line-height: 1.4;
}
dd {
order: -1;
margin: 0;
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
}
.calendar-legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
padding: 0.75rem 1rem;
background: var(--ui-panel-bg, #f7f7fa);
border-radius: var(--ui-radius-md, 0.75rem);
strong {
font-size: 0.875rem;
}
ul {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--ui-text-secondary, #45464f);
font-size: 0.875rem;
}
}
.legend-swatch {
display: inline-block;
flex-shrink: 0;
width: 1rem;
height: 1rem;
box-sizing: border-box;
border-radius: 0.25rem;
background: var(--ui-card-bg, #f1f1f5);
&.weekend {
background: var(--weekend-bg, #ffca39);
}
&.closed-day {
border: 0.125rem solid var(--closed-day-bg, #0ee3ff);
}
&.cp {
background: var(--cp-bg, #eca0ff);
}
&.rtt {
background: var(--rtt-bg, #99ff87);
}
&.other {
background: var(--other-bg, #ff9b9b);
}
&.today {
border: 0.125rem dashed var(--today-border-color, #1976d2);
}
}
.month-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
width: 100%;
max-width: 100%;
}
.month-card {
margin: 16px;
}
@@ -1,4 +1,4 @@
import { Component, effect, signal, inject, untracked } from '@angular/core';
import { Component, computed, effect, signal, inject, untracked } from '@angular/core';
import { UserInputService } from '../../services/user-input-service';
import { MatCard, MatCardContent } from '@angular/material/card';
import { CalendarMonth } from '../calendar-month/calendar-month';
@@ -9,10 +9,19 @@ import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service';
import { SelectableDayType, VacDay } from '../../services/calendar-service';
import { MatIcon } from '@angular/material/icon';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
@Component({
selector: 'app-calendar-year',
imports: [MatCard, CalendarMonth, MatCardContent, MatProgressSpinner],
imports: [
MatCard,
CalendarMonth,
MatCardContent,
MatProgressSpinner,
MatIcon,
CalendarSettings,
],
templateUrl: './calendar-year.html',
styleUrl: './calendar-year.scss',
})
@@ -31,6 +40,14 @@ export class CalendarYear {
protected loadingProgress = signal(0);
protected planningOutcome = signal<PlanningOutcome | null>(null);
protected displayedPlan = computed(() => {
const outcome = this.planningOutcome();
return outcome?.kind === 'Plan'
? outcome
: outcome?.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
});
private calculationVersion = 0;
protected hasPlannerError(): boolean {
@@ -127,12 +144,7 @@ export class CalendarYear {
private applyOutcome(outcome: PlanningOutcome): void {
this.planningOutcome.set(outcome);
const plan =
outcome.kind === 'Plan'
? outcome
: outcome.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
const plan = this.displayedPlan();
if (plan) {
this.selectedDates = this.calendarService.selectedDatesForPlan(plan);
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));