+ Ces réglages gouvernent{' '}
+
+ le planning à afficher
+
+ . Le rendu passe par la boîte d’impression du navigateur, qui sait aussi
+ enregistrer en PDF : un second moteur de rendu serait un second endroit
+ où la feuille peut diverger de l’écran.
+
+ L’objectif vaut pour l’ensemble des établissements. Vider le champ
+ l’efface plutôt que de le ramener à zéro : zéro serait un objectif
+ inatteignable, l’absence de valeur dit qu’aucun objectif n’a été fixé.
+
+
+
+ Un objectif de productivité rapporte un chiffre d’affaires à des heures
+ planifiées. Il éclaire une décision d’organisation ; il ne mesure pas le
+ travail d’une personne, et n’a pas à être opposé à un salarié.
+
+
+ );
+}
diff --git a/src/components/planning/CounterStrip.tsx b/src/components/planning/CounterStrip.tsx
index be4a338..f73882a 100644
--- a/src/components/planning/CounterStrip.tsx
+++ b/src/components/planning/CounterStrip.tsx
@@ -27,9 +27,16 @@ function Cell({ label, value }: { label: string; value: string }) {
}
/**
- * Les cinq compteurs d'une ligne salarié : contrat, planifié, absences, écart,
- * dimanches et repos. Rendus depuis `computeWeekCounters`, jamais recalculés
- * localement.
+ * Les cinq compteurs d'une ligne salarié — PLAN.md §7.4 : **heures
+ * contractuelles, planifié, absences, écart, repos compensateur**.
+ *
+ * Ce sont ces cinq-là et pas d'autres. Les dimanches travaillés et les jours de
+ * repos se lisent sur la grille ; le repos compensateur, lui, ne se lit nulle
+ * part ailleurs — c'est une contrepartie **due**, et l'omettre du bandeau
+ * masque la seule valeur qui dit qu'une dette existe.
+ *
+ * Rendus depuis `computeWeekCounters`, jamais recalculés localement : la même
+ * implémentation alimente la grille, le rapport d'heures et l'export de paie.
*/
export function CounterStrip({
counters,
@@ -57,7 +64,10 @@ export function CounterStrip({
Forfait jours
+ {/* Un forfait jours reste soumis aux repos : le RC s'affiche, l'écart
+ horaire non. */}
+
);
}
@@ -71,6 +81,7 @@ export function CounterStrip({
·
+
{counters.deltaLabel}
-
-
+
);
}
diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts
index ee143ec..bec4891 100644
--- a/src/components/shell/navigation.ts
+++ b/src/components/shell/navigation.ts
@@ -125,6 +125,12 @@ export const NAVIGATION: NavSection[] = [
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
{ id: 'preferences', label: 'Préférences', href: '/reglages/preferences' },
{ id: 'paie-reglages', label: 'Réglages de paie', href: '/reglages/paie' },
+ { id: 'impression', label: 'Impression', href: '/reglages/impression' },
+ {
+ id: 'productivite',
+ label: 'Productivité',
+ href: '/reglages/productivite',
+ },
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
diff --git a/src/domain/counters/week.ts b/src/domain/counters/week.ts
index 37cf16c..6199675 100644
--- a/src/domain/counters/week.ts
+++ b/src/domain/counters/week.ts
@@ -22,6 +22,15 @@ export interface WeekCounters {
sundaysWorked: number;
/** Jours de repos dans la semaine. */
restDays: number;
+ /**
+ * Repos compensateur posé sur la semaine, en minutes.
+ *
+ * Distinct de `restDays` : un jour sans créneau est un jour sans travail,
+ * tandis qu'un repos compensateur est une **contrepartie due** — au dimanche
+ * travaillé, au dépassement de contingent. Les confondre ferait disparaître
+ * du bandeau la seule valeur qui dit qu'une dette existe.
+ */
+ compensatoryRestMinutes: number;
}
export interface WeekCountersView extends WeekCounters {
@@ -31,6 +40,7 @@ export interface WeekCountersView extends WeekCounters {
plannedLabel: string;
absenceLabel: string;
deltaLabel: string;
+ compensatoryRestLabel: string;
tone: DeltaTone;
}
@@ -74,6 +84,7 @@ export function computeWeekCounters(input: WeekCounters): WeekCountersView {
plannedLabel: formatMinutes(input.plannedMinutes),
absenceLabel: formatMinutes(input.absenceMinutes),
deltaLabel: formatDelta(deltaMinutes),
+ compensatoryRestLabel: formatMinutes(input.compensatoryRestMinutes),
tone: deltaTone(deltaMinutes),
};
}
diff --git a/src/domain/planning/board.ts b/src/domain/planning/board.ts
index 00b889f..657bee4 100644
--- a/src/domain/planning/board.ts
+++ b/src/domain/planning/board.ts
@@ -76,6 +76,19 @@ export interface AbsenceInput {
colorKey: string;
}
+/**
+ * Repos posé sur la semaine.
+ *
+ * Seul le repos **compensateur** alimente le compteur RC : le repos
+ * hebdomadaire est un droit déjà pris, tandis que le compensateur est une
+ * contrepartie due, et le bandeau doit dire laquelle des deux est en jeu.
+ */
+export interface RestInput {
+ membershipId: string;
+ restType: 'WEEKLY_REST' | 'COMPENSATORY_REST';
+ minutes: number | null;
+}
+
/**
* Range les créneaux d'une semaine en lignes × sept colonnes.
*
@@ -91,9 +104,19 @@ export function buildRows(
timeZone: string,
isPublished: boolean,
absences: AbsenceInput[] = [],
+ rests: RestInput[] = [],
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
+ const compensatoryByMember = new Map();
+ for (const rest of rests) {
+ if (rest.restType !== 'COMPENSATORY_REST') continue;
+ compensatoryByMember.set(
+ rest.membershipId,
+ (compensatoryByMember.get(rest.membershipId) ?? 0) + (rest.minutes ?? 0),
+ );
+ }
+
const emptyDays = (): BoardShift[][] =>
Array.from({ length: 7 }, () => [] as BoardShift[]);
@@ -199,6 +222,8 @@ export function buildRows(
),
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
restDays: 7 - worked,
+ compensatoryRestMinutes:
+ compensatoryByMember.get(person.membershipId) ?? 0,
},
} satisfies BoardRow;
});
@@ -221,6 +246,7 @@ export function buildRows(
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
+ compensatoryRestMinutes: 0,
},
}
: null;
diff --git a/src/domain/settings/preferences.ts b/src/domain/settings/preferences.ts
index 6a976aa..8e419c3 100644
--- a/src/domain/settings/preferences.ts
+++ b/src/domain/settings/preferences.ts
@@ -8,7 +8,27 @@
* le JSX.
*/
-export type PreferenceGroup = 'planning' | 'rights' | 'payroll';
+export type PreferenceGroup = 'planning' | 'rights' | 'payroll' | 'print';
+
+/** Densités d'impression. Liste fermée : elle décide d'une mise en page. */
+export const PRINT_DENSITIES = [
+ {
+ key: 'large',
+ label: 'Grande',
+ hint: 'Moins de salariés par page, lisible à distance sur un mur.',
+ },
+ {
+ key: 'compact',
+ label: 'Compacte',
+ hint: 'Tout l’effectif sur une page, au prix de la lisibilité de loin.',
+ },
+] as const;
+
+export type PrintDensity = (typeof PRINT_DENSITIES)[number]['key'];
+
+export function isPrintDensity(value: string): value is PrintDensity {
+ return PRINT_DENSITIES.some((density) => density.key === value);
+}
export interface PreferenceToggle {
key: string;
@@ -138,6 +158,34 @@ export const PREFERENCE_TOGGLES: PreferenceToggle[] = [
label: 'Générer le matricule automatiquement',
hint: 'Décoché, le matricule est saisi à l’embauche — utile quand il doit correspondre à celui du logiciel de paie.',
},
+
+ // --- Impression --------------------------------------------------------
+ {
+ key: 'printContractTotals',
+ group: 'print',
+ label: 'Imprimer les totaux contractuels et planifiés',
+ hint: 'Ajoute une colonne de totaux par salarié.',
+ },
+ {
+ key: 'printOtherTeams',
+ group: 'print',
+ label: 'Imprimer les créneaux des autres équipes',
+ hint: 'Situe la journée d’ensemble sur la feuille d’une équipe.',
+ },
+ {
+ key: 'printSunday',
+ group: 'print',
+ label: 'Imprimer le dimanche',
+ hint: 'À décocher pour un établissement qui n’ouvre jamais ce jour-là : la colonne gagnée profite aux six autres.',
+ },
+ {
+ key: 'printSignatureColumn',
+ group: 'print',
+ label: 'Imprimer la colonne « émargements »',
+ hint: 'Colonne vierge pour signature manuscrite.',
+ warning:
+ 'Un émargement papier ne vaut pas décompte du temps de travail : le décompte reste celui de PlanFlow, pas celui de la feuille signée.',
+ },
];
export const PREFERENCE_KEYS: string[] = PREFERENCE_TOGGLES.map(
diff --git a/src/server/planning/queries.ts b/src/server/planning/queries.ts
index fd51489..38dcd66 100644
--- a/src/server/planning/queries.ts
+++ b/src/server/planning/queries.ts
@@ -147,6 +147,22 @@ export async function getWeekBoard(
schedules.map((schedule) => [schedule.teamId, schedule]),
);
+ // Repos posés sur la semaine. Seul le compensateur alimente le compteur
+ // RC du bandeau ; le repos hebdomadaire est chargé avec, parce que les
+ // deux se posent sur la même table et que filtrer ici coûterait une
+ // seconde requête pour une distinction que le domaine fait déjà.
+ const rests = await db.rest.findMany({
+ where: {
+ weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
+ },
+ select: {
+ weeklyScheduleId: true,
+ membershipId: true,
+ restType: true,
+ minutes: true,
+ },
+ });
+
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
@@ -236,6 +252,13 @@ export async function getWeekBoard(
location.timezone,
isPublished,
absenceInputs,
+ rests
+ .filter((rest) => rest.weeklyScheduleId === schedule?.id)
+ .map((rest) => ({
+ membershipId: rest.membershipId,
+ restType: rest.restType,
+ minutes: rest.minutes,
+ })),
);
const teamAlerts = alerts
diff --git a/src/server/settings/account.ts b/src/server/settings/account.ts
index 2745215..28f4464 100644
--- a/src/server/settings/account.ts
+++ b/src/server/settings/account.ts
@@ -5,6 +5,7 @@ import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import {
+ isPrintDensity,
PREFERENCE_KEYS,
timeToMinutes,
} from '@/domain/settings/preferences';
@@ -52,6 +53,14 @@ export interface Preferences {
smoothOvertimeMonthly: boolean;
includeRestInNormalHours: boolean;
autoEmployeeNumber: boolean;
+ printLandscape: boolean;
+ printDensity: string;
+ printContractTotals: boolean;
+ printOtherTeams: boolean;
+ printSunday: boolean;
+ printSignatureColumn: boolean;
+ /** Chaîne décimale, ou `null` quand aucun objectif n'est fixé. */
+ productivityTargetPerHour: string | null;
}
const DEFAULTS: Preferences = {
@@ -73,6 +82,13 @@ const DEFAULTS: Preferences = {
smoothOvertimeMonthly: false,
includeRestInNormalHours: false,
autoEmployeeNumber: false,
+ printLandscape: true,
+ printDensity: 'large',
+ printContractTotals: true,
+ printOtherTeams: false,
+ printSunday: true,
+ printSignatureColumn: true,
+ productivityTargetPerHour: null,
};
export async function getAccountIdentity(): Promise {
@@ -103,8 +119,20 @@ export async function getPreferences(): Promise {
});
if (!row) return DEFAULTS;
- const { accountId: _ignored, updatedAt: _stamp, ...values } = row;
- return values;
+ const {
+ accountId: _ignored,
+ updatedAt: _stamp,
+ productivityTargetPerHour,
+ ...values
+ } = row;
+
+ return {
+ ...values,
+ // Un Decimal Prisma ne traverse pas la frontière serveur/client. Une
+ // chaîne le fait sans perdre de décimale, ce qu'un `number` ne garantit
+ // pas sur un montant.
+ productivityTargetPerHour: productivityTargetPerHour?.toString() ?? null,
+ };
});
}
@@ -219,11 +247,27 @@ export async function savePreferencesAction(
return { error: 'Formulaire incomplet : aucun réglage à enregistrer.' };
}
- const values: Record = {};
+ const values: Record = {};
for (const key of scope) {
values[key] = formData.get(key) === 'on';
}
+ // L'orientation et la densité vivent dans le même formulaire que les
+ // interrupteurs d'impression : elles ne sont lues que si le formulaire les
+ // porte, sinon un autre bloc les remettrait à leur défaut.
+ const orientation = formData.get('printOrientation');
+ if (typeof orientation === 'string' && orientation !== '') {
+ values.printLandscape = orientation === 'landscape';
+ }
+
+ const density = formData.get('printDensity');
+ if (typeof density === 'string' && density !== '') {
+ if (!isPrintDensity(density)) {
+ return { error: 'Densité d’impression inconnue.' };
+ }
+ values.printDensity = density;
+ }
+
const eveningRaw = formData.get('eveningShiftStart');
if (typeof eveningRaw === 'string' && eveningRaw !== '') {
const minutes = timeToMinutes(eveningRaw);
@@ -270,5 +314,72 @@ export async function savePreferencesAction(
revalidatePath('/reglages/preferences');
revalidatePath('/reglages/paie');
+ revalidatePath('/reglages/impression');
+ return { ok: true };
+}
+
+const productivityInput = z.object({
+ target: z
+ .union([
+ z.literal(''),
+ z.coerce
+ .number()
+ .min(0, 'Un objectif négatif n’a pas de sens')
+ .max(100_000),
+ ])
+ .transform((value) => (value === '' ? null : value)),
+});
+
+/**
+ * Objectif de productivité, en euros de chiffre d'affaires par heure.
+ *
+ * Vider le champ **efface** l'objectif au lieu de le mettre à zéro : zéro est
+ * un objectif inatteignable, l'absence de valeur dit qu'aucun objectif n'a été
+ * fixé. Un écran qui afficherait « 0 €/h attendu » pousserait à corriger un
+ * réglage qui n'existe pas.
+ */
+export async function saveProductivityTargetAction(
+ _previous: ActionState,
+ formData: FormData,
+): Promise {
+ const parsed = productivityInput.safeParse({
+ target: formData.get('target') ?? '',
+ });
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
+ }
+
+ try {
+ await mutate('settings.access', async (db, actor) => {
+ const before = await db.accountPreferences.findUnique({
+ where: { accountId: actor.accountId },
+ });
+
+ await db.accountPreferences.upsert({
+ where: { accountId: actor.accountId },
+ create: { productivityTargetPerHour: parsed.data.target } as never,
+ update: { productivityTargetPerHour: parsed.data.target },
+ });
+
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: 'account.productivity.update',
+ entityType: 'AccountPreferences',
+ entityId: actor.accountId,
+ before: {
+ productivityTargetPerHour:
+ before?.productivityTargetPerHour?.toString() ?? null,
+ },
+ after: { productivityTargetPerHour: parsed.data.target },
+ });
+ });
+ } catch (error) {
+ if (error instanceof AuthorizationError) {
+ return { error: "Vous n'avez pas le droit de modifier les réglages." };
+ }
+ throw error;
+ }
+
+ revalidatePath('/reglages/productivite');
return { ok: true };
}
diff --git a/tests/unit/counters.test.ts b/tests/unit/counters.test.ts
index 62f2f6f..83d194d 100644
--- a/tests/unit/counters.test.ts
+++ b/tests/unit/counters.test.ts
@@ -50,6 +50,7 @@ describe('computeWeekCounters', () => {
absenceMinutes: h(18),
sundaysWorked: 0,
restDays: 5,
+ compensatoryRestMinutes: 0,
});
expect(counters.deltaMinutes).toBe(0);
@@ -64,6 +65,7 @@ describe('computeWeekCounters', () => {
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 2,
+ compensatoryRestMinutes: 0,
});
expect(counters.deltaLabel).toBe('+1 h 30');
@@ -77,6 +79,7 @@ describe('computeWeekCounters', () => {
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 2,
+ compensatoryRestMinutes: 0,
});
expect(counters.deltaLabel).toBe('−1 h 45');
diff --git a/tests/unit/planning-board.test.ts b/tests/unit/planning-board.test.ts
index 239b97f..d89fd22 100644
--- a/tests/unit/planning-board.test.ts
+++ b/tests/unit/planning-board.test.ts
@@ -194,6 +194,28 @@ describe('buildRows', () => {
'14:00–19:00',
]);
});
+
+ it('ne compte au RC que le repos compensateur', () => {
+ // Le repos hebdomadaire est un droit déjà pris ; le compensateur est une
+ // contrepartie due. Les additionner ferait disparaître la dette dans un
+ // total qui ne veut rien dire.
+ const { rows } = buildRows([person()], [], DATES, TZ, false, [], [
+ { membershipId: 'm1', restType: 'COMPENSATORY_REST', minutes: 420 },
+ { membershipId: 'm1', restType: 'WEEKLY_REST', minutes: 2100 },
+ ]);
+
+ expect(rows[0]?.counters.compensatoryRestMinutes).toBe(420);
+ });
+
+ it('tolère un repos sans durée', () => {
+ // `Rest.minutes` est nullable : un repos posé sans durée vaut zéro minute,
+ // pas NaN — un NaN se propagerait au bandeau et à l'export.
+ const { rows } = buildRows([person()], [], DATES, TZ, false, [], [
+ { membershipId: 'm1', restType: 'COMPENSATORY_REST', minutes: null },
+ ]);
+
+ expect(rows[0]?.counters.compensatoryRestMinutes).toBe(0);
+ });
});
describe('shiftState', () => {
diff --git a/tests/unit/planning-presence.test.ts b/tests/unit/planning-presence.test.ts
index 7502572..42bd45b 100644
--- a/tests/unit/planning-presence.test.ts
+++ b/tests/unit/planning-presence.test.ts
@@ -20,6 +20,7 @@ function row(overrides: Partial = {}): BoardRow {
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
+ compensatoryRestMinutes: 0,
},
days: [[], [], [], [], [], [], []],
absences: [],
diff --git a/tests/unit/preferences.test.ts b/tests/unit/preferences.test.ts
index eb6bcf7..288ff39 100644
--- a/tests/unit/preferences.test.ts
+++ b/tests/unit/preferences.test.ts
@@ -14,11 +14,13 @@ describe('catalogue de préférences', () => {
});
it('range chaque préférence dans un seul groupe', () => {
- const grouped = [
- ...togglesOf('planning'),
- ...togglesOf('rights'),
- ...togglesOf('payroll'),
+ // Les groupes se déduisent du catalogue plutôt que d'être listés ici : une
+ // liste tenue à la main laisserait un groupe entier sans écran, et le test
+ // passerait quand même.
+ const groups = [
+ ...new Set(PREFERENCE_TOGGLES.map((toggle) => toggle.group)),
];
+ const grouped = groups.flatMap((group) => togglesOf(group));
expect(grouped).toHaveLength(PREFERENCE_TOGGLES.length);
});