Qassure-frontend/src/pages/tenant/PromptManagement.tsx

410 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import {
Plus,
Copy,
History,
Trash2,
Edit3,
ClipboardCheck,
Play,
Activity,
} from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
type Column,
Pagination,
PrimaryButton,
ActionDropdown,
DeleteConfirmationModal,
SearchBox,
FilterDropdown,
} from "@/components/shared";
import { aiService } from "@/services/ai-service";
import { moduleService } from "@/services/module-service";
import type { AIPrompt } from "@/types/ai";
import { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date";
import { PromptVersionsModal } from "@/components/tenant/PromptVersionsModal";
import { cn } from "@/lib/utils";
const PromptManagement = (): ReactElement => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [prompts, setPrompts] = useState<AIPrompt[]>([]);
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
const [page, setPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(10);
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0,
totalPages: 1,
});
// Module filter states
const [modules, setModules] = useState<Array<{ value: string; label: string }>>([]);
const [selectedModuleId, setSelectedModuleId] = useState<string>("");
// Modal states
const [selectedPrompt, setSelectedPrompt] = useState<AIPrompt | null>(null);
const [isVersionsOpen, setIsVersionsOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isActionLoading, setIsActionLoading] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
// Load modules list on mount
useEffect(() => {
const loadModules = async (): Promise<void> => {
try {
const res = await moduleService.getMyModules();
setModules(
(res.data || []).map((m: any) => ({
value: m.id,
label: m.name,
}))
);
} catch (err: any) {
console.error("Failed to load modules list", err);
}
};
void loadModules();
}, []);
const loadPrompts = async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const result = await aiService.listPrompts({
page,
limit,
search: debouncedSearch || undefined,
module_id: selectedModuleId || undefined,
});
setPrompts(result.data || []);
setPagination({
page: result.pagination?.page || page,
limit: result.pagination?.limit || limit,
total: result.pagination?.total || 0,
totalPages: result.pagination?.totalPages || 1,
});
} catch (err: unknown) {
const message =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || "Failed to load prompts";
setError(message);
showToast.error(message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadPrompts();
}, [page, limit, debouncedSearch, selectedModuleId]);
const handleStatusToggle = async (prompt: AIPrompt) => {
const newStatus = prompt.status === "active" ? "draft" : "active";
try {
await aiService.updatePromptStatus(prompt.id, newStatus);
showToast.success(`Prompt marked as ${newStatus}`);
void loadPrompts();
} catch (err: any) {
showToast.error("Failed to update status");
}
};
const handleDelete = async () => {
if (!selectedPrompt) return;
setIsActionLoading(true);
try {
await aiService.deletePrompt(selectedPrompt.id);
showToast.success("Prompt deleted successfully");
setIsDeleteOpen(false);
void loadPrompts();
} catch (err: any) {
showToast.error("Failed to delete prompt");
} finally {
setIsActionLoading(false);
}
};
const handleClone = async (prompt: AIPrompt) => {
try {
await aiService.clonePrompt(prompt.id); // Assuming this exists or I'll add it
showToast.success("Prompt cloned successfully");
void loadPrompts();
} catch (err: any) {
showToast.error("Failed to clone prompt");
}
};
const columns: Column<AIPrompt>[] = useMemo(
() => [
{
key: "name",
label: "Name & Description",
render: (row) => (
<div className="min-w-0">
<p
className="text-sm font-semibold text-[#112868] hover:underline cursor-pointer"
onClick={() => navigate(`/tenant/ai/prompts/${row.id}/edit`)}
>
{row.name}
</p>
<p className="text-xs text-[#64748b] truncate max-w-[280px]">
{row.description || "No description"}
</p>
</div>
),
},
{
key: "useCase",
label: "Use Case",
render: (row) => {
if (!row.useCase)
return <span className="text-sm text-[#94a3b8]"></span>;
return (
<span className="inline-flex items-center rounded-full border border-[#e2e8f0] bg-[#f8fafc] px-2.5 py-0.5 text-[11px] font-medium text-[#1e293b]">
{row.useCase}
</span>
);
},
},
{
key: "status",
label: "Status",
render: (row) => (
<div className="flex items-center gap-3">
<div
className={cn(
"w-9 h-[18px] rounded-full relative transition-colors duration-200 cursor-pointer shadow-inner",
row.status === "active" ? "bg-[#112868]" : "bg-gray-200",
)}
onClick={() => handleStatusToggle(row)}
>
<div
className={cn(
"absolute top-[2px] left-[2px] w-3.5 h-3.5 rounded-full transition-transform duration-200 shadow-sm",
row.status === "active"
? "translate-x-[18px] bg-[#00cfd5]"
: "bg-white",
)}
/>
</div>
<span
className={cn(
"text-[11px] font-medium uppercase tracking-wider",
row.status === "active" ? "text-green-600" : "text-gray-400",
)}
>
{row.status || "draft"}
</span>
</div>
),
},
{
key: "version",
label: "Version",
render: (row) => (
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-blue-50 text-blue-600 border border-blue-100">
v{row.version || 1}
</span>
),
},
{
key: "tags",
label: "Tags",
render: (row) => {
const tags = ((row as any).tags || []) as string[];
if (!tags.length) {
return <span className="text-xs text-[#94a3b8]"></span>;
}
return (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600"
>
{tag}
</span>
))}
{tags.length > 2 && (
<span className="text-[10px] text-gray-400 font-medium">
+{tags.length - 2}
</span>
)}
</div>
);
},
},
{
key: "Updated",
label: "Last Updated",
render: (row) => (
<span className="text-xs text-[#64748b]">
{formatDate(row.updatedAt || row.createdAt || "")}
</span>
),
},
{
key: "actions",
label: "",
align: "right",
render: (row) => (
<div className="flex justify-end pr-2">
<ActionDropdown
actions={[
{
icon: <ClipboardCheck className="w-3.5 h-3.5 shrink-0" />,
label: "Prompt Test Cases",
onClick: () =>
navigate(`/tenant/ai/prompts/${row.id}/test-cases`),
},
{
icon: <Edit3 className="w-3.5 h-3.5 shrink-0" />,
label: "Edit Prompt",
onClick: () => navigate(`/tenant/ai/prompts/${row.id}/edit`),
},
{
icon: <History className="w-3.5 h-3.5 shrink-0" />,
label: "Version History",
onClick: () => {
setSelectedPrompt(row);
setIsVersionsOpen(true);
},
},
{
icon: <Play className="w-3.5 h-3.5 shrink-0 text-emerald-600" />,
label: "Run / Execute",
onClick: () => navigate(`/tenant/ai/prompts/${row.id}/execute`),
},
{
icon: <Activity className="w-3.5 h-3.5 shrink-0 text-blue-600" />,
label: "Execution Logs",
onClick: () => navigate(`/tenant/ai/prompts/${row.id}/executions`),
},
{
icon: <Copy className="w-3.5 h-3.5 shrink-0" />,
label: "Clone Prompt",
onClick: () => handleClone(row),
},
{
icon: <Trash2 className="w-3.5 h-3.5 shrink-0" />,
label: "Delete Prompt",
variant: "danger",
onClick: () => {
setSelectedPrompt(row);
setIsDeleteOpen(true);
},
},
]}
/>
</div>
),
},
],
[navigate, loadPrompts],
);
return (
<Layout
currentPage="Prompt Management"
pageHeader={{
title: "Prompt Management",
description: "Manage reusable AI prompts and versioned templates.",
action: (
<PrimaryButton
onClick={() => navigate("/tenant/ai/prompts/create")}
className="flex items-center gap-2 h-10 shadow-sm"
>
<Plus className="w-4 h-4" />
Create Prompt
</PrimaryButton>
),
}}
>
<div className="overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center gap-3 pb-2">
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or description..."
containerClassName="relative w-full md:w-80"
/>
{/* <div className="w-full md:w-60"> */}
<FilterDropdown
label="Module"
value={selectedModuleId || null}
onChange={(val) => {
setSelectedModuleId(val ? (Array.isArray(val) ? val[0] : val) : "");
setPage(1);
}}
options={modules.map((m) => ({ value: m.value, label: m.label }))}
placeholder="All Modules"
/>
{/* </div> */}
</div>
<DataTable
data={prompts}
columns={columns}
keyExtractor={(item) => item.id}
isLoading={isLoading}
error={error}
emptyMessage="No prompts found. Click 'Create Prompt' to get started."
/>
{pagination.total > 0 && (
<div className="border-t border-[rgba(0,0,0,0.08)] px-5 py-4 bg-gray-50/30">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={setPage}
onLimitChange={(newLimit) => {
setLimit(newLimit);
setPage(1);
}}
/>
</div>
)}
</div>
<PromptVersionsModal
isOpen={isVersionsOpen}
onClose={() => setIsVersionsOpen(false)}
prompt={selectedPrompt}
onRollbackSuccess={loadPrompts}
/>
<DeleteConfirmationModal
isOpen={isDeleteOpen}
onClose={() => setIsDeleteOpen(false)}
onConfirm={handleDelete}
isLoading={isActionLoading}
title="Delete Prompt"
message="Are you sure you want to delete this prompt"
itemName={selectedPrompt?.name}
/>
</Layout>
);
};
export default PromptManagement;