Files
planflow/src/server/settings/locations.ts
T
MichaelandClaude Opus 5 791dd523eb Donner une équipe à tout établissement neuf
Un établissement naissait sans équipe. Le planning s'ordonnant par équipe, il
n'avait alors aucune ligne où poser un créneau — même avec des salariés
rattachés. L'écran annonçait « cet établissement n'a pas encore d'équipe », ce
qui se lit « il n'y a personne », et rien ne disait où en créer une.

Deux corrections. La création d'un établissement pose une première équipe, à
l'installation comme depuis les réglages ; elle se renomme, et rien n'empêche
d'en ajouter d'autres. Et le message du planning dit désormais où aller : créer
l'équipe dans les réglages, puis rattacher les salariés depuis l'onglet
« Planification et accès » de leur fiche.

Les établissements déjà créés gardent leur situation : le correctif empêche d'en
produire de nouveaux sans équipe, il ne rattrape pas l'existant. Une équipe
s'ajoute en deux clics depuis Réglages · Établissements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:02:15 +02:00

229 lines
6.7 KiB
TypeScript

'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Établissements et équipes.
*
* Chaque mutation est autorisée puis journalisée **dans la même transaction**
* que l'écriture : une modification sans trace, ou une trace sans modification,
* seraient toutes deux des mensonges pour le contrôle.
*/
export interface LocationRow {
id: string;
name: string;
siret: string | null;
timezone: string;
employerContributionRate: string;
archivedAt: Date | null;
teams: Array<{ id: string; name: string; archivedAt: Date | null }>;
}
export async function listLocations(
includeArchived = false,
): Promise<LocationRow[]> {
return query('settings.access', async (db) => {
const locations = await db.location.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { name: 'asc' },
include: {
teams: {
where: includeArchived ? {} : { archivedAt: null },
orderBy: { position: 'asc' },
},
},
});
return locations.map((location) => ({
id: location.id,
name: location.name,
siret: location.siret,
timezone: location.timezone,
employerContributionRate: location.employerContributionRate.toString(),
archivedAt: location.archivedAt,
teams: location.teams.map((team) => ({
id: team.id,
name: team.name,
archivedAt: team.archivedAt,
})),
}));
});
}
const locationInput = z.object({
name: z.string().trim().min(1, 'Nom requis').max(120),
siret: z
.string()
.trim()
.regex(/^\d{14}$/, 'Le SIRET compte 14 chiffres')
.or(z.literal('')),
timezone: z.string().trim().min(1),
employerContributionRate: z.coerce
.number()
.min(0, 'Taux négatif impossible')
.max(100, 'Un taux de cotisations dépasse rarement 100 %'),
});
export interface ActionState {
error?: string;
ok?: boolean;
}
export async function createLocationAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = locationInput.safeParse({
name: formData.get('name'),
siret: formData.get('siret') ?? '',
timezone: formData.get('timezone') || 'Europe/Paris',
employerContributionRate: formData.get('employerContributionRate') ?? 0,
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.locations.manage', async (db, actor) => {
const created = await db.location.create({
data: {
name: parsed.data.name,
siret: parsed.data.siret || null,
timezone: parsed.data.timezone,
employerContributionRate: parsed.data.employerContributionRate,
} as never,
});
// Une première équipe, tout de suite. Le planning s'ordonne par équipe :
// un établissement qui n'en a aucune ne se planifie pas, et rien ne le
// dit avant la première semaine à couvrir — où le message « pas encore
// d'équipe » se lit « il n'y a personne ». Elle se renomme, et rien
// n'empêche d'en ajouter d'autres.
await db.team.create({
data: { locationId: created.id, name: 'Équipe', position: 0 } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'location.create',
entityType: 'Location',
entityId: created.id,
after: {
name: created.name,
siret: created.siret,
timezone: created.timezone,
},
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les établissements." };
}
throw error;
}
revalidatePath('/reglages/etablissements');
return { ok: true };
}
export async function archiveLocationAction(formData: FormData): Promise<void> {
const id = String(formData.get('id') ?? '');
if (!id) return;
await mutate('settings.locations.manage', async (db, actor) => {
const before = await db.location.findUnique({ where: { id } });
if (!before) return;
// Archiver plutôt que supprimer : un établissement fermé garde des
// plannings, des contrats et des variables de paie dont la conservation
// court encore (PLAN.md §12.5).
await db.location.update({
where: { id },
data: { archivedAt: new Date() },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'location.archive',
entityType: 'Location',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: new Date().toISOString() },
});
});
revalidatePath('/reglages/etablissements');
}
const teamInput = z.object({
locationId: z.string().min(1),
name: z.string().trim().min(1, "Nom d'équipe requis").max(120),
});
export async function createTeamAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = teamInput.safeParse({
locationId: formData.get('locationId'),
name: formData.get('name'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate(
'settings.teams.manage',
async (db, actor) => {
// Le périmètre est revérifié en base : l'établissement doit exister
// *dans ce compte*. Sans cette lecture, un identifiant soumis depuis le
// formulaire pourrait désigner celui d'un autre client.
const location = await db.location.findUnique({
where: { id: parsed.data.locationId },
});
if (!location) {
throw new AuthorizationError('settings.teams.manage');
}
const count = await db.team.count({
where: { locationId: location.id },
});
const created = await db.team.create({
data: {
locationId: location.id,
name: parsed.data.name,
position: count,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'team.create',
entityType: 'Team',
entityId: created.id,
after: { name: created.name, locationId: location.id },
});
},
{ locationId: parsed.data.locationId },
);
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les équipes." };
}
throw error;
}
revalidatePath('/reglages/etablissements');
return { ok: true };
}