Corriger le bandeau de compteurs, et régler impression et productivité
Le bandeau de la grille affichait contrat, planifié, écart, dimanches et jours de repos. PLAN.md §7.4 en demande cinq autres : heures contractuelles, planifié, absences, écart, repos compensateur. La capture du produit audité le confirme. L'écart n'était pas cosmétique. Les dimanches travaillés et les jours de repos se lisent déjà sur la grille, colonne par colonne. Le repos compensateur, lui, ne se lit nulle part ailleurs : c'est une contrepartie due — au dimanche travaillé, au dépassement de contingent — et l'omettre du bandeau masquait la seule valeur qui dit qu'une dette existe. Le compteur RC n'était alimenté par rien : les repos posés n'étaient tout simplement pas chargés avec la semaine. Ils le sont, et seul le repos compensateur y entre — le repos hebdomadaire est un droit déjà pris, les additionner ferait disparaître la dette dans un total qui ne veut rien dire. Deux écrans de réglages complètent le lot. L'impression gouverne le planning affiché en salle : orientation, densité, totaux, dimanche, colonne d'émargement. Cette dernière porte un avertissement — un émargement papier ne vaut pas décompte du temps de travail, et le décompte reste celui de PlanFlow. L'objectif de productivité est nullable et non « zéro par défaut » : zéro est un objectif inatteignable, l'absence de valeur dit qu'aucun objectif n'a été fixé. Vider le champ efface donc l'objectif au lieu de le mettre à zéro. L'écran rappelle aussi ce que l'indicateur n'est pas : il éclaire une décision d'organisation, il ne mesure pas le travail d'une personne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8060eb4a9
commit
5f656d05af
17 files changed
+541
-13
No files matched your search
@@ -0,0 +1,17 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Impression et productivité — PLAN.md §9, `/reglages/impression` et
|
||||
-- `/reglages/productivite`.
|
||||
--
|
||||
-- `productivityTargetPerHour` est nullable et non « 0 par défaut » : zéro est un
|
||||
-- objectif — inatteignable — alors que l'absence de valeur dit qu'aucun objectif
|
||||
-- n'a été fixé. Un écran qui affiche « 0 €/h attendu » pousse à corriger un
|
||||
-- réglage qui n'existe pas.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printLandscape" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printDensity" TEXT NOT NULL DEFAULT 'large';
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printContractTotals" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printOtherTeams" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printSunday" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printSignatureColumn" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "productivityTargetPerHour" DECIMAL(10,2);
|
||||
@@ -105,6 +105,25 @@ model AccountPreferences {
|
||||
/// Générer le matricule à la création plutôt que de le saisir.
|
||||
autoEmployeeNumber Boolean @default(false)
|
||||
|
||||
// --- Impression --------------------------------------------------------
|
||||
/// Paysage par défaut : une semaine tient en largeur, pas en hauteur.
|
||||
printLandscape Boolean @default(true)
|
||||
/// Densité : « large » lisible à distance, « compact » pour un gros effectif.
|
||||
printDensity String @default("large")
|
||||
printContractTotals Boolean @default(true)
|
||||
/// Créneaux des autres équipes, pour situer une journée d'ensemble.
|
||||
printOtherTeams Boolean @default(false)
|
||||
printSunday Boolean @default(true)
|
||||
/// Colonne d'émargement — une signature manuscrite ne vaut pas décompte du
|
||||
/// temps de travail, mais reste demandée pour l'affichage en salle.
|
||||
printSignatureColumn Boolean @default(true)
|
||||
|
||||
// --- Productivité ------------------------------------------------------
|
||||
/// Objectif de chiffre d'affaires par heure travaillée, en euros.
|
||||
/// Nul tant qu'aucun objectif n'est fixé : zéro serait un objectif, pas une
|
||||
/// absence d'objectif.
|
||||
productivityTargetPerHour Decimal? @db.Decimal(10, 2)
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import { PRINT_DENSITIES, togglesOf } from '@/domain/settings/preferences';
|
||||
import {
|
||||
savePreferencesAction,
|
||||
type ActionState,
|
||||
type Preferences,
|
||||
} from '@/server/settings/account';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
export function PrintForm({ preferences }: { preferences: Preferences }) {
|
||||
const [state, action] = useActionState(savePreferencesAction, empty);
|
||||
const toggles = togglesOf('print');
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-4 px-4 py-4">
|
||||
{toggles.map((toggle) => (
|
||||
<input key={`scope-${toggle.key}`} type="hidden" name="scope" value={toggle.key} />
|
||||
))}
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium">Orientation</legend>
|
||||
<p className="text-micro text-ink-3">
|
||||
Une semaine se lit en largeur : le paysage évite de couper le
|
||||
dimanche sur une seconde page.
|
||||
</p>
|
||||
{[
|
||||
{ value: 'landscape', label: 'Paysage' },
|
||||
{ value: 'portrait', label: 'Portrait' },
|
||||
].map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="printOrientation"
|
||||
value={option.value}
|
||||
defaultChecked={
|
||||
(option.value === 'landscape') === preferences.printLandscape
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<label className="flex max-w-sm flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Densité</span>
|
||||
<select
|
||||
name="printDensity"
|
||||
defaultValue={preferences.printDensity}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{PRINT_DENSITIES.map((density) => (
|
||||
<option key={density.key} value={density.key}>
|
||||
{density.label} — {density.hint}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{toggles.map((toggle) => (
|
||||
<div key={toggle.key} className="flex flex-col gap-1">
|
||||
<label className="flex items-start gap-2.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={toggle.key}
|
||||
defaultChecked={Boolean(
|
||||
preferences[toggle.key as keyof Preferences],
|
||||
)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{toggle.label}</span>
|
||||
<span className="block text-micro leading-[var(--lh-prose)] text-ink-3">
|
||||
{toggle.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{toggle.warning ? (
|
||||
<p className="ml-6 rounded-2 border border-warn bg-warn-soft px-2.5 py-1.5 text-micro leading-[var(--lh-prose)] text-warn-soft-ink">
|
||||
{toggle.warning}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Réglages enregistrés.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PrintForm } from '@/app/(app)/reglages/impression/PrintForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { getPreferences } from '@/server/settings/account';
|
||||
|
||||
export const metadata = { title: 'Impression · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ImpressionReglagesPage() {
|
||||
const preferences = await getPreferences();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Impression"
|
||||
subtitle="Mise en page du planning affiché en salle"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Planning imprimé" />
|
||||
<PrintForm preferences={preferences} />
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Ces réglages gouvernent{' '}
|
||||
<Link href="/planning/impression" className="underline">
|
||||
le planning à afficher
|
||||
</Link>
|
||||
. 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.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
saveProductivityTargetAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/account';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
export function ProductivityForm({ target }: { target: string | null }) {
|
||||
const [state, action] = useActionState(saveProductivityTargetAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-3 p-4">
|
||||
<div className="flex max-w-sm items-end gap-2">
|
||||
<Field
|
||||
label="Objectif de productivité"
|
||||
name="target"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={target ?? ''}
|
||||
hint="Chiffre d’affaires attendu par heure travaillée, en euros. Laisser vide pour n’en fixer aucun."
|
||||
/>
|
||||
<span className="pb-2.5 text-sm text-ink-2">€/h</span>
|
||||
</div>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Objectif enregistré.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ProductivityForm } from '@/app/(app)/reglages/productivite/ProductivityForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { getPreferences } from '@/server/settings/account';
|
||||
|
||||
export const metadata = { title: 'Productivité · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ProductivitePage() {
|
||||
const preferences = await getPreferences();
|
||||
const target = preferences.productivityTargetPerHour;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Productivité"
|
||||
subtitle={
|
||||
target
|
||||
? `${target} € de chiffre d’affaires par heure travaillée`
|
||||
: 'Aucun objectif fixé'
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Objectif global"
|
||||
badge={
|
||||
target ? null : <Badge tone="neutral">non fixé</Badge>
|
||||
}
|
||||
/>
|
||||
<ProductivityForm target={target} />
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
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é.
|
||||
</p>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
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é.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<span className="rounded-full bg-accent-soft px-1.5 py-px font-medium text-accent-soft-ink">
|
||||
Forfait jours
|
||||
</span>
|
||||
{/* Un forfait jours reste soumis aux repos : le RC s'affiche, l'écart
|
||||
horaire non. */}
|
||||
<Cell label="Rep." value={String(counters.restDays)} />
|
||||
<Cell label="RC" value={counters.compensatoryRestLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +81,7 @@ export function CounterStrip({
|
||||
·
|
||||
</span>
|
||||
<Cell label="Planif." value={counters.plannedLabel} />
|
||||
<Cell label="Abs." value={counters.absenceLabel} />
|
||||
<span
|
||||
className={cx(
|
||||
'tnum rounded-full px-1.5 py-px font-semibold',
|
||||
@@ -80,8 +91,7 @@ export function CounterStrip({
|
||||
>
|
||||
{counters.deltaLabel}
|
||||
</span>
|
||||
<Cell label="Dim." value={String(counters.sundaysWorked)} />
|
||||
<Cell label="Rep." value={String(counters.restDays)} />
|
||||
<Cell label="RC" value={counters.compensatoryRestLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, number>();
|
||||
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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AccountIdentity | null> {
|
||||
@@ -103,8 +119,20 @@ export async function getPreferences(): Promise<Preferences> {
|
||||
});
|
||||
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<string, boolean | number> = {};
|
||||
const values: Record<string, boolean | number | string> = {};
|
||||
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<ActionState> {
|
||||
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 };
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ function row(overrides: Partial<BoardRow> = {}): BoardRow {
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 0,
|
||||
restDays: 0,
|
||||
compensatoryRestMinutes: 0,
|
||||
},
|
||||
days: [[], [], [], [], [], [], []],
|
||||
absences: [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user