Ouvrir les référentiels au paramétrage
Emplois, étiquettes et types d'absence vivaient en base sans écran : le seed les posait, et rien ne permettait ensuite d'en ajouter un. Ils s'éditent désormais depuis les réglages. Trois choix portent la conformité plutôt que le confort : - Archiver, jamais supprimer. Un emploi figure au registre du personnel, une étiquette colore des semaines closes, un type d'absence justifie des écritures de compteur. Supprimer l'un des trois rendrait illisible un dossier dont la conservation court encore. - Le code Silae manquant est signalé. Un type sans code fait échouer l'export de la période ; l'écran le dit avant l'échéance de paie plutôt qu'après. - La convention se réédite, elle ne se modifie pas. Chaque enregistrement crée une version datée : une paie de mars doit rester reproductible après un changement de seuil en juin. L'origine de chaque valeur — ordre public, convention, accord d'entreprise — est affichée, parce que les trois ne se corrigent pas de la même main. Les mutations de types d'absence passent par settings.agreement.manage : le catalogue §5 ne déclare pas de code dédié, et ces drapeaux relèvent du paramétrage légal. Inventer une capacité aurait créé un droit sans rien protéger de neuf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e57467138
commit
527ea2e78a
13 files changed
+2039
-2
No files matched your search
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import type { AgreementParameters } from '@/domain/compliance/parameters';
|
||||
import {
|
||||
publishAgreementVersionAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/agreement';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
function Group({
|
||||
title,
|
||||
note,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
note?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2 border-t border-line-1 pt-3">
|
||||
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
{title}
|
||||
</legend>
|
||||
{note ? <p className="text-micro text-ink-3">{note}</p> : null}
|
||||
<div className="flex flex-wrap gap-3">{children}</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function Area({
|
||||
label,
|
||||
name,
|
||||
defaultValue,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<textarea
|
||||
name={name}
|
||||
rows={4}
|
||||
defaultValue={defaultValue}
|
||||
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgreementForm({
|
||||
idcc,
|
||||
name,
|
||||
parameters,
|
||||
}: {
|
||||
idcc: string;
|
||||
name: string;
|
||||
parameters: AgreementParameters;
|
||||
}) {
|
||||
const [state, action] = useActionState(publishAgreementVersionAction, empty);
|
||||
const p = parameters;
|
||||
|
||||
const tiers = p.overtime.tiers
|
||||
.map((tier) => `${tier.fromMinutes};${tier.toMinutes ?? ''};${tier.ratePercent}`)
|
||||
.join('\n');
|
||||
const derogations = p.partTime.derogations
|
||||
.map((row) => `${row.code};${row.label};${row.minWeeklyMinutes}`)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-4 p-4">
|
||||
<Group title="Version" note="Les durées sont en minutes entières.">
|
||||
<Field label="IDCC" name="idcc" defaultValue={idcc} required maxLength={10} />
|
||||
<Field label="Intitulé" name="name" defaultValue={name} required maxLength={160} />
|
||||
<Field
|
||||
label="En vigueur à partir du"
|
||||
name="effectiveFrom"
|
||||
type="date"
|
||||
required
|
||||
hint="La version précédente reste applicable aux semaines antérieures."
|
||||
/>
|
||||
<Field
|
||||
label="Source"
|
||||
name="source"
|
||||
required
|
||||
maxLength={400}
|
||||
placeholder="Legifrance, texte consolidé au…"
|
||||
hint="Sans source, le paramètre n’est pas opposable."
|
||||
/>
|
||||
<Field label="Approuvé par" name="approvedBy" maxLength={160} />
|
||||
</Group>
|
||||
|
||||
<Group title="Durées">
|
||||
<Field label="Référence hebdomadaire" name="weeklyReferenceMinutes" type="number" defaultValue={p.weeklyReferenceMinutes} required />
|
||||
<Field label="Maximum quotidien" name="maxDailyWorkMinutes" type="number" defaultValue={p.maxDailyWorkMinutes} required />
|
||||
<Field
|
||||
label="Amplitude quotidienne"
|
||||
name="maxDailyAmplitudeMinutes"
|
||||
type="number"
|
||||
defaultValue={p.maxDailyAmplitudeMinutes ?? ''}
|
||||
hint="Vide : la convention n’en fixe pas, la règle reste muette."
|
||||
/>
|
||||
<Field label="Repos quotidien minimal" name="minDailyRestMinutes" type="number" defaultValue={p.minDailyRestMinutes} required />
|
||||
<Field label="Repos hebdomadaire minimal" name="minWeeklyRestMinutes" type="number" defaultValue={p.minWeeklyRestMinutes} required />
|
||||
<Field label="Maximum hebdomadaire absolu" name="maxWeeklyWorkAbsoluteMinutes" type="number" defaultValue={p.maxWeeklyWorkAbsoluteMinutes} required />
|
||||
<Field label="Fenêtre de moyenne (semaines)" name="averagedWindowWeeks" type="number" defaultValue={p.averagedWeeklyWork.windowWeeks} required />
|
||||
<Field label="Moyenne maximale" name="averagedMaxMinutes" type="number" defaultValue={p.averagedWeeklyWork.maxAverageMinutes} required />
|
||||
<Field label="Jours consécutifs maximum" name="maxConsecutiveWorkDays" type="number" defaultValue={p.maxConsecutiveWorkDays} required />
|
||||
<Field label="Seuil de pause" name="breakThresholdMinutes" type="number" defaultValue={p.breakAfterThreshold.thresholdMinutes} required />
|
||||
<Field label="Pause minimale" name="breakMinMinutes" type="number" defaultValue={p.breakAfterThreshold.minBreakMinutes} required />
|
||||
<Field label="Tolérance d’écart au contrat" name="deviationToleranceMinutes" type="number" defaultValue={p.contractDeviationToleranceMinutes} required />
|
||||
</Group>
|
||||
|
||||
<Group title="Temps partiel">
|
||||
<Field label="Minimum hebdomadaire" name="partTimeMinWeeklyMinutes" type="number" defaultValue={p.partTime.minWeeklyMinutes} required />
|
||||
<Area
|
||||
label="Dérogations"
|
||||
name="derogations"
|
||||
defaultValue={derogations}
|
||||
hint="Une par ligne : code;libellé;minutes."
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group title="Heures supplémentaires">
|
||||
<Area
|
||||
label="Tranches"
|
||||
name="overtimeTiers"
|
||||
defaultValue={tiers}
|
||||
hint="Une par ligne : début;fin;taux. Fin vide = sans plafond. Cumulatives depuis le début de la semaine."
|
||||
/>
|
||||
<Field label="Contingent annuel" name="overtimeQuotaMinutes" type="number" defaultValue={p.overtime.annualQuotaMinutes} required />
|
||||
<Field label="Repos au-delà du contingent (%)" name="overtimeBeyondRestPercent" type="number" step="0.01" defaultValue={p.overtime.beyondQuotaRestPercent} required />
|
||||
</Group>
|
||||
|
||||
<Group title="Heures complémentaires">
|
||||
<Field label="Taux première tranche (%)" name="complementaryFirstRate" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierRatePercent} required />
|
||||
<Field label="Taux au-delà (%)" name="complementaryBeyondRate" type="number" step="0.01" defaultValue={p.complementaryHours.beyondRatePercent} required />
|
||||
<Field label="Fraction première tranche" name="complementaryFirstFraction" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierFraction} required />
|
||||
<Field label="Plafond (fraction)" name="complementaryCapFraction" type="number" step="0.01" defaultValue={p.complementaryHours.capFraction} required />
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Dimanche"
|
||||
note="La majoration et le repos compensateur vont ensemble : l’un sans l’autre est un manquement à L3132-27."
|
||||
>
|
||||
<Field label="Majoration (%)" name="sundayPremiumPercent" type="number" step="0.01" defaultValue={p.sunday.premiumPercent} required />
|
||||
<Field label="Repos compensateur (%)" name="sundayRestPercent" type="number" step="0.01" defaultValue={p.sunday.compensatoryRestPercent} required />
|
||||
<Field label="Dimanches du maire par an" name="sundayMayorQuota" type="number" defaultValue={p.sunday.mayorQuotaPerYear} required />
|
||||
</Group>
|
||||
|
||||
<Group title="Jours fériés">
|
||||
<Field label="Indemnité si travaillé (%)" name="holidayWorkedPremium" type="number" step="0.01" defaultValue={p.holiday.workedPremiumPercent} required />
|
||||
<Field label="1er mai travaillé (%)" name="holidayLabourDayPremium" type="number" step="0.01" defaultValue={p.holiday.labourDayPremiumPercent} required />
|
||||
<Field label="Fériés chômés garantis" name="holidayPaidOffDays" type="number" defaultValue={p.holiday.paidOffDaysPerYear} required />
|
||||
<Field label="Repos de substitution (%)" name="holidaySubstitutionRest" type="number" step="0.01" defaultValue={p.holiday.substitutionRestPercent} required />
|
||||
</Group>
|
||||
|
||||
<Group title="Nuit et forfait jours">
|
||||
<Field label="Début de nuit (min. depuis minuit)" name="nightStartMinutes" type="number" defaultValue={p.night.startMinutes} required />
|
||||
<Field label="Fin de nuit" name="nightEndMinutes" type="number" defaultValue={p.night.endMinutes} required />
|
||||
<Field label="Non imposable à partir de" name="nightNotEnforceableFromAge" type="number" defaultValue={p.night.notEnforceableFromAge} required />
|
||||
<Field label="Jours de forfait maximum" name="forfaitMaxDaysPerYear" type="number" defaultValue={p.forfaitJours.maxDaysPerYear} required />
|
||||
<Field label="Entretien de charge (mois)" name="forfaitReviewMonths" type="number" defaultValue={p.forfaitJours.workloadReviewIntervalMonths} required />
|
||||
</Group>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Publier une nouvelle version</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">
|
||||
Version enregistrée. Les semaines antérieures gardent la précédente.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { AgreementForm } from '@/app/(app)/reglages/convention/AgreementForm';
|
||||
import {
|
||||
IDCC_1517_PROVENANCE,
|
||||
type ParameterOrigin,
|
||||
} from '@/domain/compliance/idcc1517';
|
||||
import { listAgreementVersions } from '@/server/settings/agreement';
|
||||
|
||||
export const metadata = { title: 'Convention collective · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const ORIGIN_TONE: Record<ParameterOrigin, 'danger' | 'info' | 'warn'> = {
|
||||
OP: 'danger',
|
||||
CCN: 'info',
|
||||
ENT: 'warn',
|
||||
};
|
||||
|
||||
const ORIGIN_LABEL: Record<ParameterOrigin, string> = {
|
||||
OP: 'Ordre public',
|
||||
CCN: 'Convention',
|
||||
ENT: 'Accord d’entreprise',
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
export default async function ConventionPage() {
|
||||
const versions = await listAgreementVersions();
|
||||
const current = versions.find((version) => version.isCurrent) ?? versions[0];
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Convention collective"
|
||||
subtitle={
|
||||
current
|
||||
? `IDCC ${current.idcc} · version ${current.version}`
|
||||
: 'Aucune convention chargée'
|
||||
}
|
||||
/>
|
||||
|
||||
{!current ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Aucune convention"
|
||||
description="Le moteur de règles lit ses seuils en base. Sans convention chargée, aucune règle horaire ne s’applique."
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Origine des valeurs"
|
||||
badge={<Badge tone="neutral">{IDCC_1517_PROVENANCE.length}</Badge>}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Paramètre
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Valeur
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Origine
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Source
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{IDCC_1517_PROVENANCE.map((entry) => (
|
||||
<tr key={entry.key}>
|
||||
<td className="px-4 py-2">{entry.label}</td>
|
||||
<td className="px-4 py-2 tnum">{entry.value}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge tone={ORIGIN_TONE[entry.origin]}>
|
||||
{ORIGIN_LABEL[entry.origin]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-ink-2">{entry.source}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="border-t border-line-1 px-4 py-3 text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Une valeur d’ordre public ne se négocie pas ; une valeur
|
||||
conventionnelle change si la convention change ; une valeur d’accord
|
||||
d’entreprise suppose un accord signé. Confondre les trois fait
|
||||
apparaître comme discutable ce qui s’impose, et l’inverse.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Versions"
|
||||
badge={<Badge tone="neutral">{versions.length}</Badge>}
|
||||
/>
|
||||
{versions.length === 0 ? (
|
||||
<EmptyState title="Aucune version enregistrée" />
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{versions.map((version) => (
|
||||
<li key={version.id} className="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<Badge tone={version.isCurrent ? 'ok' : 'neutral'}>
|
||||
v{version.version}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{version.name}</span>
|
||||
<span className="text-sm text-ink-2">
|
||||
en vigueur au {dateFormat.format(version.effectiveFrom)}
|
||||
</span>
|
||||
{version.isCurrent ? <Badge tone="accent">Applicable</Badge> : null}
|
||||
<span className="flex-1" />
|
||||
<span className="text-xs text-ink-3">
|
||||
{version.approvedBy
|
||||
? `Approuvée par ${version.approvedBy}`
|
||||
: 'Non approuvée'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{current ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Éditer les paramètres"
|
||||
badge={<Badge tone="warn">crée une version</Badge>}
|
||||
/>
|
||||
<AgreementForm
|
||||
idcc={current.idcc}
|
||||
name={current.name}
|
||||
parameters={current.parameters}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
createJobTitleAction,
|
||||
renameJobTitleAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/job-titles';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
export function AddJobTitleForm() {
|
||||
const [state, action] = useActionState(createJobTitleAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Intitulé"
|
||||
name="name"
|
||||
required
|
||||
maxLength={120}
|
||||
placeholder="Hôte de caisse"
|
||||
hint="L’intitulé figure au registre unique du personnel et sur le contrat."
|
||||
/>
|
||||
<SubmitButton>Ajouter l’emploi</SubmitButton>
|
||||
</div>
|
||||
<FormError>{state.error}</FormError>
|
||||
{state.ok ? (
|
||||
<p className="text-xs text-ok-soft-ink">Emploi enregistré.</p>
|
||||
) : null}
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenameJobTitleForm({
|
||||
id,
|
||||
name,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
}) {
|
||||
const [state, action] = useActionState(renameJobTitleAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-1 items-end gap-2">
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Field
|
||||
label="Intitulé"
|
||||
name="name"
|
||||
defaultValue={name}
|
||||
required
|
||||
maxLength={120}
|
||||
className="h-8"
|
||||
/>
|
||||
<SubmitButton size="sm">Renommer</SubmitButton>
|
||||
<FormError>{state.error}</FormError>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
AddJobTitleForm,
|
||||
RenameJobTitleForm,
|
||||
} from '@/app/(app)/reglages/emplois/JobTitleForms';
|
||||
import {
|
||||
archiveJobTitleAction,
|
||||
listJobTitles,
|
||||
} from '@/server/settings/job-titles';
|
||||
|
||||
export const metadata = { title: 'Emplois · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function EmploisPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archives?: string }>;
|
||||
}) {
|
||||
const { archives } = await searchParams;
|
||||
const showArchived = archives === '1';
|
||||
const titles = await listJobTitles(showArchived);
|
||||
const active = titles.filter((title) => title.archivedAt === null);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Emplois"
|
||||
subtitle={`${active.length} emploi${active.length > 1 ? 's' : ''} en vigueur`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Référentiel"
|
||||
badge={
|
||||
<Badge tone="neutral">
|
||||
{titles.length} affiché{titles.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<a
|
||||
href={showArchived ? '/reglages/emplois' : '/reglages/emplois?archives=1'}
|
||||
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
|
||||
>
|
||||
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{titles.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun emploi"
|
||||
description="Un contrat porte une qualification ; créez d’abord les emplois de l’organisation."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{titles.map((title) => (
|
||||
<li
|
||||
key={title.id}
|
||||
className="flex flex-wrap items-end gap-3 px-4 py-3"
|
||||
>
|
||||
<RenameJobTitleForm id={title.id} name={title.name} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone="neutral">
|
||||
{title.contractCount} contrat
|
||||
{title.contractCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{title.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
|
||||
</div>
|
||||
|
||||
<form action={archiveJobTitleAction}>
|
||||
<input type="hidden" name="id" value={title.id} />
|
||||
{title.archivedAt ? (
|
||||
<input type="hidden" name="restore" value="1" />
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
{title.archivedAt ? 'Rétablir' : 'Archiver'}
|
||||
</Button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouvel emploi" />
|
||||
<div className="p-4">
|
||||
<AddJobTitleForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Un emploi porté par un contrat est archivé, jamais supprimé : le registre
|
||||
unique du personnel doit rester lisible pendant toute la durée de
|
||||
conservation, y compris pour un salarié parti.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import { POSTE_CODES, POSTE_LABELS } from '@/lib/design/postes';
|
||||
import {
|
||||
createLabelAction,
|
||||
updateLabelAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/labels';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
function PaletteSelect({ defaultValue }: { defaultValue?: string }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Teinte</span>
|
||||
<select
|
||||
name="paletteKey"
|
||||
defaultValue={defaultValue ?? POSTE_CODES[0]}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{POSTE_CODES.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{POSTE_LABELS[code]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddLabelForm() {
|
||||
const [state, action] = useActionState(createLabelAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Code"
|
||||
name="code"
|
||||
required
|
||||
maxLength={12}
|
||||
placeholder="cai"
|
||||
hint="Affiché sur le bloc de planning : la couleur ne porte jamais l’information seule."
|
||||
/>
|
||||
<Field label="Libellé" name="name" required maxLength={120} placeholder="Caisse" />
|
||||
<PaletteSelect />
|
||||
<SubmitButton>Ajouter l’étiquette</SubmitButton>
|
||||
</div>
|
||||
<FormError>{state.error}</FormError>
|
||||
{state.ok ? (
|
||||
<p className="text-xs text-ok-soft-ink">Étiquette enregistrée.</p>
|
||||
) : null}
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditLabelForm({
|
||||
id,
|
||||
name,
|
||||
paletteKey,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
paletteKey: string;
|
||||
}) {
|
||||
const [state, action] = useActionState(updateLabelAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-1 flex-wrap items-end gap-2">
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Field label="Libellé" name="name" defaultValue={name} required maxLength={120} />
|
||||
<PaletteSelect defaultValue={paletteKey} />
|
||||
<SubmitButton size="sm">Enregistrer</SubmitButton>
|
||||
<FormError>{state.error}</FormError>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
AddLabelForm,
|
||||
EditLabelForm,
|
||||
} from '@/app/(app)/reglages/etiquettes/LabelForms';
|
||||
import { isPosteCode, posteTokens } from '@/lib/design/postes';
|
||||
import { archiveLabelAction, listLabels } from '@/server/settings/labels';
|
||||
|
||||
export const metadata = { title: 'Étiquettes · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function Swatch({ paletteKey, code }: { paletteKey: string; code: string }) {
|
||||
if (!isPosteCode(paletteKey)) {
|
||||
return <Badge tone="warn">teinte inconnue</Badge>;
|
||||
}
|
||||
const tokens = posteTokens(paletteKey);
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-6 shrink-0 items-center rounded-2 border px-2 text-micro font-semibold"
|
||||
style={{
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
borderColor: tokens.edge,
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function EtiquettesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archives?: string }>;
|
||||
}) {
|
||||
const { archives } = await searchParams;
|
||||
const showArchived = archives === '1';
|
||||
const labels = await listLabels(showArchived);
|
||||
const active = labels.filter((label) => label.archivedAt === null);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Étiquettes"
|
||||
subtitle={`${active.length} étiquette${active.length > 1 ? 's' : ''} en vigueur`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Postes de la grille"
|
||||
action={
|
||||
<a
|
||||
href={
|
||||
showArchived
|
||||
? '/reglages/etiquettes'
|
||||
: '/reglages/etiquettes?archives=1'
|
||||
}
|
||||
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
|
||||
>
|
||||
{showArchived ? 'Masquer les archivées' : 'Voir les archivées'}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{labels.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucune étiquette"
|
||||
description="Une étiquette colore le créneau et nomme le poste tenu. Créez-en une pour commencer."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{labels.map((label) => (
|
||||
<li
|
||||
key={label.id}
|
||||
className="flex flex-wrap items-end gap-3 px-4 py-3"
|
||||
>
|
||||
<Swatch paletteKey={label.paletteKey} code={label.code} />
|
||||
|
||||
<EditLabelForm
|
||||
id={label.id}
|
||||
name={label.name}
|
||||
paletteKey={label.paletteKey}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone="neutral">
|
||||
{label.shiftCount} créneau{label.shiftCount > 1 ? 'x' : ''}
|
||||
</Badge>
|
||||
{label.archivedAt ? <Badge tone="warn">Archivée</Badge> : null}
|
||||
</div>
|
||||
|
||||
<form action={archiveLabelAction}>
|
||||
<input type="hidden" name="id" value={label.id} />
|
||||
{label.archivedAt ? (
|
||||
<input type="hidden" name="restore" value="1" />
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
{label.archivedAt ? 'Rétablir' : 'Archiver'}
|
||||
</Button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouvelle étiquette" />
|
||||
<div className="p-4">
|
||||
<AddLabelForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Les douze teintes viennent d’une palette calculée pour rester
|
||||
distinguables en vision daltonienne et à l’impression en noir et blanc.
|
||||
C’est pourquoi le code du poste s’affiche toujours avec la couleur, et
|
||||
pourquoi la teinte se choisit dans la liste plutôt qu’à la pipette.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
createAbsenceTypeAction,
|
||||
updateAbsenceTypeAction,
|
||||
type AbsenceTypeRow,
|
||||
type ActionState,
|
||||
} from '@/server/settings/absence-types';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
/** Drapeaux du type, avec ce que chacun décide réellement. */
|
||||
const FLAGS = [
|
||||
{
|
||||
name: 'isPaid',
|
||||
label: 'Rémunérée',
|
||||
hint: 'L’absence est payée par l’employeur.',
|
||||
},
|
||||
{
|
||||
name: 'countsAsWorkTime',
|
||||
label: 'Compte comme temps de travail',
|
||||
hint: 'Entre dans le décompte horaire, donc dans les seuils de convention.',
|
||||
},
|
||||
{
|
||||
name: 'affectsPaidLeaveAccrual',
|
||||
label: 'Acquiert des congés payés',
|
||||
hint: 'La période continue d’ouvrir des droits à congés.',
|
||||
},
|
||||
{
|
||||
name: 'isSocialSecurity',
|
||||
label: 'Absence Sécurité sociale',
|
||||
hint: 'Maladie, maternité, accident du travail. Donnée de santé : le motif est masqué en vue manager.',
|
||||
},
|
||||
{
|
||||
name: 'requiresJustification',
|
||||
label: 'Justificatif exigé',
|
||||
hint: 'La demande est refusée sans pièce jointe.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function Flags({ row }: { row?: AbsenceTypeRow }) {
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Effets
|
||||
</legend>
|
||||
{FLAGS.map((flag) => (
|
||||
<label key={flag.name} className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={flag.name}
|
||||
defaultChecked={row ? Boolean(row[flag.name]) : flag.name === 'isPaid'}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{flag.label}
|
||||
<span className="block text-micro text-ink-3">{flag.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function SharedFields({ row }: { row?: AbsenceTypeRow }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Libellé"
|
||||
name="name"
|
||||
required
|
||||
maxLength={120}
|
||||
defaultValue={row?.name}
|
||||
placeholder="Congé payé"
|
||||
/>
|
||||
<Field
|
||||
label="Clé de couleur"
|
||||
name="colorKey"
|
||||
required
|
||||
maxLength={40}
|
||||
defaultValue={row?.colorKey ?? 'neutral'}
|
||||
/>
|
||||
<Field
|
||||
label="Délai de prévenance"
|
||||
name="minNoticeDays"
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
defaultValue={row?.minNoticeDays ?? ''}
|
||||
hint="En jours. Vide : aucun délai."
|
||||
/>
|
||||
<Field
|
||||
label="Code Silae"
|
||||
name="silaeCode"
|
||||
maxLength={20}
|
||||
defaultValue={row?.silaeCode ?? ''}
|
||||
hint="Partie « code » de AB-<code>. Sans lui, l’export de la période échoue."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddAbsenceTypeForm() {
|
||||
const [state, action] = useActionState(createAbsenceTypeAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Code"
|
||||
name="code"
|
||||
required
|
||||
maxLength={20}
|
||||
placeholder="CP"
|
||||
hint="Identifiant stable du type. Il ne se renomme pas."
|
||||
/>
|
||||
</div>
|
||||
<SharedFields />
|
||||
<Flags />
|
||||
<FormError>{state.error}</FormError>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Ajouter le type</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Type enregistré.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditAbsenceTypeForm({ row }: { row: AbsenceTypeRow }) {
|
||||
const [state, action] = useActionState(updateAbsenceTypeAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<SharedFields row={row} />
|
||||
<Flags row={row} />
|
||||
<FormError>{state.error}</FormError>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton size="sm">Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
AddAbsenceTypeForm,
|
||||
EditAbsenceTypeForm,
|
||||
} from '@/app/(app)/reglages/types-absence/AbsenceTypeForms';
|
||||
import {
|
||||
archiveAbsenceTypeAction,
|
||||
listAbsenceTypes,
|
||||
type AbsenceTypeRow,
|
||||
} from '@/server/settings/absence-types';
|
||||
|
||||
export const metadata = { title: 'Types d’absence · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function Summary({ row }: { row: AbsenceTypeRow }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge tone={row.isPaid ? 'ok' : 'neutral'}>
|
||||
{row.isPaid ? 'Rémunérée' : 'Non rémunérée'}
|
||||
</Badge>
|
||||
{row.isSocialSecurity ? <Badge tone="info">Sécurité sociale</Badge> : null}
|
||||
{row.countsAsWorkTime ? <Badge tone="neutral">Temps de travail</Badge> : null}
|
||||
{row.affectsPaidLeaveAccrual ? (
|
||||
<Badge tone="neutral">Acquisition CP</Badge>
|
||||
) : null}
|
||||
{row.requiresJustification ? (
|
||||
<Badge tone="warn">Justificatif</Badge>
|
||||
) : null}
|
||||
{row.silaeCode ? (
|
||||
<Badge tone="neutral">AB-{row.silaeCode}</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">Code Silae manquant</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function TypesAbsencePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archives?: string }>;
|
||||
}) {
|
||||
const { archives } = await searchParams;
|
||||
const showArchived = archives === '1';
|
||||
const types = await listAbsenceTypes(showArchived);
|
||||
const active = types.filter((type) => type.archivedAt === null);
|
||||
const missingSilae = active.filter((type) => !type.silaeCode).length;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Types d’absence"
|
||||
subtitle={`${active.length} type${active.length > 1 ? 's' : ''} en vigueur`}
|
||||
/>
|
||||
|
||||
{missingSilae > 0 ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
|
||||
>
|
||||
{missingSilae} type{missingSilae > 1 ? 's' : ''} sans code Silae.
|
||||
L’export d’une période contenant ces absences échouera, avec la liste
|
||||
des manques — aucun fichier partiel n’est produit.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Référentiel"
|
||||
action={
|
||||
<a
|
||||
href={
|
||||
showArchived
|
||||
? '/reglages/types-absence'
|
||||
: '/reglages/types-absence?archives=1'
|
||||
}
|
||||
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
|
||||
>
|
||||
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{types.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun type d’absence"
|
||||
description="Sans type, aucune demande n’est saisissable. Commencez par le congé payé."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{types.map((type) => (
|
||||
<li key={type.id} className="px-4 py-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge tone="accent">{type.code}</Badge>
|
||||
<span className="text-sm font-semibold">{type.name}</span>
|
||||
<Badge tone="neutral">
|
||||
{type.timeOffCount} absence
|
||||
{type.timeOffCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{type.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
|
||||
<span className="flex-1" />
|
||||
<form action={archiveAbsenceTypeAction}>
|
||||
<input type="hidden" name="id" value={type.id} />
|
||||
{type.archivedAt ? (
|
||||
<input type="hidden" name="restore" value="1" />
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
{type.archivedAt ? 'Rétablir' : 'Archiver'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<Summary row={type} />
|
||||
</div>
|
||||
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
|
||||
Modifier
|
||||
</summary>
|
||||
<div className="pt-3">
|
||||
<EditAbsenceTypeForm row={type} />
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouveau type" />
|
||||
<div className="p-4">
|
||||
<AddAbsenceTypeForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Le code d’un type ne se renomme pas : il est référencé par les absences
|
||||
déjà saisies et par la correspondance de paie. Un type devenu inutile
|
||||
s’archive — les absences passées gardent alors un libellé lisible et les
|
||||
écritures de compteur restent justifiables.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user