diff --git a/MaBibli.Client/Composants/FormulaireLotLivres.razor b/MaBibli.Client/Composants/FormulaireLotLivres.razor new file mode 100644 index 0000000..8281198 --- /dev/null +++ b/MaBibli.Client/Composants/FormulaireLotLivres.razor @@ -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é. +*@ +
+

+ 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. +

+ +
+ + + + @if (Saisie.ModifierType) + { +
+ @foreach (var type in Types) + { + + } +
+ } +
+ +
+ + @if (Saisie.ModifierThemes) + { + + } +
+ +
+ + @if (Saisie.ModifierAuteurs) + { + + + @if (Saisie.Auteurs.Count > 1) + { +
+ @foreach (var role in Roles) + { + + } +
+ } +
+
+ } +
+ +
+ + + + @if (Saisie.ModifierFormat) + { +
+ @foreach (var format in Formats) + { + + } +
+ } +
+ +
+ + @if (Saisie.ModifierEditeur) + { + + } +
+ +
+ + + + @if (Saisie.ModifierStatut) + { + + } +
+ + @if (!string.IsNullOrEmpty(Erreur)) + { + + } + + @if (!string.IsNullOrEmpty(MessageBlocage)) + { +

@MessageBlocage

+ } + +
+ + + @if (OnAnnuler.HasDelegate) + { + + } +
+
+ +@code { + private static readonly Format[] Formats = Enum.GetValues(); + + private static readonly TypeDocument[] Types = Enum.GetValues(); + + private static readonly RoleAuteur[] Roles = Enum.GetValues(); + + private void PoserRole(int index, RoleAuteur role) => + Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role }; + + /// 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. + private NomsAuteurs? _noms; + + private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs); + + private SaisieLot? _saisiePrecedente; + + private IReadOnlyList _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(); +} diff --git a/MaBibli.Client/Composants/SaisieLot.cs b/MaBibli.Client/Composants/SaisieLot.cs new file mode 100644 index 0000000..f6fc09d --- /dev/null +++ b/MaBibli.Client/Composants/SaisieLot.cs @@ -0,0 +1,49 @@ +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; + +namespace MaBibli.Client.Composants; + +/// +/// Saisie du panneau « Modifier en lot » (fiche série). +/// +/// +/// ⚠️ Chaque champ porte sa propre 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. 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). +/// +public sealed class SaisieLot +{ + public bool ModifierType { get; set; } + + public TypeDocument TypeDocument { get; set; } + + public bool ModifierThemes { get; set; } + + public List Themes { get; set; } = []; + + public bool ModifierAuteurs { get; set; } + + public List Auteurs { get; set; } = []; + + public bool ModifierFormat { get; set; } + + public Format Format { get; set; } + + public bool ModifierEditeur { get; set; } + + public string? Editeur { get; set; } + + /// + /// Statut de lecture de l'appelant, comme partout ailleurs : appliquer un statut en + /// lot pose la lecture de la personne qui agit, jamais celle d'un tiers. + /// + public bool ModifierStatut { get; set; } + + public Statut? Statut { get; set; } + + public bool AucunChamp => + !ModifierType && !ModifierThemes && !ModifierAuteurs + && !ModifierFormat && !ModifierEditeur && !ModifierStatut; +} diff --git a/MaBibli.Client/Pages/Serie.razor b/MaBibli.Client/Pages/Serie.razor index 6d5fee0..c4b8a55 100644 --- a/MaBibli.Client/Pages/Serie.razor +++ b/MaBibli.Client/Pages/Serie.razor @@ -74,6 +74,20 @@ else { Changer l'ordre } + + @* + « 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) + { + + } + Modifier } else @@ -332,6 +346,38 @@ else } + @* + 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) + { +
+ @if (_selectionLot.Count > 0) + { +
+ + @_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "") + + +
+ + + } + else + { +

Cochez les tomes à modifier ensemble.

+ } +
+ } + @* ⚠️ 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))) {
  • + @if (LotActif && element.LivreId is { } livreLot + && _livresCatalogue.Any(l => l.Id == livreLot)) + { + + } +
    @if (ModeOrdre) { @@ -641,6 +698,14 @@ else private bool _enCours; private string? _erreur; + /// Le panneau de modification en lot est déplié. + private bool _lotOuvert; + + /// Identifiants des LIVRES (pas des places) cochés pour la modification en lot. + private readonly HashSet _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; + } } /// Vrai quand ce qui est tapé dans la saisie manuelle est un code et non un titre. @@ -860,6 +930,116 @@ else /// Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder. private bool ModeConsultation => !ModeEdition && !ModeOrdre; + /// + /// 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. + /// + private bool LotActif => _lotOuvert && ModeConsultation; + + /// Tomes de la série effectivement rattachés à un livre du catalogue local. + private int TomesModifiablesEnLot => + _serie?.Elements.Count(e => e.LivreId is { } id && _livresCatalogue.Any(l => l.Id == id)) ?? 0; + + /// + /// 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 : un panneau qui se + /// rouvrirait sur l'état d'avant laisserait croire à une saisie encore active. + /// + 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; + } + + /// + /// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que + /// MasquerSelectionAsync de la bibliographie : pas de point d'entrée dédié, la boucle + /// réutilise , en ne remplaçant que les champs + /// cochés et en reprenant tels quels ceux qui ne le sont pas. + /// + private async Task EnregistrerLotAsync() + { + _enCours = true; + _erreur = null; + + var erreurs = new List(); + + 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); diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index b8cb6e0..77e91f5 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -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;