- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne. - Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais. Séries et sagas - Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles). - Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un. - Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence. - Filtre catalogue « sans couverture » pour repérer les livres à illustrer. Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total). - Corrections directes sur la fiche - Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ». - Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification. - Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe. - La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper. - Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
373 lines
13 KiB
Plaintext
373 lines
13 KiB
Plaintext
@inject ServiceLivresApi Api
|
|
@inject EtatReseau Reseau
|
|
@implements IDisposable
|
|
|
|
@*
|
|
Prêts d'un livre : l'état courant, l'action qui s'impose, puis l'historique.
|
|
|
|
Mobile d'abord : une seule colonne, un seul bouton visible à la fois. Prêter et rendre sont
|
|
les deux gestes du quotidien — ils sont en haut, l'historique se déroule dessous.
|
|
*@
|
|
<section class="bloc-prets">
|
|
<h2 class="titre-section">Prêt</h2>
|
|
|
|
@if (Format == MaBibli.Shared.Entites.Format.Numerique)
|
|
{
|
|
@* CLAUDE.md : un ebook n'est qu'une fiche, il n'y a pas d'exemplaire à confier.
|
|
L'API le refuse aussi — ici on évite simplement de proposer un geste impossible. *@
|
|
<p class="message-discret">
|
|
Un livre numérique ne se prête pas : il n'y a pas d'exemplaire à confier.
|
|
</p>
|
|
}
|
|
else if (!Reseau.EnLigne)
|
|
{
|
|
@*
|
|
L'historique des prêts n'est pas mis en cache : une requête par livre pour une
|
|
information rarement consultée. En revanche l'état COURANT vient de la fiche, donc
|
|
de l'instantané du catalogue — c'est lui qui répond à « où est ce livre ? », la
|
|
seule question qui se pose vraiment sans réseau, devant l'étagère.
|
|
|
|
Afficher une liste vide ferait croire à « jamais prêté » : on dit ce qu'on sait, et
|
|
on dit ce qu'on ignore.
|
|
*@
|
|
@if (PreteA is { } absent)
|
|
{
|
|
<p class="bandeau-pret">
|
|
<span class="etiquette etiquette-prete">Prêté</span>
|
|
à <strong>@absent</strong>
|
|
@if (PreteDepuis is { } depuis)
|
|
{
|
|
@($" depuis le {Jour(depuis)}")
|
|
}
|
|
</p>
|
|
}
|
|
else
|
|
{
|
|
<p class="message-discret">Ce livre était à la maison à la dernière synchronisation.</p>
|
|
}
|
|
|
|
<p class="message-discret">
|
|
Hors ligne : l'historique des prêts n'est pas consultable, et prêter ou marquer un
|
|
retour demande le réseau.
|
|
</p>
|
|
}
|
|
else if (_prets is null)
|
|
{
|
|
<p class="message-discret">Chargement…</p>
|
|
}
|
|
else
|
|
{
|
|
@if (_erreur is not null)
|
|
{
|
|
<p class="message-erreur" role="alert">@_erreur</p>
|
|
}
|
|
|
|
@if (EnCours is { } pret)
|
|
{
|
|
<p class="bandeau-pret">
|
|
<span class="etiquette etiquette-prete">Prêté</span>
|
|
à <strong>@pret.Emprunteur</strong>
|
|
depuis le @Jour(pret.DatePret)
|
|
<span class="pret-duree">(@Duree(pret.JoursEcoules))</span>
|
|
|
|
@Croix(pret)
|
|
</p>
|
|
|
|
@Confirmation(pret)
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal" disabled="@_occupe"
|
|
@onclick="RendreAsync">
|
|
@(_occupe ? "Enregistrement…" : "Marquer comme rendu")
|
|
</button>
|
|
</div>
|
|
}
|
|
else if (!_formulaireOuvert)
|
|
{
|
|
<p class="message-discret">Ce livre est à la maison.</p>
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal" @onclick="OuvrirFormulaire">
|
|
Prêter ce livre
|
|
</button>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
@* Emprunteur en texte libre : ce sont des gens du dehors, sans compte YunoHost. *@
|
|
<label class="champ">
|
|
<span class="champ-libelle">Prêté à <span class="obligatoire" aria-hidden="true">*</span></span>
|
|
<input class="champ-saisie" type="text" @bind="_emprunteur" @bind:event="oninput"
|
|
@onkeydown="SurTouche" placeholder="Prénom, nom…" autocomplete="off" />
|
|
</label>
|
|
|
|
<label class="champ">
|
|
<span class="champ-libelle">
|
|
Depuis le<span class="champ-aide"> (on note souvent un prêt après coup)</span>
|
|
</span>
|
|
<input class="champ-saisie" type="date" @bind="_datePret"
|
|
max="@DateTime.Today.ToString("yyyy-MM-dd")" />
|
|
</label>
|
|
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-principal"
|
|
disabled="@(_occupe || string.IsNullOrWhiteSpace(_emprunteur))"
|
|
@onclick="PreterAsync">
|
|
@(_occupe ? "Enregistrement…" : "Prêter")
|
|
</button>
|
|
<button type="button" class="bouton bouton-discret" disabled="@_occupe"
|
|
@onclick="() => _formulaireOuvert = false">
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (Historique.Count > 0)
|
|
{
|
|
@* L'historique est la raison d'être de la table : un prêt rendu ne disparaît pas. *@
|
|
<h3 class="titre-sous-section">Prêts passés</h3>
|
|
|
|
<ul class="liste-historique">
|
|
@foreach (var passe in Historique)
|
|
{
|
|
<li @key="passe.Id">
|
|
@* La croix reste SUR la ligne des dates : elle appartient à ce prêt, et
|
|
reléguée dessous elle se lisait comme une action de la liste entière. *@
|
|
<div class="historique-ligne">
|
|
<span class="historique-qui">
|
|
<strong>@passe.Emprunteur</strong>
|
|
<span class="historique-dates">
|
|
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
|
|
— @Duree(passe.JoursEcoules)
|
|
</span>
|
|
</span>
|
|
|
|
@Croix(passe)
|
|
</div>
|
|
|
|
@Confirmation(passe)
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
else if (EnCours is null)
|
|
{
|
|
<p class="message-discret">Jamais prêté.</p>
|
|
}
|
|
}
|
|
</section>
|
|
|
|
@*
|
|
⚠️ Effacer un prêt N'EST PAS le rendre, et les deux ne doivent jamais se confondre. Rendre
|
|
pose une date et garde la ligne : c'est l'histoire du livre, et la raison d'être de la table.
|
|
La croix défait une SAISIE — mauvais emprunteur, mauvaise date, prêt noté deux fois. Un
|
|
historique juste vaut mieux qu'un historique complet mais faux.
|
|
|
|
⚠️ Elle se confirme, et le mot le dit : « définitivement ». C'est la seule action de cet
|
|
écran qui détruit quelque chose, et elle est à côté de deux boutons qui n'en détruisent
|
|
aucun.
|
|
*@
|
|
@code {
|
|
/// <summary>La croix d'effacement d'une ligne de prêt.</summary>
|
|
private RenderFragment Croix(PretDto pret) =>
|
|
@<button type="button" class="bouton-croix" disabled="@_occupe"
|
|
title="Effacer ce prêt (saisie erronée)"
|
|
aria-label="@($"Effacer le prêt à {pret.Emprunteur}")"
|
|
@onclick="() => _aEffacer = pret.Id">✕</button>;
|
|
|
|
/// <summary>La demande de confirmation, sous la ligne visée seulement.</summary>
|
|
private RenderFragment Confirmation(PretDto pret) => @<text>
|
|
@if (_aEffacer == pret.Id)
|
|
{
|
|
<p class="message-avertissement" role="status">
|
|
Effacer définitivement ce prêt ? Il disparaîtra de l'historique du livre.
|
|
</p>
|
|
<div class="actions-formulaire">
|
|
<button type="button" class="bouton bouton-danger" disabled="@_occupe"
|
|
@onclick="() => EffacerAsync(pret)">
|
|
@(_occupe ? "Suppression…" : "Oui, effacer")
|
|
</button>
|
|
<button type="button" class="bouton bouton-discret" disabled="@_occupe"
|
|
@onclick="() => _aEffacer = null">
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
}
|
|
</text>;
|
|
|
|
[Parameter, EditorRequired] public int LivreId { get; set; }
|
|
|
|
/// <summary>Format du livre : un ebook ne se prête pas.</summary>
|
|
[Parameter] public Format Format { get; set; }
|
|
|
|
/// <summary>
|
|
/// Emprunteur actuel d'après la fiche. Sert <b>hors-ligne</b>, où l'historique n'est pas
|
|
/// consultable mais où l'état courant, lui, vient de l'instantané du catalogue.
|
|
/// </summary>
|
|
[Parameter] public string? PreteA { get; set; }
|
|
|
|
/// <summary>Date du prêt en cours, même origine que <see cref="PreteA"/>.</summary>
|
|
[Parameter] public DateTime? PreteDepuis { get; set; }
|
|
|
|
/// <summary>Prévient la page parente qu'un prêt a changé, pour qu'elle rafraîchisse sa fiche.</summary>
|
|
[Parameter] public EventCallback OnChangement { get; set; }
|
|
|
|
private IReadOnlyList<PretDto>? _prets;
|
|
private bool _formulaireOuvert;
|
|
private bool _occupe;
|
|
private string _emprunteur = string.Empty;
|
|
private DateTime _datePret = DateTime.Today;
|
|
private string? _erreur;
|
|
private int _livreCharge;
|
|
|
|
/// <summary>Prêt dont l'effacement attend confirmation, s'il y en a un.</summary>
|
|
private int? _aEffacer;
|
|
|
|
private PretDto? EnCours => _prets?.FirstOrDefault(p => p.EstEnCours);
|
|
|
|
private IReadOnlyList<PretDto> Historique =>
|
|
_prets?.Where(p => !p.EstEnCours).ToList() ?? [];
|
|
|
|
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
|
|
|
/// <summary>Au retour du réseau, l'historique devient consultable : on va le chercher.</summary>
|
|
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
|
{
|
|
if (Reseau.EnLigne)
|
|
{
|
|
await ChargerAsync();
|
|
}
|
|
|
|
StateHasChanged();
|
|
});
|
|
|
|
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (_livreCharge != LivreId)
|
|
{
|
|
_livreCharge = LivreId;
|
|
_prets = null;
|
|
await ChargerAsync();
|
|
}
|
|
}
|
|
|
|
private async Task ChargerAsync()
|
|
{
|
|
if (!Reseau.EnLigne)
|
|
{
|
|
return; // L'historique n'est pas en cache : rien à demander, l'affichage le dit.
|
|
}
|
|
|
|
try
|
|
{
|
|
_prets = await Api.ListerPretsLivreAsync(LivreId) ?? [];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_erreur = $"Impossible de charger les prêts : {ex.Message}";
|
|
_prets = [];
|
|
}
|
|
}
|
|
|
|
private void OuvrirFormulaire()
|
|
{
|
|
_emprunteur = string.Empty;
|
|
_datePret = DateTime.Today;
|
|
_erreur = null;
|
|
_formulaireOuvert = true;
|
|
}
|
|
|
|
private async Task SurTouche(KeyboardEventArgs e)
|
|
{
|
|
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_emprunteur))
|
|
{
|
|
await PreterAsync();
|
|
}
|
|
}
|
|
|
|
private async Task PreterAsync()
|
|
{
|
|
_occupe = true;
|
|
_erreur = null;
|
|
|
|
// La date n'est transmise que si elle diffère d'aujourd'hui : sinon on laisse le serveur
|
|
// horodater à l'instant présent, plus précis que « minuit ce matin ».
|
|
var date = _datePret.Date == DateTime.Today ? (DateTime?)null : _datePret;
|
|
var resultat = await Api.PreterAsync(LivreId, _emprunteur.Trim(), date);
|
|
|
|
_occupe = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
return;
|
|
}
|
|
|
|
_formulaireOuvert = false;
|
|
await ChargerAsync();
|
|
await OnChangement.InvokeAsync();
|
|
}
|
|
|
|
private async Task RendreAsync()
|
|
{
|
|
_occupe = true;
|
|
_erreur = null;
|
|
|
|
var resultat = await Api.RendreAsync(EnCours!.Id);
|
|
|
|
_occupe = false;
|
|
|
|
if (!resultat.EstOk)
|
|
{
|
|
_erreur = resultat.Erreur;
|
|
|
|
// Le prêt a peut-être été clos ailleurs (autre membre du foyer, autre onglet) :
|
|
// recharger vaut mieux que laisser un bouton qui ne peut plus rien faire.
|
|
await ChargerAsync();
|
|
return;
|
|
}
|
|
|
|
await ChargerAsync();
|
|
await OnChangement.InvokeAsync();
|
|
}
|
|
|
|
private async Task EffacerAsync(PretDto pret)
|
|
{
|
|
_occupe = true;
|
|
_erreur = null;
|
|
|
|
var erreur = await Api.SupprimerPretAsync(pret.Id);
|
|
|
|
_occupe = false;
|
|
_aEffacer = null;
|
|
|
|
if (erreur is not null)
|
|
{
|
|
_erreur = erreur;
|
|
return;
|
|
}
|
|
|
|
await ChargerAsync();
|
|
|
|
// Effacer le prêt EN COURS remet le livre à la maison : la fiche porte cet état, elle
|
|
// doit donc se relire, comme après un retour.
|
|
await OnChangement.InvokeAsync();
|
|
}
|
|
|
|
private static string Jour(DateTime instant) =>
|
|
instant.ToLocalTime().ToString("dd/MM/yyyy");
|
|
|
|
/// <summary>Durée en clair : « 3 jours », « 2 mois ». Le nombre de jours exact ne parle plus au-delà.</summary>
|
|
internal static string Duree(int jours) => jours switch
|
|
{
|
|
0 => "aujourd'hui",
|
|
1 => "1 jour",
|
|
< 31 => $"{jours} jours",
|
|
< 365 => $"{jours / 30} mois",
|
|
< 730 => "plus d'un an",
|
|
_ => $"plus de {jours / 365} ans",
|
|
};
|
|
}
|