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.