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:
1 parent
31314f3969
commit
609b3dece9
30 files changed
+870
-382
No files matched your search
+18
-10
@@ -1,13 +1,21 @@
|
||||
<app-dark-light-mode-button class="modeButton"></app-dark-light-mode-button>
|
||||
@if (showPlanner()) {
|
||||
<main class="main">
|
||||
<h1 style="display: flex; gap: 1rem"><mat-icon>beach_access</mat-icon> {{ title() }}</h1>
|
||||
<div class="content">
|
||||
<app-user-input></app-user-input>
|
||||
<mat-divider [vertical]="true" />
|
||||
<mat-divider />
|
||||
<app-calendar-year></app-calendar-year>
|
||||
</div>
|
||||
</main>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<mat-icon aria-hidden="true">beach_access</mat-icon>
|
||||
<h1>{{ title() }}</h1>
|
||||
</div>
|
||||
<app-dark-light-mode-button></app-dark-light-mode-button>
|
||||
</header>
|
||||
|
||||
<main class="planner-layout">
|
||||
<section class="configuration-panel" aria-labelledby="leave-settings-title">
|
||||
<app-user-input></app-user-input>
|
||||
</section>
|
||||
<section class="results-panel" aria-labelledby="planning-results-title">
|
||||
<app-calendar-year></app-calendar-year>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { routes } from './app.routes';
|
||||
|
||||
describe('application routes', () => {
|
||||
it('declares the root URL for the planner shell', () => {
|
||||
expect(routes).toContainEqual({ path: '', pathMatch: 'full', children: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', children: [] },
|
||||
{
|
||||
path: 'benchmark',
|
||||
loadComponent: () =>
|
||||
|
||||
+57
-65
@@ -1,79 +1,71 @@
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
.app-shell {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 24px;
|
||||
gap: 1rem;
|
||||
width: min(100%, 120rem);
|
||||
margin-inline: auto;
|
||||
padding: 1rem clamp(1rem, 2vw, 2rem);
|
||||
box-sizing: border-box;
|
||||
border-block-end: 1px solid var(--ui-border, #c7c7d0);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-primary, #005cbb);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.planner-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(23rem, 29rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 2rem;
|
||||
width: min(100%, 120rem);
|
||||
margin-inline: auto;
|
||||
padding: 1.5rem clamp(1rem, 2vw, 2rem) 3rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
app-user-input {
|
||||
display: block;
|
||||
flex: 3;
|
||||
min-width: 350px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.configuration-panel,
|
||||
.results-panel,
|
||||
app-user-input,
|
||||
app-calendar-year {
|
||||
display: block;
|
||||
flex: 7;
|
||||
min-width: 700px;
|
||||
margin-left: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
.configuration-panel {
|
||||
padding: 1.25rem;
|
||||
background: var(--ui-panel-bg, #f7f7fa);
|
||||
border: 1px solid var(--ui-border, #c7c7d0);
|
||||
border-radius: var(--ui-radius-lg, 1rem);
|
||||
}
|
||||
|
||||
.modeButton {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mat-divider-vertical {
|
||||
height: 80vh;
|
||||
width: 1px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.mat-divider-horizontal {
|
||||
width: 80%;
|
||||
height: 1px;
|
||||
margin: 16px 0;
|
||||
display: none;
|
||||
@media (max-width: 75rem) {
|
||||
.planner-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { UserInput } from './components/user-input/user-input';
|
||||
import { CalendarYear } from './components/calendar-year/calendar-year';
|
||||
import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button';
|
||||
@@ -16,7 +15,6 @@ function isBenchmarkUrl(url: string): boolean {
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
MatDatepickerModule,
|
||||
MatDivider,
|
||||
UserInput,
|
||||
CalendarYear,
|
||||
DarkLightModeButton,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="calendar">
|
||||
<div class="calendar" role="group" [attr.aria-label]="getMonthName() + ' ' + year">
|
||||
<h3>{{ getMonthName() }} {{ year }}</h3>
|
||||
<div>
|
||||
<div class="row header">
|
||||
@@ -25,6 +25,7 @@
|
||||
@if (day === null) {
|
||||
<p class="day empty"></p>
|
||||
} @else {
|
||||
@let dayTooltip = getDayTooltip(day);
|
||||
<p
|
||||
[class.past]="day < getNow()"
|
||||
[class.start]="selectedDates.isStart(day)"
|
||||
@@ -32,8 +33,11 @@
|
||||
[class.selected]="selectedDates.isSelected(day)"
|
||||
[class]="selectedDates.getClasses(day)"
|
||||
class="day number"
|
||||
[matTooltip]="getDayTooltip(day)"
|
||||
[matTooltip]="dayTooltip"
|
||||
[matTooltipShowDelay]="500"
|
||||
[attr.aria-label]="
|
||||
dayTooltip ? day.getDate() + ': ' + dayTooltip : null
|
||||
"
|
||||
>
|
||||
{{ day.getDate() }}
|
||||
</p>
|
||||
|
||||
@@ -69,8 +69,6 @@
|
||||
.today {
|
||||
border-color: var(--today-border-color);
|
||||
border-width: 2px;
|
||||
width: 26px !important;
|
||||
height: 22px !important;
|
||||
border-style: dashed !important;
|
||||
|
||||
:has(.dark-mode) {
|
||||
@@ -88,8 +86,6 @@
|
||||
|
||||
.closed-day {
|
||||
border: 2px solid var(--closed-day-bg);
|
||||
width: 26px !important;
|
||||
height: 22px !important;
|
||||
}
|
||||
|
||||
.rtt {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<button matFab extended (click)="openDialog()">
|
||||
<mat-icon>settings</mat-icon>
|
||||
<button mat-stroked-button type="button" (click)="openDialog()">
|
||||
<mat-icon aria-hidden="true">settings</mat-icon>
|
||||
{{ tr().settings.title }}
|
||||
</button>
|
||||
@@ -0,0 +1,7 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -2,13 +2,18 @@ button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
transition: all 0.3s ease;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
transition:
|
||||
background-color 200ms ease,
|
||||
color 200ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<mat-expansion-panel class="rules-panel-container mat-elevation-z2">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon>rule</mat-icon>
|
||||
{{ tr().rules.title }}
|
||||
<mat-icon aria-hidden="true">rule</mat-icon>
|
||||
<span>{{ tr().rules.title }}</span>
|
||||
<span class="rule-count">{{ activeRuleCount }} {{ tr().rules.active }}</span>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description> {{ tr().rules.subtitle }} </mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
@@ -37,6 +38,22 @@
|
||||
>
|
||||
</mat-slide-toggle>
|
||||
<div class="rule-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
[disabled]="i === 0"
|
||||
[aria-label]="tr().rules.moveRuleUp"
|
||||
(click)="moveRule(i, -1, $event)"
|
||||
>
|
||||
<mat-icon>keyboard_arrow_up</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
[disabled]="i === rules.length - 1"
|
||||
[aria-label]="tr().rules.moveRuleDown"
|
||||
(click)="moveRule(i, 1, $event)"
|
||||
>
|
||||
<mat-icon>keyboard_arrow_down</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
[aria-label]="tr().rules.editRule"
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
.rules-panel-container {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
margin: 0;
|
||||
border-radius: var(--ui-radius-md, 0.75rem);
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
|
||||
mat-panel-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@container (max-width: 480px) {
|
||||
.rule-count {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--mat-sys-primary-container, #d7e3ff);
|
||||
border-radius: 999px;
|
||||
color: var(--mat-sys-on-primary-container, #001b3f);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@container (max-width: 30rem) {
|
||||
mat-panel-description {
|
||||
display: none !important;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,10 +37,12 @@
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--mat-sys-surface-container);
|
||||
border: 1px solid var(--mat-sys-outline-variant, #c7c7d0);
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
transition:
|
||||
border-color 200ms ease,
|
||||
background-color 200ms ease;
|
||||
background: var(--mat-sys-surface-container, #f1f1f5);
|
||||
|
||||
&:not(.editing) {
|
||||
cursor: grab;
|
||||
@@ -41,11 +53,11 @@
|
||||
}
|
||||
|
||||
&:hover:not(.editing) {
|
||||
border-color: var(--mat-sys-outline);
|
||||
border-color: var(--mat-sys-outline, #777680);
|
||||
}
|
||||
|
||||
&.editing {
|
||||
border: 1px solid var(--mat-sys-primary);
|
||||
border: 1px solid var(--mat-sys-primary, #005cbb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,29 +88,31 @@
|
||||
|
||||
.priority-statement {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
background: var(--mat-sys-primary-container, #d7e3ff);
|
||||
color: var(--mat-sys-on-primary-container, #001b3f);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-primary, #005cbb);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-drag-grip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
margin-bottom: -8px;
|
||||
color: var(--mat-sys-outline);
|
||||
color: var(--mat-sys-outline, #777680);
|
||||
opacity: 0.6;
|
||||
cursor: grab;
|
||||
transition: opacity 0.2s;
|
||||
@@ -114,6 +128,7 @@
|
||||
|
||||
.rule-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
@@ -122,24 +137,21 @@
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.rule-number {
|
||||
color: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-primary, #005cbb);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.rule-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,8 +170,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
border-radius: 8px;
|
||||
background: var(--mat-sys-surface-container-high, #e9e9ee);
|
||||
border-radius: var(--ui-radius-sm, 0.5rem);
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
@@ -195,6 +207,7 @@
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
@@ -203,6 +216,7 @@
|
||||
|
||||
.add-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
@@ -216,3 +230,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 30rem) {
|
||||
.rule-summary {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.rule-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -105,4 +105,10 @@ describe('RulesPanel', () => {
|
||||
|
||||
expect(component.rules.find((r) => r.id === targetRule.id)?.enabled).toBe(!initialState);
|
||||
});
|
||||
|
||||
it('should move a rule with the keyboard controls', () => {
|
||||
const secondRuleId = component.rules[1].id;
|
||||
component.moveRule(1, -1, { stopPropagation: () => {} } as Event);
|
||||
expect(component.rules[0].id).toBe(secondRuleId);
|
||||
});
|
||||
});
|
||||
@@ -140,12 +140,23 @@ export class RulesPanel {
|
||||
return this.ruleService.rules;
|
||||
}
|
||||
|
||||
protected get activeRuleCount(): number {
|
||||
return this.rules.filter((rule) => rule.enabled).length;
|
||||
}
|
||||
|
||||
public drop(event: CdkDragDrop<VacationRule[]>) {
|
||||
if (event.previousIndex !== event.currentIndex) {
|
||||
this.ruleService.reorderRules(event.previousIndex, event.currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public moveRule(index: number, offset: -1 | 1, event: Event) {
|
||||
event.stopPropagation();
|
||||
const destination = index + offset;
|
||||
if (destination >= 0 && destination < this.rules.length)
|
||||
this.ruleService.reorderRules(index, destination);
|
||||
}
|
||||
|
||||
public toggleRule(id: string) {
|
||||
this.ruleService.toggleRule(id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<div class="user-input">
|
||||
<p>{{ tr().app.description }}</p>
|
||||
<header class="section-heading">
|
||||
<h2 id="leave-settings-title">{{ tr().userInput.title }}</h2>
|
||||
<p>{{ tr().userInput.description }}</p>
|
||||
</header>
|
||||
|
||||
<div class="listOfDays">
|
||||
@for (day of daysOff; track day.id) {
|
||||
<div class="day-input-wrapper" animate.enter="day-enter" animate.leave="day-leave">
|
||||
@@ -10,25 +14,27 @@
|
||||
></app-day-input>
|
||||
</div>
|
||||
}
|
||||
<mat-card class="add-day-card mat-elevation-z2" (click)="addDay()">
|
||||
<button matFab [aria-label]="tr().userInput.addVacationDay">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
<h3>{{ tr().userInput.addVacationDay }}</h3>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button matFab extended (click)="triggerComputation()">
|
||||
<mat-icon>calculate</mat-icon>
|
||||
{{ tr().userInput.calculate }}
|
||||
<button mat-stroked-button class="add-day-button" type="button" (click)="addDay()">
|
||||
<mat-icon aria-hidden="true">add</mat-icon>
|
||||
{{ tr().userInput.addVacationDay }}
|
||||
</button>
|
||||
<app-calendar-settings></app-calendar-settings>
|
||||
</div>
|
||||
<app-rules-panel></app-rules-panel>
|
||||
|
||||
<button
|
||||
mat-flat-button
|
||||
class="calculate-button"
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="triggerComputation()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">calculate</mat-icon>
|
||||
{{ tr().userInput.calculate }}
|
||||
</button>
|
||||
|
||||
@if (hasRemainingDays()) {
|
||||
<mat-card class="mat-elevation-z2 warning-card">
|
||||
<mat-card class="warning-card">
|
||||
<mat-card-content class="warning-content">
|
||||
<mat-icon color="warn">warning</mat-icon>
|
||||
<mat-icon color="warn" aria-hidden="true">warning</mat-icon>
|
||||
<div class="warning">
|
||||
<span>
|
||||
{{
|
||||
@@ -49,4 +55,6 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<app-rules-panel></app-rules-panel>
|
||||
</div>
|
||||
@@ -1,34 +1,38 @@
|
||||
.user-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 1rem;
|
||||
gap: 1.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
.section-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff0000;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.warning-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
p {
|
||||
max-width: 55ch;
|
||||
color: var(--ui-text-secondary, #45464f);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.listOfDays {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.day-input-wrapper {
|
||||
@@ -68,36 +72,47 @@ form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.add-day-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
.add-day-button,
|
||||
.calculate-button {
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.add-day-card h3 {
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
.add-day-button {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
.calculate-button {
|
||||
min-height: 3.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warning-card {
|
||||
background: var(--ui-warning-bg, #fff4d6);
|
||||
color: var(--ui-warning-text, #4f3800);
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
container: user-input / inline-size;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
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';
|
||||
@@ -14,7 +13,6 @@ import { TranslationService } from '../../services/translation-service';
|
||||
selector: 'app-user-input',
|
||||
imports: [
|
||||
MatInputModule,
|
||||
CalendarSettings,
|
||||
RulesPanel,
|
||||
MatCard,
|
||||
MatCardContent,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<!-- <mat-list-item role="listitem"> -->
|
||||
<mat-card class="card">
|
||||
<mat-form-field class="numberInput">
|
||||
<mat-form-field class="numberInput" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{ tr().userInput.days }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
[value]="day().numberOfDays"
|
||||
(input)="onInput($event)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="dayTypeInput">
|
||||
<mat-form-field class="dayTypeInput" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{ tr().userInput.type }}</mat-label>
|
||||
<mat-select (valueChange)="onTypeChange($event)" [value]="day().type">
|
||||
<mat-option></mat-option>
|
||||
@for (dayType of selectableDayTypes; track $index) {
|
||||
@@ -19,31 +22,37 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="dateInput">
|
||||
<mat-label>{{ tr().userInput.availableDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="availablePicker"
|
||||
[value]="day().availableDate"
|
||||
(dateChange)="addAvailabilityEvent($event)"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="availablePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #availablePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="dateInput">
|
||||
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="datetime"
|
||||
[matDatepicker]="picker"
|
||||
[value]="day().expiryDate"
|
||||
(dateChange)="addEvent($event)"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<button matFab [aria-label]="tr().userInput.deleteEntry" (click)="deleteEntry.emit()">
|
||||
<mat-icon>close</mat-icon>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="delete-button"
|
||||
type="button"
|
||||
[aria-label]="tr().userInput.deleteEntry"
|
||||
(click)="deleteEntry.emit()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">close</mat-icon>
|
||||
</button>
|
||||
<div class="date-fields">
|
||||
<mat-form-field class="dateInput" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{ tr().userInput.availableDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="availablePicker"
|
||||
[value]="day().availableDate"
|
||||
(dateChange)="addAvailabilityEvent($event)"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="availablePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #availablePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="dateInput" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="picker"
|
||||
[value]="day().expiryDate"
|
||||
(dateChange)="addEvent($event)"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-card>
|
||||
<!-- </mat-list-item> -->
|
||||
@@ -1,72 +1,76 @@
|
||||
:host {
|
||||
display: block;
|
||||
container-type: inline-size;
|
||||
container: day-input / inline-size;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 5.25rem minmax(0, 1fr) 2.75rem;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
background: var(--ui-card-bg, #f1f1f5);
|
||||
border: 1px solid var(--ui-border, #c7c7d0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dateInput {
|
||||
flex-grow: 1;
|
||||
.card mat-form-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
width: 80px;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
.dayTypeInput {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
@container (max-width: 480px) {
|
||||
.delete-button {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
color: var(--ui-text-secondary, #45464f);
|
||||
}
|
||||
|
||||
.date-fields {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@container day-input (max-width: 24rem) {
|
||||
.card {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
grid-template-columns: 4.75rem minmax(0, 1fr) 2.75rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
width: 70px;
|
||||
flex-grow: 0;
|
||||
.date-fields {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dayTypeInput {
|
||||
flex-grow: 1;
|
||||
width: 0;
|
||||
min-width: 120px;
|
||||
@container day-input (min-width: 52rem) {
|
||||
.card {
|
||||
grid-template-columns:
|
||||
5.25rem minmax(9rem, 1fr) minmax(11rem, 1fr) minmax(11rem, 1fr)
|
||||
2.75rem;
|
||||
}
|
||||
|
||||
.dateInput {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
flex-basis: 100%;
|
||||
margin-top: 4px;
|
||||
.date-fields {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
.delete-button {
|
||||
grid-column: 5;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DayInput } from './day-input';
|
||||
import { SelectableDayType, VacDay } from '../../../services/calendar-service';
|
||||
import { TranslationService } from '../../../services/translation-service';
|
||||
|
||||
describe('DayInput', () => {
|
||||
let component: DayInput;
|
||||
@@ -18,6 +19,7 @@ describe('DayInput', () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DayInput],
|
||||
}).compileComponents();
|
||||
TestBed.inject(TranslationService).setLanguage('en');
|
||||
|
||||
fixture = TestBed.createComponent(DayInput);
|
||||
component = fixture.componentInstance;
|
||||
@@ -29,6 +31,14 @@ describe('DayInput', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('labels every balance field', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Days');
|
||||
expect(text).toContain('Leave type');
|
||||
expect(text).toContain('Available from');
|
||||
expect(text).toContain('Expires on');
|
||||
});
|
||||
|
||||
it('emits an availability-date change without mutating the input value', () => {
|
||||
const emitted: VacDay[] = [];
|
||||
component.dayChange.subscribe((value) => emitted.push(value));
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface AppTranslations {
|
||||
description: string;
|
||||
};
|
||||
userInput: {
|
||||
title: string;
|
||||
description: string;
|
||||
addVacationDay: string;
|
||||
calculate: string;
|
||||
remainingDaysWarning: string;
|
||||
@@ -16,6 +18,8 @@ export interface AppTranslations {
|
||||
deleteEntry: string;
|
||||
chooseDate: string;
|
||||
availableDate: string;
|
||||
days: string;
|
||||
type: string;
|
||||
daySuffix: string;
|
||||
daysSuffix: string;
|
||||
expires: string;
|
||||
@@ -49,6 +53,9 @@ export interface AppTranslations {
|
||||
rules: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
active: string;
|
||||
moveRuleUp: string;
|
||||
moveRuleDown: string;
|
||||
priorityInfo: string;
|
||||
ifThenTemplate: string;
|
||||
timeWindowTemplate: string;
|
||||
@@ -92,6 +99,11 @@ export interface AppTranslations {
|
||||
};
|
||||
};
|
||||
planning: {
|
||||
title: string;
|
||||
daysPlaced: string;
|
||||
daysGained: string;
|
||||
longestWorkSequence: string;
|
||||
legend: string;
|
||||
invalidInput: string;
|
||||
conflict: string;
|
||||
incomplete: string;
|
||||
|
||||
Reference in new issue
Block a user