From bb8f84696a1f28f1267bf0d9b736b7f6d61e99b6 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 10 Aug 2026 11:37:31 +0200 Subject: [PATCH] Rendre le contrat vivant depuis sa fiche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createContractAction` et `createAmendmentAction` existaient depuis le début sans qu'aucun écran ne les appelle : du code mort côté interface, et un contrat qu'on ne pouvait ni ouvrir ni modifier une fois le salarié créé. Trois gestes sur le contrat en cours — lire le détail, déclarer un changement, terminer — et l'ouverture d'un contrat quand il n'y en a pas. `endContractAction` date et clôt le contrat plutôt que de le supprimer : un contrôle porte sur une période révolue, et l'effacer effacerait la preuve que le salarié a travaillé, ainsi que la base de son solde de tout compte. Le formulaire de forfait jours ne découvre la convention individuelle et sa date d'accord qu'une fois le forfait choisi : la règle métier les exige, les autres contrats n'en ont que faire. Les établissements du formulaire de création se lisent avec `members.contract.create` et non `settings.access` : ouvrir un contrat n'est pas administrer un établissement. Co-Authored-By: Claude Opus 5 --- .../equipe/[id]/contrats/ContractActions.tsx | 254 ++++++++++++++++++ .../equipe/[id]/contrats/NewContractForm.tsx | 166 ++++++++++++ src/app/(app)/equipe/[id]/contrats/page.tsx | 96 ++++--- src/server/employees/actions.ts | 84 ++++++ src/server/employees/queries.ts | 64 ++++- 5 files changed, 622 insertions(+), 42 deletions(-) create mode 100644 src/app/(app)/equipe/[id]/contrats/ContractActions.tsx create mode 100644 src/app/(app)/equipe/[id]/contrats/NewContractForm.tsx diff --git a/src/app/(app)/equipe/[id]/contrats/ContractActions.tsx b/src/app/(app)/equipe/[id]/contrats/ContractActions.tsx new file mode 100644 index 0000000..6c9fa9e --- /dev/null +++ b/src/app/(app)/equipe/[id]/contrats/ContractActions.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useActionState, useState } from 'react'; + +import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard'; +import { Button } from '@/components/ui/Button'; +import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { + createAmendmentAction, + endContractAction, + type ActionState, +} from '@/server/employees/actions'; + +/** + * Vie du contrat en cours. + * + * Trois gestes et un seul menu : lire le détail, déclarer un changement, + * terminer. Ils sont réunis parce qu'ils portent sur le même objet, et séparés + * du formulaire de création parce qu'un contrat qui existe ne se recrée pas. + */ + +const empty: ActionState = {}; + +const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }); + +export interface ActiveContract { + id: string; + label: string; + startDate: string; + endDate: string | null; + trialEndDate: string | null; + weeklyHours: string; + forfaitJours: boolean; + forfaitDaysPerYear: string | null; + forfaitAgreementRef: string | null; + isModulated: boolean; + classification: string | null; + coefficient: string | null; + locationName: string | null; + monthlySalary: string | null; + jobTitle: string | null; +} + +const readable = (iso: string | null) => + iso ? dateFormat.format(new Date(`${iso}T00:00:00Z`)) : ''; + +type Panel = 'none' | 'detail' | 'amend' | 'end'; + +export function ContractActions({ + contract, + canEdit, + canSeeSalary, +}: { + contract: ActiveContract; + canEdit: boolean; + canSeeSalary: boolean; +}) { + const [panel, setPanel] = useState('none'); + + return ( +
+
+

Contrat en cours

+ + + {canEdit ? ( + <> + + + + ) : null} +
+ + + + + + + {canSeeSalary ? ( + + ) : null} + + + {panel === 'detail' ? ( + <> + + + + + {contract.forfaitJours ? ( + + ) : null} + + ) : null} + + + {panel === 'amend' ? ( + setPanel('none')} + /> + ) : null} + + {panel === 'end' ? ( + setPanel('none')} /> + ) : null} +
+ ); +} + +/** + * Avenant. + * + * Le motif est obligatoire : un changement de durée sans raison écrite est + * indéfendable devant un contrôle, et illisible six mois plus tard. + */ +function AmendmentForm({ + contractId, + weeklyHours, + onDone, +}: { + contractId: string; + weeklyHours: string; + onDone: () => void; +}) { + const [state, submit] = useActionState(createAmendmentAction, empty); + const [acknowledged, setAcknowledged] = useState(empty); + + if (state !== acknowledged && state.ok) { + setAcknowledged(state); + onDone(); + } + + return ( + +
+ +
+ + +
+ + + {state.error} + +
+ Enregistrer l’avenant + +
+ +
+ ); +} + +function EndForm({ + contractId, + onDone, +}: { + contractId: string; + onDone: () => void; +}) { + const [state, submit] = useActionState(endContractAction, empty); + const [acknowledged, setAcknowledged] = useState(empty); + + if (state !== acknowledged && state.ok) { + setAcknowledged(state); + onDone(); + } + + return ( + +
+ +

+ Le contrat est daté et clos, non supprimé : la période travaillée + reste opposable, et le solde de tout compte s’appuie dessus. +

+
+ + +
+ + {state.error} + +
+ Terminer le contrat + +
+
+
+ ); +} diff --git a/src/app/(app)/equipe/[id]/contrats/NewContractForm.tsx b/src/app/(app)/equipe/[id]/contrats/NewContractForm.tsx new file mode 100644 index 0000000..02dca4f --- /dev/null +++ b/src/app/(app)/equipe/[id]/contrats/NewContractForm.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useActionState, useState } from 'react'; + +import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard'; +import { Button } from '@/components/ui/Button'; +import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { + createContractAction, + type ActionState, +} from '@/server/employees/actions'; + +/** + * Ouverture d'un contrat. + * + * Le forfait jours découvre ses propres champs : la convention individuelle + * écrite et sa date d'accord sont exigées par la règle métier, et les demander + * en permanence ferait porter à tous les contrats horaires deux champs qui ne + * les concernent pas. + */ + +const empty: ActionState = {}; + +const CONTRACT_TYPES = [ + ['CDI', 'CDI'], + ['CDD', 'CDD'], + ['APPRENTISSAGE', 'Apprentissage'], + ['STAGIAIRE', 'Stagiaire'], + ['SAISONNIER', 'Saisonnier'], + ['EXTRA', 'Extra'], + ['INTERIM', 'Intérim'], + ['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'], + ['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'], +] as const; + +const selectClass = + 'h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus'; + +export function NewContractForm({ + membershipId, + locations, +}: { + membershipId: string; + locations: Array<{ id: string; name: string }>; +}) { + const [state, submit] = useActionState(createContractAction, empty); + const [forfait, setForfait] = useState(false); + const [open, setOpen] = useState(false); + + if (!open) { + return ( +
+ +
+ ); + } + + return ( + +
+ + +
+ + + +
+ +
+ + +
+ +
+ + + {forfait ? ( + + ) : ( + + )} +
+ + {forfait ? ( +
+ + +
+ ) : null} + + {state.error} + +
+ Ouvrir le contrat + +
+
+
+ ); +} diff --git a/src/app/(app)/equipe/[id]/contrats/page.tsx b/src/app/(app)/equipe/[id]/contrats/page.tsx index 5877c0a..c1aa9fb 100644 --- a/src/app/(app)/equipe/[id]/contrats/page.tsx +++ b/src/app/(app)/equipe/[id]/contrats/page.tsx @@ -1,14 +1,25 @@ import { notFound } from 'next/navigation'; -import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard'; +import { + ContractActions, + type ActiveContract, +} from '@/app/(app)/equipe/[id]/contrats/ContractActions'; +import { NewContractForm } from '@/app/(app)/equipe/[id]/contrats/NewContractForm'; import { Badge } from '@/components/ui/Badge'; import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; -import { getEmployee } from '@/server/employees/queries'; +import { + getEmployee, + listContractLocations, +} from '@/server/employees/queries'; export const dynamic = 'force-dynamic'; const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }); +/** Les dates traversent la frontière serveur/client en ISO court : un `Date` + * y perd son fuseau, et « 1er novembre » deviendrait « 31 octobre » à l'ouest. */ +const iso = (value: Date | null) => value?.toISOString().slice(0, 10) ?? null; + export default async function ContractsTab({ params, }: { @@ -21,44 +32,49 @@ export default async function ContractsTab({ const active = employee.contracts.find( (contract) => contract.status === 'ACTIVE', ); - const past = employee.contracts.filter( - (contract) => contract.id !== active?.id, - ); + + // La liste n'est chargée que si elle sert : sans contrat en cours, l'écran + // propose d'en ouvrir un, et la capacité peut manquer. + const locations = active ? [] : await listContractLocations().catch(() => []); + + const activeContract: ActiveContract | null = active + ? { + id: active.id, + label: active.label, + startDate: iso(active.startDate) as string, + endDate: iso(active.endDate), + trialEndDate: iso(active.trialEndDate), + weeklyHours: active.weeklyHours, + forfaitJours: active.forfaitJours, + forfaitDaysPerYear: active.forfaitDaysPerYear, + forfaitAgreementRef: active.forfaitAgreementRef, + isModulated: active.isModulated, + classification: active.classification, + coefficient: active.coefficient, + locationName: active.locationName, + monthlySalary: active.monthlySalary, + jobTitle: employee.headline.jobTitle, + } + : null; return (
- {active ? ( - - - - - - - {employee.canSeeSalary ? ( - - ) : null} - - - ) : ( - + ) : ( +
+ + {employee.canEdit && locations.length > 0 ? ( + + ) : null} +
)} @@ -70,7 +86,7 @@ export default async function ContractsTab({ ) : (
    - {[...(active ? [active] : []), ...past].map((contract) => ( + {employee.contracts.map((contract) => (
+ {contract.endReason ? ( +

+ Fin · {contract.endReason} +

+ ) : null} + {contract.amendments.length > 0 ? (
    {contract.amendments.map((amendment) => ( diff --git a/src/server/employees/actions.ts b/src/server/employees/actions.ts index 7e25208..455670f 100644 --- a/src/server/employees/actions.ts +++ b/src/server/employees/actions.ts @@ -329,3 +329,87 @@ export async function createAmendmentAction( if (membershipId) revalidatePath(`/equipe/${membershipId}`); return { ok: true }; } + +const endContractInput = z.object({ + contractId: z.string().min(1), + endDate: z.coerce.date(), + endReason: z.string().trim().min(1, 'Motif de fin requis').max(300), +}); + +/** + * Termine un contrat. + * + * Le contrat n'est pas supprimé : il est **daté et clos**. Un contrôle porte + * sur une période révolue, et effacer un contrat terminé effacerait la preuve + * que le salarié a travaillé — ainsi que la base de son solde de tout compte. + */ +export async function endContractAction( + _previous: ActionState, + formData: FormData, +): Promise { + const parsed = endContractInput.safeParse({ + contractId: formData.get('contractId'), + endDate: formData.get('endDate'), + endReason: formData.get('endReason'), + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + let membershipId = ''; + + try { + await mutate('members.contract.edit', async (db, actor) => { + const contract = await db.userContract.findUnique({ + where: { id: parsed.data.contractId }, + }); + if (!contract) throw new AuthorizationError('members.contract.edit'); + membershipId = contract.membershipId; + + if (contract.status === 'ENDED') { + throw new ValidationError('Ce contrat est déjà terminé.'); + } + if (parsed.data.endDate < contract.startDate) { + throw new ValidationError( + 'La fin du contrat précède son début.', + ); + } + + await db.userContract.update({ + where: { id: contract.id }, + data: { + endDate: parsed.data.endDate, + endReason: parsed.data.endReason, + status: 'ENDED', + version: { increment: 1 }, + }, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'contract.end', + entityType: 'UserContract', + entityId: contract.id, + before: { status: contract.status, endDate: contract.endDate?.toISOString() ?? null }, + after: { + status: 'ENDED', + endDate: parsed.data.endDate.toISOString(), + }, + reason: parsed.data.endReason, + }); + }); + } catch (error) { + if (error instanceof ValidationError) return { error: error.message }; + if (error instanceof AuthorizationError) { + return { error: "Vous n'avez pas le droit de terminer un contrat." }; + } + throw error; + } + + if (membershipId) { + revalidatePath(`/equipe/${membershipId}`); + revalidatePath('/equipe'); + } + return { ok: true }; +} diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts index 4c1a221..3838d70 100644 --- a/src/server/employees/queries.ts +++ b/src/server/employees/queries.ts @@ -155,13 +155,25 @@ export interface EmployeeDetail extends EmployeeListRow { label: string; startDate: Date; endDate: Date | null; + trialEndDate: Date | null; weeklyHours: string; forfaitJours: boolean; forfaitDaysPerYear: string | null; + forfaitAgreementRef: string | null; + isModulated: boolean; + classification: string | null; + coefficient: string | null; + locationName: string | null; status: string; + endReason: string | null; /** Absent quand `members.salary.view` manque. */ monthlySalary: string | null; - amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>; + amendments: Array<{ + id: string; + effectiveDate: Date; + reason: string | null; + changes: unknown; + }>; }>; canSeeSalary: boolean; canInvite: boolean; @@ -207,11 +219,24 @@ export const getEmployee = cache(async function getEmployee( const active = membership.contracts.find( (contract) => contract.status === 'ACTIVE', ); + + // Tous les établissements portés par l'historique, et pas seulement celui + // du contrat en cours : un contrat terminé ailleurs reste à lire. + const contractLocations = await db.location.findMany({ + where: { + id: { + in: [ + ...new Set(membership.contracts.map((entry) => entry.locationId)), + ], + }, + }, + select: { id: true, name: true }, + }); + const locationNames = new Map( + contractLocations.map((entry) => [entry.id, entry.name]), + ); const location = active - ? await db.location.findUnique({ - where: { id: active.locationId }, - select: { name: true }, - }) + ? { name: locationNames.get(active.locationId) ?? null } : null; // L'emploi est porté par le contrat, pas par le dossier : il change par @@ -308,10 +333,17 @@ export const getEmployee = cache(async function getEmployee( label: contractLabel(contract.contractType), startDate: contract.startDate, endDate: contract.endDate, + trialEndDate: contract.trialEndDate, weeklyHours: contract.weeklyHours.toString(), forfaitJours: contract.workTimeArrangement === 'FORFAIT_JOURS', forfaitDaysPerYear: contract.forfaitDaysPerYear?.toString() ?? null, + forfaitAgreementRef: contract.forfaitAgreementRef, + isModulated: contract.isModulated, + classification: contract.classification, + coefficient: contract.coefficient, + locationName: locationNames.get(contract.locationId) ?? null, status: contract.status, + endReason: contract.endReason, monthlySalary: canSeeSalary ? (contract.monthlySalary?.toString() ?? null) : null, @@ -319,6 +351,7 @@ export const getEmployee = cache(async function getEmployee( id: amendment.id, effectiveDate: amendment.effectiveDate, reason: amendment.reason, + changes: amendment.changes, })), })), canSeeSalary, @@ -336,6 +369,27 @@ export const getEmployee = cache(async function getEmployee( }); }); +/** + * Établissements où poser un contrat. + * + * Une liste à part de celle des réglages : ouvrir un contrat n'exige pas + * d'administrer les établissements, et emprunter `settings.access` fermerait + * la création à un gestionnaire de paie qui n'a rien à y administrer. + */ +export const listContractLocations = cache( + async function listContractLocations(): Promise< + Array<{ id: string; name: string }> + > { + return query('members.contract.create', async (db) => + db.location.findMany({ + where: { archivedAt: null }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }), + ); + }, +); + /** Rattachement et périmètre — onglet « Planification et accès ». */ export interface MemberPlacement { /** Établissement porté par le contrat en cours. Il ne se change qu'en avenant. */