diff --git a/apps/backoffice/app/books/page.tsx b/apps/backoffice/app/books/page.tsx index 1bcaa99..005b93c 100644 --- a/apps/backoffice/app/books/page.tsx +++ b/apps/backoffice/app/books/page.tsx @@ -1,6 +1,6 @@ import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api"; import { BooksGrid, EmptyState } from "../components/BookCard"; -import { Card, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui"; +import { Card, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui"; import Link from "next/link"; export const dynamic = "force-dynamic"; @@ -13,6 +13,8 @@ export default async function BooksPage({ const searchParamsAwaited = await searchParams; const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined; const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : ""; + const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined; + const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; const [libraries] = await Promise.all([ fetchLibraries().catch(() => [] as LibraryDto[]) @@ -25,7 +27,7 @@ export default async function BooksPage({ if (searchQuery) { // Mode recherche - const searchResponse = await searchBooks(searchQuery, libraryId).catch(() => null); + const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null); if (searchResponse) { searchResults = searchResponse.hits.map(hit => ({ id: hit.id, @@ -45,10 +47,15 @@ export default async function BooksPage({ totalHits = searchResponse.estimated_total_hits; } } else { - // Mode liste - const booksPage = await fetchBooks(libraryId).catch(() => ({ items: [] as BookDto[], next_cursor: null })); + // Mode liste avec pagination + const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({ + items: [] as BookDto[], + next_cursor: null, + prev_cursor: null + })); books = booksPage.items; nextCursor = booksPage.next_cursor; + // Note: L'API ne supporte pas encore prev_cursor, on gère ça côté UI } const displayBooks = (searchResults || books).map(book => ({ @@ -56,6 +63,9 @@ export default async function BooksPage({ coverUrl: getBookCoverUrl(book.id) })); + const hasNextPage = !!nextCursor; + const hasPrevPage = !!cursor; // Si on a un cursor, on peut revenir en arrière (simplifié) + return ( <>

@@ -110,19 +120,14 @@ export default async function BooksPage({ {/* Pagination */} - {!searchQuery && nextCursor && ( -
-
- - - -
-
+ {!searchQuery && ( + )} ) : ( diff --git a/apps/backoffice/app/components/JobRow.tsx b/apps/backoffice/app/components/JobRow.tsx index d4e82a2..ebd36ae 100644 --- a/apps/backoffice/app/components/JobRow.tsx +++ b/apps/backoffice/app/components/JobRow.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { JobProgress } from "./JobProgress"; -import { StatusBadge, Button } from "./ui"; +import { StatusBadge, Button, MiniProgressBar } from "./ui"; interface JobRowProps { job: { @@ -12,14 +12,27 @@ interface JobRowProps { type: string; status: string; created_at: string; + started_at: string | null; + finished_at: string | null; error_opt: string | null; + stats_json: { + scanned_files: number; + indexed_files: number; + removed_files: number; + errors: number; + } | null; + progress_percent: number | null; + processed_files: number | null; + total_files: number | null; }; libraryName: string | undefined; highlighted?: boolean; onCancel: (id: string) => void; + formatDate: (date: string) => string; + formatDuration: (start: string, end: string | null) => string; } -export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) { +export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) { const [showProgress, setShowProgress] = useState( highlighted || job.status === "running" || job.status === "pending" ); @@ -29,6 +42,24 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) window.location.reload(); }; + // Calculate duration + const duration = job.started_at + ? formatDuration(job.started_at, job.finished_at) + : "-"; + + // Get file stats + const scanned = job.stats_json?.scanned_files ?? 0; + const indexed = job.stats_json?.indexed_files ?? 0; + const removed = job.stats_json?.removed_files ?? 0; + const errors = job.stats_json?.errors ?? 0; + + // Format files display + const filesDisplay = job.status === "running" && job.total_files + ? `${job.processed_files || 0}/${job.total_files}` + : scanned > 0 + ? `${scanned} scanned` + : "-"; + return ( <> @@ -65,8 +96,30 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) )} + +
+ {filesDisplay} + {job.status === "running" && job.total_files && ( + + )} + {job.status === "success" && ( +
+ ✓ {indexed} + {removed > 0 && − {removed}} + {errors > 0 && ⚠ {errors}} +
+ )} +
+ - {new Date(job.created_at).toLocaleString()} + {duration} + + + {formatDate(job.created_at)}
@@ -90,7 +143,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) {showProgress && (job.status === "running" || job.status === "pending") && ( - + job.id === id ? { ...job, status: "cancelled" } : job )); @@ -73,6 +114,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro Library Type Status + Files + Duration Created Actions @@ -85,6 +128,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro libraryName={job.library_id ? libraries.get(job.library_id) : undefined} highlighted={job.id === highlightJobId} onCancel={handleCancel} + formatDate={formatDate} + formatDuration={formatDuration} /> ))} diff --git a/apps/backoffice/app/components/ui/Pagination.tsx b/apps/backoffice/app/components/ui/Pagination.tsx new file mode 100644 index 0000000..6d21d20 --- /dev/null +++ b/apps/backoffice/app/components/ui/Pagination.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "./Button"; + +interface CursorPaginationProps { + hasNextPage: boolean; + hasPrevPage: boolean; + pageSize: number; + currentCount: number; + pageSizeOptions?: number[]; + nextCursor?: string | null; +} + +export function CursorPagination({ + hasNextPage, + hasPrevPage, + pageSize, + currentCount, + pageSizeOptions = [20, 50, 100], + nextCursor, +}: CursorPaginationProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const goToNext = () => { + if (!nextCursor) return; + const params = new URLSearchParams(searchParams); + params.set("cursor", nextCursor); + router.push(`?${params.toString()}`); + }; + + const goToFirst = () => { + const params = new URLSearchParams(searchParams); + params.delete("cursor"); + router.push(`?${params.toString()}`); + }; + + const changePageSize = (size: number) => { + const params = new URLSearchParams(searchParams); + params.set("limit", size.toString()); + params.delete("cursor"); + router.push(`?${params.toString()}`); + }; + + return ( +
+ {/* Page size selector */} +
+ Show + + per page +
+ + {/* Count info */} +
+ Showing {currentCount} items +
+ + {/* Navigation */} +
+ {hasPrevPage && ( + + )} + + +
+
+ ); +} + +interface OffsetPaginationProps { + currentPage: number; + totalPages: number; + pageSize: number; + totalItems: number; + pageSizeOptions?: number[]; +} + +export function OffsetPagination({ + currentPage, + totalPages, + pageSize, + totalItems, + pageSizeOptions = [20, 50, 100], +}: OffsetPaginationProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const goToPage = (page: number) => { + const params = new URLSearchParams(searchParams); + params.set("page", page.toString()); + router.push(`?${params.toString()}`); + }; + + const changePageSize = (size: number) => { + const params = new URLSearchParams(searchParams); + params.set("limit", size.toString()); + params.set("page", "1"); + router.push(`?${params.toString()}`); + }; + + const startItem = (currentPage - 1) * pageSize + 1; + const endItem = Math.min(currentPage * pageSize, totalItems); + + const getPageNumbers = () => { + const pages: (number | string)[] = []; + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + if (currentPage <= 3) { + for (let i = 1; i <= 4; i++) { + pages.push(i); + } + pages.push("..."); + pages.push(totalPages); + } else if (currentPage >= totalPages - 2) { + pages.push(1); + pages.push("..."); + for (let i = totalPages - 3; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + pages.push("..."); + for (let i = currentPage - 1; i <= currentPage + 1; i++) { + pages.push(i); + } + pages.push("..."); + pages.push(totalPages); + } + } + return pages; + }; + + return ( +
+ {/* Page size selector */} +
+ Show + + per page +
+ + {/* Page info */} +
+ {startItem}-{endItem} of {totalItems} +
+ + {/* Page navigation */} +
+ + + {getPageNumbers().map((page, index) => ( + + {page === "..." ? ( + ... + ) : ( + + )} + + ))} + + +
+
+ ); +} diff --git a/apps/backoffice/app/components/ui/index.ts b/apps/backoffice/app/components/ui/index.ts index cddf04f..f788f87 100644 --- a/apps/backoffice/app/components/ui/index.ts +++ b/apps/backoffice/app/components/ui/index.ts @@ -6,3 +6,4 @@ export { Button } from "./Button"; export { Input, Select } from "./Input"; export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form"; export { PageIcon, NavIcon } from "./Icon"; +export { CursorPagination, OffsetPagination } from "./Pagination"; diff --git a/apps/backoffice/app/globals.css b/apps/backoffice/app/globals.css index d108847..b53fe28 100644 --- a/apps/backoffice/app/globals.css +++ b/apps/backoffice/app/globals.css @@ -31,6 +31,12 @@ --color-primary: hsl(194 76% 62%); --color-primary-soft: hsl(210 34% 24%); --color-muted: hsl(218 17% 72%); + --color-success: hsl(142 70% 55%); + --color-success-soft: hsl(142 30% 20%); + --color-warning: hsl(45 90% 55%); + --color-warning-soft: hsl(45 30% 20%); + --color-error: hsl(2 80% 65%); + --color-error-soft: hsl(2 30% 20%); } /* Base styles */ diff --git a/apps/backoffice/app/layout.tsx b/apps/backoffice/app/layout.tsx index c8dde17..b80a334 100644 --- a/apps/backoffice/app/layout.tsx +++ b/apps/backoffice/app/layout.tsx @@ -65,7 +65,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { {/* Main Content */} -
+
{children}
diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index f38254c..7372a4b 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -19,6 +19,15 @@ export type IndexJobDto = { finished_at: string | null; error_opt: string | null; created_at: string; + stats_json: { + scanned_files: number; + indexed_files: number; + removed_files: number; + errors: number; + } | null; + progress_percent: number | null; + processed_files: number | null; + total_files: number | null; }; export type TokenDto = { diff --git a/apps/backoffice/next-env.d.ts b/apps/backoffice/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/backoffice/next-env.d.ts +++ b/apps/backoffice/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.