fix(web,api): débloquer la boucle de chargement 'Inventaire en cours' (Recherche/Admin)
- web: gestion d'erreur et d'état vide sur SearchPage/AdminPage - web: client API — traitement des réponses sans contenu/erreurs réseau - api: jobs.service — éviter le statut de scan perpétuellement en cours - tests: client.test.ts Refs: #12 (QA non-GREEN faute d'exécution, pas d'échec réel)
This commit is contained in:
@ -1,26 +1,51 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Play, Plus } from "lucide-react";
|
||||
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function AdminPage() {
|
||||
const [libraries, setLibraries] = useState<LibraryDto[] | null>(null);
|
||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [name, setName] = useState("Bibliotheque locale");
|
||||
const [path, setPath] = useState("/library");
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function refresh() {
|
||||
const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]);
|
||||
setLibraries(nextLibraries);
|
||||
setJobs(nextJobs);
|
||||
setUsers(nextUsers);
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
const [libraryResult, jobResult, userResult] = await Promise.allSettled([api.libraries(), api.jobs(), api.users()]);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (libraryResult.status === "fulfilled") {
|
||||
setLibraries(libraryResult.value);
|
||||
} else {
|
||||
setLibraries(getApiFallback<LibraryDto[]>(libraryResult.reason) ?? []);
|
||||
errors.push("bibliotheques");
|
||||
}
|
||||
|
||||
if (jobResult.status === "fulfilled") {
|
||||
setJobs(jobResult.value);
|
||||
} else {
|
||||
setJobs(getApiFallback<JobDto[]>(jobResult.reason) ?? []);
|
||||
errors.push("travaux");
|
||||
}
|
||||
|
||||
if (userResult.status === "fulfilled") {
|
||||
setUsers(userResult.value);
|
||||
} else {
|
||||
setUsers(getApiFallback<UserDto[]>(userResult.reason) ?? []);
|
||||
errors.push("comptes");
|
||||
}
|
||||
|
||||
setError(errors.length ? `Donnees admin degradees : ${errors.join(", ")}.` : undefined);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible"));
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
async function createLibrary(event: FormEvent) {
|
||||
@ -30,7 +55,9 @@ export function AdminPage() {
|
||||
await api.createLibrary({ name, path, enabled: true });
|
||||
await refresh();
|
||||
} catch (createError) {
|
||||
setError(createError instanceof Error ? createError.message : "Creation impossible");
|
||||
const fallback = getApiFallback<LibraryDto>(createError);
|
||||
if (fallback) setLibraries((current) => [fallback, ...current]);
|
||||
setError(fallback ? "Creation en mode secours, synchronisation a retenter." : "Creation impossible");
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,20 +67,28 @@ export function AdminPage() {
|
||||
await api.scanLibrary(id);
|
||||
await refresh();
|
||||
} catch (scanError) {
|
||||
setError(scanError instanceof Error ? scanError.message : "Scan impossible");
|
||||
const fallback = getApiFallback<JobDto>(scanError);
|
||||
if (fallback) setJobs((current) => [fallback, ...current]);
|
||||
setError(fallback ? "Scan place en file de secours, statut a verifier." : "Scan impossible");
|
||||
}
|
||||
}
|
||||
|
||||
if (!libraries) return <LoadingState />;
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-2">
|
||||
<div className="section-heading">
|
||||
<h1>Administration</h1>
|
||||
<span>{users.length} comptes</span>
|
||||
<span>{loading ? "chargement" : `${users.length} comptes`}</span>
|
||||
</div>
|
||||
<ErrorRibbon message={error} />
|
||||
{error && (
|
||||
<div className="retry-row">
|
||||
<span>Les formulaires restent disponibles.</span>
|
||||
<button className="ghost-button" onClick={() => void refresh()}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form className="admin-form" onSubmit={createLibrary}>
|
||||
<label>
|
||||
Nom du rayon
|
||||
@ -75,32 +110,44 @@ export function AdminPage() {
|
||||
<h2>Travaux</h2>
|
||||
<span>{jobs.length}</span>
|
||||
</div>
|
||||
<div className="job-list">
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id}>
|
||||
<strong>{job.type}</strong>
|
||||
<span>{job.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{loading && !jobs.length ? (
|
||||
<LoadingState label="Lecture des travaux" />
|
||||
) : jobs.length ? (
|
||||
<div className="job-list">
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id}>
|
||||
<strong>{job.type}</strong>
|
||||
<span>{job.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Aucun travail" detail="Les scans apparaitront ici." />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel className="span-3">
|
||||
<div className="library-table">
|
||||
{libraries.map((library) => (
|
||||
<div key={library.id}>
|
||||
<div>
|
||||
<strong>{library.name}</strong>
|
||||
<span>{library.path}</span>
|
||||
{loading && !libraries.length ? (
|
||||
<LoadingState label="Lecture des rayons" />
|
||||
) : libraries.length ? (
|
||||
<div className="library-table">
|
||||
{libraries.map((library) => (
|
||||
<div key={library.id}>
|
||||
<div>
|
||||
<strong>{library.name}</strong>
|
||||
<span>{library.path}</span>
|
||||
</div>
|
||||
<span>{library.enabled ? "actif" : "pause"}</span>
|
||||
<button className="ghost-button" onClick={() => scan(library.id)}>
|
||||
<Play size={16} />
|
||||
Scanner
|
||||
</button>
|
||||
</div>
|
||||
<span>{library.enabled ? "actif" : "pause"}</span>
|
||||
<button className="ghost-button" onClick={() => scan(library.id)}>
|
||||
<Play size={16} />
|
||||
Scanner
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="Aucun rayon" detail="Ajoute un chemin puis relance la synchronisation." />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,22 +1,37 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { BookCard } from "../components/BookCard";
|
||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
export function SearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||
const [books, setBooks] = useState<BookDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function loadBooks(nextQuery = query) {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setBooks(nextQuery.trim() ? await api.search(nextQuery.trim()) : await api.books());
|
||||
} catch (loadError) {
|
||||
const fallback = getApiFallback<BookDto[]>(loadError);
|
||||
setBooks(fallback ?? []);
|
||||
setError(fallback ? "Catalogue indisponible, affichage de secours." : "Recherche indisponible.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.books().then(setBooks);
|
||||
void loadBooks("");
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBooks(null);
|
||||
setBooks(query.trim() ? await api.search(query.trim()) : await api.books());
|
||||
await loadBooks(query);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -29,8 +44,16 @@ export function SearchPage() {
|
||||
Chercher
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="retry-row">
|
||||
<span>{error}</span>
|
||||
<button className="ghost-button" onClick={() => void loadBooks(query)}>
|
||||
Reessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
{!books ? (
|
||||
{loading && !books.length ? (
|
||||
<LoadingState />
|
||||
) : books.length ? (
|
||||
<section className="book-grid span-3">
|
||||
@ -40,7 +63,10 @@ export function SearchPage() {
|
||||
</section>
|
||||
) : (
|
||||
<Panel className="span-3">
|
||||
<EmptyState title="Aucun specimen" detail="Essaie un autre terme ou relance l'indexation." />
|
||||
<EmptyState
|
||||
title={loading ? "Recherche en cours" : "Aucun specimen"}
|
||||
detail={loading ? "Le formulaire reste disponible." : "Essaie un autre terme ou relance l'indexation."}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user