Add drafts & feedback tab to case page, remove global feedback page
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:
2026-04-14 05:55:46 +00:00
parent ce61b88438
commit 140a2e442d
7 changed files with 612 additions and 339 deletions

View File

@@ -26,7 +26,6 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" },
{ href: "/cases/new", label: "תיק חדש" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/feedback", label: "הערות יו״ר" },
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" },
];

View 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>
);
}