feat: enhance jobs list stats with tooltips, icons, and refresh count
- Add Tooltip UI component for styled hover tooltips - Replace native title attributes with Tooltip on all job stats - Add refresh icon (green) showing actual refreshed count for metadata refresh - Add icon+tooltip to scanned files stat - Add icon prop to StatBox component - Add refreshed field to stats_json types - Distinct tooltip labels for total links vs refreshed count Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
import { JobProgress } from "./JobProgress";
|
import { JobProgress } from "./JobProgress";
|
||||||
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui";
|
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon, Tooltip } from "./ui";
|
||||||
|
|
||||||
interface JobRowProps {
|
interface JobRowProps {
|
||||||
job: {
|
job: {
|
||||||
@@ -21,6 +21,7 @@ interface JobRowProps {
|
|||||||
indexed_files: number;
|
indexed_files: number;
|
||||||
removed_files: number;
|
removed_files: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
|
refreshed?: number;
|
||||||
} | null;
|
} | null;
|
||||||
progress_percent: number | null;
|
progress_percent: number | null;
|
||||||
processed_files: number | null;
|
processed_files: number | null;
|
||||||
@@ -117,49 +118,74 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
|
|||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
{/* Files: indexed count */}
|
{/* Files: indexed count */}
|
||||||
{indexed > 0 && (
|
{indexed > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}>
|
<Tooltip label={t("jobRow.filesIndexed", { count: indexed })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-success">
|
||||||
<Icon name="document" size="sm" />
|
<Icon name="document" size="sm" />
|
||||||
{indexed}
|
{indexed}
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Removed files */}
|
{/* Removed files */}
|
||||||
{removed > 0 && (
|
{removed > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}>
|
<Tooltip label={t("jobRow.filesRemoved", { count: removed })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-warning">
|
||||||
<Icon name="trash" size="sm" />
|
<Icon name="trash" size="sm" />
|
||||||
{removed}
|
{removed}
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Thumbnails */}
|
{/* Thumbnails */}
|
||||||
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
|
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
<Tooltip label={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-primary">
|
||||||
<Icon name="image" size="sm" />
|
<Icon name="image" size="sm" />
|
||||||
{job.total_files}
|
{job.total_files}
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Metadata batch: series processed */}
|
{/* Metadata batch: series processed */}
|
||||||
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
|
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
<Tooltip label={t("jobRow.metadataProcessed", { count: job.total_files })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-info">
|
||||||
<Icon name="tag" size="sm" />
|
<Icon name="tag" size="sm" />
|
||||||
{job.total_files}
|
{job.total_files}
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Metadata refresh: links refreshed */}
|
{/* Metadata refresh: total links + refreshed count */}
|
||||||
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
|
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}>
|
<Tooltip label={t("jobRow.metadataLinks", { count: job.total_files })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-info">
|
||||||
<Icon name="tag" size="sm" />
|
<Icon name="tag" size="sm" />
|
||||||
{job.total_files}
|
{job.total_files}
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{isMetadataRefresh && job.stats_json?.refreshed != null && job.stats_json.refreshed > 0 && (
|
||||||
|
<Tooltip label={t("jobRow.metadataRefreshed", { count: job.stats_json.refreshed })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-success">
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
{job.stats_json.refreshed}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Errors */}
|
{/* Errors */}
|
||||||
{errors > 0 && (
|
{errors > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}>
|
<Tooltip label={t("jobRow.errors", { count: errors })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-error">
|
||||||
<Icon name="warning" size="sm" />
|
<Icon name="warning" size="sm" />
|
||||||
{errors}
|
{errors}
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Scanned only (no other stats) */}
|
{/* Scanned only (no other stats) */}
|
||||||
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
|
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
|
||||||
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span>
|
<Tooltip label={t("jobRow.scanned", { count: scanned })}>
|
||||||
|
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Icon name="search" size="sm" />
|
||||||
|
{scanned}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Nothing to show */}
|
{/* Nothing to show */}
|
||||||
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
|
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Job {
|
|||||||
indexed_files: number;
|
indexed_files: number;
|
||||||
removed_files: number;
|
removed_files: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
|
refreshed?: number;
|
||||||
} | null;
|
} | null;
|
||||||
progress_percent: number | null;
|
progress_percent: number | null;
|
||||||
processed_files: number | null;
|
processed_files: number | null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface StatBoxProps {
|
|||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
variant?: "default" | "primary" | "success" | "warning" | "error";
|
variant?: "default" | "primary" | "success" | "warning" | "error";
|
||||||
|
icon?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,10 +24,13 @@ const valueVariantStyles: Record<string, string> = {
|
|||||||
error: "text-destructive",
|
error: "text-destructive",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatBox({ value, label, variant = "default", className = "" }: StatBoxProps) {
|
export function StatBox({ value, label, variant = "default", icon, className = "" }: StatBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
|
<div className={`text-center p-4 rounded-lg transition-colors duration-200 ${variantStyles[variant]} ${className}`}>
|
||||||
<span className={`block text-3xl font-bold ${valueVariantStyles[variant]}`}>{value}</span>
|
<div className={`flex items-center justify-center gap-1.5 ${valueVariantStyles[variant]}`}>
|
||||||
|
{icon && <span className="text-xl">{icon}</span>}
|
||||||
|
<span className="text-3xl font-bold">{value}</span>
|
||||||
|
</div>
|
||||||
<span className={`text-xs text-muted-foreground`}>{label}</span>
|
<span className={`text-xs text-muted-foreground`}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
18
apps/backoffice/app/components/ui/Tooltip.tsx
Normal file
18
apps/backoffice/app/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ label, children, className = "" }: TooltipProps) {
|
||||||
|
return (
|
||||||
|
<span className={`relative group/tooltip inline-flex ${className}`}>
|
||||||
|
{children}
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 text-xs text-popover-foreground bg-popover border border-border rounded-lg shadow-lg whitespace-nowrap opacity-0 scale-95 transition-all duration-150 group-hover/tooltip:opacity-100 group-hover/tooltip:scale-100 z-50">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,3 +19,4 @@ export {
|
|||||||
} from "./Form";
|
} from "./Form";
|
||||||
export { PageIcon, NavIcon, Icon } from "./Icon";
|
export { PageIcon, NavIcon, Icon } from "./Icon";
|
||||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||||
|
export { Tooltip } from "./Tooltip";
|
||||||
|
|||||||
@@ -587,7 +587,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<StatBox value={refreshReport.refreshed} label={t("jobDetail.refreshed")} variant="success" />
|
<StatBox
|
||||||
|
value={refreshReport.refreshed}
|
||||||
|
label={t("jobDetail.refreshed")}
|
||||||
|
variant="success"
|
||||||
|
icon={
|
||||||
|
<svg className="w-6 h-6 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
|
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
|
||||||
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
|
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
|
||||||
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
|
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type IndexJobDto = {
|
|||||||
removed_files: number;
|
removed_files: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
warnings: number;
|
warnings: number;
|
||||||
|
refreshed?: number;
|
||||||
} | null;
|
} | null;
|
||||||
progress_percent: number | null;
|
progress_percent: number | null;
|
||||||
processed_files: number | null;
|
processed_files: number | null;
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"jobRow.thumbnailsGenerated": "{{count}} thumbnails generated",
|
"jobRow.thumbnailsGenerated": "{{count}} thumbnails generated",
|
||||||
"jobRow.metadataProcessed": "{{count}} series processed",
|
"jobRow.metadataProcessed": "{{count}} series processed",
|
||||||
"jobRow.metadataRefreshed": "{{count}} series refreshed",
|
"jobRow.metadataRefreshed": "{{count}} series refreshed",
|
||||||
|
"jobRow.metadataLinks": "{{count}} links analyzed",
|
||||||
"jobRow.errors": "{{count}} errors",
|
"jobRow.errors": "{{count}} errors",
|
||||||
"jobRow.view": "View",
|
"jobRow.view": "View",
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ const fr = {
|
|||||||
"jobRow.thumbnailsGenerated": "{{count}} miniatures générées",
|
"jobRow.thumbnailsGenerated": "{{count}} miniatures générées",
|
||||||
"jobRow.metadataProcessed": "{{count}} séries traitées",
|
"jobRow.metadataProcessed": "{{count}} séries traitées",
|
||||||
"jobRow.metadataRefreshed": "{{count}} séries rafraîchies",
|
"jobRow.metadataRefreshed": "{{count}} séries rafraîchies",
|
||||||
|
"jobRow.metadataLinks": "{{count}} liens analysés",
|
||||||
"jobRow.errors": "{{count}} erreurs",
|
"jobRow.errors": "{{count}} erreurs",
|
||||||
"jobRow.view": "Voir",
|
"jobRow.view": "Voir",
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user