diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index 5c66b48..6e982a5 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -25,6 +25,7 @@ import { PlanningOutcome, PlanningObjectiveVector, PlanningTrace, + PlanningTraceUpdate, } from '../../services/leave-planner.service'; import { UserInputService } from '../../services/user-input-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; @@ -408,7 +409,10 @@ function jointTrace(): PlanningTrace { describe('CalendarYear planning outcome boundary', () => { beforeEach(() => localStorage.clear()); - afterEach(() => localStorage.clear()); + afterEach(() => { + localStorage.clear(); + vi.useRealTimers(); + }); it.each([ { partial: false, placements: 2 }, @@ -418,11 +422,31 @@ describe('CalendarYear planning outcome boundary', () => { ])( 'opens calculated statistics independently of the trace (partial: $partial, placements: $placements)', async ({ partial, placements }) => { + vi.setSystemTime(new Date('2024-05-01T12:00:00Z')); const pendingTrace = deferred(); const validPlan: PlanOutcome = { ...plan, + calculationDate: '2024-05-01', placements: placements ? plan.placements : [], declaredDays: [{ date: '2024-05-07', type: 'rtt' }], + ifThenContributions: [ + { + ruleId: 'original-rule', + label: 'Original generating rule', + occurrence: '2024-05-08', + requested: 1, + declaredDays: [{ date: '2024-05-07', type: 'rtt' }], + placementDates: [], + earnedEntitlement: { + id: 'generated-entitlement', + type: 'cp', + quantity: 3, + availableFrom: '2024-05-09', + expiryDate: '2025-05-09', + added: true, + }, + }, + ], metrics: { ...plan.metrics, averageWorkSequence: 10 / 3, @@ -485,6 +509,8 @@ describe('CalendarYear planning outcome boundary', () => { ['Longest work sequence (working days)', '6'], ['Average time-off period (calendar days)', '2.5'], ['Longest time-off period (calendar days)', '4'], + ['Earned leave before use (days)', '3'], + ['Original generating rule', '3'], ]; expect(statistics()).toEqual(expectedStatistics); expect(dialog.textContent?.includes('Partial plan')).toBe(partial); @@ -504,6 +530,7 @@ describe('CalendarYear planning outcome boundary', () => { rules.deleteRule(rules.rules[0].id); TestBed.inject(CalendarSettingsService).setPlanningDates('2021-05-14', '2021-05-14'); input.declarationEntrySignal.set(true); + vi.setSystemTime(new Date('2024-05-10T12:00:00Z')); fixture.detectChanges(); await fixture.whenStable(); expect(statistics()).toEqual(expectedStatistics); @@ -512,6 +539,423 @@ describe('CalendarYear planning outcome boundary', () => { }, ); + it.each([2, 3])('shows 3 gross earned days after consuming %s', async (used) => { + vi.setSystemTime(new Date('2024-05-01T21:59:59Z')); + try { + const core = await import('../../services/leave-planner.core'); + const outcome = core.calculatePlanningInternal({ + horizon: { start: '2024-04-29', end: used === 2 ? '2024-05-07' : '2024-05-09' }, + balances: [], + declaredDays: ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' })), + rules: [ + { + id: 'samedi', + name: 'Samedi Malin', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placement: 'before', + placeDays: 2, + placeType: 'cp', + rewardDays: 3, + rewardType: 'other', + }, + { + id: 'use', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-02', + endDate: '2024-05-08', + placeDays: used, + placeType: 'other', + }, + ], + }); + if (outcome.kind !== 'Plan') throw new Error('Expected an earned-leave plan.'); + expect(outcome.placements).toHaveLength(used); + expect(outcome.remainingBalances).toMatchObject(used === 2 ? [{ quantity: 1 }] : []); + const { calendar } = calendarFor(outcome); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + vi.setSystemTime(new Date('2024-05-02T12:00:00Z')); + fixture.nativeElement.querySelector('.statistics-button').click(); + await fixture.whenStable(); + const dialog = document.querySelector('[role="dialog"]')!; + expect( + dialog + .querySelector('.statistics-summary > div:last-child dd') + ?.textContent?.trim(), + ).toBe('3'); + expect( + Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [ + label.textContent?.trim(), + label.nextElementSibling?.textContent?.trim(), + ]), + ).toEqual([['Samedi Malin', '3']]); + } finally { + vi.useRealTimers(); + } + }); + + describe('gross earned leave from finalized occurrences', () => { + const producer = { + id: 'samedi', + name: 'Samedi Malin', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placement: 'before', + placeDays: 2, + placeType: 'cp', + rewardDays: 2, + rewardType: 'other', + }; + const declaredDays = ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' })); + const submitted = { + horizon: { start: '2024-04-29', end: '2024-05-07' }, + balances: [], + rules: [producer], + declaredDays, + }; + it.each([ + { + scenario: 'availability after the calculation date', + today: '2024-05-01', + request: submitted, + total: 2, + availableFrom: '2024-05-02', + rows: [['Samedi Malin', '2']], + }, + { + scenario: 'availability on the calculation date', + today: '2024-05-02', + request: submitted, + total: 0, + availableFrom: '2024-05-02', + rows: [], + }, + { + scenario: 'availability before the calculation date', + today: '2024-05-03', + request: submitted, + total: 0, + availableFrom: '2024-05-02', + rows: [], + }, + { + scenario: 'future earnings after the horizon without consumption', + today: '2024-05-01', + request: { ...submitted, horizon: { start: '2024-04-29', end: '2024-05-02' } }, + total: 2, + availableFrom: '2024-05-02', + rows: [['Samedi Malin', '2']], + }, + { + scenario: 'a later extension makes a provisional historical right future', + today: '2024-05-03', + request: { + ...submitted, + horizon: { start: '2024-04-29', end: '2024-05-04' }, + declaredDays: [...declaredDays, { date: '2024-05-03', type: 'other' }], + balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }], + rules: [ + producer, + { + id: 'extend', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-02', + endDate: '2024-05-02', + placeDays: 1, + placeType: 'cp', + }, + ], + }, + total: 2, + availableFrom: '2024-05-06', + rows: [['Samedi Malin', '2']], + }, + { + scenario: 'repeated shared occurrences attributed to distinct same-name rules', + today: '2024-05-01', + request: { + ...submitted, + horizon: { start: '2024-04-29', end: '2024-05-09' }, + declaredDays: ['2024-04-30', '2024-05-07'].map((date) => ({ + date, + type: 'cp', + })), + rules: [ + { + ...producer, + id: 'first', + name: 'Same name', + placeDays: 1, + rewardDays: 1, + }, + { + ...producer, + id: 'second', + name: 'Same name', + placeDays: 1, + rewardDays: 2, + }, + { ...producer, placeDays: 1, rewardDays: 3 }, + { ...producer, id: 'disabled', enabled: false, rewardDays: 99 }, + { ...producer, id: 'zero', rewardDays: 0, placeDays: 1 }, + ], + }, + total: 12, + availableFrom: '2024-05-02', + rows: [ + ['Same name', '2'], + ['Same name', '4'], + ['Samedi Malin', '6'], + ], + }, + ])('renders $scenario', async ({ today, request, total, availableFrom, rows }) => { + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + try { + const core = await import('../../services/leave-planner.core'); + const outcome = core.calculatePlanningInternal(request); + if (outcome.kind !== 'Plan') throw new Error('Expected a valid generating plan.'); + expect(outcome.ifThenContributions?.[0].earnedEntitlement?.availableFrom).toBe( + availableFrom, + ); + const { calendar } = calendarFor(outcome); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + vi.setSystemTime(new Date('2025-01-01T12:00:00Z')); + fixture.nativeElement.querySelector('.statistics-button').click(); + await fixture.whenStable(); + const dialog = document.querySelector('[role="dialog"]')!; + expect( + dialog + .querySelector('.statistics-summary > div:last-child dd') + ?.textContent?.trim(), + ).toBe(String(total)); + expect( + Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [ + label.textContent?.trim(), + label.nextElementSibling?.textContent?.trim(), + ]), + ).toEqual(rows); + expect(!!dialog.querySelector('#earned-leave-title')).toBe(total > 0); + } finally { + vi.useRealTimers(); + } + }); + + it.each(['declarations', 'placements', 'both'])( + 'renders an occurrence funded by %s', + async (source) => { + vi.setSystemTime(new Date('2024-05-01T12:00:00Z')); + try { + const core = await import('../../services/leave-planner.core'); + const fixed = source === 'declarations' ? 2 : source === 'both' ? 1 : 0; + const outcome = core.calculatePlanningInternal({ + ...submitted, + declaredDays: declaredDays.slice(0, fixed), + balances: + fixed === 2 + ? [] + : [ + { + id: 'cp', + type: 'cp', + quantity: 2 - fixed, + expiryDate: '2025-12-31', + }, + ], + }); + if (outcome.kind !== 'Plan') + throw new Error('Expected a valid generating plan.'); + expect(outcome.ifThenContributions?.[0].declaredDays).toHaveLength(fixed); + expect(outcome.ifThenContributions?.[0].placementDates).toHaveLength(2 - fixed); + const { calendar } = calendarFor(outcome); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('.statistics-button').click(); + await fixture.whenStable(); + expect( + document + .querySelector( + '[role="dialog"] .statistics-summary > div:last-child dd', + ) + ?.textContent?.trim(), + ).toBe('2'); + expect( + Array.from( + document.querySelectorAll('[role="dialog"] .earned-leave-breakdown dt'), + (label) => [ + label.textContent?.trim(), + label.nextElementSibling?.textContent?.trim(), + ], + ), + ).toEqual([['Samedi Malin', '2']]); + } finally { + vi.useRealTimers(); + } + }, + ); + }); + + it('shows the retained earnings when cancellation interrupts a different generating candidate', async () => { + vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); + // Flush the original trace deterministically so cancellation occurs at the observed branch. + let elapsed = 0; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 30)); + let checkpoint: PlanOutcome | undefined; + let interrupted = false; + let latestGenerator: string | undefined; + let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {}; + try { + const core = await import('../../services/leave-planner.core'); + const outcome = await core.calculatePlanningAsyncInternal( + { + horizon: { start: '2024-04-29', end: '2024-05-11' }, + declaredDays: [ + { date: '2024-04-29', type: 'cp' }, + { date: '2024-05-03', type: 'cp' }, + { date: '2024-05-06', type: 'other' }, + ], + balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }], + rules: [ + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-10', + endDate: '2024-05-10', + placeDays: 1, + placeType: 'other', + }, + ...['before', 'after'].map((placement, index) => ({ + id: placement, + name: placement, + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placement, + placeDays: 2, + placeType: 'cp', + rewardDays: index + 1, + rewardType: 'other', + })), + ], + traceProgress: (_: number, update: PlanningTraceUpdate) => { + if (interrupted) return; + for (const operation of update.operations) { + const deltas = + operation.kind === 'delta' + ? [operation.delta] + : operation.kind === 'decision' + ? (operation.decision.branch?.deltas ?? []) + : []; + for (const delta of deltas) { + if (delta.kind !== 'earned-activated') continue; + latestGenerator = delta.activation?.ruleId; + if (checkpoint && latestGenerator === 'before') interrupted = true; + } + } + }, + }, + (value) => (checkpoint = value), + (value) => (artifacts = value), + undefined, + undefined, + () => interrupted, + ); + clock.mockRestore(); + if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent) + throw new Error('Expected a real cancellation with a retained valid plan.'); + expect(interrupted).toBe(true); + expect(latestGenerator).toBe('before'); + expect(outcome.incumbent.ifThenContributions).toMatchObject([ + { ruleId: 'after', earnedEntitlement: { quantity: 2, added: true } }, + ]); + expect(outcome.incumbent.ifThenContributions).toHaveLength(1); + expect(outcome.incumbent.placements.map(({ date }) => date)).toEqual([ + '2024-05-02', + '2024-05-10', + ]); + const latestBranch = artifacts.trace?.phases + .flatMap(({ decisions }) => decisions) + .filter(({ branch }) => branch) + .at(-1); + expect(latestBranch?.candidates).toMatchObject([ + { status: 'evaluated-rejected', dates: ['2024-04-29', '2024-04-30'] }, + ]); + expect(latestBranch?.branch?.deltas).toContainEqual( + expect.objectContaining({ + kind: 'earned-activated', + activation: expect.objectContaining({ ruleId: 'before' }), + }), + ); + const { calendar } = calendarFor(outcome, artifacts.explanation, artifacts.trace); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('.statistics-button').click(); + await fixture.whenStable(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain('Partial plan'); + expect( + dialog + .querySelector('.statistics-summary > div:last-child dd') + ?.textContent?.trim(), + ).toBe('2'); + expect( + Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [ + label.textContent?.trim(), + label.nextElementSibling?.textContent?.trim(), + ]), + ).toEqual([['after', '2']]); + } finally { + clock.mockRestore(); + vi.useRealTimers(); + } + }); + it.each([false, true])( 'distinguishes empty averages from zero work and gain in a valid plan (all workdays off: %s)', async (allDaysOff) => { @@ -547,7 +991,11 @@ describe('CalendarYear planning outcome boundary', () => { Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) => value.textContent?.trim(), ), - ).toEqual(allDaysOff ? ['0', '4', '—', '0', '9', '9'] : ['0', '0', '5', '5', '—', '—']); + ).toEqual( + allDaysOff + ? ['0', '4', '—', '0', '9', '9', '0'] + : ['0', '0', '5', '5', '—', '—', '0'], + ); }, ); @@ -595,7 +1043,7 @@ describe('CalendarYear planning outcome boundary', () => { expect(dialog.textContent).toContain(calendarUnit); expect( Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()), - ).toEqual(['2', '4', '3,333', '6', '2,5', '4']); + ).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0']); } expect(dialog.textContent).toContain('Statistiques du planning'); expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés'); @@ -2891,7 +3339,7 @@ describe('CalendarYear planning outcome boundary', () => { Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) => value.textContent?.trim(), ), - ).toEqual(['2', '4', '6', '6', '—', '—']); + ).toEqual(['2', '4', '6', '6', '—', '—', '0']); document.querySelector('[role="dialog"] button')!.click(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); activateWithKeyboard(afterButton); diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 2b8ceb0..31356fe 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -1295,7 +1295,35 @@
{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}
+
+
+ {{ tr().planning.statistics.earnedLeave }} ({{ + tr().planning.statistics.leaveDaysUnit + }}) +
+
{{ formatStatistic(earnedLeave().total) }}
+
+ @if (earnedLeave().contributions.length) { +
+

+ {{ tr().planning.statistics.earnedLeaveByRule }} ({{ + tr().planning.statistics.leaveDaysUnit + }}) +

+
+ @for ( + contribution of earnedLeave().contributions; + track contribution.ruleId + ) { +
+
{{ contribution.label }}
+
{{ formatStatistic(contribution.quantity) }}
+
+ } +
+
+ } } diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss index f866c15..292825e 100644 --- a/src/app/components/calendar-year/calendar-year.scss +++ b/src/app/components/calendar-year/calendar-year.scss @@ -228,6 +228,25 @@ summary:focus-visible { .statistics-summary { grid-template-columns: minmax(0, 1fr); } +.earned-leave-breakdown { + display: grid; + gap: 0.75rem; + margin: 0; + div { + display: flex; + justify-content: space-between; + gap: 1rem; + } + dt { + min-width: 0; + overflow-wrap: anywhere; + } + dd { + margin: 0; + font-weight: 600; + font-variant-numeric: tabular-nums; + } +} .placement-explanation, .outcome-explanation, diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 439a469..c3b4dcc 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -202,6 +202,25 @@ export class CalendarYear { } protected readonly displayedPlan = this.userInput.displayedPlanSignal; + protected readonly earnedLeave = computed(() => { + const byRule = new Map(); + for (const contribution of this.displayedPlan()?.ifThenContributions ?? []) { + const entitlement = contribution.earnedEntitlement; + if (!entitlement?.added || entitlement.quantity <= 0) continue; + const entry = byRule.get(contribution.ruleId) ?? { + ruleId: contribution.ruleId, + label: contribution.label, + quantity: 0, + }; + entry.quantity += entitlement.quantity; + byRule.set(entry.ruleId, entry); + } + const contributions = [...byRule.values()]; + return { + total: contributions.reduce((total, contribution) => total + contribution.quantity, 0), + contributions, + }; + }); protected readonly planExplanation = computed(() => { const explanation = this.planningExplanation(); diff --git a/src/app/services/leave-planner.worker.spec.ts b/src/app/services/leave-planner.worker.spec.ts index 6313e70..7656952 100644 --- a/src/app/services/leave-planner.worker.spec.ts +++ b/src/app/services/leave-planner.worker.spec.ts @@ -214,9 +214,36 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); ]); if (outcome.kind === 'PlanningIncomplete') expect(outcome.reasons[0].code).toBe('user-cancelled'); + expect(plan.remainingBalances).toEqual([]); + await fixture.whenStable(); + fixture.detectChanges(); + const root: HTMLElement = fixture.nativeElement; + root.querySelector('.statistics-button')!.click(); + await fixture.whenStable(); + const dialog = document.querySelector('[role="dialog"]')!; + const earnedStatistics = () => [ + dialog + .querySelector('.statistics-summary > div:last-child dt') + ?.textContent?.replace(/\s+/g, ' ') + .trim(), + dialog + .querySelector('.statistics-summary > div:last-child dd') + ?.textContent?.trim(), + ...Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [ + label.textContent?.trim(), + label.nextElementSibling?.textContent?.trim(), + ]), + ]; + expect(earnedStatistics()).toEqual([ + 'Congés acquis avant consommation (jours)', + '1', + ['CP autour du férié', '1'], + ]); + expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled); const originalDeclarations = structuredClone(calculation.snapshot.declaredDays); input.toggleDeclaredDay('2024-04-30', 'rtt'); rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' }); + rules.deleteRule('producer'); input.updateBalances(() => []); if (worker) { await vi.waitFor(() => expect(heldArtifactMessages.length).toBeGreaterThan(0)); @@ -231,6 +258,13 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); const trace = await calculation.trace; await fixture.whenStable(); fixture.detectChanges(); + expect(earnedStatistics()).toEqual([ + 'Congés acquis avant consommation (jours)', + '1', + ['CP autour du férié', '1'], + ]); + dialog.querySelector('button')!.click(); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); expect(explanation).toMatchObject({ kind: 'PlanExplanation', calculationDate: '2024-05-01', @@ -258,7 +292,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); expect(plan.placements).toEqual(direct.placements); expect(plan.ifThenContributions).toEqual(direct.ifThenContributions); expect(plan.metrics).toEqual(direct.metrics); - const root: HTMLElement = fixture.nativeElement; expect(root.textContent).toContain('Droit futur généré'); expect(root.textContent).toContain('CP autour du férié'); expect(root.textContent).not.toContain('Règle modifiée après calcul'); diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index c76a33b..ca6fbe1 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -134,6 +134,9 @@ export interface AppTranslations { averageWorkSequence: string; averageTimeOffPeriod: string; longestTimeOffPeriod: string; + earnedLeave: string; + earnedLeaveByRule: string; + leaveDaysUnit: string; workingDays: string; calendarDays: string; partialPlan: string; diff --git a/src/i18n/de.json b/src/i18n/de.json index 2045d23..ec28422 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -146,7 +146,10 @@ "workingDays": "Arbeitstage", "calendarDays": "Kalendertage", "partialPlan": "Teilplan", - "help": "Statistik des berechneten Plans. Bereits gebuchte Tage prägen Ruhe- und Arbeitsphasen, ohne das verbleibende Guthaben erneut zu verbrauchen." + "help": "Statistik des berechneten Plans. Bereits gebuchte Tage prägen Ruhe- und Arbeitsphasen, ohne das verbleibende Guthaben erneut zu verbrauchen.", + "earnedLeave": "Erworbener Urlaub vor Verbrauch", + "leaveDaysUnit": "Tage", + "earnedLeaveByRule": "Erworbener Urlaub nach Regel" }, "calculationDate": "Berechnungsdatum", "futureEntitlement": "Erzeugter künftiger Anspruch", diff --git a/src/i18n/en.json b/src/i18n/en.json index 5ed0729..124872f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -146,7 +146,10 @@ "workingDays": "working days", "calendarDays": "calendar days", "partialPlan": "Partial plan", - "help": "Statistics for the calculated plan. Already booked days shape breaks and working stretches without consuming remaining balances." + "help": "Statistics for the calculated plan. Already booked days shape breaks and working stretches without consuming remaining balances.", + "earnedLeave": "Earned leave before use", + "leaveDaysUnit": "days", + "earnedLeaveByRule": "Earned leave by rule" }, "calculationDate": "Calculation date", "futureEntitlement": "Future entitlement generated", diff --git a/src/i18n/es.json b/src/i18n/es.json index ac60500..e54274a 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -146,7 +146,10 @@ "workingDays": "días laborables", "calendarDays": "días naturales", "partialPlan": "Plan parcial", - "help": "Estadísticas del plan calculado. Los días ya reservados definen los períodos de descanso y trabajo sin consumir de nuevo los saldos restantes." + "help": "Estadísticas del plan calculado. Los días ya reservados definen los períodos de descanso y trabajo sin consumir de nuevo los saldos restantes.", + "earnedLeave": "Permisos adquiridos antes de su uso", + "leaveDaysUnit": "días", + "earnedLeaveByRule": "Permisos adquiridos por regla" }, "calculationDate": "Fecha del cálculo", "futureEntitlement": "Derecho futuro generado", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index df1ff0e..a8c7700 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -146,7 +146,10 @@ "workingDays": "jours ouvrés", "calendarDays": "jours calendaires", "partialPlan": "Planning partiel", - "help": "Statistiques du planning calculé. Les jours déjà posés contribuent aux périodes de repos et de travail sans consommer à nouveau les soldes." + "help": "Statistiques du planning calculé. Les jours déjà posés contribuent aux périodes de repos et de travail sans consommer à nouveau les soldes.", + "earnedLeave": "Congés acquis avant consommation", + "leaveDaysUnit": "jours", + "earnedLeaveByRule": "Congés acquis par règle" }, "calculationDate": "Date du calcul", "futureEntitlement": "Droit futur généré",