Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
the project-manager PNG in a separate commit).
Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.
Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.
Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
smoke tests, npm scripts test:e2e / test:e2e:install.
Made-with: Cursor
165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { ThumbsDown, ThumbsUp } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
useAIFeedbackSummary,
|
|
useSubmitAIFeedback,
|
|
} from "@/hooks/queries/useAIFeedback";
|
|
import { cn } from "@/lib/utils";
|
|
import type {
|
|
AIFeedbackRating,
|
|
AIFeedbackSubjectType,
|
|
} from "@/types/ai-feedback";
|
|
|
|
export interface AIFeedbackButtonsProps {
|
|
subjectType: AIFeedbackSubjectType;
|
|
subjectId: number;
|
|
promptKey?: string;
|
|
promptVersion?: number;
|
|
aiLogId?: number;
|
|
entityId?: number;
|
|
courseId?: number;
|
|
size?: "sm" | "md";
|
|
className?: string;
|
|
}
|
|
|
|
/** Thumbs up/down widget for any AI-generated artefact.
|
|
*
|
|
* Always renders the same UI regardless of whether the user has rated the
|
|
* artefact before — a previous rating will show as highlighted. Clicking the
|
|
* same button again is a no-op; clicking the opposite button updates the
|
|
* server-side row in place. A thumbs-down always prompts the user for a short
|
|
* comment (required by the backend).
|
|
*/
|
|
export function AIFeedbackButtons({
|
|
subjectType,
|
|
subjectId,
|
|
promptKey,
|
|
promptVersion,
|
|
aiLogId,
|
|
entityId,
|
|
courseId,
|
|
size = "sm",
|
|
className,
|
|
}: AIFeedbackButtonsProps) {
|
|
const summary = useAIFeedbackSummary(subjectType, subjectId);
|
|
const submit = useSubmitAIFeedback();
|
|
|
|
const [downOpen, setDownOpen] = useState(false);
|
|
const [comment, setComment] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (downOpen) {
|
|
setComment(summary.data?.my_comment ?? "");
|
|
}
|
|
}, [downOpen, summary.data?.my_comment]);
|
|
|
|
const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
|
|
|
|
const submitUp = async () => {
|
|
if (myRating === "up") return;
|
|
try {
|
|
await submit.mutateAsync({
|
|
subject_type: subjectType,
|
|
subject_id: subjectId,
|
|
rating: "up",
|
|
prompt_key: promptKey,
|
|
prompt_version: promptVersion,
|
|
ai_log_id: aiLogId,
|
|
entity_id: entityId,
|
|
course_id: courseId,
|
|
});
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
|
}
|
|
};
|
|
|
|
const submitDown = async () => {
|
|
if (!comment.trim()) {
|
|
toast.error("Please tell us what was wrong.");
|
|
return;
|
|
}
|
|
try {
|
|
await submit.mutateAsync({
|
|
subject_type: subjectType,
|
|
subject_id: subjectId,
|
|
rating: "down",
|
|
comment: comment.trim(),
|
|
prompt_key: promptKey,
|
|
prompt_version: promptVersion,
|
|
ai_log_id: aiLogId,
|
|
entity_id: entityId,
|
|
course_id: courseId,
|
|
});
|
|
setDownOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to submit");
|
|
}
|
|
};
|
|
|
|
const btnSize = size === "sm" ? "sm" : "default";
|
|
const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
|
|
|
|
return (
|
|
<div className={cn("flex items-center gap-1", className)}>
|
|
<Button
|
|
variant={myRating === "up" ? "default" : "outline"}
|
|
size={btnSize}
|
|
onClick={submitUp}
|
|
disabled={submit.isPending}
|
|
aria-label="Thumbs up"
|
|
>
|
|
<ThumbsUp className={iconCls} />
|
|
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
|
|
</Button>
|
|
<Button
|
|
variant={myRating === "down" ? "default" : "outline"}
|
|
size={btnSize}
|
|
onClick={() => setDownOpen(true)}
|
|
disabled={submit.isPending}
|
|
aria-label="Thumbs down"
|
|
>
|
|
<ThumbsDown className={iconCls} />
|
|
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
|
|
</Button>
|
|
|
|
<Dialog open={downOpen} onOpenChange={setDownOpen}>
|
|
<DialogContent
|
|
className="max-w-md"
|
|
description="Tell us what was wrong so we can improve this AI output."
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>What went wrong?</DialogTitle>
|
|
</DialogHeader>
|
|
<Textarea
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
rows={4}
|
|
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
|
|
/>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDownOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={submitDown} disabled={submit.isPending}>
|
|
Submit feedback
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AIFeedbackButtons;
|