All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
הבאג: ה-StatusBadge מציג מחרוזת גולמית באנגלית ("in_progress") כשהשרת
פולט סטטוס שאינו במפת-התוויות. CaseStatus ב-web-ui החסיר שני סטטוסים
שהשרת אכן פולט — in_progress (workflow.set_outcome) ו-qa_failed
(app.py human-gate) — ולכן נפלו ל-fallback `?? status` (אנגלית גולמית).
התיקון (יישור frontend↔backend SoT, X6 UI-API contract):
- CaseStatus type: הוספת "in_progress" + "qa_failed".
- status-badge.tsx: 4 מפות Record<CaseStatus> — LABELS (בעבודה / בדיקת
איכות נכשלה), ICONS (Hammer / AlertTriangle), DESCRIPTIONS, TONE
(warn / danger).
- status-donut.tsx: GROUP_OF — in_progress→intake, qa_failed→writing.
ללא שינוי-עיצוב ויזואלי (תיקון-תוכן/i18n של רכיב קיים) → חוסה תחת
החריג המפורש בשער-העיצוב ב-web-ui/AGENTS.md.
invariants: מקיים X6 (UI↔API contract — הטיפוס תואם לסטטוסי-השרת);
לא G2 (אין מסלול מקביל), לא G1-symptom (מתקן את מקור-הדריפט בטיפוס).
tsc --noEmit עובר נקי.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288 lines
8.1 KiB
TypeScript
288 lines
8.1 KiB
TypeScript
/**
|
|
* Cases domain hooks.
|
|
*
|
|
* Note on types: the FastAPI `/api/cases` endpoint doesn't declare a response
|
|
* model, so openapi-typescript emits `unknown` for its payload. Until the
|
|
* backend is annotated (see out-of-scope in the rewrite plan), we maintain a
|
|
* small local type that matches what the running API returns today. Any drift
|
|
* surfaces as a runtime TypeScript error the first time a property is touched.
|
|
*/
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
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";
|
|
|
|
export type Case = {
|
|
case_number: string;
|
|
title: string;
|
|
status: CaseStatus;
|
|
subject?: string | null;
|
|
expected_outcome?: string | null;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
/** ISO timestamp; null when active */
|
|
archived_at?: string | null;
|
|
/* Multi-tenant axis — populated by backfill + server-side derive */
|
|
practice_area?: PracticeArea;
|
|
appeal_subtype?: AppealSubtype;
|
|
/* Present when loaded with detail=true */
|
|
document_count?: number;
|
|
processing_count?: number;
|
|
committee_type?: string | null;
|
|
hearing_date?: string | null;
|
|
appellants?: string[] | null;
|
|
respondents?: string[] | null;
|
|
property_address?: string | null;
|
|
permit_number?: string | null;
|
|
/* 'ערר' = regular appeal, 'בל"מ' = extension-of-time request */
|
|
proceeding_type?: "ערר" | 'בל"מ';
|
|
};
|
|
|
|
export type CaseDocument = {
|
|
id: string;
|
|
case_id: string;
|
|
doc_type: string;
|
|
title: string;
|
|
file_path: string;
|
|
page_count: number | null;
|
|
extraction_status: string;
|
|
created_at: string;
|
|
practice_area?: PracticeArea;
|
|
appeal_subtype?: AppealSubtype;
|
|
/** Free-form JSONB. Known keys: appraiser_side, is_post_hearing, references. */
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
export type CaseDetail = Case & {
|
|
documents?: CaseDocument[];
|
|
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
|
|
};
|
|
|
|
export type CasesScope = "active" | "archived" | "all";
|
|
|
|
export const casesKeys = {
|
|
all: ["cases"] as const,
|
|
list: (detail: boolean, scope: CasesScope = "active") =>
|
|
[...casesKeys.all, "list", { detail, scope }] as const,
|
|
detail: (caseNumber: string) =>
|
|
[...casesKeys.all, "detail", caseNumber] as const,
|
|
};
|
|
|
|
export function useCases(detail = false, scope: CasesScope = "active") {
|
|
return useQuery({
|
|
queryKey: casesKeys.list(detail, scope),
|
|
queryFn: ({ signal }) => {
|
|
const params = new URLSearchParams();
|
|
if (detail) params.set("detail", "true");
|
|
if (scope === "archived") params.set("archived_only", "true");
|
|
else if (scope === "all") params.set("include_archived", "true");
|
|
const qs = params.toString();
|
|
return apiRequest<Case[]>(`/api/cases${qs ? `?${qs}` : ""}`, { signal });
|
|
},
|
|
});
|
|
}
|
|
|
|
export type ArchiveResult = {
|
|
status: string;
|
|
case_number: string;
|
|
archived_at?: string | null;
|
|
paperclip?: {
|
|
status: string;
|
|
project_id?: string;
|
|
archived_at?: string | null;
|
|
message?: string;
|
|
issues_cancelled?: Array<{ identifier: string; title: string }>;
|
|
};
|
|
};
|
|
|
|
export function useArchiveCase(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: () =>
|
|
apiRequest<ArchiveResult>(`/api/cases/${caseNumber}/archive`, {
|
|
method: "POST",
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: casesKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useRestoreCase(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: () =>
|
|
apiRequest<ArchiveResult>(`/api/cases/${caseNumber}/restore`, {
|
|
method: "POST",
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: casesKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCase(caseNumber: string | undefined) {
|
|
return useQuery({
|
|
queryKey: casesKeys.detail(caseNumber ?? ""),
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<CaseDetail>(`/api/cases/${caseNumber}/details`, { signal }),
|
|
enabled: Boolean(caseNumber),
|
|
/* Replaces the old 5s polling from vanilla index.html */
|
|
staleTime: 5_000,
|
|
refetchInterval: 5_000,
|
|
});
|
|
}
|
|
|
|
export type WorkflowStatus = {
|
|
case_number?: string;
|
|
status?: CaseStatus | string;
|
|
current_step?: string;
|
|
steps?: Array<{
|
|
key: string;
|
|
label?: string;
|
|
status: "done" | "current" | "pending" | string;
|
|
}>;
|
|
/* FastAPI returns free-form JSON; keep it permissive */
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export function useCreateCase() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (input: CaseCreateInput) =>
|
|
apiRequest<{ case_number?: string; message?: string; [k: string]: unknown }>(
|
|
`/api/cases/create`,
|
|
{ method: "POST", body: input },
|
|
),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: casesKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateCase(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (input: CaseUpdateInput) =>
|
|
apiRequest<CaseDetail>(`/api/cases/${caseNumber}`, {
|
|
method: "PUT",
|
|
body: input,
|
|
}),
|
|
onSuccess: (data) => {
|
|
/* Patch cached detail and nudge the list to refetch on next focus */
|
|
if (caseNumber) {
|
|
qc.setQueryData<CaseDetail | undefined>(
|
|
casesKeys.detail(caseNumber),
|
|
(prev) => (prev ? { ...prev, ...data } : prev),
|
|
);
|
|
}
|
|
qc.invalidateQueries({ queryKey: casesKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export type GitSyncStatus = {
|
|
synced: boolean;
|
|
has_remote: boolean;
|
|
remote_url?: string | null;
|
|
dirty_files: number;
|
|
commits_ahead: number;
|
|
last_commit_time?: string | null;
|
|
last_commit_msg?: string | null;
|
|
error?: string;
|
|
};
|
|
|
|
export function useGitStatus(caseNumber: string | undefined) {
|
|
return useQuery({
|
|
queryKey: [...casesKeys.all, "git-status", caseNumber ?? ""] as const,
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<GitSyncStatus>(`/api/cases/${caseNumber}/git-status`, { signal }),
|
|
enabled: Boolean(caseNumber),
|
|
staleTime: 30_000,
|
|
refetchInterval: 60_000,
|
|
});
|
|
}
|
|
|
|
export type CreateGiteaRepoResult = {
|
|
repo_url: string;
|
|
clone_url: string;
|
|
pushed: boolean;
|
|
};
|
|
|
|
export function useCreateGiteaRepo(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (input: { title: string; description?: string }) =>
|
|
apiRequest<CreateGiteaRepoResult>(`/api/integrations/gitea/create-repo`, {
|
|
method: "POST",
|
|
body: {
|
|
case_number: caseNumber,
|
|
title: input.title,
|
|
description: input.description ?? "",
|
|
},
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: [...casesKeys.all, "git-status", caseNumber ?? ""] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export type StartWorkflowResult = {
|
|
case_number: string;
|
|
status: string;
|
|
issue_id: string;
|
|
issue_identifier: string;
|
|
project_url: string;
|
|
wakeup: Record<string, unknown>;
|
|
};
|
|
|
|
export function useStartWorkflow(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: () =>
|
|
apiRequest<StartWorkflowResult>(
|
|
`/api/cases/${caseNumber}/start-workflow`,
|
|
{ method: "POST" },
|
|
),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: casesKeys.all });
|
|
if (caseNumber) {
|
|
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useWorkflowStatus(caseNumber: string | undefined) {
|
|
return useQuery({
|
|
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<WorkflowStatus>(`/api/cases/${caseNumber}/status`, { signal }),
|
|
enabled: Boolean(caseNumber),
|
|
staleTime: 5_000,
|
|
refetchInterval: 5_000,
|
|
});
|
|
}
|