Ajout d'un moyen de modifier par lot les ouvrages dans les séries.

This commit is contained in:
mathieu
2026-09-10 01:08:57 +02:00
parent c5b4e117ba
commit 61d4fb1678
4 changed files with 460 additions and 0 deletions
@@ -0,0 +1,226 @@
@inject ServiceLivresApi Api
@*
Panneau de modification en lot, ouvert depuis la fiche série une fois des tomes cochés.
⚠️ Reprend le DESIGN de FormulaireLivre.razor (mêmes composants, même ordre de champs), mais
chaque champ est précédé d'une case « Modifier… » : sans elle, la valeur par défaut d'un champ
(« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les livres
sélectionnés écraserait un champ qu'on ne voulait pas toucher (CLAUDE.md, invariant 10).
Un champ non coché ne s'affiche pas — c'est ce qui rend visible ce qui va être écrasé.
*@
<div class="formulaire">
<p class="message-avertissement" role="status">
Les champs cochés remplacent la valeur existante sur chacun des @NombreLivres livres
sélectionnés — pour les auteurs, une liste vide les retire tous.
</p>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierType" />
<span>Type</span>
</label>
</legend>
@if (Saisie.ModifierType)
{
<div class="segments">
@foreach (var type in Types)
{
<button type="button"
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
@onclick="() => Saisie.TypeDocument = type">
@Libelles.TypeDocument(type)
</button>
}
</div>
}
</fieldset>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierThemes" />
<span>Thèmes</span>
</label>
@if (Saisie.ModifierThemes)
{
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà dans la liste."
Suggestions="_themesConnus"
OnChangement="StateHasChanged" />
}
</div>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierAuteurs" />
<span>Auteurs</span>
</label>
@if (Saisie.ModifierAuteurs)
{
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="Prénom Nom, ou Nom, Prénom"
MessageDoublon="Cet auteur est déjà dans la liste."
OnChangement="StateHasChanged">
<Complement Context="index">
@if (Saisie.Auteurs.Count > 1)
{
<div class="segments segments-role" role="group"
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
}
</Complement>
</SaisieListe>
}
</div>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierFormat" />
<span>Format</span>
</label>
</legend>
@if (Saisie.ModifierFormat)
{
<div class="segments">
@foreach (var format in Formats)
{
<button type="button"
class="segment @(Saisie.Format == format ? "segment-actif" : null)"
@onclick="() => Saisie.Format = format">
@Libelles.Format(format)
</button>
}
</div>
}
</fieldset>
<div class="champ">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierEditeur" />
<span>Éditeur</span>
</label>
@if (Saisie.ModifierEditeur)
{
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
autocomplete="off" />
}
</div>
<fieldset class="champ">
<legend class="champ-libelle">
<label class="bascule">
<input type="checkbox" @bind="Saisie.ModifierStatut" />
<span>Statut de lecture <span class="champ-aide">(le vôtre)</span></span>
</label>
</legend>
@if (Saisie.ModifierStatut)
{
<ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
}
</fieldset>
@if (!string.IsNullOrEmpty(Erreur))
{
<p class="message-erreur" role="alert">@Erreur</p>
}
@if (!string.IsNullOrEmpty(MessageBlocage))
{
<p class="message-avertissement" role="status">@MessageBlocage</p>
}
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(EnCours || Bloque || Saisie.AucunChamp)"
title="@(Saisie.AucunChamp ? "Cochez au moins un champ à modifier." : MessageBlocage)"
@onclick="Valider">
@(EnCours ? "Enregistrement…" : LibelleValidation)
</button>
@if (OnAnnuler.HasDelegate)
{
<button type="button" class="bouton bouton-discret" disabled="@EnCours"
@onclick="OnAnnuler">
Annuler
</button>
}
</div>
</div>
@code {
private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
/// <summary>Voir la remarque équivalente sur FormulaireLivre.razor : référence stable tant
/// que la saisie ne change pas, sans quoi SaisieListe se remet à zéro à chaque frappe.</summary>
private NomsAuteurs? _noms;
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
private SaisieLot? _saisiePrecedente;
private IReadOnlyList<string> _themesConnus = [];
protected override async Task OnInitializedAsync()
{
try
{
_themesConnus =
[
.. (await Api.ListerAsync(new CritereLivres()))
.SelectMany(l => l.Themes)
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
];
}
catch (Exception)
{
_themesConnus = [];
}
}
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
_noms = null;
}
}
[Parameter, EditorRequired] public SaisieLot Saisie { get; set; } = new();
[Parameter] public int NombreLivres { get; set; }
[Parameter] public string LibelleValidation { get; set; } = "Appliquer";
[Parameter] public string? Erreur { get; set; }
[Parameter] public bool EnCours { get; set; }
[Parameter] public string? MessageBlocage { get; set; }
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
[Parameter] public EventCallback OnValider { get; set; }
[Parameter] public EventCallback OnAnnuler { get; set; }
private Task Valider() => OnValider.InvokeAsync();
}
+49
View File
@@ -0,0 +1,49 @@
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Client.Composants;
/// <summary>
/// Saisie du panneau « Modifier en lot » (fiche série).
/// </summary>
/// <remarks>
/// ⚠️ Chaque champ porte sa propre case <c>Modifier…</c> : sans elle, la valeur par défaut d'un
/// champ (« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les
/// livres sélectionnés écraserait un champ qu'on ne voulait pas toucher. Seuls les champs cochés
/// sont envoyés ; les autres livres restent inchangés sur les champs non cochés (CLAUDE.md,
/// invariant 10 : on n'affiche — et n'applique — que ce qui a été choisi).
/// </remarks>
public sealed class SaisieLot
{
public bool ModifierType { get; set; }
public TypeDocument TypeDocument { get; set; }
public bool ModifierThemes { get; set; }
public List<string> Themes { get; set; } = [];
public bool ModifierAuteurs { get; set; }
public List<AuteurSaisi> Auteurs { get; set; } = [];
public bool ModifierFormat { get; set; }
public Format Format { get; set; }
public bool ModifierEditeur { get; set; }
public string? Editeur { get; set; }
/// <summary>
/// Statut de lecture <b>de l'appelant</b>, comme partout ailleurs : appliquer un statut en
/// lot pose la lecture de la personne qui agit, jamais celle d'un tiers.
/// </summary>
public bool ModifierStatut { get; set; }
public Statut? Statut { get; set; }
public bool AucunChamp =>
!ModifierType && !ModifierThemes && !ModifierAuteurs
&& !ModifierFormat && !ModifierEditeur && !ModifierStatut;
}
+180
View File
@@ -74,6 +74,20 @@ else
{
<a class="bouton bouton-compact bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
}
@*
« Modifier en lot » n'apparaît qu'à partir de DEUX tomes possédés : sur un
seul, l'écran de la fiche fait aussi bien. Même seuil que « Changer l'ordre ».
*@
@if (TomesModifiablesEnLot > 1)
{
<button type="button" class="bouton bouton-compact bouton-discret"
aria-expanded="@Aria.Etat(_lotOuvert)" aria-controls="lot-tomes"
@onclick="BasculerLot">
Modifier en lot
</button>
}
<a class="bouton bouton-compact bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
}
else
@@ -332,6 +346,38 @@ else
}
</div>
@*
Modification en lot : on coche des tomes possédés, puis on choisit ce qu'on leur
applique. Même patron que la sélection de la bibliographie (barre en haut, collante),
sauf que l'action n'est pas un simple bouton mais un formulaire — il reste donc
affiché sous la barre plutôt que de se déclencher au clic.
*@
@if (LotActif)
{
<div id="lot-tomes">
@if (_selectionLot.Count > 0)
{
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
<span class="selection-compte">
@_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "")
</span>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCours" @onclick="() => _selectionLot.Clear()">
Tout décocher
</button>
</div>
<FormulaireLotLivres Saisie="_saisieLot" NombreLivres="_selectionLot.Count"
Erreur="@_erreur" EnCours="_enCours" MessageBlocage="@MotifBlocage"
OnValider="EnregistrerLotAsync" OnAnnuler="AnnulerLot" />
}
else
{
<p class="message-discret">Cochez les tomes à modifier ensemble.</p>
}
</div>
}
@*
⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le
@@ -342,6 +388,17 @@ else
@foreach (var (element, index) in TomesAffiches.Select((e, i) => (e, i)))
{
<li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
@if (LotActif && element.LivreId is { } livreLot
&& _livresCatalogue.Any(l => l.Id == livreLot))
{
<label class="oeuvre-case">
<input type="checkbox"
checked="@_selectionLot.Contains(livreLot)"
@onchange="e => SelectionnerLot(livreLot, e.Value is true)" />
<span class="lecture-ecran">Sélectionner « @element.Titre »</span>
</label>
}
<div class="rang-actions">
@if (ModeOrdre)
{
@@ -641,6 +698,14 @@ else
private bool _enCours;
private string? _erreur;
/// <summary>Le panneau de modification en lot est déplié.</summary>
private bool _lotOuvert;
/// <summary>Identifiants des LIVRES (pas des places) cochés pour la modification en lot.</summary>
private readonly HashSet<int> _selectionLot = [];
private SaisieLot _saisieLot = new();
private string _titre = string.Empty;
private int _parenteId;
private bool _confirmeSuppression;
@@ -765,6 +830,11 @@ else
{
Ouvrir(VoieAjout.Aucune);
}
else
{
// Les deux panneaux se marcheraient dessus sur un petit écran : un seul à la fois.
_lotOuvert = false;
}
}
/// <summary>Vrai quand ce qui est tapé dans la saisie manuelle est un code et non un titre.</summary>
@@ -860,6 +930,116 @@ else
/// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
private bool ModeConsultation => !ModeEdition && !ModeOrdre;
/// <summary>
/// Le panneau de lot n'a de sens qu'en consultation : la case à cocher se confondrait avec
/// les flèches de l'écran d'ordre, qui reste dédié au déplacement des tomes.
/// </summary>
private bool LotActif => _lotOuvert && ModeConsultation;
/// <summary>Tomes de la série effectivement rattachés à un livre du catalogue local.</summary>
private int TomesModifiablesEnLot =>
_serie?.Elements.Count(e => e.LivreId is { } id && _livresCatalogue.Any(l => l.Id == id)) ?? 0;
/// <summary>
/// Ouvre ou ferme le panneau de modification en lot. Le refermer vide aussi la sélection et
/// la saisie en cours — même règle que <see cref="BasculerAjout"/> : un panneau qui se
/// rouvrirait sur l'état d'avant laisserait croire à une saisie encore active.
/// </summary>
private void BasculerLot()
{
_lotOuvert = !_lotOuvert;
_erreur = null;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
// Les deux panneaux se marcheraient dessus sur un petit écran : un seul à la fois.
if (_lotOuvert)
{
_ajoutOuvert = false;
Ouvrir(VoieAjout.Aucune);
}
}
private void SelectionnerLot(int livreId, bool selectionne)
{
if (selectionne)
{
_selectionLot.Add(livreId);
}
else
{
_selectionLot.Remove(livreId);
}
}
private void AnnulerLot()
{
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_erreur = null;
}
/// <summary>
/// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que
/// <c>MasquerSelectionAsync</c> de la bibliographie : pas de point d'entrée dédié, la boucle
/// réutilise <see cref="ServiceLivresApi.ModifierAsync"/>, en ne remplaçant que les champs
/// cochés et en reprenant tels quels ceux qui ne le sont pas.
/// </summary>
private async Task EnregistrerLotAsync()
{
_enCours = true;
_erreur = null;
var erreurs = new List<string>();
foreach (var livreId in _selectionLot)
{
var livre = _livresCatalogue.FirstOrDefault(l => l.Id == livreId);
if (livre is null)
{
continue;
}
var saisie = new EnregistrementLivre
{
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteurs = _saisieLot.ModifierAuteurs
? _saisieLot.Auteurs
: livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
Themes = _saisieLot.ModifierThemes ? _saisieLot.Themes : [.. livre.Themes],
Editeur = _saisieLot.ModifierEditeur ? _saisieLot.Editeur : livre.Editeur,
Format = _saisieLot.ModifierFormat ? _saisieLot.Format : livre.Format,
TypeDocument = _saisieLot.ModifierType ? _saisieLot.TypeDocument : livre.TypeDocument,
Statut = _saisieLot.ModifierStatut ? _saisieLot.Statut : livre.Statut,
NombrePages = livre.NombrePages,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
};
var resultat = await Api.ModifierAsync(livre.Id, saisie);
if (!resultat.EstOk)
{
erreurs.Add(resultat.Erreur!);
}
}
_enCours = false;
if (erreurs.Count > 0)
{
_erreur = string.Join(" ", erreurs.Distinct());
await ChargerAsync();
return;
}
_lotOuvert = false;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_message = "Les livres sélectionnés ont été modifiés.";
await ChargerAsync();
}
private bool Suffixe(string fin) =>
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
.EndsWith(fin, StringComparison.OrdinalIgnoreCase);
+5
View File
@@ -164,6 +164,11 @@ body {
.entete-fiche-actions {
flex: 0 0 auto;
/* Sans elle, un flex-basis « auto » prend la largeur de TOUS les boutons sur une seule
ligne (leur taille de contenu), et le flex-wrap ci-dessous ne peut jamais s'en servir :
la ligne déborde au lieu de se replier. Quatre boutons (dont « Modifier en lot ») y
tiennent tout juste à 1280px, mais débordent à 375px sans ce plafond. */
max-width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;