Add drafts & feedback tab to case page, remove global feedback page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s
Move draft management (export DOCX, download, upload revised version, mark final) and chair feedback into a new "טיוטות והערות" tab on the case detail page. Remove the standalone /feedback page and its nav link since feedback is now case-scoped. Also fix /api/admin/skills 500 error when Paperclip DB is unreachable by adding a connection timeout and graceful fallback to disk-only skills. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
|||||||
import { StatusGuide } from "@/components/cases/status-guide";
|
import { StatusGuide } from "@/components/cases/status-guide";
|
||||||
import { StatusChanger } from "@/components/cases/status-changer";
|
import { StatusChanger } from "@/components/cases/status-changer";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
|
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||||
import { useCase } from "@/lib/api/cases";
|
import { useCase } from "@/lib/api/cases";
|
||||||
@@ -78,6 +79,9 @@ export default function CaseDetailPage({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="drafts">
|
||||||
|
טיוטות והערות
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<UploadSheet caseNumber={caseNumber} />
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +119,13 @@ export default function CaseDetailPage({
|
|||||||
<TabsContent value="documents" className="mt-5">
|
<TabsContent value="documents" className="mt-5">
|
||||||
<DocumentsPanel data={data} />
|
<DocumentsPanel data={data} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="drafts" className="mt-5">
|
||||||
|
<DraftsPanel
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
status={data?.status}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { AppShell } from "@/components/app-shell";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
useFeedbackList,
|
|
||||||
useCreateFeedback,
|
|
||||||
useResolveFeedback,
|
|
||||||
CATEGORY_LABELS,
|
|
||||||
BLOCK_LABELS,
|
|
||||||
type FeedbackCategory,
|
|
||||||
} from "@/lib/api/feedback";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
|
|
||||||
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
|
|
||||||
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
|
|
||||||
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
|
|
||||||
factual_error: "bg-red-100 text-red-800 border-red-200",
|
|
||||||
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
|
||||||
other: "bg-gray-100 text-gray-800 border-gray-200",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FeedbackPage() {
|
|
||||||
const [showResolved, setShowResolved] = useState(false);
|
|
||||||
const [filterCategory, setFilterCategory] = useState<string>("");
|
|
||||||
|
|
||||||
const { data: feedbacks, isLoading } = useFeedbackList({
|
|
||||||
category: filterCategory || undefined,
|
|
||||||
unresolved_only: !showResolved,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolveMutation = useResolveFeedback();
|
|
||||||
|
|
||||||
function handleResolve(id: string) {
|
|
||||||
resolveMutation.mutate(
|
|
||||||
{ feedbackId: id, applied_to: [] },
|
|
||||||
{
|
|
||||||
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
|
||||||
onError: () => toast.error("שגיאה בעדכון"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell>
|
|
||||||
<section className="space-y-6">
|
|
||||||
<header>
|
|
||||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
|
||||||
<Link href="/" className="hover:text-gold-deep">
|
|
||||||
בית
|
|
||||||
</Link>
|
|
||||||
<span aria-hidden> · </span>
|
|
||||||
<span className="text-navy">הערות יו״ר</span>
|
|
||||||
</nav>
|
|
||||||
<h1 className="text-navy mb-0">הערות יו״ר על טיוטות</h1>
|
|
||||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
|
||||||
תיעוד הערות דפנה על טיוטות החלטות. כל הערה מנותחת ומשפיעה על שיפור
|
|
||||||
כתיבת ההחלטות העתידיות.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<NewFeedbackDialog />
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={filterCategory}
|
|
||||||
onChange={(e) => setFilterCategory(e.target.value)}
|
|
||||||
className="rounded-md border border-rule bg-surface px-3 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">כל הקטגוריות</option>
|
|
||||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
|
||||||
<option key={key} value={key}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm text-ink-muted cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showResolved}
|
|
||||||
onChange={(e) => setShowResolved(e.target.checked)}
|
|
||||||
className="rounded border-rule"
|
|
||||||
/>
|
|
||||||
הצג גם מטופלות
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{feedbacks && (
|
|
||||||
<span className="text-sm text-ink-muted me-auto">
|
|
||||||
{feedbacks.length} הערות
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feedback list */}
|
|
||||||
{isLoading ? (
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
|
||||||
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
|
||||||
טוען...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : !feedbacks?.length ? (
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
|
||||||
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
|
||||||
אין הערות{!showResolved ? " פתוחות" : ""}
|
|
||||||
{filterCategory ? ` בקטגוריה ${CATEGORY_LABELS[filterCategory as FeedbackCategory]}` : ""}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{feedbacks.map((fb) => (
|
|
||||||
<Card
|
|
||||||
key={fb.id}
|
|
||||||
className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-60" : ""}`}
|
|
||||||
>
|
|
||||||
<CardHeader className="border-b pb-3">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Badge
|
|
||||||
className={`text-[0.7rem] border ${CATEGORY_COLORS[fb.category]}`}
|
|
||||||
>
|
|
||||||
{CATEGORY_LABELS[fb.category]}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-[0.7rem]">
|
|
||||||
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
|
|
||||||
</Badge>
|
|
||||||
{fb.case_number && (
|
|
||||||
<Link
|
|
||||||
href={`/cases/${fb.case_number}`}
|
|
||||||
className="text-[0.7rem] text-gold-deep hover:underline"
|
|
||||||
>
|
|
||||||
תיק {fb.case_number}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{fb.resolved && (
|
|
||||||
<Badge className="bg-emerald-100 text-emerald-700 text-[0.7rem] border border-emerald-200">
|
|
||||||
טופל
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<span className="text-[0.7rem] text-ink-muted me-auto">
|
|
||||||
{fb.created_at
|
|
||||||
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
|
||||||
: ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-6 py-4 space-y-3">
|
|
||||||
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
|
||||||
|
|
||||||
{fb.lesson_extracted && (
|
|
||||||
<div className="bg-gold/5 border border-gold/20 rounded-md px-4 py-3">
|
|
||||||
<p className="text-[0.7rem] font-semibold text-gold-deep mb-1">
|
|
||||||
לקח שהופק:
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-ink-muted leading-relaxed">
|
|
||||||
{fb.lesson_extracted}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!fb.resolved && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleResolve(fb.id)}
|
|
||||||
disabled={resolveMutation.isPending}
|
|
||||||
>
|
|
||||||
סמן כמטופל
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── New feedback dialog ─────────────────────────────────── */
|
|
||||||
|
|
||||||
function NewFeedbackDialog() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const createMutation = useCreateFeedback();
|
|
||||||
|
|
||||||
const [caseNumber, setCaseNumber] = useState("");
|
|
||||||
const [blockId, setBlockId] = useState("block-yod");
|
|
||||||
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
|
|
||||||
const [feedbackText, setFeedbackText] = useState("");
|
|
||||||
const [lesson, setLesson] = useState("");
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!feedbackText.trim()) return;
|
|
||||||
|
|
||||||
createMutation.mutate(
|
|
||||||
{
|
|
||||||
case_number: caseNumber || undefined,
|
|
||||||
block_id: blockId,
|
|
||||||
feedback_text: feedbackText,
|
|
||||||
category,
|
|
||||||
lesson_extracted: lesson || undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("ההערה נרשמה בהצלחה");
|
|
||||||
setOpen(false);
|
|
||||||
setCaseNumber("");
|
|
||||||
setFeedbackText("");
|
|
||||||
setLesson("");
|
|
||||||
},
|
|
||||||
onError: () => toast.error("שגיאה ברישום ההערה"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>+ הערה חדשה</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg" dir="rtl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>רישום הערת יו״ר</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="fb-case">מספר תיק (אופציונלי)</Label>
|
|
||||||
<Input
|
|
||||||
id="fb-case"
|
|
||||||
value={caseNumber}
|
|
||||||
onChange={(e) => setCaseNumber(e.target.value)}
|
|
||||||
placeholder="1130-25"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="fb-block">בלוק</Label>
|
|
||||||
<select
|
|
||||||
id="fb-block"
|
|
||||||
value={blockId}
|
|
||||||
onChange={(e) => setBlockId(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
|
|
||||||
<option key={key} value={key}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="fb-category">קטגוריה</Label>
|
|
||||||
<select
|
|
||||||
id="fb-category"
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value as FeedbackCategory)}
|
|
||||||
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
|
||||||
<option key={key} value={key}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="fb-text">ההערה</Label>
|
|
||||||
<Textarea
|
|
||||||
id="fb-text"
|
|
||||||
value={feedbackText}
|
|
||||||
onChange={(e) => setFeedbackText(e.target.value)}
|
|
||||||
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
|
|
||||||
rows={4}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="fb-lesson"
|
|
||||||
value={lesson}
|
|
||||||
onChange={(e) => setLesson(e.target.value)}
|
|
||||||
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
ביטול
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={createMutation.isPending}>
|
|
||||||
{createMutation.isPending ? "שומר..." : "שמור הערה"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ href: "/", label: "בית" },
|
{ href: "/", label: "בית" },
|
||||||
{ href: "/cases/new", label: "תיק חדש" },
|
{ href: "/cases/new", label: "תיק חדש" },
|
||||||
{ href: "/training", label: "אימון סגנון" },
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
{ href: "/feedback", label: "הערות יו״ר" },
|
|
||||||
{ href: "/skills", label: "מיומנויות" },
|
{ href: "/skills", label: "מיומנויות" },
|
||||||
{ href: "/diagnostics", label: "אבחון" },
|
{ href: "/diagnostics", label: "אבחון" },
|
||||||
];
|
];
|
||||||
|
|||||||
461
web-ui/src/components/cases/drafts-panel.tsx
Normal file
461
web-ui/src/components/cases/drafts-panel.tsx
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
useExports,
|
||||||
|
useExportDocx,
|
||||||
|
useUploadDraft,
|
||||||
|
useMarkFinal,
|
||||||
|
} from "@/lib/api/exports";
|
||||||
|
import {
|
||||||
|
useCaseFeedback,
|
||||||
|
useCreateFeedback,
|
||||||
|
useResolveFeedback,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
CATEGORY_COLORS,
|
||||||
|
BLOCK_LABELS,
|
||||||
|
type FeedbackCategory,
|
||||||
|
} from "@/lib/api/feedback";
|
||||||
|
import type { CaseStatus } from "@/lib/api/cases";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Award,
|
||||||
|
Loader2,
|
||||||
|
FileOutput,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/* Statuses at which a draft is considered ready */
|
||||||
|
const DRAFT_READY: CaseStatus[] = [
|
||||||
|
"drafted",
|
||||||
|
"exported",
|
||||||
|
"reviewed",
|
||||||
|
"final",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1024) return `${kb.toFixed(0)} KB`;
|
||||||
|
return `${(kb / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(epoch: number): string {
|
||||||
|
return new Date(epoch * 1000).toLocaleDateString("he-IL", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main component ─────────────────────────────────── */
|
||||||
|
|
||||||
|
export function DraftsPanel({
|
||||||
|
caseNumber,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
status?: CaseStatus;
|
||||||
|
}) {
|
||||||
|
const { data: exports, isLoading: exportsLoading } = useExports(caseNumber);
|
||||||
|
const { data: feedbacks, isLoading: feedbackLoading } =
|
||||||
|
useCaseFeedback(caseNumber);
|
||||||
|
const exportDocx = useExportDocx(caseNumber);
|
||||||
|
const uploadDraft = useUploadDraft(caseNumber);
|
||||||
|
const markFinal = useMarkFinal(caseNumber);
|
||||||
|
const resolveMutation = useResolveFeedback();
|
||||||
|
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const isDraftReady = status && DRAFT_READY.includes(status);
|
||||||
|
const openFeedbacks = feedbacks?.filter((f) => !f.resolved) ?? [];
|
||||||
|
|
||||||
|
function handleUpload(file: File) {
|
||||||
|
uploadDraft.mutate(file, {
|
||||||
|
onSuccess: (data) =>
|
||||||
|
toast.success(`הועלה: ${data.filename}`),
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
exportDocx.mutate(undefined, {
|
||||||
|
onSuccess: () => toast.success("הטיוטה יוצאה בהצלחה"),
|
||||||
|
onError: () => toast.error("שגיאה בייצוא"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMarkFinal(filename: string) {
|
||||||
|
markFinal.mutate(filename, {
|
||||||
|
onSuccess: () => toast.success("סומן כסופי"),
|
||||||
|
onError: () => toast.error("שגיאה בסימון"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResolve(id: string) {
|
||||||
|
resolveMutation.mutate(
|
||||||
|
{ feedbackId: id, applied_to: [] },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
||||||
|
onError: () => toast.error("שגיאה בעדכון"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── Banner ── */}
|
||||||
|
{isDraftReady && (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
||||||
|
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-gold-deep">
|
||||||
|
טיוטה ראשונה מוכנה לעיון
|
||||||
|
</span>
|
||||||
|
<div className="me-auto" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exportDocx.isPending}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{exportDocx.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
||||||
|
) : (
|
||||||
|
<FileOutput className="w-4 h-4 me-1.5" />
|
||||||
|
)}
|
||||||
|
הפק DOCX
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Exports list ── */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-navy text-base">קבצי טיוטה</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".docx"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleUpload(f);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploadDraft.isPending}
|
||||||
|
>
|
||||||
|
{uploadDraft.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-4 h-4 me-1.5" />
|
||||||
|
)}
|
||||||
|
העלה גרסה מתוקנת
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exportsLoading ? (
|
||||||
|
<p className="text-sm text-ink-muted">טוען...</p>
|
||||||
|
) : !exports?.length ? (
|
||||||
|
<p className="text-sm text-ink-muted">
|
||||||
|
אין טיוטות עדיין.{" "}
|
||||||
|
{!isDraftReady && "הטיוטה תופיע כאן כשתהיה מוכנה."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-rule overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
|
||||||
|
<th className="text-start px-4 py-2 font-medium">File</th>
|
||||||
|
<th className="text-start px-4 py-2 font-medium">Size</th>
|
||||||
|
<th className="text-start px-4 py-2 font-medium">Date</th>
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{exports.map((file) => (
|
||||||
|
<tr
|
||||||
|
key={file.filename}
|
||||||
|
className="border-t border-rule hover:bg-rule-soft/20"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 flex items-center gap-2">
|
||||||
|
<span>{file.filename}</span>
|
||||||
|
{file.is_final && (
|
||||||
|
<Badge className="bg-success-bg text-success border-success/40 text-[0.65rem]">
|
||||||
|
<Award className="w-3 h-3 me-0.5" />
|
||||||
|
סופי
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink-muted tabular-nums">
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink-muted tabular-nums">
|
||||||
|
{formatDate(file.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
`/api/cases/${caseNumber}/exports/${file.filename}/download`,
|
||||||
|
"_blank",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 me-1" />
|
||||||
|
הורד
|
||||||
|
</Button>
|
||||||
|
{!file.is_final && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-ink-muted"
|
||||||
|
onClick={() => handleMarkFinal(file.filename)}
|
||||||
|
disabled={markFinal.isPending}
|
||||||
|
>
|
||||||
|
<Award className="w-3.5 h-3.5 me-1" />
|
||||||
|
סמן כסופי
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Chair feedback ── */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-navy text-base">
|
||||||
|
הערות יו״ר
|
||||||
|
{openFeedbacks.length > 0 && (
|
||||||
|
<Badge className="ms-2 bg-red-100 text-red-700 border-red-200 text-[0.65rem]">
|
||||||
|
{openFeedbacks.length} פתוחות
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<NewCaseFeedbackDialog caseNumber={caseNumber} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feedbackLoading ? (
|
||||||
|
<p className="text-sm text-ink-muted">טוען...</p>
|
||||||
|
) : !feedbacks?.length ? (
|
||||||
|
<p className="text-sm text-ink-muted">אין הערות לתיק זה.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{feedbacks.map((fb) => (
|
||||||
|
<div
|
||||||
|
key={fb.id}
|
||||||
|
className={`rounded-lg border border-rule bg-surface px-4 py-3 space-y-2 ${
|
||||||
|
fb.resolved ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge
|
||||||
|
className={`text-[0.65rem] border ${CATEGORY_COLORS[fb.category]}`}
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[fb.category]}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-[0.65rem]">
|
||||||
|
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
|
||||||
|
</Badge>
|
||||||
|
{fb.resolved && (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 text-[0.65rem] border border-emerald-200">
|
||||||
|
טופל
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[0.65rem] text-ink-muted me-auto">
|
||||||
|
{fb.created_at
|
||||||
|
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
||||||
|
|
||||||
|
{fb.lesson_extracted && (
|
||||||
|
<div className="bg-gold/5 border border-gold/20 rounded-md px-3 py-2">
|
||||||
|
<p className="text-[0.65rem] font-semibold text-gold-deep mb-0.5">
|
||||||
|
לקח שהופק:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-ink-muted leading-relaxed">
|
||||||
|
{fb.lesson_extracted}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fb.resolved && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[0.75rem]"
|
||||||
|
onClick={() => handleResolve(fb.id)}
|
||||||
|
disabled={resolveMutation.isPending}
|
||||||
|
>
|
||||||
|
סמן כמטופל
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── New feedback dialog (case-scoped) ─────────────── */
|
||||||
|
|
||||||
|
function NewCaseFeedbackDialog({ caseNumber }: { caseNumber: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const createMutation = useCreateFeedback();
|
||||||
|
|
||||||
|
const [blockId, setBlockId] = useState("block-yod");
|
||||||
|
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
|
||||||
|
const [feedbackText, setFeedbackText] = useState("");
|
||||||
|
const [lesson, setLesson] = useState("");
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!feedbackText.trim()) return;
|
||||||
|
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
case_number: caseNumber,
|
||||||
|
block_id: blockId,
|
||||||
|
feedback_text: feedbackText,
|
||||||
|
category,
|
||||||
|
lesson_extracted: lesson || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("ההערה נרשמה בהצלחה");
|
||||||
|
setOpen(false);
|
||||||
|
setFeedbackText("");
|
||||||
|
setLesson("");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה ברישום ההערה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="w-3.5 h-3.5 me-1" />
|
||||||
|
הערה חדשה
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>הערת יו״ר — תיק {caseNumber}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-block">בלוק</Label>
|
||||||
|
<select
|
||||||
|
id="fb-block"
|
||||||
|
value={blockId}
|
||||||
|
onChange={(e) => setBlockId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-category">קטגוריה</Label>
|
||||||
|
<select
|
||||||
|
id="fb-category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCategory(e.target.value as FeedbackCategory)
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-text">ההערה</Label>
|
||||||
|
<Textarea
|
||||||
|
id="fb-text"
|
||||||
|
value={feedbackText}
|
||||||
|
onChange={(e) => setFeedbackText(e.target.value)}
|
||||||
|
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="fb-lesson"
|
||||||
|
value={lesson}
|
||||||
|
onChange={(e) => setLesson(e.target.value)}
|
||||||
|
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "שומר..." : "שמור הערה"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
web-ui/src/lib/api/exports.ts
Normal file
87
web-ui/src/lib/api/exports.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Exports domain hooks — draft DOCX files for a case.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
import { casesKeys } from "./cases";
|
||||||
|
|
||||||
|
export type ExportFile = {
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
created_at: number;
|
||||||
|
is_final: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportsKeys = {
|
||||||
|
all: ["exports"] as const,
|
||||||
|
list: (caseNumber: string) =>
|
||||||
|
[...exportsKeys.all, "list", caseNumber] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useExports(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: exportsKeys.list(caseNumber ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<ExportFile[]>(`/api/cases/${caseNumber}/exports`, { signal }),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportDocx(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiRequest<{ status: string; path: string; message: string }>(
|
||||||
|
`/api/cases/${caseNumber}/export-docx`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||||
|
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadDraft(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const res = await fetch(`/api/cases/${caseNumber}/exports/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: "שגיאה בהעלאה" }));
|
||||||
|
throw new Error(err.detail ?? "שגיאה בהעלאה");
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
version: number;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkFinal(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (filename: string) =>
|
||||||
|
apiRequest<{ final_filename: string; status: string }>(
|
||||||
|
`/api/cases/${caseNumber}/exports/${filename}/mark-final`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||||
|
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -56,6 +56,19 @@ export function useFeedbackList(filters: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Feedback filtered by case number */
|
||||||
|
export function useCaseFeedback(caseNumber: string | undefined) {
|
||||||
|
const params = caseNumber ? `?case_number=${caseNumber}` : "";
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...feedbackKeys.all, "case", caseNumber ?? ""] as const,
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<ChairFeedback[]>(`/api/feedback${params}`, { signal }),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateFeedback() {
|
export function useCreateFeedback() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -100,6 +113,16 @@ export const CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
|||||||
other: "אחר",
|
other: "אחר",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Tailwind color classes per category */
|
||||||
|
export const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
|
||||||
|
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
|
||||||
|
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
|
||||||
|
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
factual_error: "bg-red-100 text-red-800 border-red-200",
|
||||||
|
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
|
other: "bg-gray-100 text-gray-800 border-gray-200",
|
||||||
|
};
|
||||||
|
|
||||||
/** Block ID labels */
|
/** Block ID labels */
|
||||||
export const BLOCK_LABELS: Record<string, string> = {
|
export const BLOCK_LABELS: Record<string, string> = {
|
||||||
"block-he": "ה — פתיחה",
|
"block-he": "ה — פתיחה",
|
||||||
|
|||||||
25
web/app.py
25
web/app.py
@@ -1102,7 +1102,23 @@ async def api_case_create(req: CaseCreateRequest):
|
|||||||
practice_area=req.practice_area,
|
practice_area=req.practice_area,
|
||||||
appeal_subtype=req.appeal_subtype,
|
appeal_subtype=req.appeal_subtype,
|
||||||
)
|
)
|
||||||
return json.loads(result)
|
parsed = json.loads(result)
|
||||||
|
|
||||||
|
# Auto-create Paperclip project for the new case
|
||||||
|
appeal_type = req.appeal_subtype or "רישוי"
|
||||||
|
try:
|
||||||
|
pc_result = await pc_create_project(
|
||||||
|
case_number=req.case_number,
|
||||||
|
title=req.title,
|
||||||
|
appeal_type=appeal_type,
|
||||||
|
)
|
||||||
|
parsed["paperclip"] = pc_result
|
||||||
|
logger.info("Auto-created Paperclip project for case %s: %s", req.case_number, pc_result.get("url"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to auto-create Paperclip project for case %s: %s", req.case_number, e)
|
||||||
|
parsed["paperclip_error"] = str(e)
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases/{case_number}/details")
|
@app.get("/api/cases/{case_number}/details")
|
||||||
@@ -2091,7 +2107,9 @@ SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-a
|
|||||||
@app.get("/api/admin/skills")
|
@app.get("/api/admin/skills")
|
||||||
async def api_list_skills():
|
async def api_list_skills():
|
||||||
"""List installed Paperclip skills with DB sync status."""
|
"""List installed Paperclip skills with DB sync status."""
|
||||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
rows = []
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL, timeout=5)
|
||||||
try:
|
try:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
|
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
|
||||||
@@ -2100,6 +2118,9 @@ async def api_list_skills():
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
except (OSError, asyncpg.PostgresError, asyncpg.InterfaceError, TimeoutError):
|
||||||
|
# Paperclip DB unreachable — continue with disk-only skills
|
||||||
|
pass
|
||||||
|
|
||||||
skills = []
|
skills = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|||||||
Reference in New Issue
Block a user