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

494 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronDown, SlidersHorizontal } from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
type Column,
FilterDropdown,
Pagination,
PrimaryButton,
StatusBadge,
} from "@/components/shared";
import { aiService } from "@/services/ai-service";
import { moduleService } from "@/services/module-service";
import { tenantService, type TenantUserDropdownItem } from "@/services/tenant-service";
import type { AICompletion, AIProviderInfo } from "@/types/ai";
import type { MyModule } from "@/types/module";
import { showToast } from "@/utils/toast";
import { cn } from "@/lib/utils";
import { useAppTheme } from "@/hooks/useAppTheme";
const formatListDate = (value?: string | null): string => {
if (!value) return "—";
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const CompletionHistory = (): ReactElement => {
const navigate = useNavigate();
const { primaryColor } = useAppTheme();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isMetaLoading, setIsMetaLoading] = useState<boolean>(true);
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
const [models, setModels] = useState<Array<{ id: string; provider: string; isDefault?: boolean }>>([]);
const [modules, setModules] = useState<MyModule[]>([]);
const [tenantUsers, setTenantUsers] = useState<TenantUserDropdownItem[]>([]);
const [completions, setCompletions] = useState<AICompletion[]>([]);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [page, setPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(10);
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0,
totalPages: 1,
});
const [showMoreFilters, setShowMoreFilters] = useState<boolean>(false);
const [filters, setFilters] = useState({
provider: null as string | null,
model: null as string | null,
status: null as string | null,
moduleId: null as string | null,
userId: null as string | null,
startDate: "",
endDate: "",
});
const providerOptions = useMemo(
() => providers.map((p) => ({ value: p.name, label: p.displayName || p.name })),
[providers],
);
const modelOptions = useMemo(
() =>
models.map((m) => ({
value: m.id,
label: `${m.provider} · ${m.id}`,
})),
[models],
);
const moduleOptions = useMemo(
() => modules.map((m) => ({ value: m.id, label: m.name })),
[modules],
);
const userOptions = useMemo(
() =>
tenantUsers.map((u) => ({
value: u.id,
label: `${u.name} (${u.role ?? "—"})`,
})),
[tenantUsers],
);
const hasExtraFilters = Boolean(
filters.moduleId || filters.userId || filters.startDate || filters.endDate,
);
useEffect(() => {
if (hasExtraFilters) {
setShowMoreFilters(true);
}
}, [hasExtraFilters]);
const loadMeta = useCallback(async (): Promise<void> => {
setIsMetaLoading(true);
try {
const [providerData, modelData, modulesRes, usersData] = await Promise.all([
aiService.getProviders(),
aiService.getModels(),
moduleService.getMyModules(),
tenantService.getCurrentTenantUsersDropdown(),
]);
setProviders(providerData);
setModels(modelData);
setModules(modulesRes.data || []);
setTenantUsers(usersData);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error
?.message || "Failed to load filter options";
showToast.error(msg);
} finally {
setIsMetaLoading(false);
}
}, []);
const loadCompletions = useCallback(async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const listData = await aiService.listCompletions({
page,
limit,
provider: filters.provider || undefined,
model: filters.model || undefined,
status: filters.status || undefined,
user_id: filters.userId || undefined,
module_id: filters.moduleId || undefined,
start_date: filters.startDate.trim() || undefined,
end_date: filters.endDate.trim() || undefined,
});
setCompletions(listData.data || []);
setExpandedId(null);
setPagination({
page: listData.pagination?.page || page,
limit: listData.pagination?.limit || limit,
total: listData.pagination?.total || 0,
totalPages: listData.pagination?.totalPages || 1,
});
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error
?.message || "Failed to load completion history";
setError(msg);
showToast.error(msg);
} finally {
setIsLoading(false);
}
}, [
page,
limit,
filters.provider,
filters.model,
filters.status,
filters.userId,
filters.moduleId,
filters.startDate,
filters.endDate,
]);
useEffect(() => {
void loadMeta();
}, [loadMeta]);
useEffect(() => {
void loadCompletions();
}, [loadCompletions]);
const clearFilters = (): void => {
setPage(1);
setFilters({
provider: null,
model: null,
status: null,
moduleId: null,
userId: null,
startDate: "",
endDate: "",
});
setShowMoreFilters(false);
};
const toggleExpand = (id: string): void => {
setExpandedId((prev) => (prev === id ? null : id));
};
const renderExpanded = (row: AICompletion): ReactElement => {
const total =
row.usage?.total_tokens ?? row.total_tokens ?? (row.prompt_tokens ?? 0) + (row.completion_tokens ?? 0);
const preview = (row.response || row.content || "").slice(0, 800);
return (
<div className="bg-[#f8fafc] border border-[rgba(0,0,0,0.06)] rounded-md p-3 md:p-4 text-xs text-[#334155] space-y-2">
{/* <p>
<span className="font-semibold text-[#475569]">IDs — </span>
Module: <code className="text-[11px]">{row.module_id || "—"}</code>
{" · "}
User: <code className="text-[11px]">{row.user_id || "—"}</code>
</p>
<p>
<span className="font-semibold text-[#475569]">Correlation — </span>
{row.correlation_id || "—"}
</p> */}
<p>
<span className="font-semibold text-[#475569]">Tokens / cost / latency </span>
{`${total} tokens · USD ${Number(row.cost ?? 0).toFixed(6)} · ${row.latency_ms ?? 0} ms`}
</p>
{row.use_case && (
<p>
<span className="font-semibold text-[#475569]">Use case </span>
{row.use_case}
</p>
)}
{(row.error_message || row.error_code) && (
<p className="text-red-600">
<span className="font-semibold">Error </span>
{[row.error_code, row.error_message].filter(Boolean).join(" · ")}
</p>
)}
<div>
<span className="font-semibold text-[#475569] block mb-1">Response preview</span>
<p className="whitespace-pre-wrap break-words leading-relaxed">{preview || "—"}</p>
</div>
<button
type="button"
className="text-xs font-semibold hover:underline"
style={{ color: primaryColor }}
onClick={() => navigate(`/tenant/ai/completions/${row.id}`)}
>
Open full detail view
</button>
</div>
);
};
const columns: Column<AICompletion>[] = [
{
key: "date",
label: "Date",
render: (row) => <span className="whitespace-nowrap">{formatListDate(row.created_at)}</span>,
},
{
key: "module_name",
label: "Module",
render: (row) => <span className="line-clamp-2">{row.module_name || "Platform"}</span>,
},
{
key: "user_name",
label: "User",
render: (row) => <span className="line-clamp-2">{row.user_name || "—"}</span>,
},
{ key: "provider", label: "Provider", render: (row) => row.provider || "—" },
{
key: "model",
label: "Model",
render: (row) => <span className="line-clamp-2">{row.model || "—"}</span>,
},
{
key: "status",
label: "Status",
render: (row) => (
<StatusBadge variant={row.status === "completed" ? "success" : "failure"}>
{row.status || "unknown"}
</StatusBadge>
),
},
{
key: "view",
label: "View",
align: "right",
render: (row) => (
<button
type="button"
className="text-xs font-semibold hover:underline"
style={{ color: primaryColor }}
onClick={(e) => {
e.stopPropagation();
navigate(`/tenant/ai/completions/${row.id}`);
}}
>
View
</button>
),
},
];
return (
<Layout
currentPage="Completion History"
pageHeader={{
title: "Completion History",
description:
"Track provider/model/token usage for persisted completions.",
action: (
<PrimaryButton onClick={() => navigate("/tenant/ai/completions/create")}>
Create Completion
</PrimaryButton>
),
}}
>
<div className="space-y-5">
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="p-4 md:p-5 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm md:text-base font-semibold text-[#0f1724] mb-3">
Completion List
</h3>
<div className="flex flex-col gap-3">
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<FilterDropdown
label="Provider"
options={providerOptions}
value={filters.provider}
placeholder="All providers"
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, provider: value as string | null }));
}}
/>
<FilterDropdown
label="Model"
options={modelOptions}
value={filters.model}
placeholder="All models"
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, model: value as string | null }));
}}
/>
<FilterDropdown
label="Status"
options={[
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
{ value: "pending", label: "Pending" },
]}
value={filters.status}
placeholder="All statuses"
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, status: value as string | null }));
}}
/>
<button
type="button"
onClick={() => setShowMoreFilters((open) => !open)}
className={cn(
"inline-flex items-center gap-1.5 h-10 px-3 rounded-md text-sm font-medium border bg-white transition-colors",
showMoreFilters || hasExtraFilters
? "border-[rgba(8,76,200,0.35)] text-[#0f1724]"
: "border-[rgba(0,0,0,0.08)] text-[#475569] hover:border-[#084cc8]/30",
)}
style={
showMoreFilters || hasExtraFilters
? { borderColor: `${primaryColor}55`, color: primaryColor }
: undefined
}
>
<SlidersHorizontal className="w-3.5 h-3.5 shrink-0" />
More filters
<ChevronDown
className={cn(
"w-3.5 h-3.5 shrink-0 opacity-70 transition-transform",
showMoreFilters && "rotate-180",
)}
/>
</button>
</div>
<button
type="button"
onClick={clearFilters}
className="text-sm font-medium text-[#6b7280] hover:text-[#0f1724] transition-colors self-start lg:self-center"
>
Clear filters
</button>
</div>
{showMoreFilters && (
<div className="flex flex-col gap-3 pt-3 border-t border-[rgba(0,0,0,0.08)]">
<div className="flex flex-wrap items-center gap-2">
<FilterDropdown
label="Module"
options={moduleOptions}
value={filters.moduleId}
placeholder="All modules"
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, moduleId: value as string | null }));
}}
/>
<FilterDropdown
label="User"
options={userOptions}
value={filters.userId}
placeholder="All users"
isSearchable
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, userId: value as string | null }));
}}
/>
</div>
<div className="flex flex-wrap items-center gap-4 md:gap-6">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">From:</span>
<input
type="date"
value={filters.startDate}
onChange={(e) => {
setPage(1);
setFilters((prev) => ({ ...prev, startDate: e.target.value }));
}}
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">To:</span>
<input
type="date"
value={filters.endDate}
onChange={(e) => {
setPage(1);
setFilters((prev) => ({ ...prev, endDate: e.target.value }));
}}
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
/>
</div>
</div>
</div>
)}
</div>
{isMetaLoading && (
<p className="text-xs text-[#64748b] mt-2">Loading filter options</p>
)}
</div>
<DataTable
data={completions}
columns={columns}
keyExtractor={(item) => item.id}
isLoading={isLoading}
error={error}
emptyMessage="No completion records found"
expandableRows
isRowExpanded={(row) => expandedId === row.id}
onRowExpandToggle={(row) => toggleExpand(row.id)}
renderExpandedRow={renderExpanded}
onRowClick={(row) => toggleExpand(row.id)}
/>
{pagination.total > 0 && (
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={setPage}
onLimitChange={(newLimit) => {
setLimit(newLimit);
setPage(1);
}}
limitOptions={[
{ value: "5", label: "5 per page" },
{ value: "10", label: "10 per page" },
{ value: "20", label: "20 per page" },
]}
/>
</div>
)}
</section>
</div>
</Layout>
);
};
export default CompletionHistory;