feat(ui): WS6 — party accordion, full-width compose, status-hero overview (#206)
שלושת שינויי-ה-UI של WS6 לפי המוקאפים המאושרים (25b/03b/18b, X17): 1. טאב "טיעונים" (legal-arguments-panel) — אקורדיון ברמת-הצד, סגור כברירת-מחדל (type="multiple" ללא defaultValue). כל trigger מציג שם-צד + תת-כותרת + באדג'-מונה. בתוך כל צד נשמר הקיבוץ-לפי-קדימות והאקורדיון הפנימי ברמת-הטיעון. 2. טאב "עמדות וטענות" (compose) — רוחב-דף מלא: בוטל ה-grid 1fr/320px, ראיל "מסמכי התיק" הוסר (DocumentsPanel בסקירה הוא הבעלים היחיד) ועבר לפס-מסמכים מתקפל למעלה (סגור כברירת-מחדל). כרטיסי-הסוגיות 2-up. פסיקה-מצורפת ושערי-הייצוא/העלאה שומרו (relocate, לא remove). מפת-StatusChip המקבילה הוסרה → StatusBadge המשותף (G2). 3. סטטוס רק בסקירה (overview) — StatusHero חדש (stepper אופקי + chip שלב-נוכחי + שינוי-סטטוס ידני + meta-strip), הבעלים היחיד של הסטטוס. WorkflowTimeline/StatusChanger/AgentStatusWidget ירדו משאר הטאבים; הטאבים האחרים רוחב-מלא. StatusBadge read-only נשאר ב-H1. Invariants: INV-IA1 (בעלים-יחיד לסטטוס=hero), INV-IA3/G10 (כל שער-אנוש נשמר במקום אחד — שינוי-סטטוס בסקירה, ייצוא/העלאה ב-compose), INV-IA4 (compose=מרחב-עבודה ממוקד-משימה רוחב-מלא), INV-IA6 (מפת-סטטוסים progressive-disclosure), INV-UI7/UI8, G2 (StatusBadge משותף, אין מפת-סטטוס מקבילה). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,23 @@ const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
|
||||
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
|
||||
};
|
||||
|
||||
// Per-party colour mark + one-line role description (mockup 25b party trigger).
|
||||
const PARTY_MARK_TONE: Record<LegalArgumentParty, string> = {
|
||||
appellant: "bg-info",
|
||||
respondent: "bg-gold-deep",
|
||||
committee: "bg-success",
|
||||
permit_applicant: "bg-warn",
|
||||
unknown: "bg-ink-muted",
|
||||
};
|
||||
|
||||
const PARTY_SUB_HE: Record<LegalArgumentParty, string> = {
|
||||
appellant: "הצד היוזם של הערר",
|
||||
respondent: "הצד המשיב לערר",
|
||||
committee: "עמדת הגורם המאשר",
|
||||
permit_applicant: "צד שלישי שזכותו עשויה להיפגע",
|
||||
unknown: "צד שלא זוהה אוטומטית",
|
||||
};
|
||||
|
||||
function groupByPriority(
|
||||
args: LegalArgument[],
|
||||
): Record<LegalArgumentPriority, LegalArgument[]> {
|
||||
@@ -55,22 +72,18 @@ function groupByPriority(
|
||||
}
|
||||
|
||||
type PartySectionProps = {
|
||||
party: LegalArgumentParty;
|
||||
args: LegalArgument[];
|
||||
};
|
||||
|
||||
function PartySection({ party, args }: PartySectionProps) {
|
||||
/**
|
||||
* Inner body of a single party — the priority-grouping + per-argument inner
|
||||
* accordion. The party header (name / sub / count) now lives on the enclosing
|
||||
* party-accordion trigger (mockup 25b), so this renders the groups only.
|
||||
*/
|
||||
function PartySection({ args }: PartySectionProps) {
|
||||
const grouped = useMemo(() => groupByPriority(args), [args]);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between border-b border-rule pb-2">
|
||||
<h3 className="text-navy text-base font-semibold">
|
||||
{PARTY_LABELS_HE[party] ?? party}
|
||||
</h3>
|
||||
<span className="text-ink-muted text-xs">
|
||||
{args.length} טיעונים
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{PRIORITY_ORDER.map((priority) => {
|
||||
const list = grouped[priority];
|
||||
if (!list?.length) return null;
|
||||
@@ -253,15 +266,47 @@ export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
|
||||
החישוב למנתח המשפטי.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{parties.map((party) => (
|
||||
<PartySection
|
||||
key={party}
|
||||
party={party}
|
||||
args={data.by_party[party] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
// Top-level party accordion — each party is its own collapsible section,
|
||||
// closed by default (empty defaultValue). The count badge on every
|
||||
// trigger shows the scope without expanding (mockup 25b).
|
||||
<Accordion type="multiple" className="space-y-3.5">
|
||||
{parties.map((party) => {
|
||||
const args = data.by_party[party] ?? [];
|
||||
return (
|
||||
<AccordionItem
|
||||
key={party}
|
||||
value={party}
|
||||
className="overflow-hidden rounded-lg border border-rule bg-surface shadow-sm"
|
||||
>
|
||||
<AccordionTrigger className="px-5 py-4 hover:no-underline data-[state=open]:border-b data-[state=open]:border-rule-soft data-[state=open]:bg-parchment">
|
||||
<span className="flex flex-1 items-center gap-3.5">
|
||||
<span
|
||||
className={`h-8 w-1.5 flex-none rounded ${PARTY_MARK_TONE[party]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex flex-1 flex-col items-start gap-0.5">
|
||||
<span className="text-navy text-base font-bold leading-tight">
|
||||
{PARTY_LABELS_HE[party] ?? party}
|
||||
</span>
|
||||
<span className="text-ink-muted text-xs">
|
||||
{PARTY_SUB_HE[party]}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-gold-wash text-gold-deep border-rule rounded-full px-3 py-0.5 text-xs font-bold whitespace-nowrap"
|
||||
>
|
||||
{args.length} טיעונים
|
||||
</Badge>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-5 pb-4 pt-4">
|
||||
<PartySection args={args} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,9 +21,13 @@ const ALL_STATUSES: readonly CaseStatus[] = CASE_STATUSES;
|
||||
export function StatusChanger({
|
||||
caseNumber,
|
||||
currentStatus,
|
||||
inline = false,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
currentStatus?: CaseStatus;
|
||||
/** Compact horizontal layout for the status hero (mockup 18b hero-controls);
|
||||
* default is the stacked rail layout. */
|
||||
inline?: boolean;
|
||||
}) {
|
||||
// `null` = untouched → the dropdown tracks the live `currentStatus` (which
|
||||
// arrives async and changes on the 5s poll / external updates). Only an
|
||||
@@ -45,8 +49,10 @@ export function StatusChanger({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-rule pt-3 space-y-2">
|
||||
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
|
||||
<div className={inline ? "space-y-0" : "mt-4 border-t border-rule pt-3 space-y-2"}>
|
||||
{!inline && (
|
||||
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={effective || "__current__"}
|
||||
@@ -77,7 +83,7 @@ export function StatusChanger({
|
||||
disabled={!canSave || mutate.isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{mutate.isPending ? "שומר…" : "עדכן"}
|
||||
{mutate.isPending ? "שומר…" : inline ? "עדכן סטטוס" : "עדכן"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
170
web-ui/src/components/cases/status-hero.tsx
Normal file
170
web-ui/src/components/cases/status-hero.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { PHASES, type CaseStatus } from "@/lib/api/case-status";
|
||||
import { statusLabel } from "@/lib/api/case-status";
|
||||
import { StatusChanger } from "@/components/cases/status-changer";
|
||||
import { StatusGuide } from "@/components/cases/status-guide";
|
||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
|
||||
/*
|
||||
* StatusHero — the single, prominent home for case status (mockup 18b, INV-IA1).
|
||||
* Renders the horizontal pipeline stepper, the current-stage chip, the manual
|
||||
* status changer, and a meta strip. This is the ONLY surface in the app that
|
||||
* owns the status changer / pipeline timeline (status was removed from the
|
||||
* compose editor and the other case tabs). Reuses the shared StatusChanger,
|
||||
* StatusGuide and AgentStatusWidget so no parallel status path is created (G2).
|
||||
*/
|
||||
|
||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso ?? "—";
|
||||
}
|
||||
}
|
||||
|
||||
function phaseIndexOf(status?: CaseStatus): number {
|
||||
if (!status) return -1;
|
||||
return PHASES.findIndex((p) => p.statuses.includes(status));
|
||||
}
|
||||
|
||||
/** Horizontal RTL stepper over the 5 lifecycle phases (mockup 18b .stepper). */
|
||||
function HorizontalStepper({ status }: { status?: CaseStatus }) {
|
||||
const currentIdx = phaseIndexOf(status);
|
||||
return (
|
||||
<ol className="flex items-start gap-0">
|
||||
{PHASES.map((phase, i) => {
|
||||
const state =
|
||||
currentIdx === -1 ? "pending"
|
||||
: i < currentIdx ? "done"
|
||||
: i === currentIdx ? "now"
|
||||
: "pending";
|
||||
|
||||
const isLast = i === PHASES.length - 1;
|
||||
|
||||
const knobTone =
|
||||
state === "done"
|
||||
? "bg-success [box-shadow:0_0_0_1px_var(--color-success)]"
|
||||
: state === "now"
|
||||
? "bg-gold [box-shadow:0_0_0_2px_var(--color-gold-wash),0_0_0_3px_var(--color-gold)]"
|
||||
: "bg-rule [box-shadow:0_0_0_1px_var(--color-rule)]";
|
||||
|
||||
const labelTone =
|
||||
state === "done"
|
||||
? "text-ink-soft"
|
||||
: state === "now"
|
||||
? "text-navy font-bold"
|
||||
: "text-ink-muted";
|
||||
|
||||
// connector runs toward the next (earlier-completed) phase; in RTL the
|
||||
// line sits to the start side. Coloured when this phase is done.
|
||||
const segTone = state === "done" ? "bg-success" : "bg-rule";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={phase.key}
|
||||
className="relative flex-1 pt-7 text-center"
|
||||
>
|
||||
{!isLast && (
|
||||
<span
|
||||
className={`absolute top-[9px] h-0.5 w-full ${segTone}`}
|
||||
style={{ insetInlineStart: "50%" }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`absolute top-[3px] h-4 w-4 -translate-x-1/2 rounded-full border-[3px] border-surface ${knobTone}`}
|
||||
style={{ insetInlineStart: "50%", transform: "translateX(50%)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={`block text-[0.78rem] ${labelTone}`}>
|
||||
{phase.label}
|
||||
<span className="block text-[0.68rem] text-ink-muted mt-0.5 tabular-nums">
|
||||
{state === "now" ? "כעת" : state === "done" ? "✓" : "—"}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusHero({
|
||||
caseNumber,
|
||||
data,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
data?: CaseDetail;
|
||||
}) {
|
||||
const status = data?.status;
|
||||
const expectedOutcomeLabel = data?.expected_outcome
|
||||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-rule bg-surface shadow-sm px-6 py-5">
|
||||
{/* hero top — title + current stage chip + manual changer */}
|
||||
<div className="flex items-start justify-between flex-wrap gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-navy text-base font-bold">סטטוס התיק</h2>
|
||||
{status && (
|
||||
<span className="rounded-full bg-warn-bg text-warn text-[0.8rem] font-bold px-3.5 py-1">
|
||||
כעת: {statusLabel(status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* manual status changer — the SINGLE manual-status surface (INV-IA1) */}
|
||||
<StatusChanger caseNumber={caseNumber} currentStatus={status} inline />
|
||||
</div>
|
||||
|
||||
{/* horizontal pipeline */}
|
||||
<HorizontalStepper status={status} />
|
||||
|
||||
{/* meta strip below the stepper (mockup 18b .hero-meta) */}
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 mt-5 pt-4 border-t border-rule-soft">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.72rem] text-ink-muted">תוצאה צפויה</span>
|
||||
<span className="text-sm font-semibold mt-0.5">
|
||||
{expectedOutcomeLabel ? (
|
||||
<span className="inline-block rounded-full bg-warn-bg text-warn text-[0.78rem] font-semibold px-2.5 py-0.5">
|
||||
{expectedOutcomeLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ink-soft">לא נקבע</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.72rem] text-ink-muted">תאריך דיון</span>
|
||||
<span className="text-sm font-semibold text-ink-soft mt-0.5 tabular-nums">
|
||||
{formatDate(data?.hearing_date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.72rem] text-ink-muted">עודכן לאחרונה</span>
|
||||
<span className="text-sm font-semibold text-ink-soft mt-0.5 tabular-nums">
|
||||
{formatDate(data?.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* agents activity + status-map progressive disclosure (INV-IA6) */}
|
||||
<div className="mt-4 pt-4 border-t border-rule-soft space-y-3">
|
||||
<AgentStatusWidget caseNumber={caseNumber} />
|
||||
<StatusGuide />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user