Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is localized, and now deterministically: every timestamp renders pinned to Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and the browser agree on any runtime. No layout/visible-format change — only the tz. - New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate / formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull / formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey / israelMidnightMs / israelParts + formatRelative (long/short/tight wording). - Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString + hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call sites (30 files) through it. Number toLocaleString left untouched. - Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display). Display-only; no layout/design/IA change → Claude Design gate N/A. Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
6.0 KiB
TypeScript
159 lines
6.0 KiB
TypeScript
"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 { formatDate } from "@/lib/format-date";
|
||
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 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>
|
||
);
|
||
}
|