feat(precedents): שיפור טאב-הסטטיסטיקה — משפך-סקירה + פסי-פילוח (#5) #277

Merged
chaim merged 1 commits from worktree-stats-improve into main 2026-06-16 18:58:39 +00:00
Showing only changes of commit 37cd28eab6 - Show all commits

View File

@@ -4,18 +4,82 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useLibraryStats } from "@/lib/api/precedent-library";
import { practiceAreaLabel } from "./practice-area";
function StatCard({ label, value, accent }: { label: string; value: number | string; accent?: boolean }) {
const fmt = (n: number) => n.toLocaleString("he-IL");
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div
className={`
rounded-lg border p-5 bg-surface shadow-sm
${accent ? "border-gold bg-gold-wash/40" : "border-rule"}
`}
>
<div className="rounded-[10px] border border-rule bg-surface p-5 shadow-sm">
<div className="text-[0.78rem] text-ink-muted mb-1">{label}</div>
<div className={`text-3xl font-bold tabular-nums ${accent ? "text-gold-deep" : "text-navy"}`}>
{value}
<div className="text-3xl font-bold tabular-nums text-navy">{value}</div>
</div>
);
}
/** #5 — review-status funnel: one stacked bar (approved / pending / rejected)
* + legend with counts. Surfaces the rejected count now exposed by the stats
* endpoint, so the review pipeline is visible at a glance (mockup 07b). */
function ReviewFunnel({
approved, pending, rejected,
}: { approved: number; pending: number; rejected: number }) {
const total = approved + pending + rejected;
const pct = (n: number) => (total > 0 ? `${(n / total) * 100}%` : "0%");
return (
<div className="rounded-[10px] border border-rule bg-surface p-5 shadow-sm">
<h3 className="text-navy font-semibold mb-3.5">סטטוס סקירת הלכות</h3>
<div className="flex h-[26px] overflow-hidden rounded-md border border-rule">
<span className="block h-full bg-success" style={{ width: pct(approved) }} />
<span className="block h-full bg-warn" style={{ width: pct(pending) }} />
<span className="block h-full bg-danger" style={{ width: pct(rejected) }} />
</div>
<div className="flex flex-wrap gap-x-6 gap-y-2 mt-3.5 text-[0.84rem] text-ink-soft">
<span className="flex items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-[3px] bg-success" />
מאושרות (זמינות לסוכנים)
<span className="font-bold text-navy tabular-nums">{fmt(approved)}</span>
</span>
<span className="flex items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-[3px] bg-warn" />
ממתינות לאישור
<span className="font-bold text-navy tabular-nums">{fmt(pending)}</span>
</span>
<span className="flex items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-[3px] bg-danger" />
נדחו
<span className="font-bold text-navy tabular-nums">{fmt(rejected)}</span>
</span>
</div>
</div>
);
}
/** Distribution rendered as horizontal bars (was a plain count list). */
function DistributionBars({
title, rows,
}: { title: string; rows: { label: string; count: number }[] }) {
const max = Math.max(1, ...rows.map((r) => r.count));
return (
<div className="rounded-[10px] border border-rule bg-surface p-5 shadow-sm">
<h3 className="text-navy font-semibold mb-3.5">{title}</h3>
{rows.length === 0 ? (
<p className="text-ink-muted text-sm">אין נתונים</p>
) : (
<ul className="space-y-2.5">
{rows.map((r) => (
<li key={r.label} className="flex items-center gap-3">
<span className="w-[108px] shrink-0 text-[0.82rem] text-ink-soft">{r.label}</span>
<span className="flex-1 h-3 rounded-full bg-rule-soft overflow-hidden">
<span
className="block h-full rounded-full bg-gold transition-[width] duration-500"
style={{ width: `${(r.count / max) * 100}%` }}
/>
</span>
<span className="w-9 shrink-0 text-start text-[0.82rem] font-semibold text-navy tabular-nums">
{r.count}
</span>
</li>
))}
</ul>
)}
</div>
);
}
@@ -33,56 +97,43 @@ export function LibraryStatsPanel() {
if (isPending || !data) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-28 w-full" />)}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{[...Array(2)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
<Skeleton className="h-28 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="פסיקה בקורפוס" value={data.precedents_total} />
<StatCard label="הלכות בסך הכל" value={data.halachot_total} />
<StatCard
label="ממתינות לאישור" value={data.halachot_pending}
accent={data.halachot_pending > 0}
/>
<StatCard label="מאושרות (זמינות לסוכנים)" value={data.halachot_approved} />
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<StatCard label="פסיקות בקורפוס" value={fmt(data.precedents_total)} />
<StatCard label="הלכות בסך הכל" value={fmt(data.halachot_total)} />
</div>
<ReviewFunnel
approved={data.halachot_approved}
pending={data.halachot_pending}
rejected={data.halachot_rejected}
/>
<div className="grid md:grid-cols-2 gap-4">
<div className="rounded-lg border border-rule bg-surface p-5">
<h3 className="text-navy font-semibold mb-3">פילוח לפי תחום</h3>
{data.by_practice_area.length === 0 ? (
<p className="text-ink-muted text-sm">אין נתונים</p>
) : (
<ul className="space-y-2">
{data.by_practice_area.map((row) => (
<li key={row.practice_area || "—"} className="flex items-center gap-2 text-sm">
<span className="text-ink">{practiceAreaLabel(row.practice_area || null)}</span>
<span className="ms-auto tabular-nums text-navy font-semibold">{row.count}</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg border border-rule bg-surface p-5">
<h3 className="text-navy font-semibold mb-3">פילוח לפי רמת תקדים</h3>
{data.by_precedent_level.length === 0 ? (
<p className="text-ink-muted text-sm">אין נתונים</p>
) : (
<ul className="space-y-2">
{data.by_precedent_level.map((row) => (
<li key={row.precedent_level || "—"} className="flex items-center gap-2 text-sm">
<span className="text-ink">{row.precedent_level || "—"}</span>
<span className="ms-auto tabular-nums text-navy font-semibold">{row.count}</span>
</li>
))}
</ul>
)}
</div>
<DistributionBars
title="פילוח לפי תחום"
rows={data.by_practice_area.map((r) => ({
label: practiceAreaLabel(r.practice_area || null),
count: r.count,
}))}
/>
<DistributionBars
title="פילוח לפי רמת-תקדים"
rows={data.by_precedent_level.map((r) => ({
label: r.precedent_level || "לא סווג",
count: r.count,
}))}
/>
</div>
</div>
);