Ajout d'un moyen de modifier par lot les ouvrages dans les séries.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user