La fiche d'une revue portait tout à la fois : ses numéros, le formulaire d'ajout, les champs de la revue et sa suppression. Elle prend la forme déjà tenue par la fiche d'un livre et par celle d'une série — une route pour regarder, une route pour changer. « Ajouter un numéro » et « Modifier la revue » remontent à droite du titre, dans le bloc d'en-tête partagé avec la fiche d'une série : les deux actions sont visibles sans descendre au bas d'une collection de trente numéros. ⚠️ Modifier et retirer un numéro n'existent plus QUE dans /revues/{id}/edition. En consultation, ces deux boutons se déclenchaient sous le pouce en faisant défiler la liste — même raison que les flèches d'ordre d'une série, reléguées sur leur propre écran. ⚠️ Ajouter un numéro, lui, RESTE en consultation, et ce n'est pas une entorse : le geste ne touche pas à la fiche de la revue, il range un objet de plus, comme noter un prêt depuis la fiche d'un livre. Le formulaire se replie derrière son bouton, et s'ouvre de lui-même quand on arrive du scanner avec un numéro lu sur l'add-on EAN-2 : demander un clic de plus pour saisir ce qu'on tient en main serait un détour. Un retrait de numéro se confirme désormais, comme la suppression d'un livre ou d'une revue : il ne se défait pas, et la liste s'égrène sous le pouce. ⚠️ Les deux routes partagent le paramètre {id} : le routeur ne redessine rien en passant de l'une à l'autre, d'où l'abonnement à LocationChanged — piège déjà rencontré sur la fiche livre. Quitter l'édition referme au passage ce qui n'a de sens que là : formulaire de numéro ouvert, retrait ou suppression en attente de confirmation. /revues/{id}/edition entre dans la table de RemonteeRoutes : elle retombait jusqu'ici sur le repli de branche, donc sur la liste des revues au lieu de la fiche qu'on venait de quitter. Deux tests de service viennent avec, sur ce dont l'écran dépend sans le recalculer : la revue rendue par la modification d'un numéro revient déjà rangée (corriger une parution déplace le numéro, les numéros sans date fermant toujours la liste), et un retrait ne touche qu'au numéro visé. 605 tests au vert. Pas de vérification en navigateur : le rendu repose sur la relecture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
539 lines
20 KiB
Plaintext
539 lines
20 KiB
Plaintext
@page "/revues/{Id:int}"
|
|
@page "/revues/{Id:int}/edition"
|
|
@inject ServiceLivresApi Api
|
|
@inject NavigationManager Navigation
|
|
@inject EtatReseau Reseau
|
|
@implements IDisposable
|
|
|
|
<PageTitle>MaBibli — @(_revue?.Titre ?? "revue")</PageTitle>
|
|
|
|
@if (_revue is null)
|
|
{
|
|
<p class="message-discret" role="status">
|
|
@(_chargement ? "Chargement…" : "Cette revue n'est pas dans les données enregistrées sur cet appareil.")
|
|
</p>
|
|
}
|
|
else
|
|
{
|
|
@*
|
|
Titre, identité de la revue et actions sur la MÊME ligne (lot W) : disposition
|
|
symétrique de celle de la fiche d'une série, et déjà tenue par la bibliographie et la
|
|
liste des auteurs. Elle rend les deux actions visibles sans descendre au bas d'une
|
|
collection de trente numéros.
|
|
*@
|
|
<div class="entete-fiche">
|
|
<div class="entete-fiche-texte">
|
|
<h1 class="titre-page">@_revue.Titre</h1>
|
|
|
|
<p class="carte-details">
|
|
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
|
|
{
|
|
@* K2 : un ISSN se coupe TOUJOURS au même endroit, contrairement aux tranches
|
|
d'un ISBN. Aucune table à embarquer, et aucune coupure ne peut être fausse. *@
|
|
<span class="code-isbn">ISSN @FormatageIssn.Afficher(_revue.Issn)</span>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
|
|
{
|
|
<span>@_revue.Editeur</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
|
|
@if (!ModeEdition)
|
|
{
|
|
<div class="entete-fiche-actions">
|
|
@* Ajouter un numéro reste en CONSULTATION, et ce n'est pas une entorse à
|
|
« consulter d'abord, modifier ensuite » : le geste ne touche pas à la fiche de
|
|
la revue, il range un objet de plus — comme noter un prêt depuis la fiche d'un
|
|
livre. Ce sont la modification et le retrait d'un numéro qui vivent, elles,
|
|
uniquement dans l'écran d'édition. *@
|
|
<button type="button" class="bouton bouton-compact bouton-discret"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="BasculerAjout">
|
|
@(_ajout ? "Fermer" : "Ajouter un numéro")
|
|
</button>
|
|
|
|
@if (Reseau.EnLigne)
|
|
{
|
|
<a class="bouton bouton-compact bouton-principal"
|
|
href="@($"revues/{Id}/edition")">Modifier la revue</a>
|
|
}
|
|
else
|
|
{
|
|
<button type="button" class="bouton bouton-compact bouton-principal" disabled
|
|
title="@EtatReseau.MotifHorsLigne">Modifier la revue</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (_erreur is not null)
|
|
{
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
}
|
|
|
|
@if (!ModeEdition && _ajout)
|
|
{
|
|
<h2 class="titre-section">Ajouter un numéro</h2>
|
|
|
|
@*
|
|
⚠️ Le numéro se saisit à la main, et ce n'est pas un oubli : les deux chiffres de
|
|
parution du code-barres ne sont PAS un numéro fiable (CLAUDE.md), et l'add-on EAN-2
|
|
qui le porterait vraiment n'a pas pu être vérifié sur un magazine réel.
|
|
*@
|
|
@if (_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
|
|
{
|
|
<p class="message-avertissement" role="status">
|
|
Le code-barres portait « @NumeroScanne » à côté du code principal, ce qui est
|
|
normalement le numéro de parution. <strong>Vérifiez-le sur la couverture</strong>
|
|
avant d'enregistrer.
|
|
</p>
|
|
}
|
|
|
|
<ChampsNumeroRevue Saisie="_saisie" OnEntree="AjouterAsync" />
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Numero))"
|
|
title="@MotifBlocage"
|
|
@onclick="AjouterAsync">
|
|
Ajouter ce numéro
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (ModeEdition)
|
|
{
|
|
<h2 class="titre-section">La revue</h2>
|
|
|
|
<label class="champ">
|
|
<span class="champ-libelle">Nom</span>
|
|
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
|
|
</label>
|
|
|
|
<label class="champ">
|
|
<span class="champ-libelle">
|
|
ISSN <span class="champ-aide">(identifiant de la revue ; le tiret est posé à l'enregistrement)</span>
|
|
</span>
|
|
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
|
|
placeholder="2466-6718" autocomplete="off" />
|
|
</label>
|
|
|
|
<label class="champ">
|
|
<span class="champ-libelle">Éditeur</span>
|
|
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput" />
|
|
</label>
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
|
|
title="@MotifBlocage"
|
|
@onclick="EnregistrerAsync">
|
|
Enregistrer
|
|
</button>
|
|
|
|
<button type="button" class="bouton bouton-discret bouton-danger"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
@onclick="SupprimerAsync">
|
|
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la revue")
|
|
</button>
|
|
</div>
|
|
|
|
@if (_confirmeSuppression)
|
|
{
|
|
<p class="message-avertissement" role="status">
|
|
Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils
|
|
n'existent que par leur revue.
|
|
</p>
|
|
}
|
|
}
|
|
|
|
<h2 class="titre-section">Numéros possédés</h2>
|
|
|
|
@if (_revue.Numeros.Count == 0)
|
|
{
|
|
<p class="message-discret">
|
|
Aucun numéro recensé pour l'instant.
|
|
</p>
|
|
}
|
|
else
|
|
{
|
|
@* Du plus récent au plus ancien : devant un kiosque, la question est « ai-je le
|
|
dernier ? », pas « par quoi ai-je commencé ». C'est l'inverse d'une série, qui suit
|
|
son ordre de lecture. L'ordre est celui rendu par l'API — les numéros sans date
|
|
ferment la liste, une date inconnue n'étant pas une date ancienne. *@
|
|
<ul class="liste-numeros">
|
|
@foreach (var numero in _revue.Numeros)
|
|
{
|
|
<li class="carte-numero" @key="numero.Id">
|
|
@if (ModeEdition && _edition == numero.Id)
|
|
{
|
|
<div class="carte-corps">
|
|
<ChampsNumeroRevue Saisie="_saisieEdition" OnEntree="EnregistrerNumeroAsync" />
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieEdition.Numero))"
|
|
title="@MotifBlocage"
|
|
@onclick="EnregistrerNumeroAsync">
|
|
Enregistrer ce numéro
|
|
</button>
|
|
|
|
<button type="button" class="bouton bouton-discret"
|
|
disabled="@_enCours" @onclick="FermerEdition">
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
@* La couverture d'un numéro ne vient d'aucune source — l'ISSN désigne la
|
|
revue, pas la parution : sans URL collée, c'est le substitut à
|
|
initiale qui s'affiche, et il n'est pas cliquable. *@
|
|
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
|
|
Classe="couverture-petite" Agrandissable="true" />
|
|
|
|
<div class="carte-corps">
|
|
<p class="carte-titre">N° @numero.Numero</p>
|
|
|
|
<p class="carte-details">
|
|
@if (numero.DateParution is { } parution)
|
|
{
|
|
<span>@parution.ToLocalTime().ToString("MMMM yyyy")</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Date de parution inconnue</span>
|
|
}
|
|
</p>
|
|
|
|
@* Les articles à la une décrivent CE numéro, pas la revue : ils
|
|
changent à chaque parution. D'où une étiquette par titre, sur la
|
|
fiche du numéro et nulle part ailleurs. *@
|
|
@if (numero.Articles.Count > 0)
|
|
{
|
|
<p class="carte-details">
|
|
@foreach (var article in numero.Articles)
|
|
{
|
|
<span class="etiquette etiquette-une">@article</span>
|
|
}
|
|
</p>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(numero.Note))
|
|
{
|
|
<p class="souhait-note">@numero.Note</p>
|
|
}
|
|
</div>
|
|
|
|
@*
|
|
⚠️ Modifier et retirer un numéro n'existent QUE dans l'écran
|
|
d'édition : en consultation, ces deux boutons se déclenchaient sous
|
|
le pouce en faisant défiler la collection — même raison que les
|
|
flèches d'ordre d'une série, reléguées sur leur propre écran.
|
|
*@
|
|
@if (ModeEdition)
|
|
{
|
|
<div class="actions-tome">
|
|
<button type="button" class="bouton bouton-compact"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
aria-label="@($"Modifier le numéro {numero.Numero}")"
|
|
@onclick="() => OuvrirEdition(numero)">
|
|
Modifier
|
|
</button>
|
|
|
|
@* Un retrait est destructeur et ne se défait pas : il se
|
|
confirme, comme la suppression d'un livre ou d'une revue. *@
|
|
<button type="button" class="bouton bouton-compact bouton-danger"
|
|
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
|
aria-label="@($"Retirer le numéro {numero.Numero}")"
|
|
@onclick="() => RetirerAsync(numero)">
|
|
@(_confirmeRetrait == numero.Id ? "Confirmer le retrait" : "Retirer")
|
|
</button>
|
|
</div>
|
|
}
|
|
}
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public int Id { get; set; }
|
|
|
|
/// <summary>
|
|
/// Numéro lu sur l'add-on <b>EAN-2</b> du code-barres, transmis par l'écran de scan.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ <b>Proposé, pas acquis.</b> L'EAN-2 est normativement le numéro de parution, mais
|
|
/// rien ne l'a confirmé sur des magazines réels : il atterrit donc dans le champ, où
|
|
/// l'utilisateur le compare à la couverture et le corrige. C'est aussi pourquoi l'écran le
|
|
/// dit explicitement au lieu de le glisser en douce.
|
|
/// </remarks>
|
|
[SupplyParameterFromQuery(Name = "numero")]
|
|
public string? NumeroScanne { get; set; }
|
|
|
|
/// <summary>Le numéro scanné n'est proposé qu'une fois : ensuite, le champ appartient à l'utilisateur.</summary>
|
|
private bool _numeroPropose;
|
|
|
|
private RevueDto? _revue;
|
|
private bool _chargement = true;
|
|
private bool _enCours;
|
|
private string? _erreur;
|
|
private bool _confirmeSuppression;
|
|
|
|
private string _titre = string.Empty;
|
|
private string? _issn;
|
|
private string? _editeur;
|
|
|
|
/// <summary>Saisie du numéro qu'on ajoute, et repli du formulaire qui la porte.</summary>
|
|
private AjoutNumeroRevue _saisie = new();
|
|
private bool _ajout;
|
|
|
|
/// <summary>Numéro en cours de modification, et sa saisie — <c>null</c> = aucun.</summary>
|
|
private int? _edition;
|
|
private AjoutNumeroRevue _saisieEdition = new();
|
|
|
|
/// <summary>Numéro dont le retrait attend confirmation — <c>null</c> = aucun.</summary>
|
|
private int? _confirmeRetrait;
|
|
|
|
/// <summary>
|
|
/// Écran d'édition de la revue : nom, ISSN, éditeur, suppression, et la seule place où un
|
|
/// numéro se modifie ou se retire.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : convention du projet depuis
|
|
/// la fiche livre. Comme les deux routes partagent le même paramètre <c>Id</c>, le routeur
|
|
/// ne déclenche <b>aucun</b> nouveau rendu en passant de l'une à l'autre — d'où l'abonnement
|
|
/// à <c>LocationChanged</c>, sans lequel l'URL changerait et l'écran resterait le même.
|
|
/// </remarks>
|
|
private bool ModeEdition =>
|
|
Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
|
|
.EndsWith("/edition", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
Reseau.Change += SurChangementReseau;
|
|
Navigation.LocationChanged += SurNavigation;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Quitter l'édition referme ce qui n'a de sens que là : un formulaire de numéro resté
|
|
/// ouvert, et une confirmation de retrait en attente.
|
|
/// </summary>
|
|
private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(() =>
|
|
{
|
|
if (!ModeEdition)
|
|
{
|
|
_edition = null;
|
|
_confirmeRetrait = null;
|
|
_confirmeSuppression = false;
|
|
}
|
|
|
|
StateHasChanged();
|
|
});
|
|
|
|
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
|
{
|
|
await ChargerAsync();
|
|
StateHasChanged();
|
|
});
|
|
|
|
public void Dispose()
|
|
{
|
|
Reseau.Change -= SurChangementReseau;
|
|
Navigation.LocationChanged -= SurNavigation;
|
|
}
|
|
|
|
protected override Task OnParametersSetAsync() => ChargerAsync();
|
|
|
|
private async Task ChargerAsync()
|
|
{
|
|
_chargement = true;
|
|
_revue = (await Api.ListerRevuesAsync()).FirstOrDefault(r => r.Id == Id);
|
|
_chargement = false;
|
|
|
|
if (_revue is not null)
|
|
{
|
|
_titre = _revue.Titre;
|
|
_issn = _revue.Issn;
|
|
_editeur = _revue.Editeur;
|
|
}
|
|
|
|
// Un numéro lu au code-barres ouvre le formulaire de lui-même : on vient de scanner,
|
|
// demander un clic de plus pour saisir ce qu'on tient en main serait un détour.
|
|
if (!_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
|
|
{
|
|
_numeroPropose = true;
|
|
_saisie.Numero = NumeroScanne.Trim();
|
|
_ajout = true;
|
|
}
|
|
}
|
|
|
|
private void BasculerAjout()
|
|
{
|
|
_erreur = null;
|
|
_ajout = !_ajout;
|
|
}
|
|
|
|
private void Appliquer(RevueDto revue)
|
|
{
|
|
_revue = revue;
|
|
_titre = revue.Titre;
|
|
_issn = revue.Issn;
|
|
_editeur = revue.Editeur;
|
|
}
|
|
|
|
private async Task AjouterAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_saisie.Numero) || !Reseau.EnLigne || _enCours)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_erreur = null;
|
|
|
|
// La conversion de la date en UTC vit dans ChampsNumeroRevue : les deux formulaires qui
|
|
// s'en servent ne peuvent donc pas l'oublier chacun de leur côté.
|
|
var resultat = await Api.AjouterNumeroAsync(Id, _saisie);
|
|
|
|
_enCours = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
Appliquer(resultat.Valeur!);
|
|
_saisie = new AjoutNumeroRevue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ouvre la modification d'un numéro, en recopiant ce qu'il porte déjà.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// La couverture et les articles à la une <b>ne peuvent pas</b> être connus à l'achat : on
|
|
/// note un numéro quand on le range, et on en recopie la une plus tard. Sans cet écran, ces
|
|
/// deux champs n'auraient existé qu'à la création, c'est-à-dire jamais.
|
|
/// </remarks>
|
|
private void OuvrirEdition(NumeroRevueDto numero)
|
|
{
|
|
_erreur = null;
|
|
_confirmeRetrait = null;
|
|
_edition = numero.Id;
|
|
_saisieEdition = new AjoutNumeroRevue
|
|
{
|
|
Numero = numero.Numero,
|
|
DateParution = numero.DateParution,
|
|
Note = numero.Note,
|
|
CoverUrl = numero.CoverUrl,
|
|
Articles = [.. numero.Articles],
|
|
};
|
|
}
|
|
|
|
private void FermerEdition() => _edition = null;
|
|
|
|
private async Task EnregistrerNumeroAsync()
|
|
{
|
|
if (_edition is not { } numeroId
|
|
|| string.IsNullOrWhiteSpace(_saisieEdition.Numero)
|
|
|| !Reseau.EnLigne
|
|
|| _enCours)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_erreur = null;
|
|
|
|
var resultat = await Api.ModifierNumeroAsync(numeroId, _saisieEdition);
|
|
|
|
_enCours = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
Appliquer(resultat.Valeur!);
|
|
_edition = null;
|
|
}
|
|
|
|
private async Task RetirerAsync(NumeroRevueDto numero)
|
|
{
|
|
// Premier clic : on demande confirmation. Un numéro retiré ne se récupère pas, et la
|
|
// liste s'égrène sous le pouce.
|
|
if (_confirmeRetrait != numero.Id)
|
|
{
|
|
_erreur = null;
|
|
_confirmeRetrait = numero.Id;
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_erreur = await Api.RetirerNumeroAsync(numero.Id);
|
|
_enCours = false;
|
|
_confirmeRetrait = null;
|
|
|
|
// Le numéro retiré était peut-être celui qu'on modifiait : un formulaire ouvert sur une
|
|
// ligne disparue n'aurait plus rien à enregistrer.
|
|
if (_edition == numero.Id)
|
|
{
|
|
_edition = null;
|
|
}
|
|
|
|
if (_erreur is null)
|
|
{
|
|
await ChargerAsync();
|
|
}
|
|
}
|
|
|
|
private async Task EnregistrerAsync()
|
|
{
|
|
_enCours = true;
|
|
_erreur = null;
|
|
|
|
var resultat = await Api.ModifierRevueAsync(
|
|
Id, new EnregistrementRevue { Titre = _titre, Issn = _issn, Editeur = _editeur });
|
|
|
|
_enCours = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
Appliquer(resultat.Valeur!);
|
|
|
|
// La fiche est à jour : on retombe sur la consultation, comme après l'édition d'un
|
|
// livre ou d'une série. Rester en édition inviterait à réenregistrer.
|
|
Navigation.NavigateTo($"revues/{Id}");
|
|
}
|
|
|
|
private async Task SupprimerAsync()
|
|
{
|
|
if (!_confirmeSuppression)
|
|
{
|
|
_confirmeSuppression = true;
|
|
return;
|
|
}
|
|
|
|
_enCours = true;
|
|
_erreur = await Api.SupprimerRevueAsync(Id);
|
|
_enCours = false;
|
|
|
|
if (_erreur is null)
|
|
{
|
|
Navigation.NavigateTo("revues");
|
|
}
|
|
}
|
|
}
|