feat(stats): show gross earned leave by rule (#67)

This commit is contained in:
nessar committed 2026-10-07 22:58:15 +02:00
1 parent 3fbf9c3172
commit da08ee054d
10 files changed
+571 -9

No files matched your search

@@ -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<PlanningTrace | undefined>();
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<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton);
@@ -1295,7 +1295,35 @@
</dt>
<dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.earnedLeave }} ({{
tr().planning.statistics.leaveDaysUnit
}})
</dt>
<dd>{{ formatStatistic(earnedLeave().total) }}</dd>
</div>
</dl>
@if (earnedLeave().contributions.length) {
<section aria-labelledby="earned-leave-title">
<h3 id="earned-leave-title">
{{ tr().planning.statistics.earnedLeaveByRule }} ({{
tr().planning.statistics.leaveDaysUnit
}})
</h3>
<dl class="earned-leave-breakdown">
@for (
contribution of earnedLeave().contributions;
track contribution.ruleId
) {
<div>
<dt>{{ contribution.label }}</dt>
<dd>{{ formatStatistic(contribution.quantity) }}</dd>
</div>
}
</dl>
</section>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@@ -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,
@@ -202,6 +202,25 @@ export class CalendarYear {
}
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
protected readonly earnedLeave = computed(() => {
const byRule = new Map<string, { ruleId: string; label: string; quantity: number }>();
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();
+34 -1
View File
@@ -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<HTMLButtonElement>('.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<HTMLButtonElement>('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');
+3
View File
@@ -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;
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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é",