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>
229 lines
6.7 KiB
TypeScript
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 };
|
|
}
|