feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform. Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,7 @@ import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
|||||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||||
import { usePermissions } from "@/hooks/usePermissions";
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
@@ -238,9 +239,16 @@ function RouteContentFallback() {
|
|||||||
|
|
||||||
// ============= Main Layout =============
|
// ============= Main Layout =============
|
||||||
export default function AdminLmsLayout() {
|
export default function AdminLmsLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||||
|
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
|
||||||
|
// on the wizard's own Next/Finish footer (real bug repro: trying to
|
||||||
|
// click "Next" in the Course-plan wizard at the standard 1024×768
|
||||||
|
// viewport hits the pill instead). The AI Assistant orb also at the
|
||||||
|
// bottom-right is preserved — it's smaller and useful in-wizard.
|
||||||
|
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||||
|
|
||||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||||
|
|
||||||
@@ -261,6 +269,44 @@ export default function AdminLmsLayout() {
|
|||||||
</div>
|
</div>
|
||||||
<AiSearchBar />
|
<AiSearchBar />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{user?.entities?.length ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
<span className="max-w-40 truncate">
|
||||||
|
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
{t("nav.entities")}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{user.entities.map((entity) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={entity.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEntityId(entity.id);
|
||||||
|
// Force page data to refresh against the newly
|
||||||
|
// selected entity scope.
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span className="truncate">{entity.name}</span>
|
||||||
|
{selectedEntity?.id === entity.id && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{t("common.active", "Active")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
@@ -303,13 +349,15 @@ export default function AdminLmsLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AiAssistantDrawer />
|
<AiAssistantDrawer />
|
||||||
<Link
|
{!isWizardRoute && (
|
||||||
to="/admin/tickets"
|
<Link
|
||||||
className="fixed bottom-6 end-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
to="/admin/tickets"
|
||||||
>
|
className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||||
<HelpCircle className="h-4 w-4" />
|
>
|
||||||
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
<HelpCircle className="h-4 w-4" />
|
||||||
</Link>
|
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { AppSidebar } from "@/components/AppSidebar";
|
import { AppSidebar } from "@/components/AppSidebar";
|
||||||
@@ -7,14 +7,16 @@ import {
|
|||||||
BreadcrumbPage, BreadcrumbSeparator,
|
BreadcrumbPage, BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react";
|
import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||||
import AiSearchBar from "@/components/ai/AiSearchBar";
|
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
const routeLabels: Record<string, string> = {
|
const routeLabels: Record<string, string> = {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -90,6 +92,14 @@ function RouteContentFallback() {
|
|||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user, selectedEntity, setSelectedEntityId } = useAuth();
|
||||||
|
// Hide the floating "Need help?" pill on multi-step wizard routes.
|
||||||
|
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
|
||||||
|
// intercepting clicks on the wizard's own Next/Finish footer at most
|
||||||
|
// viewport heights, blocking users from finishing the flow. The AI
|
||||||
|
// Assistant orb (also bottom-right) stays — it's smaller and
|
||||||
|
// genuinely useful inside the wizard.
|
||||||
|
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
@@ -103,6 +113,34 @@ export default function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
<AiSearchBar />
|
<AiSearchBar />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{user?.entities?.length ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
<span className="max-w-40 truncate">
|
||||||
|
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
|
{user.entities.map((entity) => {
|
||||||
|
const active = selectedEntity?.id === entity.id;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={entity.id}
|
||||||
|
onClick={() => setSelectedEntityId(entity.id)}
|
||||||
|
className="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span className="truncate">{entity.name}</span>
|
||||||
|
{active ? <Badge variant="secondary">Current</Badge> : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
|
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||||
<Ticket className="h-4 w-4" />
|
<Ticket className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -145,14 +183,15 @@ export default function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AiAssistantDrawer />
|
<AiAssistantDrawer />
|
||||||
{/* Floating help button */}
|
{!isWizardRoute && (
|
||||||
<Link
|
<Link
|
||||||
to="/tickets"
|
to="/tickets"
|
||||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
<HelpCircle className="h-4 w-4" />
|
<HelpCircle className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium">Need help?</span>
|
<span className="text-sm font-medium">Need help?</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
214
src/components/coursePlan/LibraryPickerDialog.tsx
Normal file
214
src/components/coursePlan/LibraryPickerDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Library, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
import { resourcesService } from "@/services/resources.service";
|
||||||
|
import type { Resource } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic, reusable picker over `/api/resources`.
|
||||||
|
*
|
||||||
|
* Two callers wire this up today:
|
||||||
|
*
|
||||||
|
* 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards
|
||||||
|
* the picked resources to `coursePlanService.attachResources` in the
|
||||||
|
* `onConfirm` handler and invalidates the sources query.
|
||||||
|
*
|
||||||
|
* 2. **CoursePlanWizard** — the plan does not exist yet at pick time,
|
||||||
|
* so the wizard simply pushes the picks into its draft state and
|
||||||
|
* attaches them in one batch after the plan is created.
|
||||||
|
*
|
||||||
|
* The dialog itself is purposefully agnostic: it only reports the
|
||||||
|
* selected `Resource` rows; the parent decides what to do.
|
||||||
|
*/
|
||||||
|
export function LibraryPickerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
alreadyLinkedIds,
|
||||||
|
onConfirm,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Resources whose ids are in this set render disabled + checked. */
|
||||||
|
alreadyLinkedIds?: Set<number>;
|
||||||
|
/** Called when the admin clicks "Attach selected". */
|
||||||
|
onConfirm: (resources: Resource[]) => void | Promise<void>;
|
||||||
|
/** Optional spinner state from the parent's mutation. */
|
||||||
|
isPending?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Reset every time the dialog re-opens so a previous session's
|
||||||
|
// selection doesn't bleed into the next attach.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelected(new Set());
|
||||||
|
setSearch("");
|
||||||
|
setTypeFilter("all");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["library-resources", { search, type: typeFilter }],
|
||||||
|
queryFn: () =>
|
||||||
|
resourcesService.list({
|
||||||
|
search: search || undefined,
|
||||||
|
resource_type: typeFilter === "all" ? undefined : typeFilter,
|
||||||
|
review_status: "approved",
|
||||||
|
}),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const resources: Resource[] = data?.items ?? [];
|
||||||
|
|
||||||
|
const toggle = (id: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
const picked = resources.filter((r) => selected.has(r.id));
|
||||||
|
if (!picked.length) return;
|
||||||
|
await onConfirm(picked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Library className="h-5 w-5 text-primary" />
|
||||||
|
{t("coursePlan.sources.libraryTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("coursePlan.sources.libraryDescription")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t("coursePlan.sources.librarySearchPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
{t("coursePlan.sources.libraryTypeAll")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
|
<SelectItem value="document">DOCX</SelectItem>
|
||||||
|
<SelectItem value="link">URL</SelectItem>
|
||||||
|
<SelectItem value="article">Article</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-1">
|
||||||
|
{isLoading && <Skeleton className="h-32 w-full" />}
|
||||||
|
{!isLoading && resources.length === 0 && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground py-8">
|
||||||
|
{t("coursePlan.sources.libraryEmpty")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isLoading &&
|
||||||
|
resources.map((r) => {
|
||||||
|
const isLinked = alreadyLinkedIds?.has(r.id) ?? false;
|
||||||
|
const isSelected = selected.has(r.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={r.id}
|
||||||
|
className={`flex items-start gap-3 rounded-md border p-2 text-sm transition-colors ${
|
||||||
|
isLinked
|
||||||
|
? "opacity-60 cursor-not-allowed bg-muted/40"
|
||||||
|
: "cursor-pointer hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isLinked || isSelected}
|
||||||
|
disabled={isLinked}
|
||||||
|
onCheckedChange={() => !isLinked && toggle(r.id)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium truncate">{r.name}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] capitalize"
|
||||||
|
>
|
||||||
|
{r.resource_type || r.type || "resource"}
|
||||||
|
</Badge>
|
||||||
|
{isLinked && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{t("coursePlan.sources.libraryAlreadyLinked")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(r.subject_name || (r.topic_names ?? []).length > 0) && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{[r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" › ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t pt-3">
|
||||||
|
<span className="mr-auto text-xs text-muted-foreground self-center">
|
||||||
|
{t("coursePlan.sources.librarySelectedCount", {
|
||||||
|
count: selected.size,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selected.size || isPending}
|
||||||
|
>
|
||||||
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t("coursePlan.sources.libraryAttach")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/coursePlan/MaterialBookView.tsx
Normal file
78
src/components/coursePlan/MaterialBookView.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CoursePlanMaterial } from "@/types";
|
||||||
|
|
||||||
|
export const SKILL_STYLE: Record<string, string> = {
|
||||||
|
reading: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
writing: "bg-purple-100 text-purple-800 border-purple-200",
|
||||||
|
listening: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
|
speaking: "bg-amber-100 text-amber-800 border-amber-200",
|
||||||
|
grammar: "bg-rose-100 text-rose-800 border-rose-200",
|
||||||
|
vocabulary: "bg-cyan-100 text-cyan-800 border-cyan-200",
|
||||||
|
integrated: "bg-slate-100 text-slate-800 border-slate-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SkillBadge({ skill }: { skill: string }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("capitalize border", SKILL_STYLE[skill] ?? SKILL_STYLE.integrated)}
|
||||||
|
>
|
||||||
|
{skill || "integrated"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAny(value: unknown): React.ReactNode {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return <p className="leading-7">{String(value)}</p>;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ul className="list-disc ps-5 space-y-1">
|
||||||
|
{value.map((item, idx) => (
|
||||||
|
<li key={idx}>{renderAny(item)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>);
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k}>
|
||||||
|
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
|
||||||
|
{k.replace(/_/g, " ")}
|
||||||
|
</h5>
|
||||||
|
<div className="text-sm">{renderAny(v)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MaterialBookView({
|
||||||
|
material,
|
||||||
|
}: {
|
||||||
|
material: Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
|
||||||
|
}) {
|
||||||
|
const body = material.body ?? {};
|
||||||
|
const hasStructured = Object.keys(body).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
|
||||||
|
{hasStructured ? (
|
||||||
|
renderAny(body)
|
||||||
|
) : (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-7">
|
||||||
|
{material.body_text || "No content available yet."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||||
import { authService } from "@/services/auth.service";
|
import { authService } from "@/services/auth.service";
|
||||||
import { clearToken } from "@/lib/api-client";
|
import { clearToken, getActiveEntityId, setActiveEntityId } from "@/lib/api-client";
|
||||||
import type { User, UserRole } from "@/types/auth";
|
import type { User, UserRole } from "@/types/auth";
|
||||||
|
|
||||||
export type { UserRole };
|
export type { UserRole };
|
||||||
@@ -9,6 +9,9 @@ interface AuthContextType {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
selectedEntityId: number | null;
|
||||||
|
selectedEntity: User["entities"][number] | null;
|
||||||
|
setSelectedEntityId: (entityId: number | null) => void;
|
||||||
login: (email: string, password: string) => Promise<User>;
|
login: (email: string, password: string) => Promise<User>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -17,8 +20,23 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [selectedEntityId, setSelectedEntityIdState] = useState<number | null>(getActiveEntityId());
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const syncEntitySelection = useCallback((nextUser: User | null) => {
|
||||||
|
const available = nextUser?.entities ?? [];
|
||||||
|
if (available.length === 0) {
|
||||||
|
setSelectedEntityIdState(null);
|
||||||
|
setActiveEntityId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stored = getActiveEntityId();
|
||||||
|
const validStored = stored && available.some((e) => e.id === stored) ? stored : null;
|
||||||
|
const next = validStored ?? available[0].id;
|
||||||
|
setSelectedEntityIdState(next);
|
||||||
|
setActiveEntityId(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("encoach_token");
|
const token = localStorage.getItem("encoach_token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
authService
|
authService
|
||||||
.getUser()
|
.getUser()
|
||||||
.then(setUser)
|
.then((u) => {
|
||||||
|
setUser(u);
|
||||||
|
syncEntitySelection(u);
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
clearToken();
|
clearToken();
|
||||||
|
setActiveEntityId(null);
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, [syncEntitySelection]);
|
||||||
|
|
||||||
const login = useCallback(async (email: string, password: string): Promise<User> => {
|
const login = useCallback(async (email: string, password: string): Promise<User> => {
|
||||||
const res = await authService.login({ login: email, password });
|
const res = await authService.login({ login: email, password });
|
||||||
setUser(res.user);
|
setUser(res.user);
|
||||||
|
syncEntitySelection(res.user);
|
||||||
return res.user;
|
return res.user;
|
||||||
}, []);
|
}, [syncEntitySelection]);
|
||||||
|
|
||||||
|
const setSelectedEntityId = useCallback((entityId: number | null) => {
|
||||||
|
if (!user) {
|
||||||
|
setSelectedEntityIdState(null);
|
||||||
|
setActiveEntityId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entityId == null) {
|
||||||
|
setSelectedEntityIdState(null);
|
||||||
|
setActiveEntityId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!user.entities.some((e) => e.id === entityId)) return;
|
||||||
|
setSelectedEntityIdState(entityId);
|
||||||
|
setActiveEntityId(entityId);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setSelectedEntityIdState(null);
|
||||||
|
setActiveEntityId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const selectedEntity =
|
||||||
|
user?.entities.find((e) => e.id === selectedEntityId) ??
|
||||||
|
user?.entities[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
selectedEntityId,
|
||||||
|
selectedEntity,
|
||||||
|
setSelectedEntityId,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -816,9 +816,32 @@ const ar: Translations = {
|
|||||||
file: "ملف",
|
file: "ملف",
|
||||||
url: "رابط",
|
url: "رابط",
|
||||||
text: "نص",
|
text: "نص",
|
||||||
|
resource: "المكتبة",
|
||||||
},
|
},
|
||||||
chunks_one: "{{count}} جزء",
|
chunks_one: "{{count}} جزء",
|
||||||
chunks_other: "{{count}} أجزاء",
|
chunks_other: "{{count}} أجزاء",
|
||||||
|
pickFromLibrary: "اختيار من المكتبة",
|
||||||
|
libraryHint: "أعد استخدام مورد قمت برفعه من قبل.",
|
||||||
|
libraryTitle: "اختر من مكتبة الموارد",
|
||||||
|
libraryDescription:
|
||||||
|
"اختر مورداً واحداً أو أكثر من الموارد المعتمدة لدعم الذكاء الاصطناعي. سنقوم بفهرستها تماماً مثل الملفات المرفوعة.",
|
||||||
|
librarySearchPlaceholder: "ابحث بالعنوان…",
|
||||||
|
libraryTypeAll: "كل الأنواع",
|
||||||
|
libraryEmpty: "لا توجد موارد مطابقة لهذه الفلاتر.",
|
||||||
|
libraryAlreadyLinked: "مربوط مسبقاً",
|
||||||
|
libraryAttach: "ربط المختار",
|
||||||
|
librarySelectedCount_one: "تم اختيار {{count}}",
|
||||||
|
librarySelectedCount_other: "تم اختيار {{count}}",
|
||||||
|
libraryAttached_one: "تم ربط {{count}} مورد.",
|
||||||
|
libraryAttached_other: "تم ربط {{count}} موارد.",
|
||||||
|
librarySkipped_one: "{{count}} مورد كان مربوطاً مسبقاً.",
|
||||||
|
librarySkipped_other: "{{count}} موارد كانت مربوطة مسبقاً.",
|
||||||
|
libraryAttachFailed: "تعذّر ربط الموارد المختارة.",
|
||||||
|
linkedToLibrary: "مرتبط بـ /admin/resources",
|
||||||
|
fromLibrary: "المكتبة",
|
||||||
|
libraryPickTitle: "اختر من المكتبة",
|
||||||
|
libraryPickHint: "أعد استخدام موارد معتمدة سبق رفعها إلى /admin/resources.",
|
||||||
|
libraryPickButton: "تصفح المكتبة",
|
||||||
},
|
},
|
||||||
sourceKind: {
|
sourceKind: {
|
||||||
file: "ملف",
|
file: "ملف",
|
||||||
@@ -932,6 +955,83 @@ const ar: Translations = {
|
|||||||
agents: "الوكلاء",
|
agents: "الوكلاء",
|
||||||
tools: "الأدوات",
|
tools: "الأدوات",
|
||||||
prompts: "التعليمات",
|
prompts: "التعليمات",
|
||||||
|
providers: "المزوّدون والمفاتيح",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aiProviders: {
|
||||||
|
title: "مزوّدو الذكاء الاصطناعي ومفاتيح API",
|
||||||
|
subtitle:
|
||||||
|
"اختر المزوّد الفعّال لكل قدرة واحفظ مفاتيح API. تُطبَّق التغييرات في الطلب التالي مباشرةً دون الحاجة لإعادة تشغيل Odoo.",
|
||||||
|
activeProvider: "المزوّد الفعّال",
|
||||||
|
save: "حفظ الإعدادات",
|
||||||
|
testButton: "اختبار سلسلة الاحتياط",
|
||||||
|
noPaidKeys:
|
||||||
|
"لا توجد مفاتيح API مدفوعة لهذه القدرة — سيتم استخدام البديل المجاني.",
|
||||||
|
paidConfigured: "المزوّدون المدفوعون المهيَّؤون:",
|
||||||
|
empty: "تعذّر تحميل الإعدادات.",
|
||||||
|
footer:
|
||||||
|
"تُقرأ إعدادات المزوّدين من ir.config_parameter في كل طلب — بدون تخزين مؤقّت ولا حاجة لإعادة تشغيل.",
|
||||||
|
cap: {
|
||||||
|
text: "توليد النصوص",
|
||||||
|
text_hint: "يستخدمه كل وكلاء LangGraph (مخطّط المقرر، مولّد الاختبارات، المصحّحون).",
|
||||||
|
image: "توليد الصور",
|
||||||
|
image_hint: "رسومات لنصوص القراءة ومشاهد الاستماع وبطاقات المفردات.",
|
||||||
|
audio: "الصوت (TTS)",
|
||||||
|
audio_hint: "سرد نصوص الاستماع وأمثلة إجابات أسئلة المحادثة.",
|
||||||
|
video: "تركيب الفيديو",
|
||||||
|
video_hint: "مقاطع MP4 تجمع بين الصورة المُولَّدة والسرد الصوتي.",
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
paid: "مدفوع",
|
||||||
|
free: "مجاني",
|
||||||
|
auto: "تلقائي",
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
title: "مفاتيح API",
|
||||||
|
subtitle:
|
||||||
|
"المفاتيح للكتابة فقط وتُحفَظ مشفّرة في ir.config_parameter. لا تُرجَع أبداً إلى المتصفّح.",
|
||||||
|
saved: "محفوظ",
|
||||||
|
unsaved: "غير محفوظ",
|
||||||
|
clear: "مسح",
|
||||||
|
placeholderSaved: "•••••• (انقر للاستبدال)",
|
||||||
|
openai: "مفتاح OpenAI",
|
||||||
|
openai_hint: "يُستخدَم لـ GPT-4o والتضمين و DALL-E 3.",
|
||||||
|
aws_access: "AWS Access Key ID",
|
||||||
|
aws_secret: "AWS Secret Access Key",
|
||||||
|
elevenlabs: "مفتاح ElevenLabs",
|
||||||
|
gptzero: "مفتاح GPTZero",
|
||||||
|
paymob_api: "مفتاح Paymob API",
|
||||||
|
paymob_integration: "Paymob integration ID",
|
||||||
|
paymob_iframe: "Paymob iframe ID",
|
||||||
|
paymob_hmac: "Paymob HMAC secret",
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
openai: "OpenAI",
|
||||||
|
aws: "AWS Polly",
|
||||||
|
elevenlabs: "ElevenLabs",
|
||||||
|
other: "خدمات ذكاء اصطناعي أخرى",
|
||||||
|
paymob: "Paymob (المدفوعات)",
|
||||||
|
},
|
||||||
|
plain: {
|
||||||
|
title: "النموذج والتشغيل",
|
||||||
|
subtitle:
|
||||||
|
"معلَمات تشغيل غير سرّية: النموذج الافتراضي، منطقة AWS، مهلة الطلب.",
|
||||||
|
openai_model: "نموذج OpenAI",
|
||||||
|
openai_fast: "نموذج OpenAI السريع",
|
||||||
|
aws_region: "منطقة AWS",
|
||||||
|
elevenlabs_model: "نموذج ElevenLabs",
|
||||||
|
timeout: "مهلة الطلب (ثوانٍ)",
|
||||||
|
max_retries: "أقصى عدد محاولات",
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
loadFailed: "تعذّر تحميل إعدادات الذكاء الاصطناعي",
|
||||||
|
saved: "تم حفظ إعدادات المزوّد",
|
||||||
|
savedDescription:
|
||||||
|
"تم تحديث المزوّدين. تُطبَّق التغييرات في الطلب التالي.",
|
||||||
|
saveFailed: "تعذّر حفظ الإعدادات",
|
||||||
|
testOk: "سلسلة المزوّد جاهزة",
|
||||||
|
testPartial: "بعض المزوّدين تنقصه بيانات الاعتماد",
|
||||||
|
testFailed: "فشل اختبار المزوّد",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface Translations {
|
|||||||
aiAdmin: Record<string, unknown>;
|
aiAdmin: Record<string, unknown>;
|
||||||
agents: Record<string, unknown>;
|
agents: Record<string, unknown>;
|
||||||
tools: Record<string, unknown>;
|
tools: Record<string, unknown>;
|
||||||
|
/** AI provider selection & API key management (Providers & Keys tab). */
|
||||||
|
aiProviders: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const en: Translations = {
|
const en: Translations = {
|
||||||
@@ -874,9 +876,33 @@ const en: Translations = {
|
|||||||
file: "File",
|
file: "File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
text: "Text",
|
text: "Text",
|
||||||
|
resource: "Library",
|
||||||
},
|
},
|
||||||
chunks_one: "{{count}} chunk",
|
chunks_one: "{{count}} chunk",
|
||||||
chunks_other: "{{count}} chunks",
|
chunks_other: "{{count}} chunks",
|
||||||
|
// Library picker — reuses /admin/resources items as RAG sources.
|
||||||
|
pickFromLibrary: "Pick from library",
|
||||||
|
libraryHint: "Reuse a resource you've already uploaded.",
|
||||||
|
libraryTitle: "Pick from resource library",
|
||||||
|
libraryDescription:
|
||||||
|
"Select one or more approved resources to ground the AI on. We'll index them just like uploaded files.",
|
||||||
|
librarySearchPlaceholder: "Search by title…",
|
||||||
|
libraryTypeAll: "All types",
|
||||||
|
libraryEmpty: "No resources match those filters.",
|
||||||
|
libraryAlreadyLinked: "Already linked",
|
||||||
|
libraryAttach: "Attach selected",
|
||||||
|
librarySelectedCount_one: "{{count}} selected",
|
||||||
|
librarySelectedCount_other: "{{count}} selected",
|
||||||
|
libraryAttached_one: "Attached {{count}} resource.",
|
||||||
|
libraryAttached_other: "Attached {{count}} resources.",
|
||||||
|
librarySkipped_one: "{{count}} resource was already linked.",
|
||||||
|
librarySkipped_other: "{{count}} resources were already linked.",
|
||||||
|
libraryAttachFailed: "Couldn't attach the selected resources.",
|
||||||
|
linkedToLibrary: "Linked to /admin/resources",
|
||||||
|
fromLibrary: "Library",
|
||||||
|
libraryPickTitle: "Pick from library",
|
||||||
|
libraryPickHint: "Reuse approved resources you've already uploaded to /admin/resources.",
|
||||||
|
libraryPickButton: "Browse library",
|
||||||
},
|
},
|
||||||
sourceKind: {
|
sourceKind: {
|
||||||
file: "File",
|
file: "File",
|
||||||
@@ -991,6 +1017,83 @@ const en: Translations = {
|
|||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
tools: "Tools",
|
tools: "Tools",
|
||||||
prompts: "Prompts",
|
prompts: "Prompts",
|
||||||
|
providers: "Providers & Keys",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aiProviders: {
|
||||||
|
title: "AI Providers & API Keys",
|
||||||
|
subtitle:
|
||||||
|
"Pick the active provider per capability and store API keys. Changes take effect on the next request — no Odoo restart required.",
|
||||||
|
activeProvider: "Active provider",
|
||||||
|
save: "Save settings",
|
||||||
|
testButton: "Test fallback chain",
|
||||||
|
noPaidKeys:
|
||||||
|
"No paid API keys configured for this capability — free fallback will be used.",
|
||||||
|
paidConfigured: "Configured paid providers:",
|
||||||
|
empty: "Settings could not be loaded.",
|
||||||
|
footer:
|
||||||
|
"Provider settings are read fresh from ir.config_parameter on every request — no caching, no restart required.",
|
||||||
|
cap: {
|
||||||
|
text: "Text generation",
|
||||||
|
text_hint: "Used by every LangGraph agent (course planner, exam generator, graders).",
|
||||||
|
image: "Image generation",
|
||||||
|
image_hint: "Illustrations for reading texts, listening scenes, and vocabulary cards.",
|
||||||
|
audio: "Audio (TTS)",
|
||||||
|
audio_hint: "Listening-script narration and speaking-prompt model answers.",
|
||||||
|
video: "Video composition",
|
||||||
|
video_hint: "Slideshow MP4s combining a generated image with the audio narration.",
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
paid: "Paid",
|
||||||
|
free: "Free",
|
||||||
|
auto: "Auto",
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
title: "API keys",
|
||||||
|
subtitle:
|
||||||
|
"Keys are write-only and stored encrypted at rest in ir.config_parameter. They are never returned to the browser.",
|
||||||
|
saved: "Saved",
|
||||||
|
unsaved: "Unsaved",
|
||||||
|
clear: "Clear",
|
||||||
|
placeholderSaved: "•••••• (click to replace)",
|
||||||
|
openai: "OpenAI API key",
|
||||||
|
openai_hint: "Used for GPT-4o, embeddings, and DALL-E 3.",
|
||||||
|
aws_access: "AWS Access Key ID",
|
||||||
|
aws_secret: "AWS Secret Access Key",
|
||||||
|
elevenlabs: "ElevenLabs API key",
|
||||||
|
gptzero: "GPTZero API key",
|
||||||
|
paymob_api: "Paymob API key",
|
||||||
|
paymob_integration: "Paymob integration ID",
|
||||||
|
paymob_iframe: "Paymob iframe ID",
|
||||||
|
paymob_hmac: "Paymob HMAC secret",
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
openai: "OpenAI",
|
||||||
|
aws: "AWS Polly",
|
||||||
|
elevenlabs: "ElevenLabs",
|
||||||
|
other: "Other AI services",
|
||||||
|
paymob: "Paymob (payments)",
|
||||||
|
},
|
||||||
|
plain: {
|
||||||
|
title: "Model & runtime",
|
||||||
|
subtitle:
|
||||||
|
"Non-secret runtime parameters: default model, AWS region, request timeout.",
|
||||||
|
openai_model: "OpenAI model",
|
||||||
|
openai_fast: "OpenAI fast model",
|
||||||
|
aws_region: "AWS region",
|
||||||
|
elevenlabs_model: "ElevenLabs model",
|
||||||
|
timeout: "Request timeout (seconds)",
|
||||||
|
max_retries: "Max retries",
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
loadFailed: "Could not load AI settings",
|
||||||
|
saved: "AI provider settings saved",
|
||||||
|
savedDescription:
|
||||||
|
"Active providers updated. Changes apply to the next request.",
|
||||||
|
saveFailed: "Could not save settings",
|
||||||
|
testOk: "Provider chain ready",
|
||||||
|
testPartial: "Some providers missing credentials",
|
||||||
|
testFailed: "Provider test failed",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -101,11 +101,53 @@ export function describeApiError(
|
|||||||
const ACCESS_KEY = "encoach_token";
|
const ACCESS_KEY = "encoach_token";
|
||||||
const REFRESH_KEY = "encoach_refresh_token";
|
const REFRESH_KEY = "encoach_refresh_token";
|
||||||
const EXP_KEY = "encoach_token_exp";
|
const EXP_KEY = "encoach_token_exp";
|
||||||
|
const ENTITY_KEY = "encoach_entity_id";
|
||||||
|
|
||||||
function getAccessToken(): string | null {
|
function getAccessToken(): string | null {
|
||||||
return localStorage.getItem(ACCESS_KEY);
|
return localStorage.getItem(ACCESS_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getActiveEntityId(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(ENTITY_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveEntityId(entityId: number | null | undefined): void {
|
||||||
|
try {
|
||||||
|
if (entityId && Number.isFinite(entityId)) {
|
||||||
|
localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId)));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(ENTITY_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a URL to a media-streaming endpoint with the JWT attached as a
|
||||||
|
* query parameter. Used by ``<img>`` / ``<audio>`` / ``<video>`` tags
|
||||||
|
* (which can't attach custom Authorization headers) and by ``<a download>``
|
||||||
|
* tags so the browser can fetch the binary directly without going through
|
||||||
|
* a fetch + blob URL dance.
|
||||||
|
*
|
||||||
|
* Returns the original path unchanged when no token is stored, which lets
|
||||||
|
* callers render a placeholder rather than crashing on ``null``.
|
||||||
|
*/
|
||||||
|
export function withAuthQuery(path: string): string {
|
||||||
|
if (!path) return path;
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) return path;
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
return `${path}${sep}token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getRefreshToken(): string | null {
|
function getRefreshToken(): string | null {
|
||||||
return localStorage.getItem(REFRESH_KEY);
|
return localStorage.getItem(REFRESH_KEY);
|
||||||
}
|
}
|
||||||
@@ -240,6 +282,11 @@ export type QueryParamValue =
|
|||||||
*/
|
*/
|
||||||
export type QueryParams = object;
|
export type QueryParams = object;
|
||||||
|
|
||||||
|
const ENTITY_QUERY_SCOPE_RE =
|
||||||
|
/^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
|
||||||
|
const ENTITY_BODY_SCOPE_RE =
|
||||||
|
/^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;
|
||||||
|
|
||||||
function buildUrl(path: string, params?: QueryParams): string {
|
function buildUrl(path: string, params?: QueryParams): string {
|
||||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -253,9 +300,30 @@ function buildUrl(path: string, params?: QueryParams): string {
|
|||||||
url.searchParams.set(key, String(rawValue));
|
url.searchParams.set(key, String(rawValue));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Multi-entity LMS scope: when the user has selected an active entity
|
||||||
|
// in the UI, append it to entity-scoped endpoints unless the caller
|
||||||
|
// already provided an explicit entity_id.
|
||||||
|
const activeEntityId = getActiveEntityId();
|
||||||
|
if (
|
||||||
|
activeEntityId &&
|
||||||
|
!url.searchParams.has("entity_id") &&
|
||||||
|
ENTITY_QUERY_SCOPE_RE.test(path)
|
||||||
|
) {
|
||||||
|
url.searchParams.set("entity_id", String(activeEntityId));
|
||||||
|
}
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeInjectEntityIntoBody(path: string, body: unknown): unknown {
|
||||||
|
const activeEntityId = getActiveEntityId();
|
||||||
|
if (!activeEntityId) return body;
|
||||||
|
if (!ENTITY_BODY_SCOPE_RE.test(path)) return body;
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
||||||
|
const rec = body as Record<string, unknown>;
|
||||||
|
if (rec.entity_id !== undefined && rec.entity_id !== null) return body;
|
||||||
|
return { ...rec, entity_id: activeEntityId };
|
||||||
|
}
|
||||||
|
|
||||||
async function parseResponse<T>(response: Response): Promise<T> {
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
const data = await response.json().catch(() => null);
|
const data = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
|
||||||
@@ -315,26 +383,29 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const payload = maybeInjectEntityIntoBody(path, body);
|
||||||
return performRequest<T>(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const payload = maybeInjectEntityIntoBody(path, body);
|
||||||
return performRequest<T>(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const payload = maybeInjectEntityIntoBody(path, body);
|
||||||
return performRequest<T>(buildUrl(path), {
|
return performRequest<T>(buildUrl(path), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -7,10 +7,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield } from "lucide-react";
|
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield, UserCog } from "lucide-react";
|
||||||
import { entitiesService } from "@/services/entities.service";
|
import { entitiesService } from "@/services/entities.service";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
const ENTITY_TYPES = [
|
const ENTITY_TYPES = [
|
||||||
{ value: "corporate", label: "Corporate" },
|
{ value: "corporate", label: "Corporate" },
|
||||||
@@ -21,10 +23,16 @@ const ENTITY_TYPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function EntitiesPage() {
|
export default function EntitiesPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdminUser = user?.user_type === "admin";
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
|
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
|
||||||
|
const [manageOpen, setManageOpen] = useState(false);
|
||||||
|
const [manageEntity, setManageEntity] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
const [usersSearch, setUsersSearch] = useState("");
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
|
||||||
const [form, setForm] = useState({ name: "", code: "", type: "" });
|
const [form, setForm] = useState({ name: "", code: "", type: "" });
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -34,6 +42,18 @@ export default function EntitiesPage() {
|
|||||||
queryFn: () => entitiesService.list({ size: 200 }),
|
queryFn: () => entitiesService.list({ size: 200 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const platformUsersQ = useQuery({
|
||||||
|
queryKey: ["platform-users-for-entities"],
|
||||||
|
queryFn: () => entitiesService.listPlatformUsers({ size: 500 }),
|
||||||
|
enabled: isAdminUser && manageOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const entityUsersQ = useQuery({
|
||||||
|
queryKey: ["entity-users", manageEntity?.id],
|
||||||
|
queryFn: () => entitiesService.listEntityUsers(manageEntity!.id),
|
||||||
|
enabled: isAdminUser && !!manageEntity?.id,
|
||||||
|
});
|
||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (data: { name: string; code?: string; type?: string }) =>
|
mutationFn: (data: { name: string; code?: string; type?: string }) =>
|
||||||
entitiesService.create(data as Partial<import("@/types").Entity>),
|
entitiesService.create(data as Partial<import("@/types").Entity>),
|
||||||
@@ -66,6 +86,21 @@ export default function EntitiesPage() {
|
|||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveEntityUsersMut = useMutation({
|
||||||
|
mutationFn: ({ entityId, userIds }: { entityId: number; userIds: number[] }) =>
|
||||||
|
entitiesService.updateEntityUsers(entityId, userIds),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["entities"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["entity-users"] });
|
||||||
|
setManageOpen(false);
|
||||||
|
setManageEntity(null);
|
||||||
|
setUsersSearch("");
|
||||||
|
setSelectedUserIds(new Set());
|
||||||
|
toast({ title: "Entity users updated" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
const raw = entitiesQ.data as unknown;
|
const raw = entitiesQ.data as unknown;
|
||||||
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -82,11 +117,43 @@ export default function EntitiesPage() {
|
|||||||
);
|
);
|
||||||
const loading = entitiesQ.isLoading;
|
const loading = entitiesQ.isLoading;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entityUsersQ.data) return;
|
||||||
|
setSelectedUserIds(new Set(entityUsersQ.data.map((u) => u.id)));
|
||||||
|
}, [entityUsersQ.data]);
|
||||||
|
|
||||||
|
const filteredPlatformUsers = useMemo(() => {
|
||||||
|
const all = platformUsersQ.data ?? [];
|
||||||
|
const q = usersSearch.trim().toLowerCase();
|
||||||
|
if (!q) return all;
|
||||||
|
return all.filter((u) =>
|
||||||
|
(u.name || "").toLowerCase().includes(q)
|
||||||
|
|| (u.email || "").toLowerCase().includes(q)
|
||||||
|
|| (u.login || "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [platformUsersQ.data, usersSearch]);
|
||||||
|
|
||||||
function openEdit(e: typeof entities[0]) {
|
function openEdit(e: typeof entities[0]) {
|
||||||
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
|
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openManageUsers(e: typeof entities[0]) {
|
||||||
|
setManageEntity({ id: e.id, name: e.name });
|
||||||
|
setUsersSearch("");
|
||||||
|
setSelectedUserIds(new Set());
|
||||||
|
setManageOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUser(userId: number) {
|
||||||
|
setSelectedUserIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(userId)) next.delete(userId);
|
||||||
|
else next.add(userId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -159,6 +226,11 @@ export default function EntitiesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
{isAdminUser ? (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openManageUsers(e)}>
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -279,6 +351,79 @@ export default function EntitiesPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Manage Users Dialog (admin only) */}
|
||||||
|
<Dialog open={manageOpen} onOpenChange={(open) => {
|
||||||
|
setManageOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setManageEntity(null);
|
||||||
|
setUsersSearch("");
|
||||||
|
setSelectedUserIds(new Set());
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage Users — {manageEntity?.name ?? ""}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{!isAdminUser ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-4">Only admin users can manage entity members.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search users by name/email/login..."
|
||||||
|
className="pl-9"
|
||||||
|
value={usersSearch}
|
||||||
|
onChange={(e) => setUsersSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border max-h-[420px] overflow-y-auto">
|
||||||
|
{platformUsersQ.isLoading || entityUsersQ.isLoading ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
|
||||||
|
) : filteredPlatformUsers.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">No users found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredPlatformUsers.map((u) => (
|
||||||
|
<label key={u.id} className="flex items-center gap-3 p-3 hover:bg-muted/40 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedUserIds.has(u.id)}
|
||||||
|
onCheckedChange={() => toggleUser(u.id)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{u.name || u.login || u.email}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{u.email || u.login}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="text-xs text-muted-foreground mr-auto">
|
||||||
|
{selectedUserIds.size} user(s) selected
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => setManageOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!manageEntity || saveEntityUsersMut.isPending || !isAdminUser}
|
||||||
|
onClick={() => {
|
||||||
|
if (!manageEntity) return;
|
||||||
|
saveEntityUsersMut.mutate({
|
||||||
|
entityId: manageEntity.id,
|
||||||
|
userIds: Array.from(selectedUserIds),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saveEntityUsersMut.isPending ? "Saving..." : "Save Users"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
useRenderAIPrompt,
|
useRenderAIPrompt,
|
||||||
} from "@/hooks/queries/useAIPrompts";
|
} from "@/hooks/queries/useAIPrompts";
|
||||||
import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel";
|
import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel";
|
||||||
|
import AIProviderSettings from "@/pages/admin/AIProviderSettings";
|
||||||
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
|
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
|
||||||
import type { AIPromptSummary } from "@/types/ai-prompt";
|
import type { AIPromptSummary } from "@/types/ai-prompt";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileText,
|
FileText,
|
||||||
History,
|
History,
|
||||||
|
KeyRound,
|
||||||
Play,
|
Play,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Wrench,
|
Wrench,
|
||||||
@@ -582,6 +584,13 @@ export default function AIPromptEditor() {
|
|||||||
<FileText className="me-1 h-4 w-4" />
|
<FileText className="me-1 h-4 w-4" />
|
||||||
{t("aiAdmin.tabs.prompts", "Prompts")}
|
{t("aiAdmin.tabs.prompts", "Prompts")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="providers"
|
||||||
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
|
||||||
|
>
|
||||||
|
<KeyRound className="me-1 h-4 w-4" />
|
||||||
|
{t("aiAdmin.tabs.providers", "Providers & Keys")}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="agents" className="mt-2">
|
<TabsContent value="agents" className="mt-2">
|
||||||
@@ -593,6 +602,9 @@ export default function AIPromptEditor() {
|
|||||||
<TabsContent value="prompts" className="mt-2">
|
<TabsContent value="prompts" className="mt-2">
|
||||||
<AIPromptsPanel />
|
<AIPromptsPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="providers" className="mt-2">
|
||||||
|
<AIProviderSettings />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
704
src/pages/admin/AIProviderSettings.tsx
Normal file
704
src/pages/admin/AIProviderSettings.tsx
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Settings2,
|
||||||
|
Volume2,
|
||||||
|
Wand2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
aiSettingsService,
|
||||||
|
type AISettingsPatchPayload,
|
||||||
|
type AISettingsState,
|
||||||
|
type CapabilityKey,
|
||||||
|
type CapabilityState,
|
||||||
|
type ProviderTestResult,
|
||||||
|
} from "@/services/aiSettings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin UI for AI provider selection and API-key management.
|
||||||
|
*
|
||||||
|
* - Dropdowns set the active provider per capability (text / image / audio /
|
||||||
|
* video). Selecting "auto" tells the backend to try paid providers first
|
||||||
|
* and silently fall back to free providers on quota / billing errors.
|
||||||
|
* - API-key inputs are write-only — the backend never returns a key value;
|
||||||
|
* we only render a "saved" badge based on `keys_set[<name>]`.
|
||||||
|
* - Changes persist to `ir.config_parameter` and take effect on the very
|
||||||
|
* next request (no caching), so admins can flip OpenAI -> Mock without
|
||||||
|
* restarting Odoo.
|
||||||
|
*
|
||||||
|
* Mounted as the fourth tab on `/admin/ai/prompts` so the user keeps a
|
||||||
|
* single "AI" surface in the admin nav.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CAPABILITY_META: Array<{
|
||||||
|
key: CapabilityKey;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
i18nKey: string;
|
||||||
|
defaultLabel: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "text", icon: Wand2, i18nKey: "aiProviders.cap.text", defaultLabel: "Text generation" },
|
||||||
|
{ key: "image", icon: ImageIcon, i18nKey: "aiProviders.cap.image", defaultLabel: "Image generation" },
|
||||||
|
{ key: "audio", icon: Volume2, i18nKey: "aiProviders.cap.audio", defaultLabel: "Audio (TTS)" },
|
||||||
|
{ key: "video", icon: Activity, i18nKey: "aiProviders.cap.video", defaultLabel: "Video composition" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface KeyFieldDef {
|
||||||
|
shortName: string;
|
||||||
|
i18nKey: string;
|
||||||
|
defaultLabel: string;
|
||||||
|
placeholder?: string;
|
||||||
|
hint?: string;
|
||||||
|
hintI18nKey?: string;
|
||||||
|
group: "openai" | "aws" | "elevenlabs" | "other" | "paymob";
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_FIELDS: KeyFieldDef[] = [
|
||||||
|
// OpenAI
|
||||||
|
{
|
||||||
|
shortName: "openai_api_key",
|
||||||
|
i18nKey: "aiProviders.keys.openai",
|
||||||
|
defaultLabel: "OpenAI API key",
|
||||||
|
placeholder: "sk-…",
|
||||||
|
hintI18nKey: "aiProviders.keys.openai_hint",
|
||||||
|
hint: "Used for GPT-4o, embeddings, and DALL-E 3.",
|
||||||
|
group: "openai",
|
||||||
|
},
|
||||||
|
// AWS
|
||||||
|
{
|
||||||
|
shortName: "aws_access_key",
|
||||||
|
i18nKey: "aiProviders.keys.aws_access",
|
||||||
|
defaultLabel: "AWS Access Key ID",
|
||||||
|
placeholder: "AKIA…",
|
||||||
|
group: "aws",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortName: "aws_secret_key",
|
||||||
|
i18nKey: "aiProviders.keys.aws_secret",
|
||||||
|
defaultLabel: "AWS Secret Access Key",
|
||||||
|
placeholder: "wJalr…",
|
||||||
|
group: "aws",
|
||||||
|
},
|
||||||
|
// ElevenLabs
|
||||||
|
{
|
||||||
|
shortName: "elevenlabs_api_key",
|
||||||
|
i18nKey: "aiProviders.keys.elevenlabs",
|
||||||
|
defaultLabel: "ElevenLabs API key",
|
||||||
|
placeholder: "el_…",
|
||||||
|
group: "elevenlabs",
|
||||||
|
},
|
||||||
|
// GPTZero
|
||||||
|
{
|
||||||
|
shortName: "gptzero_api_key",
|
||||||
|
i18nKey: "aiProviders.keys.gptzero",
|
||||||
|
defaultLabel: "GPTZero API key",
|
||||||
|
group: "other",
|
||||||
|
},
|
||||||
|
// Paymob (payments)
|
||||||
|
{
|
||||||
|
shortName: "paymob_api_key",
|
||||||
|
i18nKey: "aiProviders.keys.paymob_api",
|
||||||
|
defaultLabel: "Paymob API key",
|
||||||
|
group: "paymob",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortName: "paymob_integration_id",
|
||||||
|
i18nKey: "aiProviders.keys.paymob_integration",
|
||||||
|
defaultLabel: "Paymob integration ID",
|
||||||
|
group: "paymob",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortName: "paymob_iframe_id",
|
||||||
|
i18nKey: "aiProviders.keys.paymob_iframe",
|
||||||
|
defaultLabel: "Paymob iframe ID",
|
||||||
|
group: "paymob",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortName: "paymob_hmac_secret",
|
||||||
|
i18nKey: "aiProviders.keys.paymob_hmac",
|
||||||
|
defaultLabel: "Paymob HMAC secret",
|
||||||
|
group: "paymob",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const KEY_GROUPS: Array<{
|
||||||
|
group: KeyFieldDef["group"];
|
||||||
|
i18nKey: string;
|
||||||
|
defaultLabel: string;
|
||||||
|
}> = [
|
||||||
|
{ group: "openai", i18nKey: "aiProviders.group.openai", defaultLabel: "OpenAI" },
|
||||||
|
{ group: "aws", i18nKey: "aiProviders.group.aws", defaultLabel: "AWS Polly" },
|
||||||
|
{ group: "elevenlabs", i18nKey: "aiProviders.group.elevenlabs", defaultLabel: "ElevenLabs" },
|
||||||
|
{ group: "other", i18nKey: "aiProviders.group.other", defaultLabel: "Other AI services" },
|
||||||
|
{ group: "paymob", i18nKey: "aiProviders.group.paymob", defaultLabel: "Paymob (payments)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const KIND_BADGE: Record<string, { className: string; labelKey: string; defaultLabel: string }> = {
|
||||||
|
paid: { className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||||
|
labelKey: "aiProviders.kind.paid", defaultLabel: "Paid" },
|
||||||
|
free: { className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||||
|
labelKey: "aiProviders.kind.free", defaultLabel: "Free" },
|
||||||
|
auto: { className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
|
||||||
|
labelKey: "aiProviders.kind.auto", defaultLabel: "Auto" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function CapabilityCard({
|
||||||
|
capKey,
|
||||||
|
state,
|
||||||
|
onChange,
|
||||||
|
onTest,
|
||||||
|
testing,
|
||||||
|
testResult,
|
||||||
|
}: {
|
||||||
|
capKey: CapabilityKey;
|
||||||
|
state: CapabilityState;
|
||||||
|
onChange: (newValue: string) => void;
|
||||||
|
onTest: () => void;
|
||||||
|
testing: boolean;
|
||||||
|
testResult: ProviderTestResult | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const meta = CAPABILITY_META.find((m) => m.key === capKey)!;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const activeOption = state.options.find((o) => o.value === state.active);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{t(meta.i18nKey, meta.defaultLabel)}
|
||||||
|
</CardTitle>
|
||||||
|
{activeOption ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={KIND_BADGE[activeOption.kind]?.className}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
KIND_BADGE[activeOption.kind]?.labelKey ?? "aiProviders.kind.auto",
|
||||||
|
KIND_BADGE[activeOption.kind]?.defaultLabel ?? activeOption.kind,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{t(`${meta.i18nKey}_hint`, "Active provider for this capability.")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={`provider-${capKey}`}>
|
||||||
|
{t("aiProviders.activeProvider", "Active provider")}
|
||||||
|
</Label>
|
||||||
|
<Select value={state.active} onValueChange={onChange}>
|
||||||
|
<SelectTrigger id={`provider-${capKey}`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{state.options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{opt.label}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${KIND_BADGE[opt.kind]?.className ?? ""}`}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
KIND_BADGE[opt.kind]?.labelKey ?? "aiProviders.kind.auto",
|
||||||
|
KIND_BADGE[opt.kind]?.defaultLabel ?? opt.kind,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.paid_with_credentials.length === 0 && capKey !== "video" ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
"aiProviders.noPaidKeys",
|
||||||
|
"No paid API keys configured for this capability — free fallback will be used.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t("aiProviders.paidConfigured", "Configured paid providers:")}{" "}
|
||||||
|
{state.paid_with_credentials.join(", ") || "—"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={testing}
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<Loader2 className="me-1 h-3 w-3 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t("aiProviders.testButton", "Test fallback chain")}
|
||||||
|
</Button>
|
||||||
|
{testResult ? (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{testResult.chain
|
||||||
|
.map((c) => `${c.provider}${c.ok ? " ✓" : " ✗"}`)
|
||||||
|
.join(" → ")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecretField({
|
||||||
|
field,
|
||||||
|
isSet,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
field: KeyFieldDef;
|
||||||
|
isSet: boolean;
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const dirty = value !== undefined;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label htmlFor={`key-${field.shortName}`} className="text-sm">
|
||||||
|
{t(field.i18nKey, field.defaultLabel)}
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isSet && !dirty ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="me-1 h-3 w-3" />
|
||||||
|
{t("aiProviders.keys.saved", "Saved")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{dirty ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{t("aiProviders.keys.unsaved", "Unsaved")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id={`key-${field.shortName}`}
|
||||||
|
type={revealed ? "text" : "password"}
|
||||||
|
placeholder={
|
||||||
|
isSet
|
||||||
|
? t("aiProviders.keys.placeholderSaved", "•••••• (click to replace)")
|
||||||
|
: (field.placeholder ?? "")
|
||||||
|
}
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealed((v) => !v)}
|
||||||
|
className="text-muted-foreground hover:text-foreground absolute end-2 top-1/2 -translate-y-1/2"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={revealed ? "Hide value" : "Show value"}
|
||||||
|
>
|
||||||
|
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isSet ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
{t("aiProviders.keys.clear", "Clear")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{field.hint ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t(field.hintI18nKey ?? "", field.hint)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlainField({
|
||||||
|
shortName,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
shortName: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={`plain-${shortName}`} className="text-sm">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`plain-${shortName}`}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIProviderSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<AISettingsState | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Pending diff applied on Save. Provider edits are confirmed
|
||||||
|
// immediately so the UI reflects the active selection — but we still
|
||||||
|
// batch them into one PATCH so we don't burn requests on every click.
|
||||||
|
const [pendingProviders, setPendingProviders] = useState<
|
||||||
|
Partial<Record<CapabilityKey, string>>
|
||||||
|
>({});
|
||||||
|
const [pendingKeys, setPendingKeys] = useState<Record<string, string>>({});
|
||||||
|
const [pendingPlain, setPendingPlain] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const [testing, setTesting] = useState<CapabilityKey | null>(null);
|
||||||
|
const [testResults, setTestResults] = useState<
|
||||||
|
Partial<Record<CapabilityKey, ProviderTestResult>>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await aiSettingsService.get();
|
||||||
|
setState(data);
|
||||||
|
setPendingProviders({});
|
||||||
|
setPendingKeys({});
|
||||||
|
setPendingPlain({});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(t("aiProviders.toast.loadFailed", "Could not load AI settings"), {
|
||||||
|
description: msg,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirty = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(pendingProviders).length > 0 ||
|
||||||
|
Object.keys(pendingKeys).length > 0 ||
|
||||||
|
Object.keys(pendingPlain).length > 0,
|
||||||
|
[pendingProviders, pendingKeys, pendingPlain],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!state || !dirty) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: AISettingsPatchPayload = {};
|
||||||
|
if (Object.keys(pendingProviders).length) payload.providers = pendingProviders;
|
||||||
|
if (Object.keys(pendingKeys).length) payload.keys = pendingKeys;
|
||||||
|
if (Object.keys(pendingPlain).length) payload.plain = pendingPlain;
|
||||||
|
const updated = await aiSettingsService.update(payload);
|
||||||
|
setState(updated);
|
||||||
|
setPendingProviders({});
|
||||||
|
setPendingKeys({});
|
||||||
|
setPendingPlain({});
|
||||||
|
toast.success(
|
||||||
|
t("aiProviders.toast.saved", "AI provider settings saved"),
|
||||||
|
{
|
||||||
|
description: t(
|
||||||
|
"aiProviders.toast.savedDescription",
|
||||||
|
"Active providers updated. Changes apply to the next request.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(t("aiProviders.toast.saveFailed", "Could not save settings"), {
|
||||||
|
description: msg,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest(cap: CapabilityKey) {
|
||||||
|
setTesting(cap);
|
||||||
|
try {
|
||||||
|
const result = await aiSettingsService.test(cap);
|
||||||
|
setTestResults((prev) => ({ ...prev, [cap]: result }));
|
||||||
|
const allOk = result.chain.every((c) => c.ok);
|
||||||
|
if (allOk) {
|
||||||
|
toast.success(
|
||||||
|
t("aiProviders.toast.testOk", "Provider chain ready") +
|
||||||
|
` · ${result.chain.map((c) => c.provider).join(" → ")}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.warning(
|
||||||
|
t("aiProviders.toast.testPartial", "Some providers missing credentials") +
|
||||||
|
` · ${result.chain
|
||||||
|
.map((c) => `${c.provider}${c.ok ? "✓" : "✗"}`)
|
||||||
|
.join(" → ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(t("aiProviders.toast.testFailed", "Provider test failed"), {
|
||||||
|
description: msg,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTesting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||||
|
{t("aiProviders.empty", "Settings could not be loaded.")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveProviders: Record<CapabilityKey, CapabilityState> = {
|
||||||
|
text: { ...state.providers.text, active: pendingProviders.text ?? state.providers.text.active },
|
||||||
|
image: { ...state.providers.image, active: pendingProviders.image ?? state.providers.image.active },
|
||||||
|
audio: { ...state.providers.audio, active: pendingProviders.audio ?? state.providers.audio.active },
|
||||||
|
video: { ...state.providers.video, active: pendingProviders.video ?? state.providers.video.active },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Settings2 className="h-5 w-5" />
|
||||||
|
{t("aiProviders.title", "AI Providers & API Keys")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"aiProviders.subtitle",
|
||||||
|
"Pick the active provider per capability and store API keys. Changes take effect on the next request — no Odoo restart required.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="me-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="me-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("aiProviders.save", "Save settings")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{CAPABILITY_META.map((meta) => (
|
||||||
|
<CapabilityCard
|
||||||
|
key={meta.key}
|
||||||
|
capKey={meta.key}
|
||||||
|
state={effectiveProviders[meta.key]}
|
||||||
|
onChange={(newValue) =>
|
||||||
|
setPendingProviders((prev) => ({ ...prev, [meta.key]: newValue }))
|
||||||
|
}
|
||||||
|
onTest={() => handleTest(meta.key)}
|
||||||
|
testing={testing === meta.key}
|
||||||
|
testResult={testResults[meta.key] ?? null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t("aiProviders.keys.title", "API keys")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"aiProviders.keys.subtitle",
|
||||||
|
"Keys are write-only and stored encrypted at rest in ir.config_parameter. They are never returned to the browser.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{KEY_GROUPS.map((group) => {
|
||||||
|
const fields = KEY_FIELDS.filter((f) => f.group === group.group);
|
||||||
|
if (!fields.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={group.group} className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
{t(group.i18nKey, group.defaultLabel)}
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<SecretField
|
||||||
|
key={field.shortName}
|
||||||
|
field={field}
|
||||||
|
isSet={Boolean(state.keys_set[field.shortName])}
|
||||||
|
value={pendingKeys[field.shortName]}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPendingKeys((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.shortName]: v,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onClear={() =>
|
||||||
|
setPendingKeys((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.shortName]: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t("aiProviders.plain.title", "Model & runtime")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"aiProviders.plain.subtitle",
|
||||||
|
"Non-secret runtime parameters: default model, AWS region, request timeout.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<PlainField
|
||||||
|
shortName="openai_model"
|
||||||
|
label={t("aiProviders.plain.openai_model", "OpenAI model")}
|
||||||
|
value={pendingPlain.openai_model ?? state.plain.openai_model ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPendingPlain((prev) => ({ ...prev, openai_model: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PlainField
|
||||||
|
shortName="openai_fast_model"
|
||||||
|
label={t("aiProviders.plain.openai_fast", "OpenAI fast model")}
|
||||||
|
value={pendingPlain.openai_fast_model ?? state.plain.openai_fast_model ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPendingPlain((prev) => ({ ...prev, openai_fast_model: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PlainField
|
||||||
|
shortName="aws_region"
|
||||||
|
label={t("aiProviders.plain.aws_region", "AWS region")}
|
||||||
|
value={pendingPlain.aws_region ?? state.plain.aws_region ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPendingPlain((prev) => ({ ...prev, aws_region: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PlainField
|
||||||
|
shortName="elevenlabs_model"
|
||||||
|
label={t("aiProviders.plain.elevenlabs_model", "ElevenLabs model")}
|
||||||
|
value={pendingPlain.elevenlabs_model ?? state.plain.elevenlabs_model ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPendingPlain((prev) => ({ ...prev, elevenlabs_model: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PlainField
|
||||||
|
shortName="request_timeout"
|
||||||
|
label={t("aiProviders.plain.timeout", "Request timeout (seconds)")}
|
||||||
|
value={pendingPlain.request_timeout ?? state.plain.request_timeout ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPendingPlain((prev) => ({ ...prev, request_timeout: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PlainField
|
||||||
|
shortName="max_retries"
|
||||||
|
label={t("aiProviders.plain.max_retries", "Max retries")}
|
||||||
|
value={pendingPlain.max_retries ?? state.plain.max_retries ?? ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPendingPlain((prev) => ({ ...prev, max_retries: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
"aiProviders.footer",
|
||||||
|
"Provider settings are read fresh from ir.config_parameter on every request — no caching, no restart required.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
Headphones,
|
Headphones,
|
||||||
@@ -80,8 +82,13 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { coursePlanService } from "@/services/coursePlan.service";
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
|
import { entitiesService } from "@/services/entities.service";
|
||||||
import { lmsService } from "@/services/lms.service";
|
import { lmsService } from "@/services/lms.service";
|
||||||
import { describeApiError } from "@/lib/api-client";
|
import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog";
|
||||||
|
import MaterialBookView, {
|
||||||
|
SkillBadge,
|
||||||
|
} from "@/components/coursePlan/MaterialBookView";
|
||||||
|
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||||
import type {
|
import type {
|
||||||
CoursePlan,
|
CoursePlan,
|
||||||
CoursePlanAssignment,
|
CoursePlanAssignment,
|
||||||
@@ -91,6 +98,7 @@ import type {
|
|||||||
CoursePlanSkill,
|
CoursePlanSkill,
|
||||||
CoursePlanSource,
|
CoursePlanSource,
|
||||||
CoursePlanWeek,
|
CoursePlanWeek,
|
||||||
|
Entity,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,6 +167,7 @@ export default function AdminCoursePlanDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const plan = planQ.data?.data as CoursePlan | undefined;
|
const plan = planQ.data?.data as CoursePlan | undefined;
|
||||||
|
const [studentPreview, setStudentPreview] = useState(false);
|
||||||
|
|
||||||
const generateMut = useMutation({
|
const generateMut = useMutation({
|
||||||
mutationFn: (weekNumber: number) =>
|
mutationFn: (weekNumber: number) =>
|
||||||
@@ -237,6 +246,20 @@ export default function AdminCoursePlanDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant={studentPreview ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStudentPreview((v) => !v)}
|
||||||
|
>
|
||||||
|
{studentPreview ? (
|
||||||
|
<EyeOff className="mr-1 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{studentPreview
|
||||||
|
? t("coursePlan.preview.exitStudentView", "Exit student view")
|
||||||
|
: t("coursePlan.preview.studentView", "Student view")}
|
||||||
|
</Button>
|
||||||
<Badge variant="secondary" className="uppercase">
|
<Badge variant="secondary" className="uppercase">
|
||||||
{plan.cefr_level || "—"}
|
{plan.cefr_level || "—"}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -269,6 +292,7 @@ export default function AdminCoursePlanDetail() {
|
|||||||
<AssignmentsCard
|
<AssignmentsCard
|
||||||
plan={plan}
|
plan={plan}
|
||||||
assignments={assignmentsQ.data?.items ?? []}
|
assignments={assignmentsQ.data?.items ?? []}
|
||||||
|
studentPreview={studentPreview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{plan.objectives.length > 0 && (
|
{plan.objectives.length > 0 && (
|
||||||
@@ -395,6 +419,7 @@ export default function AdminCoursePlanDetail() {
|
|||||||
bulkMediaMut.isPending &&
|
bulkMediaMut.isPending &&
|
||||||
bulkMediaMut.variables?.week === week.week_number
|
bulkMediaMut.variables?.week === week.week_number
|
||||||
}
|
}
|
||||||
|
studentPreview={studentPreview}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@@ -479,6 +504,19 @@ function SourcesCard({
|
|||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [urlDraft, setUrlDraft] = useState("");
|
const [urlDraft, setUrlDraft] = useState("");
|
||||||
const [textDraft, setTextDraft] = useState("");
|
const [textDraft, setTextDraft] = useState("");
|
||||||
|
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||||
|
// The set of resource ids that are already attached to this plan.
|
||||||
|
// Passed into the picker so already-linked rows render disabled and
|
||||||
|
// can't produce duplicate sources on re-attach.
|
||||||
|
const linkedResourceIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
sources
|
||||||
|
.filter((s) => s.kind === "resource" && s.resource_id)
|
||||||
|
.map((s) => s.resource_id as number),
|
||||||
|
),
|
||||||
|
[sources],
|
||||||
|
);
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["course-plan", planId, "sources"] });
|
qc.invalidateQueries({ queryKey: ["course-plan", planId, "sources"] });
|
||||||
@@ -552,6 +590,34 @@ function SourcesCard({
|
|||||||
toast.error(describeApiError(err, t("coursePlan.sources.deleteFailed"))),
|
toast.error(describeApiError(err, t("coursePlan.sources.deleteFailed"))),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach a batch of library resources to this plan; the picker reports
|
||||||
|
// the selected `Resource[]` and we just feed their ids to the
|
||||||
|
// `from-resources` endpoint, then invalidate so the new rows appear.
|
||||||
|
const attachLibraryMut = useMutation({
|
||||||
|
mutationFn: (resourceIds: number[]) =>
|
||||||
|
coursePlanService.attachResources(planId, resourceIds),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.count > 0) {
|
||||||
|
toast.success(
|
||||||
|
t("coursePlan.sources.libraryAttached", { count: res.count }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (res.skipped_existing.length > 0) {
|
||||||
|
toast.message(
|
||||||
|
t("coursePlan.sources.librarySkipped", {
|
||||||
|
count: res.skipped_existing.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
invalidate();
|
||||||
|
setLibraryOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(
|
||||||
|
describeApiError(err, t("coursePlan.sources.libraryAttachFailed")),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -562,7 +628,7 @@ function SourcesCard({
|
|||||||
<CardDescription>{t("coursePlan.sources.sectionDesc")}</CardDescription>
|
<CardDescription>{t("coursePlan.sources.sectionDesc")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="rounded-md border-dashed border-2 px-4 py-5 text-center bg-muted/20">
|
<div className="rounded-md border-dashed border-2 px-4 py-5 text-center bg-muted/20">
|
||||||
<FileText className="mx-auto h-5 w-5 text-muted-foreground" />
|
<FileText className="mx-auto h-5 w-5 text-muted-foreground" />
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -589,6 +655,24 @@ function SourcesCard({
|
|||||||
{t("coursePlan.sources.uploadFiles")}
|
{t("coursePlan.sources.uploadFiles")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Pick from /admin/resources — surfaces the central learning
|
||||||
|
library inside the course-plan workflow so admins don't
|
||||||
|
have to re-upload PDFs/links they already curated. */}
|
||||||
|
<div className="rounded-md border-dashed border-2 px-4 py-5 text-center bg-muted/20">
|
||||||
|
<Library className="mx-auto h-5 w-5 text-muted-foreground" />
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("coursePlan.sources.libraryHint")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() => setLibraryOpen(true)}
|
||||||
|
>
|
||||||
|
<Library className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.sources.pickFromLibrary")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">{t("coursePlan.sources.urlLabel")}</Label>
|
<Label className="text-xs">{t("coursePlan.sources.urlLabel")}</Label>
|
||||||
<div className="mt-1 flex gap-2">
|
<div className="mt-1 flex gap-2">
|
||||||
@@ -655,8 +739,20 @@ function SourcesCard({
|
|||||||
{t(`coursePlan.sources.kindLabel.${s.kind}`, s.kind)}
|
{t(`coursePlan.sources.kindLabel.${s.kind}`, s.kind)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="flex-1 min-w-0 truncate font-medium">
|
<span className="flex-1 min-w-0 truncate font-medium">
|
||||||
{s.name || s.url || s.file_name || `Source #${s.id}`}
|
{s.kind === "resource"
|
||||||
|
? s.resource_name || s.name || `Resource #${s.resource_id}`
|
||||||
|
: s.name || s.url || s.file_name || `Source #${s.id}`}
|
||||||
</span>
|
</span>
|
||||||
|
{s.kind === "resource" && s.resource_id && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] inline-flex items-center gap-1"
|
||||||
|
title={t("coursePlan.sources.linkedToLibrary")}
|
||||||
|
>
|
||||||
|
<Library className="h-3 w-3" />
|
||||||
|
{t("coursePlan.sources.fromLibrary")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<SourceStatusBadge status={s.status} />
|
<SourceStatusBadge status={s.status} />
|
||||||
{s.chunks_count > 0 && (
|
{s.chunks_count > 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -688,6 +784,15 @@ function SourcesCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<LibraryPickerDialog
|
||||||
|
open={libraryOpen}
|
||||||
|
onOpenChange={setLibraryOpen}
|
||||||
|
alreadyLinkedIds={linkedResourceIds}
|
||||||
|
isPending={attachLibraryMut.isPending}
|
||||||
|
onConfirm={(picked) =>
|
||||||
|
attachLibraryMut.mutate(picked.map((r) => r.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -711,9 +816,11 @@ function SourceStatusBadge({ status }: { status: CoursePlanSource["status"] }) {
|
|||||||
function AssignmentsCard({
|
function AssignmentsCard({
|
||||||
plan,
|
plan,
|
||||||
assignments,
|
assignments,
|
||||||
|
studentPreview,
|
||||||
}: {
|
}: {
|
||||||
plan: CoursePlan;
|
plan: CoursePlan;
|
||||||
assignments: CoursePlanAssignment[];
|
assignments: CoursePlanAssignment[];
|
||||||
|
studentPreview: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -741,10 +848,12 @@ function AssignmentsCard({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{plan.name}</CardDescription>
|
<CardDescription>{plan.name}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setOpen(true)}>
|
{!studentPreview && (
|
||||||
<UserPlus className="mr-1 h-4 w-4" />
|
<Button size="sm" onClick={() => setOpen(true)}>
|
||||||
{t("coursePlan.assignments.assign")}
|
<UserPlus className="mr-1 h-4 w-4" />
|
||||||
</Button>
|
{t("coursePlan.assignments.assign")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -762,12 +871,16 @@ function AssignmentsCard({
|
|||||||
<Badge variant="outline" className="capitalize">
|
<Badge variant="outline" className="capitalize">
|
||||||
{a.mode === "batch"
|
{a.mode === "batch"
|
||||||
? t("coursePlan.assignments.modeBatch")
|
? t("coursePlan.assignments.modeBatch")
|
||||||
|
: a.mode === "entities"
|
||||||
|
? t("coursePlan.assignments.modeEntities", "Entities")
|
||||||
: t("coursePlan.assignments.modeStudents")}
|
: t("coursePlan.assignments.modeStudents")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
{a.mode === "batch"
|
{a.mode === "batch"
|
||||||
? a.batch_name || `Batch #${a.batch_id}`
|
? a.batch_name || `Batch #${a.batch_id}`
|
||||||
|
: a.mode === "entities"
|
||||||
|
? (a.entity_names?.join(", ") || t("coursePlan.assignments.modeEntities", "Entities"))
|
||||||
: t("coursePlan.assignments.students", {
|
: t("coursePlan.assignments.students", {
|
||||||
count: a.student_count,
|
count: a.student_count,
|
||||||
})}
|
})}
|
||||||
@@ -780,17 +893,19 @@ function AssignmentsCard({
|
|||||||
{a.due_date ? ` · ${a.due_date}` : ""}
|
{a.due_date ? ` · ${a.due_date}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!studentPreview && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
if (!window.confirm(t("coursePlan.assignments.confirmRemove")))
|
onClick={() => {
|
||||||
return;
|
if (!window.confirm(t("coursePlan.assignments.confirmRemove")))
|
||||||
removeMut.mutate(a.id);
|
return;
|
||||||
}}
|
removeMut.mutate(a.id);
|
||||||
>
|
}}
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -813,9 +928,10 @@ function AssignDialog({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [mode, setMode] = useState<"batch" | "students">("batch");
|
const [mode, setMode] = useState<"batch" | "students" | "entities">("batch");
|
||||||
const [batchId, setBatchId] = useState<string>("");
|
const [batchId, setBatchId] = useState<string>("");
|
||||||
const [studentIds, setStudentIds] = useState<number[]>([]);
|
const [studentIds, setStudentIds] = useState<number[]>([]);
|
||||||
|
const [entityIds, setEntityIds] = useState<number[]>([]);
|
||||||
const [dueDate, setDueDate] = useState<string>("");
|
const [dueDate, setDueDate] = useState<string>("");
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
|
|
||||||
@@ -831,11 +947,18 @@ function AssignDialog({
|
|||||||
enabled: open && mode === "students",
|
enabled: open && mode === "students",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const entitiesQ = useQuery({
|
||||||
|
queryKey: ["entities-for-course-plan-assign"],
|
||||||
|
queryFn: () => entitiesService.list({ size: 200 }),
|
||||||
|
enabled: open && mode === "entities",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setMode("batch");
|
setMode("batch");
|
||||||
setBatchId("");
|
setBatchId("");
|
||||||
setStudentIds([]);
|
setStudentIds([]);
|
||||||
|
setEntityIds([]);
|
||||||
setDueDate("");
|
setDueDate("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
}
|
}
|
||||||
@@ -851,6 +974,14 @@ function AssignDialog({
|
|||||||
message: message.trim() || undefined,
|
message: message.trim() || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (mode === "entities") {
|
||||||
|
return coursePlanService.createAssignment(planId, {
|
||||||
|
mode: "entities",
|
||||||
|
entity_ids: entityIds,
|
||||||
|
due_date: dueDate || null,
|
||||||
|
message: message.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
return coursePlanService.createAssignment(planId, {
|
return coursePlanService.createAssignment(planId, {
|
||||||
mode: "students",
|
mode: "students",
|
||||||
student_user_ids: studentIds,
|
student_user_ids: studentIds,
|
||||||
@@ -869,6 +1000,7 @@ function AssignDialog({
|
|||||||
|
|
||||||
const canSave =
|
const canSave =
|
||||||
(mode === "batch" && batchId) ||
|
(mode === "batch" && batchId) ||
|
||||||
|
(mode === "entities" && entityIds.length > 0) ||
|
||||||
(mode === "students" && studentIds.length > 0);
|
(mode === "students" && studentIds.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -882,7 +1014,7 @@ function AssignDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={mode === "batch" ? "default" : "outline"}
|
variant={mode === "batch" ? "default" : "outline"}
|
||||||
@@ -892,6 +1024,15 @@ function AssignDialog({
|
|||||||
<Users className="mr-1 h-4 w-4" />
|
<Users className="mr-1 h-4 w-4" />
|
||||||
{t("coursePlan.assignments.modeBatch")}
|
{t("coursePlan.assignments.modeBatch")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={mode === "entities" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMode("entities")}
|
||||||
|
>
|
||||||
|
<Users className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.assignments.modeEntities", "Entities")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={mode === "students" ? "default" : "outline"}
|
variant={mode === "students" ? "default" : "outline"}
|
||||||
@@ -989,6 +1130,55 @@ function AssignDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "entities" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
{t("coursePlan.assignments.pickEntities", "Pick entities")}
|
||||||
|
</Label>
|
||||||
|
<div className="mt-1 max-h-56 overflow-auto rounded-md border">
|
||||||
|
{entitiesQ.isLoading ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
) : entitiesQ.data?.items.length ? (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{entitiesQ.data.items.map((e: Entity) => {
|
||||||
|
const checked = entityIds.includes(e.id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={e.id}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
setEntityIds((prev) =>
|
||||||
|
v
|
||||||
|
? Array.from(new Set([...prev, e.id]))
|
||||||
|
: prev.filter((x) => x !== e.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
id={`entity-${e.id}`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`entity-${e.id}`}
|
||||||
|
className="text-sm flex-1 truncate cursor-pointer"
|
||||||
|
>
|
||||||
|
{e.name}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
{t("coursePlan.assignments.noEntities", "No entities found")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">
|
||||||
@@ -1050,6 +1240,7 @@ function WeekAccordionItem({
|
|||||||
onGenerate,
|
onGenerate,
|
||||||
onBulkMedia,
|
onBulkMedia,
|
||||||
bulkBusy,
|
bulkBusy,
|
||||||
|
studentPreview,
|
||||||
}: {
|
}: {
|
||||||
week: CoursePlanWeek;
|
week: CoursePlanWeek;
|
||||||
materials: CoursePlanMaterial[];
|
materials: CoursePlanMaterial[];
|
||||||
@@ -1057,8 +1248,18 @@ function WeekAccordionItem({
|
|||||||
onGenerate: () => void;
|
onGenerate: () => void;
|
||||||
onBulkMedia: (kinds: Array<"audio" | "image" | "video">) => void;
|
onBulkMedia: (kinds: Array<"audio" | "image" | "video">) => void;
|
||||||
bulkBusy: boolean;
|
bulkBusy: boolean;
|
||||||
|
studentPreview: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||||
|
const materialSkills = useMemo(
|
||||||
|
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||||
|
[materials],
|
||||||
|
);
|
||||||
|
const filteredMaterials = useMemo(
|
||||||
|
() => materials.filter((m) => skillFilter === "all" || m.skill === skillFilter),
|
||||||
|
[materials, skillFilter],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<AccordionItem value={String(week.week_number)}>
|
<AccordionItem value={String(week.week_number)}>
|
||||||
<AccordionTrigger className="text-left">
|
<AccordionTrigger className="text-left">
|
||||||
@@ -1108,7 +1309,7 @@ function WeekAccordionItem({
|
|||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className="flex items-center gap-1.5 capitalize">
|
<span className="flex items-center gap-1.5 capitalize">
|
||||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{item.skill}
|
<SkillBadge skill={item.skill} />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 flex flex-wrap gap-1">
|
<td className="px-3 py-2 flex flex-wrap gap-1">
|
||||||
@@ -1134,38 +1335,64 @@ function WeekAccordionItem({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" onClick={onGenerate} disabled={generating}>
|
{!studentPreview && (
|
||||||
<Wand2 className="mr-1 h-4 w-4" />
|
<>
|
||||||
{generating
|
<Button size="sm" onClick={onGenerate} disabled={generating}>
|
||||||
? t("coursePlan.generating")
|
<Wand2 className="mr-1 h-4 w-4" />
|
||||||
: materials.length > 0
|
{generating
|
||||||
? t("coursePlan.regenerateMaterials")
|
? t("coursePlan.generating")
|
||||||
: t("coursePlan.generateMaterials")}
|
: materials.length > 0
|
||||||
</Button>
|
? t("coursePlan.regenerateMaterials")
|
||||||
{materials.length > 0 && (
|
: t("coursePlan.generateMaterials")}
|
||||||
<Button
|
</Button>
|
||||||
size="sm"
|
{materials.length > 0 && (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => onBulkMedia(["audio", "image"])}
|
size="sm"
|
||||||
disabled={bulkBusy}
|
variant="outline"
|
||||||
>
|
onClick={() => onBulkMedia(["audio", "image"])}
|
||||||
{bulkBusy ? (
|
disabled={bulkBusy}
|
||||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
>
|
||||||
) : (
|
{bulkBusy ? (
|
||||||
<Sparkles className="mr-1 h-4 w-4" />
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("coursePlan.media.bulk")}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{t("coursePlan.media.bulk")}
|
<span className="text-xs text-muted-foreground">
|
||||||
</Button>
|
{t("coursePlan.generateHint")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{materialSkills.length > 0 && (
|
||||||
|
<div className="ms-auto flex items-center gap-1 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={skillFilter === "all" ? "default" : "outline"}
|
||||||
|
onClick={() => setSkillFilter("all")}
|
||||||
|
>
|
||||||
|
{t("common.all", "All")}
|
||||||
|
</Button>
|
||||||
|
{materialSkills.map((skill) => (
|
||||||
|
<Button
|
||||||
|
key={skill}
|
||||||
|
size="sm"
|
||||||
|
variant={skillFilter === skill ? "default" : "outline"}
|
||||||
|
onClick={() => setSkillFilter(skill)}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t("coursePlan.generateHint")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{materials.length > 0 && (
|
{materials.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{materials.map((m) => (
|
{filteredMaterials.map((m) => (
|
||||||
<MaterialCard key={m.id} material={m} />
|
<MaterialCard key={m.id} material={m} studentPreview={studentPreview} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1174,11 +1401,53 @@ function WeekAccordionItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MaterialCard({ material }: { material: CoursePlanMaterial }) {
|
function MaterialCard({
|
||||||
|
material,
|
||||||
|
studentPreview,
|
||||||
|
}: {
|
||||||
|
material: CoursePlanMaterial;
|
||||||
|
studentPreview: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [title, setTitle] = useState(material.title);
|
||||||
|
const [summary, setSummary] = useState(material.summary || "");
|
||||||
|
const [bodyText, setBodyText] = useState(material.body_text || "");
|
||||||
|
const [shareDate, setShareDate] = useState(material.share_date ?? "");
|
||||||
|
const [isStatic, setIsStatic] = useState(Boolean(material.is_static));
|
||||||
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||||
const mediaCount = material.media?.length ?? 0;
|
const mediaCount = material.media?.length ?? 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(material.title);
|
||||||
|
setSummary(material.summary || "");
|
||||||
|
setBodyText(material.body_text || "");
|
||||||
|
setShareDate(material.share_date ?? "");
|
||||||
|
setIsStatic(Boolean(material.is_static));
|
||||||
|
}, [material]);
|
||||||
|
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
coursePlanService.updateMaterial(material.id, {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
body_text: bodyText,
|
||||||
|
share_date: shareDate || null,
|
||||||
|
is_static: isStatic,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["course-plan", material.plan_id] });
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["course-plan", material.plan_id, "deliverables"],
|
||||||
|
});
|
||||||
|
toast.success(t("common.saved", "Saved"));
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(describeApiError(err, t("common.error", "Error"))),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
@@ -1193,6 +1462,7 @@ function MaterialCard({ material }: { material: CoursePlanMaterial }) {
|
|||||||
material.material_type,
|
material.material_type,
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<SkillBadge skill={material.skill} />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1206,19 +1476,87 @@ function MaterialCard({ material }: { material: CoursePlanMaterial }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!studentPreview && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={editing ? "default" : "outline"}
|
||||||
|
onClick={() => setEditing((v) => !v)}
|
||||||
|
>
|
||||||
|
{editing ? t("common.cancel", "Cancel") : t("common.edit", "Edit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
|
<div className="mb-3 flex items-center gap-2 text-xs text-muted-foreground flex-wrap">
|
||||||
{material.body_text || JSON.stringify(material.body, null, 2)}
|
{material.share_date && (
|
||||||
</pre>
|
<span>
|
||||||
|
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{material.is_static && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{t("coursePlan.staticMaterial", "Static material (keep on regenerate)")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing && !studentPreview ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">{t("common.title", "Title")}</Label>
|
||||||
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">{t("coursePlan.shareDate", "Share date")}</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={shareDate || ""}
|
||||||
|
onChange={(e) => setShareDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`static-${material.id}`}
|
||||||
|
checked={isStatic}
|
||||||
|
onCheckedChange={(v) => setIsStatic(Boolean(v))}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`static-${material.id}`} className="text-sm">
|
||||||
|
{t("coursePlan.staticMaterial", "Static material (keep on regenerate)")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">{t("common.summary", "Summary")}</Label>
|
||||||
|
<Textarea rows={3} value={summary} onChange={(e) => setSummary(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1 block">{t("coursePlan.content", "Content")}</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={10}
|
||||||
|
value={bodyText}
|
||||||
|
onChange={(e) => setBodyText(e.target.value)}
|
||||||
|
placeholder={t("coursePlan.contentPlaceholder", "Write material content here...")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
|
||||||
|
{saveMut.isPending && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
|
||||||
|
{t("common.save", "Save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MaterialBookView material={material} />
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<MediaDrawer
|
<MediaDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onOpenChange={setDrawerOpen}
|
onOpenChange={setDrawerOpen}
|
||||||
material={material}
|
material={material}
|
||||||
|
studentPreview={studentPreview}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -1228,10 +1566,12 @@ function MediaDrawer({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
material,
|
material,
|
||||||
|
studentPreview,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
material: CoursePlanMaterial;
|
material: CoursePlanMaterial;
|
||||||
|
studentPreview: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -1304,47 +1644,49 @@ function MediaDrawer({
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
{!studentPreview && (
|
||||||
<Button
|
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => audioMut.mutate()}
|
size="sm"
|
||||||
disabled={audioMut.isPending}
|
onClick={() => audioMut.mutate()}
|
||||||
>
|
disabled={audioMut.isPending}
|
||||||
{audioMut.isPending ? (
|
>
|
||||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
{audioMut.isPending ? (
|
||||||
) : (
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
<Music className="mr-1 h-4 w-4" />
|
) : (
|
||||||
)}
|
<Music className="mr-1 h-4 w-4" />
|
||||||
{t("coursePlan.media.generateAudio")}
|
)}
|
||||||
</Button>
|
{t("coursePlan.media.generateAudio")}
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => imageMut.mutate()}
|
size="sm"
|
||||||
disabled={imageMut.isPending}
|
onClick={() => imageMut.mutate()}
|
||||||
>
|
disabled={imageMut.isPending}
|
||||||
{imageMut.isPending ? (
|
>
|
||||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
{imageMut.isPending ? (
|
||||||
) : (
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
<ImageIcon className="mr-1 h-4 w-4" />
|
) : (
|
||||||
)}
|
<ImageIcon className="mr-1 h-4 w-4" />
|
||||||
{t("coursePlan.media.generateImage")}
|
)}
|
||||||
</Button>
|
{t("coursePlan.media.generateImage")}
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => videoMut.mutate()}
|
size="sm"
|
||||||
disabled={videoMut.isPending}
|
onClick={() => videoMut.mutate()}
|
||||||
>
|
disabled={videoMut.isPending}
|
||||||
{videoMut.isPending ? (
|
>
|
||||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
{videoMut.isPending ? (
|
||||||
) : (
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
<Video className="mr-1 h-4 w-4" />
|
) : (
|
||||||
)}
|
<Video className="mr-1 h-4 w-4" />
|
||||||
{t("coursePlan.media.generateVideo")}
|
)}
|
||||||
</Button>
|
{t("coursePlan.media.generateVideo")}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
@@ -1358,8 +1700,9 @@ function MediaDrawer({
|
|||||||
media={m}
|
media={m}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
if (!window.confirm(t("common.confirm", "Are you sure?"))) return;
|
if (!window.confirm(t("common.confirm", "Are you sure?"))) return;
|
||||||
deleteMut.mutate(m.id);
|
if (!studentPreview) deleteMut.mutate(m.id);
|
||||||
}}
|
}}
|
||||||
|
canDelete={!studentPreview}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1371,12 +1714,21 @@ function MediaDrawer({
|
|||||||
function MediaTile({
|
function MediaTile({
|
||||||
media,
|
media,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
canDelete,
|
||||||
}: {
|
}: {
|
||||||
media: CoursePlanMedia;
|
media: CoursePlanMedia;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
canDelete: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const url = media.download_url || "";
|
// The streaming endpoint accepts the JWT either via the Authorization
|
||||||
|
// header (REST clients) or via ``?token=…`` (so plain media tags work,
|
||||||
|
// since browsers can't attach custom headers to <img>/<audio>/<video>
|
||||||
|
// element fetches). We always go through ``withAuthQuery`` here so the
|
||||||
|
// preview renders for the currently logged-in user without requiring an
|
||||||
|
// Odoo session cookie.
|
||||||
|
const previewUrl = withAuthQuery(media.preview_url || media.download_url || "");
|
||||||
|
const downloadUrl = withAuthQuery(media.download_url || "");
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border p-3 space-y-2">
|
<div className="rounded-md border p-3 space-y-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -1385,42 +1737,44 @@ function MediaTile({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">{media.provider}</span>
|
<span className="text-xs text-muted-foreground">{media.provider}</span>
|
||||||
<span className="ml-auto flex gap-1">
|
<span className="ml-auto flex gap-1">
|
||||||
{url && (
|
{previewUrl && (
|
||||||
<Button asChild variant="ghost" size="icon" className="h-7 w-7">
|
<Button asChild variant="ghost" size="icon" className="h-7 w-7">
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
<a href={previewUrl} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{url && (
|
{downloadUrl && (
|
||||||
<Button asChild variant="ghost" size="icon" className="h-7 w-7">
|
<Button asChild variant="ghost" size="icon" className="h-7 w-7">
|
||||||
<a href={url} download>
|
<a href={downloadUrl} download>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{canDelete && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-7 w-7"
|
size="icon"
|
||||||
onClick={onDelete}
|
className="h-7 w-7"
|
||||||
>
|
onClick={onDelete}
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{media.kind === "audio" && url && (
|
{media.kind === "audio" && previewUrl && (
|
||||||
<audio src={url} controls className="w-full" />
|
<audio src={previewUrl} controls className="w-full" />
|
||||||
)}
|
)}
|
||||||
{media.kind === "image" && url && (
|
{media.kind === "image" && previewUrl && (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={previewUrl}
|
||||||
alt={media.title}
|
alt={media.title}
|
||||||
className="w-full rounded-md max-h-64 object-contain bg-muted/30"
|
className="w-full rounded-md max-h-64 object-contain bg-muted/30"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{media.kind === "video" && url && (
|
{media.kind === "video" && previewUrl && (
|
||||||
<video src={url} controls className="w-full rounded-md max-h-64" />
|
<video src={previewUrl} controls className="w-full rounded-md max-h-64" />
|
||||||
)}
|
)}
|
||||||
{media.status === "failed" && media.error && (
|
{media.status === "failed" && media.error && (
|
||||||
<p className="text-xs text-destructive">{media.error}</p>
|
<p className="text-xs text-destructive">{media.error}</p>
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import {
|
import {
|
||||||
Upload, Search, FileText, Video, Link2, Download, Trash2,
|
Upload, Search, FileText, Video, Link2, Download, Trash2,
|
||||||
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
|
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
|
||||||
Tag, Plus, Pencil, X, CalendarDays,
|
Tag, Plus, Pencil, X, CalendarDays, Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { resourcesService } from "@/services/resources.service";
|
import { resourcesService } from "@/services/resources.service";
|
||||||
import { coursewareService } from "@/services/courseware.service";
|
import { coursewareService } from "@/services/courseware.service";
|
||||||
import { taxonomyService } from "@/services/taxonomy.service";
|
import { taxonomyService } from "@/services/taxonomy.service";
|
||||||
import { describeApiError } from "@/lib/api-client";
|
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
|
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
|
||||||
import type { Resource, ResourceTag } from "@/types";
|
import type { Resource, ResourceTag } from "@/types";
|
||||||
@@ -165,13 +165,13 @@ function EditResourceDialog({
|
|||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
<SelectItem value="video">Video</SelectItem>
|
|
||||||
<SelectItem value="link">Link</SelectItem>
|
|
||||||
<SelectItem value="document">Document</SelectItem>
|
|
||||||
<SelectItem value="interactive">Interactive</SelectItem>
|
|
||||||
<SelectItem value="audio">Audio</SelectItem>
|
|
||||||
<SelectItem value="image">Image</SelectItem>
|
<SelectItem value="image">Image</SelectItem>
|
||||||
|
<SelectItem value="audio">Audio</SelectItem>
|
||||||
|
<SelectItem value="video">Video</SelectItem>
|
||||||
|
<SelectItem value="document">Document</SelectItem>
|
||||||
<SelectItem value="article">Article</SelectItem>
|
<SelectItem value="article">Article</SelectItem>
|
||||||
|
<SelectItem value="link">Link</SelectItem>
|
||||||
|
<SelectItem value="interactive">Interactive</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -351,12 +351,19 @@ export default function ResourceManager() {
|
|||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
<SelectItem value="pdf">PDF</SelectItem>
|
||||||
|
<SelectItem value="image">Image</SelectItem>
|
||||||
|
<SelectItem value="audio">Audio</SelectItem>
|
||||||
<SelectItem value="video">Video</SelectItem>
|
<SelectItem value="video">Video</SelectItem>
|
||||||
<SelectItem value="link">Link</SelectItem>
|
|
||||||
<SelectItem value="document">Document</SelectItem>
|
<SelectItem value="document">Document</SelectItem>
|
||||||
|
<SelectItem value="article">Article</SelectItem>
|
||||||
|
<SelectItem value="link">Link</SelectItem>
|
||||||
<SelectItem value="interactive">Interactive</SelectItem>
|
<SelectItem value="interactive">Interactive</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Tip: leave this on "PDF" — the server auto-detects the
|
||||||
|
actual type from the uploaded file's MIME and corrects it.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<TaxonomyCascade
|
<TaxonomyCascade
|
||||||
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
|
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
|
||||||
@@ -619,12 +626,46 @@ function ContentTable({
|
|||||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell>
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{row.source === "resource" && row.resourceId && (
|
{row.source === "resource" && row.resourceId && row.raw && (
|
||||||
<>
|
<>
|
||||||
|
{/* Smart preview — opens the file inline (PDF in
|
||||||
|
iframe, image / audio / video in their native
|
||||||
|
tag) without forcing a download. ``link`` rows
|
||||||
|
jump to the external URL directly. */}
|
||||||
|
{(row.raw.preview_url || row.raw.url) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Preview"
|
||||||
|
onClick={() => {
|
||||||
|
const target =
|
||||||
|
row.raw?.preview_url
|
||||||
|
? withAuthQuery(row.raw.preview_url)
|
||||||
|
: row.raw?.url || "";
|
||||||
|
if (target) window.open(target, "_blank", "noopener,noreferrer");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="ghost" size="icon" title="Edit" onClick={() => row.raw && onEditResource(row.raw)}><Pencil className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" title="Edit" onClick={() => row.raw && onEditResource(row.raw)}><Pencil className="h-4 w-4" /></Button>
|
||||||
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
{row.raw.has_file && (
|
||||||
try { const blob = await resourcesService.download(row.resourceId!); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url); } catch { toast({ title: "Download failed", variant: "destructive" }); }
|
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||||
}}><Download className="h-4 w-4" /></Button>
|
try {
|
||||||
|
const { blob, filename } = await resourcesService.download(row.resourceId!);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
// Prefer the server-provided filename (which always
|
||||||
|
// carries the extension) over ``row.name``, which
|
||||||
|
// is often just "test" — the previous behaviour
|
||||||
|
// saved files with no extension on disk.
|
||||||
|
a.download = filename || row.raw?.original_filename || row.raw?.file_name || row.name || "resource";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||||
|
}}><Download className="h-4 w-4" /></Button>
|
||||||
|
)}
|
||||||
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDeleteResource(row.resourceId!)} disabled={deletePending}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDeleteResource(row.resourceId!)} disabled={deletePending}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Library,
|
||||||
Link2,
|
Link2,
|
||||||
Mic,
|
Mic,
|
||||||
Music,
|
Music,
|
||||||
@@ -28,9 +29,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog";
|
||||||
import { coursePlanService } from "@/services/coursePlan.service";
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
import { describeApiError } from "@/lib/api-client";
|
import { describeApiError } from "@/lib/api-client";
|
||||||
import type { CoursePlanGenerateBrief } from "@/types";
|
import type { CoursePlanGenerateBrief, Resource } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI course-plan generation wizard.
|
* AI course-plan generation wizard.
|
||||||
@@ -48,7 +50,7 @@ import type { CoursePlanGenerateBrief } from "@/types";
|
|||||||
* materials immediately.
|
* materials immediately.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type DraftSourceKind = "file" | "url" | "text";
|
type DraftSourceKind = "file" | "url" | "text" | "resource";
|
||||||
|
|
||||||
interface DraftSource {
|
interface DraftSource {
|
||||||
/** Stable client-side id; not persisted. */
|
/** Stable client-side id; not persisted. */
|
||||||
@@ -61,6 +63,10 @@ interface DraftSource {
|
|||||||
url?: string;
|
url?: string;
|
||||||
/** Only set when kind === "text". */
|
/** Only set when kind === "text". */
|
||||||
text?: string;
|
text?: string;
|
||||||
|
/** Only set when kind === "resource" — pointer into the central library. */
|
||||||
|
resourceId?: number;
|
||||||
|
/** Display-only metadata so we can render the row without a refetch. */
|
||||||
|
resourceType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaToggleState {
|
interface MediaToggleState {
|
||||||
@@ -383,6 +389,26 @@ export default function CoursePlanWizard() {
|
|||||||
});
|
});
|
||||||
const planId = resp?.data?.id;
|
const planId = resp?.data?.id;
|
||||||
if (planId && state.sources.length) {
|
if (planId && state.sources.length) {
|
||||||
|
// Library picks go through the dedicated bulk endpoint — one
|
||||||
|
// round-trip for the whole set, with server-side dedupe — so we
|
||||||
|
// collect their ids first and post them together. The remaining
|
||||||
|
// file/url/text drafts are posted individually as before.
|
||||||
|
const resourceIds = state.sources
|
||||||
|
.filter((s) => s.kind === "resource" && s.resourceId)
|
||||||
|
.map((s) => s.resourceId as number);
|
||||||
|
if (resourceIds.length) {
|
||||||
|
try {
|
||||||
|
await coursePlanService.attachResources(planId, resourceIds);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
describeApiError(
|
||||||
|
err,
|
||||||
|
t("coursePlan.sources.libraryAttachFailed"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Best-effort upload — we keep going even if a single one fails so
|
// Best-effort upload — we keep going even if a single one fails so
|
||||||
// the user lands on the detail page with whatever did make it in.
|
// the user lands on the detail page with whatever did make it in.
|
||||||
for (const src of state.sources) {
|
for (const src of state.sources) {
|
||||||
@@ -404,6 +430,8 @@ export default function CoursePlanWizard() {
|
|||||||
name: src.name || t("coursePlan.sourceKind.text"),
|
name: src.name || t("coursePlan.sourceKind.text"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// src.kind === "resource" was already handled in the bulk
|
||||||
|
// attach above.
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
describeApiError(err, t("coursePlan.sources.uploadFailed", {
|
describeApiError(err, t("coursePlan.sources.uploadFailed", {
|
||||||
@@ -433,6 +461,15 @@ function SourcesStep({
|
|||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [draftUrl, setDraftUrl] = useState("");
|
const [draftUrl, setDraftUrl] = useState("");
|
||||||
const [draftText, setDraftText] = useState("");
|
const [draftText, setDraftText] = useState("");
|
||||||
|
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||||
|
|
||||||
|
// Resource ids already queued in this wizard session; passed to the
|
||||||
|
// picker so the same resource can't be added twice.
|
||||||
|
const queuedResourceIds = new Set(
|
||||||
|
sources
|
||||||
|
.filter((s) => s.kind === "resource" && s.resourceId)
|
||||||
|
.map((s) => s.resourceId as number),
|
||||||
|
);
|
||||||
|
|
||||||
const addFiles = (files: FileList | null) => {
|
const addFiles = (files: FileList | null) => {
|
||||||
if (!files || !files.length) return;
|
if (!files || !files.length) return;
|
||||||
@@ -470,38 +507,73 @@ function SourcesStep({
|
|||||||
setDraftText("");
|
setDraftText("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addLibraryResources = (picked: Resource[]) => {
|
||||||
|
if (!picked.length) return;
|
||||||
|
const additions: DraftSource[] = picked.map((r) => ({
|
||||||
|
uid: genUid(),
|
||||||
|
kind: "resource",
|
||||||
|
name: r.name,
|
||||||
|
resourceId: r.id,
|
||||||
|
resourceType: r.resource_type || r.type || "resource",
|
||||||
|
}));
|
||||||
|
onChange([...sources, ...additions]);
|
||||||
|
setLibraryOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
|
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
|
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||||
<p className="mt-2 text-sm font-medium">
|
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||||
{t("coursePlan.sources.dropTitle")}
|
<p className="mt-2 text-sm font-medium">
|
||||||
</p>
|
{t("coursePlan.sources.dropTitle")}
|
||||||
<p className="text-xs text-muted-foreground">
|
</p>
|
||||||
{t("coursePlan.sources.dropHint")}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{t("coursePlan.sources.dropHint")}
|
||||||
<div className="mt-3">
|
</p>
|
||||||
<input
|
<div className="mt-3">
|
||||||
ref={fileRef}
|
<input
|
||||||
type="file"
|
ref={fileRef}
|
||||||
multiple
|
type="file"
|
||||||
className="hidden"
|
multiple
|
||||||
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
|
className="hidden"
|
||||||
onChange={(e) => {
|
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
|
||||||
addFiles(e.currentTarget.files);
|
onChange={(e) => {
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
addFiles(e.currentTarget.files);
|
||||||
}}
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
/>
|
}}
|
||||||
<Button
|
/>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => fileRef.current?.click()}
|
size="sm"
|
||||||
>
|
onClick={() => fileRef.current?.click()}
|
||||||
<Upload className="mr-1 h-4 w-4" />
|
>
|
||||||
{t("coursePlan.sources.uploadFiles")}
|
<Upload className="mr-1 h-4 w-4" />
|
||||||
</Button>
|
{t("coursePlan.sources.uploadFiles")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||||
|
<Library className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||||
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
{t("coursePlan.sources.libraryPickTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("coursePlan.sources.libraryPickHint")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLibraryOpen(true)}
|
||||||
|
>
|
||||||
|
<Library className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.sources.libraryPickButton")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -566,8 +638,19 @@ function SourcesStep({
|
|||||||
className="flex items-center gap-2 px-3 py-2 text-sm"
|
className="flex items-center gap-2 px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<Badge variant="outline" className="capitalize text-[10px]">
|
<Badge variant="outline" className="capitalize text-[10px]">
|
||||||
{s.kind}
|
{s.kind === "resource"
|
||||||
|
? s.resourceType || "library"
|
||||||
|
: s.kind}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{s.kind === "resource" && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 text-[10px] flex items-center"
|
||||||
|
>
|
||||||
|
<Library className="h-3 w-3" />
|
||||||
|
{t("coursePlan.sources.fromLibrary")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<span className="flex-1 truncate">{s.name}</span>
|
<span className="flex-1 truncate">{s.name}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -583,6 +666,13 @@ function SourcesStep({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LibraryPickerDialog
|
||||||
|
open={libraryOpen}
|
||||||
|
onOpenChange={setLibraryOpen}
|
||||||
|
alreadyLinkedIds={queuedResourceIds}
|
||||||
|
onConfirm={addLibraryResources}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,10 +40,23 @@ function buildAnswerMap(sections: ExamSessionSection[]) {
|
|||||||
const map = new Map<number, ExamAnswer>();
|
const map = new Map<number, ExamAnswer>();
|
||||||
for (const sec of sections) {
|
for (const sec of sections) {
|
||||||
for (const q of sec.questions) {
|
for (const q of sec.questions) {
|
||||||
|
// The backend `/api/exam/<id>/session` endpoint returns a
|
||||||
|
// `saved_answer` per question whenever a previous attempt is
|
||||||
|
// resumed (browser refresh, network drop, accidental tab-close).
|
||||||
|
// Seed the local map from that value so the student picks up
|
||||||
|
// exactly where they left off — without this, autosaved answers
|
||||||
|
// were silently dropped on every reload.
|
||||||
|
const saved = (q as unknown as { saved_answer?: unknown }).saved_answer;
|
||||||
|
const flagged = Boolean(
|
||||||
|
(q as unknown as { flagged?: boolean }).flagged,
|
||||||
|
);
|
||||||
map.set(q.id, {
|
map.set(q.id, {
|
||||||
question_id: q.id,
|
question_id: q.id,
|
||||||
answer: null,
|
answer:
|
||||||
flagged: false,
|
saved === undefined || saved === null
|
||||||
|
? null
|
||||||
|
: (saved as ExamAnswer["answer"]),
|
||||||
|
flagged,
|
||||||
time_spent_ms: 0,
|
time_spent_ms: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -37,7 +37,8 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { coursePlanService } from "@/services/coursePlan.service";
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
import { describeApiError } from "@/lib/api-client";
|
import { describeApiError, withAuthQuery } from "@/lib/api-client";
|
||||||
|
import MaterialBookView, { SkillBadge } from "@/components/coursePlan/MaterialBookView";
|
||||||
import type {
|
import type {
|
||||||
CoursePlan,
|
CoursePlan,
|
||||||
CoursePlanMaterial,
|
CoursePlanMaterial,
|
||||||
@@ -217,6 +218,15 @@ function StudentWeek({
|
|||||||
materials: CoursePlanMaterial[];
|
materials: CoursePlanMaterial[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [skillFilter, setSkillFilter] = useState<string>("all");
|
||||||
|
const skills = useMemo(
|
||||||
|
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
|
||||||
|
[materials],
|
||||||
|
);
|
||||||
|
const filteredMaterials = useMemo(
|
||||||
|
() => materials.filter((m) => skillFilter === "all" || m.skill === skillFilter),
|
||||||
|
[materials, skillFilter],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<AccordionItem value={String(week.week_number)}>
|
<AccordionItem value={String(week.week_number)}>
|
||||||
<AccordionTrigger className="text-left">
|
<AccordionTrigger className="text-left">
|
||||||
@@ -237,12 +247,34 @@ function StudentWeek({
|
|||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-3">
|
<AccordionContent className="space-y-3">
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={skillFilter === "all" ? "default" : "outline"}
|
||||||
|
onClick={() => setSkillFilter("all")}
|
||||||
|
>
|
||||||
|
{t("common.all", "All")}
|
||||||
|
</Button>
|
||||||
|
{skills.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s}
|
||||||
|
size="sm"
|
||||||
|
variant={skillFilter === s ? "default" : "outline"}
|
||||||
|
onClick={() => setSkillFilter(s)}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{materials.length === 0 && (
|
{materials.length === 0 && (
|
||||||
<p className="text-sm italic text-muted-foreground">
|
<p className="text-sm italic text-muted-foreground">
|
||||||
{t("coursePlan.media.noMedia")}
|
{t("coursePlan.media.noMedia")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{materials.map((m) => (
|
{filteredMaterials.map((m) => (
|
||||||
<StudentMaterial key={m.id} material={m} />
|
<StudentMaterial key={m.id} material={m} />
|
||||||
))}
|
))}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
@@ -267,23 +299,27 @@ function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
|
|||||||
material.material_type,
|
material.material_type,
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<SkillBadge skill={material.skill} />
|
||||||
</div>
|
</div>
|
||||||
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
||||||
|
{material.share_date && (
|
||||||
|
<CardDescription>
|
||||||
|
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{(material.media ?? []).map((m) => (
|
{(material.media ?? []).map((m) => (
|
||||||
<StudentMediaTile key={m.id} media={m} />
|
<StudentMediaTile key={m.id} media={m} />
|
||||||
))}
|
))}
|
||||||
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
|
<MaterialBookView material={material} />
|
||||||
{material.body_text || JSON.stringify(material.body, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
|
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||||
const url = media.download_url || "";
|
const url = withAuthQuery(media.preview_url || media.download_url || "");
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||||
|
|||||||
@@ -440,9 +440,9 @@ function ResourceTable({
|
|||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
<Button variant="ghost" size="icon" title="Download" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const blob = await resourcesService.download(row.resourceId!);
|
const { blob, filename } = await resourcesService.download(row.resourceId!);
|
||||||
const url = URL.createObjectURL(blob); const a = document.createElement("a");
|
const url = URL.createObjectURL(blob); const a = document.createElement("a");
|
||||||
a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url);
|
a.href = url; a.download = filename || row.name || "resource"; a.click(); URL.revokeObjectURL(url);
|
||||||
} catch { toast({ title: "Download failed", variant: "destructive" }); }
|
} catch { toast({ title: "Download failed", variant: "destructive" }); }
|
||||||
}}>
|
}}>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
|
|||||||
82
src/services/aiSettings.service.ts
Normal file
82
src/services/aiSettings.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI provider & API-key settings client.
|
||||||
|
*
|
||||||
|
* Backed by `backend/custom_addons/encoach_ai/controllers/ai_settings_controller.py`
|
||||||
|
* (`/api/ai/settings/providers`). API keys are write-only — the GET response
|
||||||
|
* only ever exposes `<key>_set: boolean` markers, never the key itself.
|
||||||
|
*
|
||||||
|
* Provider switches take effect on the very next request (no caching),
|
||||||
|
* so the LangGraph runtime instantly picks up the new selection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CapabilityKey = "text" | "image" | "audio" | "video";
|
||||||
|
export type ProviderKind = "paid" | "free" | "auto";
|
||||||
|
|
||||||
|
export interface ProviderOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
kind: ProviderKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityState {
|
||||||
|
active: string;
|
||||||
|
options: ProviderOption[];
|
||||||
|
paid_with_credentials: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AISettingsState {
|
||||||
|
providers: Record<CapabilityKey, CapabilityState>;
|
||||||
|
/** Boolean markers — `true` means a value is stored, never the value itself. */
|
||||||
|
keys_set: Record<string, boolean>;
|
||||||
|
/** Plain (non-secret) parameters, e.g. region, model name. */
|
||||||
|
plain: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderTestResult {
|
||||||
|
capability: CapabilityKey;
|
||||||
|
active: string;
|
||||||
|
chain: { provider: string; ok: boolean; note: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AISettingsPatchPayload {
|
||||||
|
/** Active provider per capability. */
|
||||||
|
providers?: Partial<Record<CapabilityKey, string>>;
|
||||||
|
/**
|
||||||
|
* API keys keyed by short name (`openai_api_key`, `aws_access_key`,
|
||||||
|
* `aws_secret_key`, `aws_region`, `elevenlabs_api_key`,
|
||||||
|
* `gptzero_api_key`, `paymob_api_key`, `paymob_integration_id`,
|
||||||
|
* `paymob_iframe_id`, `paymob_hmac_secret`).
|
||||||
|
*
|
||||||
|
* - Sending an empty string clears the key.
|
||||||
|
* - Omitting the field leaves it unchanged.
|
||||||
|
*/
|
||||||
|
keys?: Record<string, string>;
|
||||||
|
/** Plain (non-secret) values like `aws_region`, `openai_model`. */
|
||||||
|
plain?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiSettingsService = {
|
||||||
|
async get(): Promise<AISettingsState> {
|
||||||
|
const resp = await api.get<{ data: AISettingsState }>(
|
||||||
|
"/ai/settings/providers",
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(payload: AISettingsPatchPayload): Promise<AISettingsState> {
|
||||||
|
const resp = await api.patch<{ data: AISettingsState }>(
|
||||||
|
"/ai/settings/providers",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async test(capability: CapabilityKey): Promise<ProviderTestResult> {
|
||||||
|
return api.post<ProviderTestResult>(
|
||||||
|
"/ai/settings/providers/test",
|
||||||
|
{ capability },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -54,6 +54,20 @@ export const coursePlanService = {
|
|||||||
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateMaterial(
|
||||||
|
materialId: number,
|
||||||
|
payload: {
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
body_text?: string;
|
||||||
|
share_date?: string | null;
|
||||||
|
is_static?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<{ data: CoursePlanMaterial }> {
|
||||||
|
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Phase A — Sources
|
// Phase A — Sources
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -101,6 +115,28 @@ export const coursePlanService = {
|
|||||||
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
|
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach existing items from /admin/resources to a course plan as RAG
|
||||||
|
* sources. Returns the rows that were newly attached plus the ids
|
||||||
|
* that were skipped (already linked) or missing (deleted from the
|
||||||
|
* library) so the caller can show a clear toast — e.g. "Attached 2,
|
||||||
|
* skipped 1 already-linked".
|
||||||
|
*/
|
||||||
|
async attachResources(
|
||||||
|
planId: number,
|
||||||
|
resourceIds: number[],
|
||||||
|
): Promise<{
|
||||||
|
attached: CoursePlanSource[];
|
||||||
|
skipped_existing: number[];
|
||||||
|
missing: number[];
|
||||||
|
count: number;
|
||||||
|
}> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/${planId}/sources/from-resources`,
|
||||||
|
{ resource_ids: resourceIds },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Phase B — Deliverables / progress
|
// Phase B — Deliverables / progress
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -191,6 +227,12 @@ export const coursePlanService = {
|
|||||||
student_user_ids: number[];
|
student_user_ids: number[];
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "entities";
|
||||||
|
entity_ids: number[];
|
||||||
|
due_date?: string | null;
|
||||||
|
message?: string;
|
||||||
},
|
},
|
||||||
): Promise<{ data: CoursePlanAssignment }> {
|
): Promise<{ data: CoursePlanAssignment }> {
|
||||||
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
|
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { api } from "@/lib/api-client";
|
|||||||
import { asPaginated, asRecordData } from "@/lib/odoo-api";
|
import { asPaginated, asRecordData } from "@/lib/odoo-api";
|
||||||
import type { Entity, EntityRole, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
import type { Entity, EntityRole, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
||||||
|
|
||||||
|
export interface EntityUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
login: string;
|
||||||
|
email: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const entitiesService = {
|
export const entitiesService = {
|
||||||
async list(params?: PaginationParams): Promise<PaginatedResponse<Entity>> {
|
async list(params?: PaginationParams): Promise<PaginatedResponse<Entity>> {
|
||||||
const raw = await api.get<unknown>("/entities", params as Record<string, string | number | boolean | undefined>);
|
const raw = await api.get<unknown>("/entities", params as Record<string, string | number | boolean | undefined>);
|
||||||
@@ -42,4 +50,20 @@ export const entitiesService = {
|
|||||||
async getPermissions(entityId: number): Promise<string[]> {
|
async getPermissions(entityId: number): Promise<string[]> {
|
||||||
return api.get<string[]>(`/permissions`, { entity_id: entityId });
|
return api.get<string[]>(`/permissions`, { entity_id: entityId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listEntityUsers(entityId: number): Promise<EntityUser[]> {
|
||||||
|
const raw = await api.get<unknown>(`/entities/${entityId}/users`);
|
||||||
|
const out = asPaginated<EntityUser>(raw);
|
||||||
|
return out.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateEntityUsers(entityId: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
||||||
|
return api.patch<ApiSuccessResponse>(`/entities/${entityId}/users`, { user_ids: userIds });
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPlatformUsers(params?: PaginationParams): Promise<EntityUser[]> {
|
||||||
|
const raw = await api.get<unknown>("/users/list", params as Record<string, string | number | boolean | undefined>);
|
||||||
|
const out = asPaginated<EntityUser>(raw);
|
||||||
|
return out.items;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,12 +42,27 @@ export const resourcesService = {
|
|||||||
return api.post<ApiSuccessResponse>(`/resources/${id}/rate`, { rating });
|
return api.post<ApiSuccessResponse>(`/resources/${id}/rate`, { rating });
|
||||||
},
|
},
|
||||||
|
|
||||||
async download(id: number): Promise<Blob> {
|
/**
|
||||||
|
* Downloads the binary and returns the blob alongside the filename
|
||||||
|
* the server suggested via ``Content-Disposition``. Callers should
|
||||||
|
* prefer that filename over the human ``name`` on the record so the
|
||||||
|
* extension is preserved (".pdf" / ".mp3" / ".png" etc.) — the
|
||||||
|
* legacy code dropped the extension because the human name was just
|
||||||
|
* "test".
|
||||||
|
*/
|
||||||
|
async download(id: number): Promise<{ blob: Blob; filename: string }> {
|
||||||
const res = await fetch(`${API_BASE_URL}/resources/${id}/download`, {
|
const res = await fetch(`${API_BASE_URL}/resources/${id}/download`, {
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
||||||
return res.blob();
|
const cd = res.headers.get("content-disposition") || "";
|
||||||
|
let filename = "";
|
||||||
|
// Match either ``filename*=UTF-8''…`` (RFC 5987) or ``filename="…"``
|
||||||
|
const match =
|
||||||
|
/filename\*\s*=\s*[^']*''([^;]+)/i.exec(cd) ||
|
||||||
|
/filename\s*=\s*"?([^";]+)"?/i.exec(cd);
|
||||||
|
if (match) filename = decodeURIComponent(match[1].trim());
|
||||||
|
return { blob: await res.blob(), filename };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tag management
|
// Tag management
|
||||||
|
|||||||
@@ -61,8 +61,24 @@ export interface Resource {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
resource_type: "pdf" | "video" | "link" | "document" | "interactive";
|
resource_type:
|
||||||
|
| "pdf"
|
||||||
|
| "video"
|
||||||
|
| "link"
|
||||||
|
| "document"
|
||||||
|
| "interactive"
|
||||||
|
| "audio"
|
||||||
|
| "image"
|
||||||
|
| "article";
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/** Authenticated REST URL for forced download (Content-Disposition: attachment). */
|
||||||
|
download_url?: string;
|
||||||
|
/** Authenticated REST URL for inline preview (Content-Disposition: inline). */
|
||||||
|
preview_url?: string;
|
||||||
|
/** Cached MIME type from upload — drives the right preview widget. */
|
||||||
|
mimetype?: string;
|
||||||
|
/** Filename as uploaded, including extension. */
|
||||||
|
original_filename?: string;
|
||||||
file_name?: string;
|
file_name?: string;
|
||||||
subject_id?: number | null;
|
subject_id?: number | null;
|
||||||
subject_name?: string;
|
subject_name?: string;
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export interface CoursePlanMaterial {
|
|||||||
skill: string;
|
skill: string;
|
||||||
material_type: CoursePlanMaterialType | string;
|
material_type: CoursePlanMaterialType | string;
|
||||||
title: string;
|
title: string;
|
||||||
|
is_static?: boolean;
|
||||||
|
share_date?: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
/** Loose shape: depends on material_type. */
|
/** Loose shape: depends on material_type. */
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
@@ -88,7 +90,7 @@ export interface CoursePlanMaterial {
|
|||||||
// Phase A — Reference sources
|
// Phase A — Reference sources
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type CoursePlanSourceKind = "file" | "url" | "text";
|
export type CoursePlanSourceKind = "file" | "url" | "text" | "resource";
|
||||||
export type CoursePlanSourceStatus = "pending" | "indexing" | "indexed" | "failed";
|
export type CoursePlanSourceStatus = "pending" | "indexing" | "indexed" | "failed";
|
||||||
|
|
||||||
export interface CoursePlanSource {
|
export interface CoursePlanSource {
|
||||||
@@ -100,6 +102,10 @@ export interface CoursePlanSource {
|
|||||||
mime_type: string;
|
mime_type: string;
|
||||||
url: string;
|
url: string;
|
||||||
has_inline_text: boolean;
|
has_inline_text: boolean;
|
||||||
|
/** Set when the source is linked to an item from /admin/resources. */
|
||||||
|
resource_id?: number | null;
|
||||||
|
resource_name?: string;
|
||||||
|
resource_type?: string;
|
||||||
status: CoursePlanSourceStatus;
|
status: CoursePlanSourceStatus;
|
||||||
error: string;
|
error: string;
|
||||||
chunks_count: number;
|
chunks_count: number;
|
||||||
@@ -168,6 +174,14 @@ export type CoursePlanMediaProvider =
|
|||||||
| "openai_image"
|
| "openai_image"
|
||||||
| "ffmpeg"
|
| "ffmpeg"
|
||||||
| "elai"
|
| "elai"
|
||||||
|
// Free fallbacks (Phase 24.1)
|
||||||
|
| "pillow"
|
||||||
|
| "unsplash"
|
||||||
|
| "gtts"
|
||||||
|
| "silent"
|
||||||
|
| "static"
|
||||||
|
| "mock"
|
||||||
|
| "auto"
|
||||||
| "manual";
|
| "manual";
|
||||||
|
|
||||||
export interface CoursePlanMedia {
|
export interface CoursePlanMedia {
|
||||||
@@ -187,7 +201,15 @@ export interface CoursePlanMedia {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
attachment_id: number | null;
|
attachment_id: number | null;
|
||||||
|
/** REST URL with ``Content-Disposition: attachment`` — used by the
|
||||||
|
* download buttons. The frontend appends ``?token=<jwt>`` so it works
|
||||||
|
* from plain ``<a download>`` tags. */
|
||||||
download_url: string;
|
download_url: string;
|
||||||
|
/** REST URL with ``Content-Disposition: inline`` — used as the ``src``
|
||||||
|
* for ``<img>`` / ``<audio>`` / ``<video>`` tags. The frontend appends
|
||||||
|
* ``?token=<jwt>`` because browsers can't attach custom headers to
|
||||||
|
* these element fetches. */
|
||||||
|
preview_url?: string;
|
||||||
status: CoursePlanMediaStatus;
|
status: CoursePlanMediaStatus;
|
||||||
error: string;
|
error: string;
|
||||||
cost_cents: number;
|
cost_cents: number;
|
||||||
@@ -198,7 +220,7 @@ export interface CoursePlanMedia {
|
|||||||
// Phase D — Assignments
|
// Phase D — Assignments
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type CoursePlanAssignmentMode = "batch" | "students";
|
export type CoursePlanAssignmentMode = "batch" | "students" | "entities";
|
||||||
export type CoursePlanAssignmentState = "active" | "archived";
|
export type CoursePlanAssignmentState = "active" | "archived";
|
||||||
|
|
||||||
export interface CoursePlanAssignment {
|
export interface CoursePlanAssignment {
|
||||||
@@ -210,6 +232,8 @@ export interface CoursePlanAssignment {
|
|||||||
batch_name: string;
|
batch_name: string;
|
||||||
student_user_ids: number[];
|
student_user_ids: number[];
|
||||||
student_user_names: string[];
|
student_user_names: string[];
|
||||||
|
entity_ids?: number[];
|
||||||
|
entity_names?: string[];
|
||||||
student_count: number;
|
student_count: number;
|
||||||
assigned_by_id: number | null;
|
assigned_by_id: number | null;
|
||||||
assigned_by_name: string;
|
assigned_by_name: string;
|
||||||
@@ -222,6 +246,8 @@ export interface CoursePlanAssignment {
|
|||||||
export interface CoursePlan {
|
export interface CoursePlan {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
entity_id?: number | null;
|
||||||
|
entity_name?: string;
|
||||||
course_id: number | null;
|
course_id: number | null;
|
||||||
course_name: string;
|
course_name: string;
|
||||||
cefr_level: string;
|
cefr_level: string;
|
||||||
@@ -252,6 +278,7 @@ export interface CoursePlan {
|
|||||||
|
|
||||||
export interface CoursePlanGenerateBrief {
|
export interface CoursePlanGenerateBrief {
|
||||||
title: string;
|
title: string;
|
||||||
|
entity_id?: number;
|
||||||
cefr_level?: string;
|
cefr_level?: string;
|
||||||
total_weeks?: number;
|
total_weeks?: number;
|
||||||
contact_hours_per_week?: number;
|
contact_hours_per_week?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user