Files
legal-ai/web-ui/src/components/cases/generate-status-chip.tsx
Chaim b296422d87
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 15s
feat(generate): סימון-סטטוס לריצת-הרקע של "הפקת מסמכים" (#227 ג)
עד כה ה-"מייצר…" כיסה רק את שנייה-ה-POST; אחרי זה לא היה חיווי אם ההפקה
רצה/הצליחה/נכשלה — ריצה שבוטלה נראתה כמו הצלחה. עכשיו צ'יפ-סטטוס ליד כל
כפתור-הפקה: בתור · רץ+שעון-חי · הושלם+מתי · נכשל+סיבה+"נסה שוב".

**נשמר-שרת (דרישת חיים):** הסטטוס נגזר בשרת מ-issue-הילד של ה-CEO +
ה-heartbeat_run האחרון + קיום-הקובץ — לא מזיכרון-הדף. שעון-הריצה מעוגן
ל-started_at של השרת, כך שעזיבה+חזרה לדף מציגות את המצב והזמן הנכונים,
בלי צורך להישאר בדף.

Backend:
- paperclip_client.get_generation_run_status — קורא issue-ילד + latest run
  מ-Paperclip DB (read-only, מאחורי שער-הפלטפורמה G12); dedup לפי כותרת.
- GET /api/cases/{n}/generate/{action}/status — ממפה ל-idle/queued/running/
  done/failed + output_ready + started_at/finished_at (UTC). run שהסתיים בלי
  קובץ = failed (שלא ייראה כהצלחה).

Frontend:
- useGenerateStatus (poll 5s רק כשפעיל; עוצר בטרמינלי) + GenerateStatusChip
  (שעון-ריצה חי מ-started_at; רכיב חסר-מצב). כפתורי-ההפקה מרעננים את
  שאילתת-הסטטוס ב-onSuccess. שורה 3 (החלטה מלאה, סינכרוני) — בלי צ'יפ.

עיצוב: מוקאפ 29-generate-status-indicator אושר ע"י חיים דרך שער Claude Design (X17).
tsc + eslint + py_compile ירוקים. (api:types לריענון post-deploy.)
Invariants: G12 (Paperclip רק בשער), G1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:33:41 +00:00

128 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle2, Clock, Loader2, RotateCw, XCircle } from "lucide-react";
import type { GenerateStatus } from "@/lib/api/generate";
/**
* Background-run status chip for the "הפקת מסמכים" generation actions (#227 ג).
*
* The chip is driven entirely by the server-derived {@link GenerateStatus} — it
* holds no run state of its own, so it renders correctly after the chair
* navigates away and back. The running timer is anchored to the server
* `started_at`, not a click-time counter, so the elapsed value is right on
* return too.
*/
function useElapsedSeconds(startedAt: string | null, active: boolean): number | null {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
const id = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(id);
}, [active]);
if (!startedAt) return null;
const start = Date.parse(startedAt);
if (Number.isNaN(start)) return null;
return Math.max(0, Math.floor((now - start) / 1000));
}
function fmtDuration(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m >= 60) {
const h = Math.floor(m / 60);
return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
function relativeTime(iso: string | null): string {
if (!iso) return "";
const t = Date.parse(iso);
if (Number.isNaN(t)) return "";
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (sec < 60) return "הרגע";
const min = Math.floor(sec / 60);
if (min < 60) return `לפני ${min} דק׳`;
const hr = Math.floor(min / 60);
if (hr < 24) return `לפני ${hr} שע׳`;
return `לפני ${Math.floor(hr / 24)} ימים`;
}
const CHIP_BASE =
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold whitespace-nowrap";
export function GenerateStatusChip({
status,
onRetry,
retrying,
}: {
status: GenerateStatus | undefined;
onRetry?: () => void;
retrying?: boolean;
}) {
const state = status?.state ?? "idle";
const elapsed = useElapsedSeconds(status?.started_at ?? null, state === "running");
if (!status || state === "idle") return null;
if (state === "queued") {
return (
<span className={`${CHIP_BASE} bg-rule-soft text-ink-soft border-rule`}>
<Clock className="size-3.5" aria-hidden />
בתור
</span>
);
}
if (state === "running") {
return (
<span className={`${CHIP_BASE} bg-info-bg text-info border-info/35`}>
<Loader2 className="size-3.5 animate-spin" aria-hidden />
רץ ברקע
{elapsed !== null && (
<span className="tabular-nums">· {fmtDuration(elapsed)}</span>
)}
</span>
);
}
if (state === "done") {
const rel = relativeTime(status.finished_at);
return (
<span className={`${CHIP_BASE} bg-success-bg text-success border-success/35`}>
<CheckCircle2 className="size-3.5" aria-hidden />
הושלם{rel && ` · ${rel}`}
</span>
);
}
// failed
return (
<span className="inline-flex items-center gap-2">
<span className={`${CHIP_BASE} bg-danger-bg text-danger border-danger/35`}>
<XCircle className="size-3.5" aria-hidden />
{status.detail || "נכשל"}
</span>
{onRetry && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
disabled={retrying}
onClick={onRetry}
>
{retrying ? (
<Loader2 className="size-3.5 animate-spin me-1" />
) : (
<RotateCw className="size-3.5 me-1" />
)}
נסה שוב
</Button>
)}
</span>
);
}