refactor(cases): צמצום תפריט-סטטוס 17→10 + מקור-אמת יחיד (UI-B1/G2)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s

תפריט הסטטוס-הידני הכיל 17 סטטוסים שמתוכם ~9 דקורציה טהורה — שלבי-ביניים
שאף קוד בפייפליין לא קבע ושום לוגיקה לא הסתעפה לפיהם, עם רשימות כפולות
לא-עקביות ב-6+ קבצים (UI-B1) ו-exported כסטטוס-רפאים (באג agent-audit).

הליבה (10): new, processing, documents_ready, outcome_set, direction_approved,
qa_review, drafted, exported, reviewed, final.

- SSoT חדש web-ui/src/lib/api/case-status.ts (רשימה/שלבים/תוויות/statusLabel);
  כל הצרכנים (badge/changer/timeline/guide/donut/kpi/compose) מייבאים משם.
- statusLabel() מבטיח תווית עברית תמיד — גם לערך-מורשת (נפילה עברית, לא סלאג).
- בקאנד: STATUS_ORDER 10, models.CaseStatus מיושר, set_outcome קובע
  outcome_set/direction_approved (במקום in_progress) כמו endpoint ה-web.
- exported מוקשח אחרי export-DOCX מוצלח (forward-only); widget "נכשל ב-QA"
  עודכן ל-qa_review (הסטטוס שנקבע בפועל בכשל-QA).
- scripts/backfill_case_status_trim.py: מיפוי שורות-מורשת לסטטוס-הליבה הקודם.

Invariants: UI-B1 (מקור-אמת יחיד)  · G2 (אין מסלול מקביל)  · GAP-42 (חלקי).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 09:47:13 +00:00
parent 5370ada37c
commit ba542f9c21
16 changed files with 316 additions and 190 deletions

View File

@@ -21,17 +21,10 @@ import { DOC_TYPE_LABELS, type DocType } from "@/lib/doc-types";
// ── Case-status → Hebrew label + tone (mockup 03 status chip) ────────────────
const STATUS_CHIP: Record<string, { label: string; cls: string }> = {
new: { label: "חדש", cls: "bg-rule-soft text-ink-muted border-rule" },
uploading: { label: "בהעלאה", cls: "bg-info-bg text-info border-info/30" },
processing: { label: "בעיבוד", cls: "bg-info-bg text-info border-info/30" },
documents_ready: { label: "מסמכים מוכנים", cls: "bg-info-bg text-info border-info/30" },
analyst_verified: { label: "אומת ע״י אנליסט", cls: "bg-info-bg text-info border-info/30" },
research_complete: { label: "מחקר הושלם", cls: "bg-info-bg text-info border-info/30" },
outcome_set: { label: "תוצאה נקבעה", cls: "bg-info-bg text-info border-info/30" },
brainstorming: { label: "סיעור-מוחות", cls: "bg-info-bg text-info border-info/30" },
direction_approved: { label: "כיוון אושר", cls: "bg-info-bg text-info border-info/30" },
analysis_enriched: { label: "ניתוח הועשר", cls: "bg-info-bg text-info border-info/30" },
ready_for_writing: { label: "מוכן לכתיבה", cls: "bg-gold-wash text-gold-deep border-gold/40" },
drafting: { label: "בעריכה", cls: "bg-info-bg text-info border-info/30" },
qa_review: { label: "בדיקת-איכות", cls: "bg-gold-wash text-gold-deep border-gold/40" },
drafted: { label: "טיוטה", cls: "bg-gold-wash text-gold-deep border-gold/40" },
exported: { label: "יוצא", cls: "bg-success-bg text-success border-success/40" },

View File

@@ -1,5 +1,6 @@
import { Card, CardContent } from "@/components/ui/card";
import type { Case } from "@/lib/api/cases";
import { phaseOf } from "@/lib/api/case-status";
type Bucket = {
label: string;
@@ -17,15 +18,14 @@ const TONE_STYLES: Record<Bucket["tone"], string> = {
function bucketize(cases: Case[] | undefined): Bucket[] {
const c = cases ?? [];
const inProgress = c.filter((x) =>
["processing", "documents_ready", "analyst_verified", "research_complete", "outcome_set", "brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"].includes(x.status),
).length;
const drafting = c.filter((x) =>
["drafting", "qa_review", "drafted"].includes(x.status),
).length;
const done = c.filter((x) =>
["exported", "reviewed", "final"].includes(x.status),
).length;
/* Buckets derive from the SSoT phases: "בהכנה" = everything from processing
* through direction-approval (intake-after-new + prep + thinking). */
const inProgress = c.filter((x) => {
const p = phaseOf(x.status);
return x.status === "processing" || p === "prep" || p === "thinking";
}).length;
const drafting = c.filter((x) => phaseOf(x.status) === "writing").length;
const done = c.filter((x) => phaseOf(x.status) === "done").length;
return [
{ label: "סה״כ תיקי ערר", caption: "בכל הסטטוסים", value: c.length, tone: "navy" },

View File

@@ -1,105 +1,48 @@
import { Badge } from "@/components/ui/badge";
import {
FilePlus2, Upload, Loader2, FileCheck, Target,
Lightbulb, Compass, PenLine, SearchCheck, FileText,
FileOutput, CheckCircle2, Award, ShieldCheck, BookOpen,
Microscope, PlayCircle, Hammer, AlertTriangle,
FilePlus2, Loader2, FileCheck, Target,
Compass, SearchCheck, FileText,
FileOutput, CheckCircle2, Award,
} from "lucide-react";
import type { CaseStatus } from "@/lib/api/cases";
import {
STATUS_LABELS, STATUS_DESCRIPTIONS, statusLabel, type CaseStatus,
} from "@/lib/api/case-status";
import type { LucideIcon } from "lucide-react";
const STATUS_LABELS: Record<CaseStatus, string> = {
new: "חדש",
in_progress: "בעבודה",
uploading: "מעלה",
processing: "בעיבוד",
documents_ready: "מסמכים מוכנים",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
outcome_set: "תוצאה נקבעה",
brainstorming: "סיעור מוחות",
direction_approved: "כיוון אושר",
analysis_enriched: "ניתוח הועמק",
ready_for_writing: "מוכן לכתיבה",
drafting: "בכתיבה",
qa_review: "בדיקת איכות",
qa_failed: "בדיקת איכות נכשלה",
drafted: "טיוטה",
exported: "יוצא",
reviewed: "נבדק",
final: "סופי",
};
/* Labels + descriptions come from the SSoT (@/lib/api/case-status).
* Icons + color tones are view-layer concerns and live here, keyed off the
* same 10 core statuses. */
const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
new: FilePlus2,
in_progress: Hammer,
uploading: Upload,
processing: Loader2,
documents_ready: FileCheck,
analyst_verified: ShieldCheck,
research_complete: BookOpen,
outcome_set: Target,
brainstorming: Lightbulb,
direction_approved: Compass,
analysis_enriched: Microscope,
ready_for_writing: PlayCircle,
drafting: PenLine,
qa_review: SearchCheck,
qa_failed: AlertTriangle,
drafted: FileText,
exported: FileOutput,
reviewed: CheckCircle2,
final: Award,
};
const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
new: "התיק נוצר וממתין להעלאת מסמכים",
in_progress: "התיק בעבודה",
uploading: "מסמכים בתהליך העלאה לשרת",
processing: "המערכת מעבדת ומנתחת את המסמכים",
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
analyst_verified: "ניתוח ראשוני אומת — ממתין למחקר תקדימים",
research_complete: "מחקר תקדימים הושלם — ממתין לבחירת תוצאה",
outcome_set: "נקבעה תוצאה צפויה לערר",
brainstorming: "ניתוח כיוונים אפשריים להחלטה",
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח",
analysis_enriched: "ניתוח הועמק ופסיקה אומתה — מוכן לכתיבה",
ready_for_writing: "הכל מוכן — ממתין לכותב ההחלטה",
drafting: "טיוטת ההחלטה בתהליך כתיבה",
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
qa_failed: "בדיקת האיכות נכשלה — נדרש תיקון",
drafted: "טיוטה מוכנה לעיון",
exported: "ההחלטה יוצאה לקובץ DOCX",
reviewed: "ההחלטה נבדקה ע\"י היו\"ר",
final: "החלטה סופית — מוכנה להגשה",
};
/* Status color groups:
* intake → new, uploading, processing (muted parchment)
* prep → documents_ready, outcome_set (info blue)
* thinking→ brainstorming, direction_approved (gold)
* writing → drafting, qa_review, drafted (warn amber)
* done → exported, reviewed, final (success green) */
* intake → new, processing (muted parchment / info)
* prep → documents_ready (info blue)
* thinking→ outcome_set, direction_approved (gold)
* writing → qa_review, drafted (warn amber)
* done → exported, reviewed, final (success green) */
const STATUS_TONE: Record<CaseStatus, string> = {
new: "bg-rule-soft text-ink-muted border-rule",
in_progress: "bg-warn-bg text-warn border-warn/40",
uploading: "bg-rule-soft text-ink-muted border-rule",
processing: "bg-info-bg text-info border-info/30",
documents_ready: "bg-info-bg text-info border-info/40",
analyst_verified: "bg-info-bg text-info border-info/40",
research_complete:"bg-info-bg text-info border-info/40",
outcome_set: "bg-info-bg text-info border-info/40",
brainstorming: "bg-gold-wash text-gold-deep border-gold/40",
new: "bg-rule-soft text-ink-muted border-rule",
processing: "bg-info-bg text-info border-info/30",
documents_ready: "bg-info-bg text-info border-info/40",
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
analysis_enriched:"bg-gold-wash text-gold-deep border-gold/50",
ready_for_writing:"bg-gold-wash text-gold-deep border-gold/50",
drafting: "bg-warn-bg text-warn border-warn/40",
qa_review: "bg-warn-bg text-warn border-warn/40",
qa_failed: "bg-danger-bg text-danger border-danger/40",
drafted: "bg-warn-bg text-warn border-warn/50",
exported: "bg-success-bg text-success border-success/40",
reviewed: "bg-success-bg text-success border-success/50",
final: "bg-success-bg text-success border-success/60 font-semibold",
qa_review: "bg-warn-bg text-warn border-warn/40",
drafted: "bg-warn-bg text-warn border-warn/50",
exported: "bg-success-bg text-success border-success/40",
reviewed: "bg-success-bg text-success border-success/50",
final: "bg-success-bg text-success border-success/60 font-semibold",
};
export function StatusBadge({ status }: { status: CaseStatus }) {
@@ -110,7 +53,7 @@ export function StatusBadge({ status }: { status: CaseStatus }) {
className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium inline-flex items-center gap-1 ${STATUS_TONE[status] ?? ""}`}
>
{Icon && <Icon className="w-3 h-3 shrink-0" />}
{STATUS_LABELS[status] ?? status}
{statusLabel(status)}
</Badge>
);
}

View File

@@ -7,21 +7,16 @@ import {
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { STATUS_LABELS, STATUS_ICONS } from "@/components/cases/status-badge";
import { useUpdateCase, type CaseStatus } from "@/lib/api/cases";
import { useUpdateCase } from "@/lib/api/cases";
import { CASE_STATUSES, type CaseStatus } from "@/lib/api/case-status";
/*
* Dropdown for manually overriding the case status — skip a step
* or revert to an earlier stage. Calls PUT /api/cases/:caseNumber
* with { status: newValue }.
* with { status: newValue }. The option list is the SSoT lifecycle.
*/
const ALL_STATUSES: CaseStatus[] = [
"new", "uploading", "processing",
"documents_ready", "analyst_verified", "research_complete", "outcome_set",
"brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing",
"drafting", "qa_review", "drafted",
"exported", "reviewed", "final",
];
const ALL_STATUSES: readonly CaseStatus[] = CASE_STATUSES;
export function StatusChanger({
caseNumber,

View File

@@ -1,23 +1,16 @@
"use client";
import type { Case, CaseStatus } from "@/lib/api/cases";
import type { Case } from "@/lib/api/cases";
import { phaseOf, type PhaseKey } from "@/lib/api/case-status";
import { STATUS_LABELS } from "@/components/cases/status-badge";
/*
* Conic-gradient donut — ported from legal-ai/web/static/index.html renderHero().
* Kept deliberately dependency-free (no D3/recharts) — a single background-image.
* Five status groups map onto the navy/gold/info/warn/success palette.
* Five status groups (= the SSoT phases) map onto navy/gold/info/warn/success.
*/
type GroupKey = "intake" | "prep" | "thinking" | "writing" | "done";
const GROUP_OF: Record<CaseStatus, GroupKey> = {
new: "intake", in_progress: "intake", uploading: "intake", processing: "intake",
documents_ready: "prep", analyst_verified: "prep", research_complete: "prep", outcome_set: "prep",
brainstorming: "thinking", direction_approved: "thinking", analysis_enriched: "thinking", ready_for_writing: "thinking",
drafting: "writing", qa_review: "writing", qa_failed: "writing", drafted: "writing",
exported: "done", reviewed: "done", final: "done",
};
type GroupKey = PhaseKey;
const GROUP_META: Record<GroupKey, { label: string; color: string }> = {
intake: { label: "חדש / בעיבוד", color: "var(--color-ink-muted)" },
@@ -32,7 +25,7 @@ export function StatusDonut({ cases }: { cases?: Case[] }) {
intake: 0, prep: 0, thinking: 0, writing: 0, done: 0,
};
(cases ?? []).forEach((c) => {
const g = GROUP_OF[c.status];
const g = phaseOf(c.status);
if (g) counts[g] += 1;
});
const total = Object.values(counts).reduce((a, b) => a + b, 0);

View File

@@ -2,28 +2,16 @@
import { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { CaseStatus } from "@/lib/api/cases";
import { PHASES as PHASE_GROUPS } from "@/lib/api/case-status";
import { STATUS_LABELS, STATUS_ICONS, STATUS_DESCRIPTIONS, STATUS_TONE } from "@/components/cases/status-badge";
/*
* Collapsible guide showing all 13 statuses grouped by phase,
* Collapsible guide showing the core statuses grouped by phase,
* each with its icon, Hebrew label, color badge, and description.
* Phases come from the SSoT (@/lib/api/case-status).
* Intended to sit below the WorkflowTimeline in the case sidebar.
*/
type PhaseGroup = {
label: string;
statuses: CaseStatus[];
};
const PHASE_GROUPS: PhaseGroup[] = [
{ label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] },
{ label: "הכנת תיק", statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
{ label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] },
{ label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] },
{ label: "סגירה", statuses: ["exported", "reviewed", "final"] },
];
export function StatusGuide() {
const [open, setOpen] = useState(false);

View File

@@ -1,6 +1,6 @@
"use client";
import type { CaseStatus } from "@/lib/api/cases";
import { PHASES, type CaseStatus, type PhaseKey } from "@/lib/api/case-status";
import { STATUS_LABELS, STATUS_ICONS, STATUS_DESCRIPTIONS } from "@/components/cases/status-badge";
import {
FolderInput, ClipboardList, Brain, PenLine, CheckCircle2,
@@ -8,27 +8,19 @@ import {
import type { LucideIcon } from "lucide-react";
/*
* Vertical RTL workflow timeline showing the 13-status case pipeline.
* Groups the raw statuses into the 5 visual phases used across the app
* (intake → prep → thinking → writing → done) so the user sees
* "where am I in the process" rather than 13 micro-steps.
* Vertical RTL workflow timeline. Phases + their statuses come from the SSoT
* (@/lib/api/case-status); only the per-phase icon is a view-layer concern and
* stays here. The user sees "where am I in the process" rather than micro-steps.
*/
type Phase = {
key: string;
label: string;
icon: LucideIcon;
statuses: CaseStatus[];
const PHASE_ICONS: Record<PhaseKey, LucideIcon> = {
intake: FolderInput,
prep: ClipboardList,
thinking: Brain,
writing: PenLine,
done: CheckCircle2,
};
const PHASES: Phase[] = [
{ key: "intake", label: "קליטה ועיבוד", icon: FolderInput, statuses: ["new", "uploading", "processing"] },
{ key: "prep", label: "הכנת תיק", icon: ClipboardList, statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
{ key: "thinking", label: "ניתוח וכיוון", icon: Brain, statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] },
{ key: "writing", label: "כתיבת טיוטה", icon: PenLine, statuses: ["drafting", "qa_review", "drafted"] },
{ key: "done", label: "סגירה", icon: CheckCircle2, statuses: ["exported", "reviewed", "final"] },
];
function phaseIndexOf(status?: CaseStatus): number {
if (!status) return -1;
return PHASES.findIndex((p) => p.statuses.includes(status));
@@ -61,7 +53,7 @@ export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
: state === "current" ? "text-gold-deep"
: "text-ink-muted/50";
const PhaseIcon = phase.icon;
const PhaseIcon = PHASE_ICONS[phase.key];
const isLast = i === PHASES.length - 1;
return (

View File

@@ -0,0 +1,103 @@
/**
* Single source of truth for the case-status lifecycle (UI-B1 / G2).
*
* The 17-status manual menu was trimmed to the **10 core statuses** that the
* pipeline actually sets or that gate real logic. Decorative mid-stage markers
* (uploading, analyst_verified, research_complete, brainstorming,
* analysis_enriched, ready_for_writing, drafting) plus the legacy/dead
* `in_progress` and `qa_failed` were removed — no pipeline code ever set them.
*
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards,
* compose chip) imports the list / labels / phases from here instead of
* re-declaring its own array. Keep this file in sync with the backend
* STATUS_ORDER in `mcp-server/src/legal_mcp/tools/cases.py`.
*/
/** Ordered lifecycle — also the order shown in the manual status dropdown. */
export const CASE_STATUSES = [
"new",
"processing",
"documents_ready",
"outcome_set",
"direction_approved",
"qa_review",
"drafted",
"exported",
"reviewed",
"final",
] as const;
export type CaseStatus = (typeof CASE_STATUSES)[number];
export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done";
/** The 5 visual phases the lifecycle collapses into across the app. */
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] },
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["outcome_set", "direction_approved"] },
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
];
/** Which phase a status belongs to (undefined for unknown/legacy values). */
export function phaseOf(status?: CaseStatus | string): PhaseKey | undefined {
if (!status) return undefined;
return PHASES.find((p) => (p.statuses as string[]).includes(status))?.key;
}
export const STATUS_LABELS: Record<CaseStatus, string> = {
new: "חדש",
processing: "בעיבוד",
documents_ready: "מסמכים מוכנים",
outcome_set: "תוצאה נקבעה",
direction_approved: "כיוון אושר",
qa_review: "בדיקת איכות",
drafted: "טיוטה",
exported: "יוצא",
reviewed: "נבדק",
final: "סופי",
};
/**
* Hebrew labels for the removed/legacy statuses — display-only fallback so a
* row that hasn't been migrated yet (or arrives from an old client) never
* renders a raw English slug as a label. These are NOT selectable statuses.
*/
const LEGACY_STATUS_LABELS: Record<string, string> = {
in_progress: "בעבודה",
uploading: "מעלה",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
brainstorming: "סיעור מוחות",
analysis_enriched: "ניתוח הועמק",
ready_for_writing: "מוכן לכתיבה",
drafting: "בכתיבה",
qa_failed: "בדיקת איכות נכשלה",
};
/**
* Always-Hebrew label for any status string. Use this everywhere a status is
* rendered as a label so an unknown/legacy value never leaks an English slug.
*/
export function statusLabel(status?: string): string {
if (!status) return "";
return (
STATUS_LABELS[status as CaseStatus] ??
LEGACY_STATUS_LABELS[status] ??
"לא ידוע"
);
}
export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
new: "התיק נוצר וממתין להעלאת מסמכים",
processing: "המערכת מעבדת ומנתחת את המסמכים",
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
outcome_set: "נקבעה תוצאה צפויה לערר",
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
drafted: "טיוטה מוכנה לעיון",
exported: "ההחלטה יוצאה לקובץ DOCX",
reviewed: 'ההחלטה נבדקה ע"י היו"ר',
final: "החלטה סופית — מוכנה להגשה",
};

View File

@@ -13,26 +13,11 @@ import { apiRequest } from "./client";
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
export type CaseStatus =
| "new"
| "in_progress"
| "uploading"
| "processing"
| "documents_ready"
| "analyst_verified"
| "research_complete"
| "outcome_set"
| "brainstorming"
| "direction_approved"
| "analysis_enriched"
| "ready_for_writing"
| "drafting"
| "qa_review"
| "qa_failed"
| "drafted"
| "exported"
| "reviewed"
| "final";
/* CaseStatus + the status list/labels/phases are defined once in ./case-status
* (single source of truth, UI-B1). Re-exported here so existing
* `import { CaseStatus } from "@/lib/api/cases"` sites keep working. */
export type { CaseStatus } from "./case-status";
import type { CaseStatus } from "./case-status";
export type Case = {
case_number: string;