home: split cases table by appeal type + add appeal-type chart
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
Backend (cases listing) - /api/cases: also return updated_at, created_at, practice_area, appeal_subtype, subject. The detail-mode response was previously dropping these even though db.list_cases reads them, leaving the UI's "תחום" and "עודכן" columns blank. Frontend - Split the home table into two: רישוי (1xxx) and היטל השבחה ופיצויים (8xxx + 9xxx), bucketing on appeal_subtype with a case-number-prefix fallback. The "תחום" column is now redundant and removed. - New AppealTypeBars chart in the right rail next to the existing status donut. - Donut: switch to a vertical layout (donut on top, legend below in a 3-col grid) so labels like "חדש / בעיבוד" no longer wrap inside the 320px sidebar; counts now align in a tabular column. - CasesTable accepts emptyText/searchPlaceholder so each split table has its own copy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { KPICards } from "@/components/cases/kpi-cards";
|
import { KPICards } from "@/components/cases/kpi-cards";
|
||||||
import { StatusDonut } from "@/components/cases/status-donut";
|
import { StatusDonut } from "@/components/cases/status-donut";
|
||||||
|
import { AppealTypeBars, subtypeOf } from "@/components/cases/appeal-type-bars";
|
||||||
import { CasesTable } from "@/components/cases/cases-table";
|
import { CasesTable } from "@/components/cases/cases-table";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useCases } from "@/lib/api/cases";
|
import { useCases, type Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { data, isPending, error } = useCases(true);
|
const { data, isPending, error } = useCases(true);
|
||||||
|
|
||||||
|
const { permits, levies } = useMemo(() => {
|
||||||
|
const permits: Case[] = [];
|
||||||
|
const levies: Case[] = [];
|
||||||
|
(data ?? []).forEach((c) => {
|
||||||
|
const s = subtypeOf(c);
|
||||||
|
if (s === "building_permit") permits.push(c);
|
||||||
|
else if (s === "betterment_levy" || s === "compensation_197") levies.push(c);
|
||||||
|
else permits.push(c); // fallback bucket — keep visible
|
||||||
|
});
|
||||||
|
return { permits, levies };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<section className="space-y-8">
|
<section className="space-y-8">
|
||||||
@@ -35,25 +49,70 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<KPICards cases={data} loading={isPending} />
|
<KPICards cases={data} loading={isPending} />
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_auto]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
|
<div className="space-y-6 min-w-0">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||||
<h2 className="text-navy text-xl mb-0">רשימת תיקים</h2>
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h2 className="text-navy text-xl mb-0">רישוי ובנייה</h2>
|
||||||
|
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||||
|
עררים 1xxx
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||||
מעודכן חי
|
מעודכן חי
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CasesTable cases={data} loading={isPending} error={error} />
|
<CasesTable
|
||||||
|
cases={permits}
|
||||||
|
loading={isPending}
|
||||||
|
error={error}
|
||||||
|
emptyText="אין תיקי רישוי פעילים"
|
||||||
|
searchPlaceholder="חיפוש בעררי רישוי…"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-surface border-rule shadow-sm lg:w-[320px]">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h2 className="text-navy text-xl mb-0">היטל השבחה ופיצויים</h2>
|
||||||
|
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||||
|
עררים 8xxx · 9xxx
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||||
|
מעודכן חי
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CasesTable
|
||||||
|
cases={levies}
|
||||||
|
loading={isPending}
|
||||||
|
error={error}
|
||||||
|
emptyText="אין תיקי היטל השבחה או פיצויים פעילים"
|
||||||
|
searchPlaceholder="חיפוש בעררי השבחה ופיצויים…"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<h2 className="text-navy text-lg mb-4">פיזור סטטוסים</h2>
|
<h2 className="text-navy text-lg mb-4">פיזור סטטוסים</h2>
|
||||||
<StatusDonut cases={data} />
|
<StatusDonut cases={data} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h2 className="text-navy text-lg mb-4">פיזור לפי תחום</h2>
|
||||||
|
<AppealTypeBars cases={data} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
55
web-ui/src/components/cases/appeal-type-bars.tsx
Normal file
55
web-ui/src/components/cases/appeal-type-bars.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { deriveSubtype } from "@/lib/practice-area";
|
||||||
|
import type { AppealSubtype } from "@/lib/practice-area";
|
||||||
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
|
type Bucket = { key: AppealSubtype; label: string; color: string };
|
||||||
|
|
||||||
|
const BUCKETS: Bucket[] = [
|
||||||
|
{ key: "building_permit", label: "רישוי ובנייה", color: "var(--color-info)" },
|
||||||
|
{ key: "betterment_levy", label: "היטל השבחה", color: "var(--color-gold)" },
|
||||||
|
{ key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function subtypeOf(c: Case): AppealSubtype {
|
||||||
|
return c.appeal_subtype && c.appeal_subtype !== "unknown"
|
||||||
|
? c.appeal_subtype
|
||||||
|
: deriveSubtype(c.case_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppealTypeBars({ cases }: { cases?: Case[] }) {
|
||||||
|
const counts: Record<AppealSubtype, number> = {
|
||||||
|
building_permit: 0,
|
||||||
|
betterment_levy: 0,
|
||||||
|
compensation_197: 0,
|
||||||
|
unknown: 0,
|
||||||
|
};
|
||||||
|
(cases ?? []).forEach((c) => {
|
||||||
|
counts[subtypeOf(c)] += 1;
|
||||||
|
});
|
||||||
|
const max = Math.max(1, ...BUCKETS.map((b) => counts[b.key]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{BUCKETS.map((b) => {
|
||||||
|
const n = counts[b.key];
|
||||||
|
const widthPct = (n / max) * 100;
|
||||||
|
return (
|
||||||
|
<li key={b.key} className="space-y-1.5">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||||
|
<span className="text-ink-soft truncate">{b.label}</span>
|
||||||
|
<span className="text-ink font-semibold tabular-nums">{n}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-rule-soft/60 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-[width] duration-500"
|
||||||
|
style={{ width: `${widthPct}%`, background: b.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
import { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
|
|
||||||
import type { Case } from "@/lib/api/cases";
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
function formatDate(iso?: string) {
|
function formatDate(iso?: string) {
|
||||||
@@ -60,15 +59,6 @@ const columns: ColumnDef<Case>[] = [
|
|||||||
header: "סטטוס",
|
header: "סטטוס",
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "appeal_subtype",
|
|
||||||
header: "תחום",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const s = row.original.appeal_subtype;
|
|
||||||
if (!s || s === "unknown") return <span className="text-ink-muted">—</span>;
|
|
||||||
return <span className="text-ink-soft text-sm">{APPEAL_SUBTYPE_LABELS[s]}</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "document_count",
|
accessorKey: "document_count",
|
||||||
header: "מסמכים",
|
header: "מסמכים",
|
||||||
@@ -91,10 +81,14 @@ export function CasesTable({
|
|||||||
cases,
|
cases,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
emptyText = "עדיין אין תיקי ערר",
|
||||||
|
searchPlaceholder = "חיפוש לפי מס׳ ערר או כותרת…",
|
||||||
}: {
|
}: {
|
||||||
cases?: Case[];
|
cases?: Case[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: Error | null;
|
error?: Error | null;
|
||||||
|
emptyText?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
}) {
|
}) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "updated_at", desc: true },
|
{ id: "updated_at", desc: true },
|
||||||
@@ -128,7 +122,7 @@ export function CasesTable({
|
|||||||
<Input
|
<Input
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
placeholder="חיפוש לפי מס׳ ערר או כותרת…"
|
placeholder={searchPlaceholder}
|
||||||
className="max-w-sm bg-surface"
|
className="max-w-sm bg-surface"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
@@ -176,7 +170,7 @@ export function CasesTable({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length} className="text-center text-ink-muted py-12">
|
<TableCell colSpan={columns.length} className="text-center text-ink-muted py-12">
|
||||||
<div className="text-gold text-2xl mb-2" aria-hidden>❦</div>
|
<div className="text-gold text-2xl mb-2" aria-hidden>❦</div>
|
||||||
{globalFilter ? "אין תיקים תואמים לחיפוש" : "עדיין אין תיקי ערר"}
|
{globalFilter ? "אין תיקים תואמים לחיפוש" : emptyText}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -55,29 +55,32 @@ export function StatusDonut({ cases }: { cases?: Case[] }) {
|
|||||||
.join(", ")})`;
|
.join(", ")})`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex flex-col items-center gap-5">
|
||||||
<div
|
<div
|
||||||
className="relative w-[140px] h-[140px] rounded-full shadow-sm"
|
className="relative w-[150px] h-[150px] rounded-full shadow-sm"
|
||||||
style={{ background }}
|
style={{ background }}
|
||||||
aria-label="פיזור תיקים לפי סטטוס"
|
aria-label="פיזור תיקים לפי סטטוס"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-[18px] bg-surface rounded-full flex flex-col items-center justify-center">
|
<div className="absolute inset-[20px] bg-surface rounded-full flex flex-col items-center justify-center">
|
||||||
<span className="font-display text-2xl font-black text-navy leading-none">
|
<span className="font-display text-3xl font-black text-navy leading-none tabular-nums">
|
||||||
{total}
|
{total}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[0.7rem] text-ink-muted mt-1">תיקים</span>
|
<span className="text-[0.7rem] text-ink-muted mt-1 tracking-wide">תיקים</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="flex flex-col gap-1.5 text-sm">
|
<ul className="grid grid-cols-1 gap-1.5 text-sm w-full">
|
||||||
{(Object.keys(GROUP_META) as GroupKey[]).map((k) => (
|
{(Object.keys(GROUP_META) as GroupKey[]).map((k) => (
|
||||||
<li key={k} className="flex items-center gap-2">
|
<li
|
||||||
|
key={k}
|
||||||
|
className="grid grid-cols-[auto_1fr_auto] items-center gap-2 py-1 px-2 rounded-md hover:bg-rule-soft/40 transition-colors"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2.5 h-2.5 rounded-full"
|
className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
style={{ background: GROUP_META[k].color }}
|
style={{ background: GROUP_META[k].color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-ink-soft">{GROUP_META[k].label}</span>
|
<span className="text-ink-soft truncate">{GROUP_META[k].label}</span>
|
||||||
<span className="text-ink-muted tabular-nums me-auto ms-1">
|
<span className="text-ink font-semibold tabular-nums">
|
||||||
{counts[k]}
|
{counts[k]}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1080,6 +1080,9 @@ async def list_cases(
|
|||||||
"title": c["title"],
|
"title": c["title"],
|
||||||
"status": c["status"],
|
"status": c["status"],
|
||||||
"archived_at": c["archived_at"].isoformat() if c.get("archived_at") else None,
|
"archived_at": c["archived_at"].isoformat() if c.get("archived_at") else None,
|
||||||
|
"updated_at": c["updated_at"].isoformat() if c.get("updated_at") else None,
|
||||||
|
"practice_area": c.get("practice_area"),
|
||||||
|
"appeal_subtype": c.get("appeal_subtype"),
|
||||||
}
|
}
|
||||||
for c in cases
|
for c in cases
|
||||||
]
|
]
|
||||||
@@ -1100,10 +1103,15 @@ async def list_cases(
|
|||||||
"case_number": c["case_number"],
|
"case_number": c["case_number"],
|
||||||
"title": c["title"],
|
"title": c["title"],
|
||||||
"status": c["status"],
|
"status": c["status"],
|
||||||
|
"subject": c.get("subject", "") or "",
|
||||||
"expected_outcome": c.get("expected_outcome", ""),
|
"expected_outcome": c.get("expected_outcome", ""),
|
||||||
"committee_type": c.get("committee_type", ""),
|
"committee_type": c.get("committee_type", ""),
|
||||||
"hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "",
|
"hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "",
|
||||||
"archived_at": c["archived_at"].isoformat() if c.get("archived_at") else None,
|
"archived_at": c["archived_at"].isoformat() if c.get("archived_at") else None,
|
||||||
|
"created_at": c["created_at"].isoformat() if c.get("created_at") else None,
|
||||||
|
"updated_at": c["updated_at"].isoformat() if c.get("updated_at") else None,
|
||||||
|
"practice_area": c.get("practice_area"),
|
||||||
|
"appeal_subtype": c.get("appeal_subtype"),
|
||||||
"document_count": doc_count,
|
"document_count": doc_count,
|
||||||
"processing_count": processing_count,
|
"processing_count": processing_count,
|
||||||
"gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}",
|
"gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}",
|
||||||
|
|||||||
Reference in New Issue
Block a user