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>
This commit is contained in:
@@ -31,7 +31,9 @@ import {
|
||||
usePartyClaimsSummary,
|
||||
useGeneratePartyClaimsSummary,
|
||||
useGenerateInterimDraft,
|
||||
useGenerateStatus,
|
||||
} from "@/lib/api/generate";
|
||||
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
|
||||
import {
|
||||
useCaseFeedback,
|
||||
useCreateFeedback,
|
||||
@@ -133,6 +135,9 @@ export function DraftsPanel({
|
||||
const { data: summary } = usePartyClaimsSummary(caseNumber);
|
||||
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
|
||||
const genInterim = useGenerateInterimDraft(caseNumber);
|
||||
// Server-derived background-run status (#227 ג) — survives navigation/reload.
|
||||
const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary");
|
||||
const interimStatus = useGenerateStatus(caseNumber, "interim_draft");
|
||||
const qc = useQueryClient();
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -342,6 +347,11 @@ export function DraftsPanel({
|
||||
"הפק"
|
||||
)}
|
||||
</Button>
|
||||
<GenerateStatusChip
|
||||
status={summaryStatus.data}
|
||||
onRetry={handleGenerateSummary}
|
||||
retrying={genSummary.isPending}
|
||||
/>
|
||||
{summaryReady && (
|
||||
<>
|
||||
<Button
|
||||
@@ -410,6 +420,11 @@ export function DraftsPanel({
|
||||
"הפק"
|
||||
)}
|
||||
</Button>
|
||||
<GenerateStatusChip
|
||||
status={interimStatus.data}
|
||||
onRetry={handleGenerateInterim}
|
||||
retrying={genInterim.isPending}
|
||||
/>
|
||||
{latestInterim && (
|
||||
<span className="text-[0.7rem] text-ink-muted">
|
||||
{latestInterim.version
|
||||
|
||||
127
web-ui/src/components/cases/generate-status-chip.tsx
Normal file
127
web-ui/src/components/cases/generate-status-chip.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -29,12 +29,58 @@ export type GenerateTriggerResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** The two host-side background generation actions (poll target of the status chip). */
|
||||
export type GenerateAction = "party_claims_summary" | "interim_draft";
|
||||
|
||||
export const generateKeys = {
|
||||
all: ["generate"] as const,
|
||||
partyClaimsSummary: (caseNumber: string) =>
|
||||
[...generateKeys.all, "party-claims-summary", caseNumber] as const,
|
||||
status: (caseNumber: string, action: GenerateAction) =>
|
||||
[...generateKeys.all, "status", action, caseNumber] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-derived background-run status (#227 ג). Reconstructed on the backend
|
||||
* from the CEO child issue + its heartbeat_run + the output artifact — so it is
|
||||
* correct after navigating away and back; it never depends on the browser having
|
||||
* stayed on the page. `started_at`/`finished_at` are server ISO-8601 UTC stamps,
|
||||
* so the client renders a correct elapsed time on return.
|
||||
*/
|
||||
export type GenerateState = "idle" | "queued" | "running" | "done" | "failed";
|
||||
|
||||
export type GenerateStatus = {
|
||||
case_number: string;
|
||||
action: GenerateAction;
|
||||
state: GenerateState;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
output_ready: boolean;
|
||||
detail: string;
|
||||
issue_id: string | null;
|
||||
};
|
||||
|
||||
export function useGenerateStatus(
|
||||
caseNumber: string | undefined,
|
||||
action: GenerateAction,
|
||||
) {
|
||||
return useQuery<GenerateStatus>({
|
||||
queryKey: generateKeys.status(caseNumber ?? "", action),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<GenerateStatus>(
|
||||
`/api/cases/${caseNumber}/generate/${action}/status`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(caseNumber),
|
||||
// Poll only while a run is in flight; a terminal state stops the interval.
|
||||
refetchInterval: (query) => {
|
||||
const s = query.state.data?.state;
|
||||
return s === "running" || s === "queued" ? 5_000 : false;
|
||||
},
|
||||
staleTime: 2_000,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Read: party-claims executive summary (poll target for button 1) ──
|
||||
404 (not generated yet) is a normal "not ready" state, not an error — we
|
||||
coerce it to null so the panel renders the empty/idle state cleanly. */
|
||||
@@ -70,23 +116,33 @@ export function useGeneratePartyClaimsSummary(caseNumber: string) {
|
||||
{ method: "POST" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
// Start polling fresh — the file will appear after the host run completes.
|
||||
// Start polling fresh — the file will appear after the host run completes,
|
||||
// and the status chip should pick up the new queued/running run.
|
||||
qc.invalidateQueries({
|
||||
queryKey: generateKeys.partyClaimsSummary(caseNumber),
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: generateKeys.status(caseNumber, "party_claims_summary"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
|
||||
export function useGenerateInterimDraft(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiRequest<GenerateTriggerResult>(
|
||||
`/api/cases/${caseNumber}/generate/interim-draft`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
// The result lands in the exports list (טיוטת-ביניים-*.docx); useExports
|
||||
// already polls on a 5s interval, so no extra invalidation is needed here.
|
||||
// The result lands in the exports list (טיוטת-ביניים-*.docx, polled by
|
||||
// useExports); re-arm the status chip so it shows the new run immediately.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: generateKeys.status(caseNumber, "interim_draft"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user