refactor: implement breadcrumb navigation and modernize layout components across tenant pages
This commit is contained in:
parent
af7e83c59c
commit
4024711100
@ -115,6 +115,17 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
||||
<nav className="flex items-center gap-1.5 md:gap-2">
|
||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||
<>
|
||||
{breadcrumbs[0].label !== 'QAssure' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate(roles.includes('super_admin') ? '/dashboard' : '/tenant')}
|
||||
className="text-xs md:text-[13px] font-normal text-[#6b7280] hover:text-[#0f1724] transition-colors cursor-pointer"
|
||||
>
|
||||
QAssure
|
||||
</button>
|
||||
<ChevronRight className="w-3 h-3 md:w-3.5 md:h-3.5 text-[#6b7280]" />
|
||||
</>
|
||||
)}
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5 md:gap-2">
|
||||
{crumb.path ? (
|
||||
@ -138,7 +149,7 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
onClick={() => navigate(roles.includes('super_admin') ? '/dashboard' : '/tenant')}
|
||||
className="text-xs md:text-[13px] font-normal text-[#6b7280] hover:text-[#0f1724] transition-colors cursor-pointer"
|
||||
>
|
||||
QAssure
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
align?: "left" | "right" | "center";
|
||||
mobileLabel?: string;
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ export const DataTable = <T,>({
|
||||
columns,
|
||||
keyExtractor,
|
||||
mobileCardRenderer,
|
||||
emptyMessage = 'No data found',
|
||||
emptyMessage = "No data found",
|
||||
isLoading = false,
|
||||
error = null,
|
||||
expandableRows = false,
|
||||
@ -43,8 +43,13 @@ export const DataTable = <T,>({
|
||||
showExpandColumn = true,
|
||||
expandedColSpan,
|
||||
}: DataTableProps<T>): ReactElement => {
|
||||
const canExpand = expandableRows && !!onRowExpandToggle && !!isRowExpanded && !!renderExpandedRow;
|
||||
const desktopColSpan = expandedColSpan || columns.length + (canExpand && showExpandColumn ? 1 : 0);
|
||||
const canExpand =
|
||||
expandableRows &&
|
||||
!!onRowExpandToggle &&
|
||||
!!isRowExpanded &&
|
||||
!!renderExpandedRow;
|
||||
const desktopColSpan =
|
||||
expandedColSpan || columns.length + (canExpand && showExpandColumn ? 1 : 0);
|
||||
|
||||
// Loading State
|
||||
if (isLoading) {
|
||||
@ -75,15 +80,18 @@ export const DataTable = <T,>({
|
||||
<thead>
|
||||
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
|
||||
{canExpand && showExpandColumn && (
|
||||
<th className="w-10 px-2 py-2 md:py-1 lg:py-2.5 xl:py-3 text-left" aria-label="Expand" />
|
||||
<th
|
||||
className="w-10 px-2 py-2 md:py-1 lg:py-2.5 xl:py-3 text-left"
|
||||
aria-label="Expand"
|
||||
/>
|
||||
)}
|
||||
{columns.map((column) => {
|
||||
const alignClass =
|
||||
column.align === 'right'
|
||||
? 'text-right'
|
||||
: column.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left';
|
||||
column.align === "right"
|
||||
? "text-right"
|
||||
: column.align === "center"
|
||||
? "text-center"
|
||||
: "text-left";
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
@ -97,7 +105,10 @@ export const DataTable = <T,>({
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={desktopColSpan} className="px-3 md:px-2 lg:px-4 xl:px-5 py-6 md:py-3 lg:py-7 xl:py-8 text-center text-xs md:text-xs lg:text-[13px] text-[#6b7280]">
|
||||
<td
|
||||
colSpan={desktopColSpan}
|
||||
className="px-3 md:px-2 lg:px-4 xl:px-5 py-6 md:py-3 lg:py-7 xl:py-8 text-center text-xs md:text-xs lg:text-[13px] text-[#6b7280]"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
@ -122,23 +133,26 @@ export const DataTable = <T,>({
|
||||
<thead>
|
||||
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
|
||||
{canExpand && showExpandColumn && (
|
||||
<th className="w-10 px-2 py-2 md:py-1 lg:py-2.5 xl:py-3 text-left" aria-label="Expand" />
|
||||
<th
|
||||
className="w-10 px-2 py-2 md:py-1 lg:py-2.5 xl:py-3 text-left"
|
||||
aria-label="Expand"
|
||||
/>
|
||||
)}
|
||||
{columns.map((column) => {
|
||||
const alignClass =
|
||||
column.align === 'right'
|
||||
? 'text-right'
|
||||
: column.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left';
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2 md:py-1 lg:py-2.5 xl:py-3 ${alignClass} text-[10px] md:text-[8px] lg:text-[13px] font-medium text-[#9aa6b2] uppercase`}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
);
|
||||
column.align === "right"
|
||||
? "text-right"
|
||||
: column.align === "center"
|
||||
? "text-center"
|
||||
: "text-left";
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2 md:py-1 lg:py-2.5 xl:py-3 ${alignClass} text-[10px] md:text-[8px] lg:text-[13px] font-medium text-[#9aa6b2] uppercase`}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
@ -163,28 +177,37 @@ export const DataTable = <T,>({
|
||||
onRowExpandToggle(item);
|
||||
}}
|
||||
>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => {
|
||||
const alignClass =
|
||||
column.align === 'right'
|
||||
? 'text-right'
|
||||
: column.align === 'center'
|
||||
? 'text-center'
|
||||
: 'text-left';
|
||||
column.align === "right"
|
||||
? "text-right"
|
||||
: column.align === "center"
|
||||
? "text-center"
|
||||
: "text-left";
|
||||
return (
|
||||
<td key={column.key} className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2.5 md:py-1.5 lg:py-3 xl:py-4 ${alignClass} text-xs md:text-[10px] lg:text-[13px]`}>
|
||||
{column.render ? column.render(item) : String((item as any)[column.key])}
|
||||
<td
|
||||
key={column.key}
|
||||
className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2.5 md:py-1.5 lg:py-3 xl:py-4 ${alignClass} text-xs md:text-[10px] lg:text-[13px]`}
|
||||
>
|
||||
{column.render
|
||||
? column.render(item)
|
||||
: String((item as any)[column.key])}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
{canExpand && expanded && (
|
||||
<tr className="border-b border-[rgba(0,0,0,0.08)] bg-white">
|
||||
<td colSpan={desktopColSpan} className="px-3 md:px-2 lg:px-4 xl:px-5 py-2.5 md:py-1.5 lg:py-3 xl:py-4">
|
||||
{renderExpandedRow(item)}
|
||||
<tr className="border-t border-[rgba(0,0,0,0.08)] bg-[#F9F9F9]">
|
||||
<td colSpan={desktopColSpan}>
|
||||
<div className="flex flex-col items-start w-full bg-[#FFF] border border-gray-300 rounded-md p-4 text-xs text-gray-700 m-4">
|
||||
{renderExpandedRow(item)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@ -199,7 +222,9 @@ export const DataTable = <T,>({
|
||||
{/* Mobile Card View */}
|
||||
<div className="md:hidden divide-y divide-[rgba(0,0,0,0.08)]">
|
||||
{mobileCardRenderer
|
||||
? data.map((item) => <div key={keyExtractor(item)}>{mobileCardRenderer(item)}</div>)
|
||||
? data.map((item) => (
|
||||
<div key={keyExtractor(item)}>{mobileCardRenderer(item)}</div>
|
||||
))
|
||||
: data.map((item) => {
|
||||
const expanded = canExpand ? !!isRowExpanded(item) : false;
|
||||
return (
|
||||
@ -212,7 +237,9 @@ export const DataTable = <T,>({
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onRowExpandToggle(item)}
|
||||
>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -222,14 +249,14 @@ export const DataTable = <T,>({
|
||||
{column.mobileLabel || column.label}:
|
||||
</span>
|
||||
<div className="text-xs sm:text-sm text-[#0f1724]">
|
||||
{column.render ? column.render(item) : String((item as any)[column.key])}
|
||||
{column.render
|
||||
? column.render(item)
|
||||
: String((item as any)[column.key])}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{canExpand && expanded && renderExpandedRow && (
|
||||
<div className="mt-3">
|
||||
{renderExpandedRow(item)}
|
||||
</div>
|
||||
<div className="mt-3">{renderExpandedRow(item)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -133,7 +133,7 @@ export const PromptTestCaseResultsListModal = ({
|
||||
showExpandColumn={true}
|
||||
expandedColSpan={columns.length + 1}
|
||||
renderExpandedRow={(item) => (
|
||||
<div className="bg-slate-50/70 border border-slate-200/50 p-4 rounded-xl text-xs text-slate-800 select-all">
|
||||
<>
|
||||
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide mb-2 block select-none">
|
||||
Output
|
||||
</span>
|
||||
@ -142,7 +142,7 @@ export const PromptTestCaseResultsListModal = ({
|
||||
) : (
|
||||
"No output generated."
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import {
|
||||
ViewAuditLogModal,
|
||||
DataTable,
|
||||
@ -9,14 +9,14 @@ import {
|
||||
StatusBadge,
|
||||
SearchBox,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Download, ArrowUpDown } from 'lucide-react';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
} from "@/components/shared";
|
||||
import { Download, ArrowUpDown } from "lucide-react";
|
||||
import { auditLogService } from "@/services/audit-log-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import type { AuditLog } from "@/types/audit-log";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { PrimaryButton } from "@/components/shared";
|
||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||
|
||||
export interface AuditLogsProps {
|
||||
customTenantId?: string;
|
||||
@ -26,55 +26,79 @@ export interface AuditLogsProps {
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get action badge variant
|
||||
const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'process' => {
|
||||
const getActionVariant = (
|
||||
action: string,
|
||||
): "success" | "failure" | "info" | "process" => {
|
||||
const lowerAction = action.toLowerCase();
|
||||
if (lowerAction.includes('create') || lowerAction.includes('register')) return 'success';
|
||||
if (lowerAction.includes('update') || lowerAction.includes('version_update') || lowerAction.includes('login')) return 'info';
|
||||
if (lowerAction.includes('delete') || lowerAction.includes('deregister')) return 'failure';
|
||||
if (lowerAction.includes('read') || lowerAction.includes('get') || lowerAction.includes('status_change')) return 'process';
|
||||
return 'info';
|
||||
if (lowerAction.includes("create") || lowerAction.includes("register"))
|
||||
return "success";
|
||||
if (
|
||||
lowerAction.includes("update") ||
|
||||
lowerAction.includes("version_update") ||
|
||||
lowerAction.includes("login")
|
||||
)
|
||||
return "info";
|
||||
if (lowerAction.includes("delete") || lowerAction.includes("deregister"))
|
||||
return "failure";
|
||||
if (
|
||||
lowerAction.includes("read") ||
|
||||
lowerAction.includes("get") ||
|
||||
lowerAction.includes("status_change")
|
||||
)
|
||||
return "process";
|
||||
return "info";
|
||||
};
|
||||
|
||||
// Helper function to get method badge variant
|
||||
const getMethodVariant = (method: string | null): 'success' | 'failure' | 'info' | 'process' => {
|
||||
if (!method) return 'info';
|
||||
const getMethodVariant = (
|
||||
method: string | null,
|
||||
): "success" | "failure" | "info" | "process" => {
|
||||
if (!method) return "info";
|
||||
const upperMethod = method.toUpperCase();
|
||||
if (upperMethod === 'GET') return 'success';
|
||||
if (upperMethod === 'POST') return 'info';
|
||||
if (upperMethod === 'PUT' || upperMethod === 'PATCH') return 'process';
|
||||
if (upperMethod === 'DELETE') return 'failure';
|
||||
return 'info';
|
||||
if (upperMethod === "GET") return "success";
|
||||
if (upperMethod === "POST") return "info";
|
||||
if (upperMethod === "PUT" || upperMethod === "PATCH") return "process";
|
||||
if (upperMethod === "DELETE") return "failure";
|
||||
return "info";
|
||||
};
|
||||
|
||||
// Helper function to get status badge color based on response status
|
||||
const getStatusColor = (status: number | null): string => {
|
||||
if (!status) return 'text-[#6b7280]';
|
||||
if (status >= 200 && status < 300) return 'text-[#10b981]';
|
||||
if (status >= 300 && status < 400) return 'text-[#f59e0b]';
|
||||
if (status >= 400) return 'text-[#ef4444]';
|
||||
return 'text-[#6b7280]';
|
||||
if (!status) return "text-[#6b7280]";
|
||||
if (status >= 200 && status < 300) return "text-[#10b981]";
|
||||
if (status >= 300 && status < 400) return "text-[#f59e0b]";
|
||||
if (status >= 400) return "text-[#ef4444]";
|
||||
return "text-[#6b7280]";
|
||||
};
|
||||
|
||||
const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}): ReactElement => {
|
||||
const AuditLogs = ({
|
||||
customTenantId,
|
||||
hideLayout = false,
|
||||
}: AuditLogsProps = {}): ReactElement => {
|
||||
const { primaryColor } = useAppTheme();
|
||||
const roles = useAppSelector((state) => state.auth.roles);
|
||||
const authTenantId = useAppSelector((state) => state.auth.tenantId);
|
||||
const tenantId = customTenantId || authTenantId;
|
||||
const isTenantAdmin = customTenantId ? true : roles?.includes('tenant_admin');
|
||||
|
||||
const isTenantAdmin = customTenantId ? true : roles?.includes("tenant_admin");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
|
||||
const [modules, setModules] = useState<{ value: string; label: string }[]>([]);
|
||||
const [resourceTypes, setResourceTypes] = useState<
|
||||
{ value: string; label: string }[]
|
||||
>([]);
|
||||
const [modules, setModules] = useState<{ value: string; label: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -98,45 +122,53 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
// Filter state
|
||||
const [methodFilter, setMethodFilter] = useState<string | null>(null);
|
||||
const [actionFilter, setActionFilter] = useState<string | null>(null);
|
||||
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null);
|
||||
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [moduleIdFilter, setModuleIdFilter] = useState<string | null>(null); // New module_id filter
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
||||
|
||||
// View modal
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
|
||||
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const fetchResourceTypes = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await auditLogService.getResourceTypesDropdown(tenantId as string | undefined);
|
||||
const response = await auditLogService.getResourceTypesDropdown(
|
||||
tenantId as string | undefined,
|
||||
);
|
||||
if (response.success) {
|
||||
const options = response.data.map((rt: any) => ({
|
||||
value: rt.value,
|
||||
label: rt.label
|
||||
label: rt.label,
|
||||
}));
|
||||
setResourceTypes(options);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load resource types', err);
|
||||
console.error("Failed to load resource types", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchModules = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await moduleService.getMyModules(tenantId as string | undefined);
|
||||
const response = await moduleService.getMyModules(
|
||||
tenantId as string | undefined,
|
||||
);
|
||||
if (response.success) {
|
||||
const options = response.data.map((m: any) => ({
|
||||
value: m.id,
|
||||
label: m.name
|
||||
label: m.name,
|
||||
}));
|
||||
setModules(options);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load modules', err);
|
||||
console.error("Failed to load modules", err);
|
||||
}
|
||||
};
|
||||
|
||||
@ -144,12 +176,13 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
setExpandedId(null);
|
||||
|
||||
let response;
|
||||
if (isTenantAdmin) {
|
||||
response = await auditLogService.getAll(
|
||||
currentPage,
|
||||
limit,
|
||||
currentPage,
|
||||
limit,
|
||||
{
|
||||
method: methodFilter,
|
||||
action: actionFilter,
|
||||
@ -158,22 +191,29 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
tenant_id: tenantId as string | null,
|
||||
search: debouncedSearch || null
|
||||
},
|
||||
orderBy
|
||||
search: debouncedSearch || null,
|
||||
},
|
||||
orderBy,
|
||||
);
|
||||
} else {
|
||||
response = await auditLogService.getMyLogs(currentPage, limit, moduleIdFilter, debouncedSearch || null);
|
||||
response = await auditLogService.getMyLogs(
|
||||
currentPage,
|
||||
limit,
|
||||
moduleIdFilter,
|
||||
debouncedSearch || null,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
setAuditLogs(response.data);
|
||||
setPagination(response.pagination);
|
||||
} else {
|
||||
setError('Failed to load audit logs');
|
||||
setError("Failed to load audit logs");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load audit logs');
|
||||
setError(
|
||||
err?.response?.data?.error?.message || "Failed to load audit logs",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -197,7 +237,19 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
// Fetch audit logs on mount and when pagination/filters change
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
}, [currentPage, limit, methodFilter, actionFilter, resourceTypeFilter, moduleIdFilter, startDate, endDate, orderBy, tenantId, debouncedSearch]);
|
||||
}, [
|
||||
currentPage,
|
||||
limit,
|
||||
methodFilter,
|
||||
actionFilter,
|
||||
resourceTypeFilter,
|
||||
moduleIdFilter,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy,
|
||||
tenantId,
|
||||
debouncedSearch,
|
||||
]);
|
||||
|
||||
// View audit log handler
|
||||
const handleViewAuditLog = (auditLogId: string): void => {
|
||||
@ -209,27 +261,30 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
const handleExport = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await auditLogService.export({
|
||||
format: 'json',
|
||||
format: "json",
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
tenantId: tenantId || undefined
|
||||
tenantId: tenantId || undefined,
|
||||
});
|
||||
|
||||
|
||||
if (response.success) {
|
||||
// In a real app, we'd trigger a file download here.
|
||||
// In a real app, we'd trigger a file download here.
|
||||
// For now, we'll just log and show a message since the response is JSON.
|
||||
console.log('Export data:', response.data.records);
|
||||
const blob = new Blob([JSON.stringify(response.data.records, null, 2)], { type: 'application/json' });
|
||||
console.log("Export data:", response.data.records);
|
||||
const blob = new Blob(
|
||||
[JSON.stringify(response.data.records, null, 2)],
|
||||
{ type: "application/json" },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.json`;
|
||||
link.download = `audit-logs-${new Date().toISOString().split("T")[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert('Failed to export audit logs: ' + (err.message || 'Unknown error'));
|
||||
alert("Failed to export audit logs: " + (err.message || "Unknown error"));
|
||||
}
|
||||
};
|
||||
|
||||
@ -239,81 +294,107 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const toggleExpand = (id: string): void => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
const renderExpanded = (row: AuditLog): ReactElement => {
|
||||
return (
|
||||
<div className="w-full overflow-auto font-mono leading-6">
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(row.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<AuditLog>[] = [
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Timestamp',
|
||||
key: "created_at",
|
||||
label: "Timestamp",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#0f1724] whitespace-nowrap">{formatDate(log.created_at)}</span>
|
||||
<span className="text-sm font-normal text-[#0f1724] whitespace-nowrap">
|
||||
{formatDate(log.created_at)}
|
||||
</span>
|
||||
),
|
||||
mobileLabel: 'Time',
|
||||
mobileLabel: "Time",
|
||||
},
|
||||
{
|
||||
key: 'resource_type',
|
||||
label: 'Resource Type',
|
||||
key: "resource_type",
|
||||
label: "Resource Type",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{log.resource_type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'module',
|
||||
label: 'Module',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#475569]">
|
||||
{log.module?.name || 'Platform'}
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{log.resource_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
key: "module",
|
||||
label: "Module",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#475569]">
|
||||
{log.module?.name || "Platform"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
label: "Action",
|
||||
render: (log) => (
|
||||
<StatusBadge variant={getActionVariant(log.action)}>
|
||||
{log.action}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
...(isTenantAdmin ? [{
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
render: (log: AuditLog) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')}
|
||||
</span>
|
||||
),
|
||||
}] : []),
|
||||
...(isTenantAdmin
|
||||
? [
|
||||
{
|
||||
key: "user",
|
||||
label: "User",
|
||||
render: (log: AuditLog) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{log.user
|
||||
? `${log.user.first_name} ${log.user.last_name}`
|
||||
: log.user_email || "N/A"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'request_method',
|
||||
label: 'Method',
|
||||
key: "request_method",
|
||||
label: "Method",
|
||||
render: (log) => (
|
||||
<StatusBadge variant={getMethodVariant(log.request_method)}>
|
||||
{log.request_method ? log.request_method.toUpperCase() : 'N/A'}
|
||||
{log.request_method ? log.request_method.toUpperCase() : "N/A"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'response_status',
|
||||
label: 'Status',
|
||||
key: "response_status",
|
||||
label: "Status",
|
||||
render: (log) => (
|
||||
<span className={`text-sm font-normal ${getStatusColor(log.response_status)}`}>
|
||||
{log.response_status || 'N/A'}
|
||||
<span
|
||||
className={`text-sm font-normal ${getStatusColor(log.response_status)}`}
|
||||
>
|
||||
{log.response_status || "N/A"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ip_address',
|
||||
label: 'IP Address',
|
||||
key: "ip_address",
|
||||
label: "IP Address",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#0f1724] font-mono">
|
||||
{log.ip_address || 'N/A'}
|
||||
{log.ip_address || "N/A"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
align: "right",
|
||||
render: (log) => (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
@ -334,7 +415,9 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">{log.resource_type}</h3>
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
||||
{log.resource_type}
|
||||
</h3>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getActionVariant(log.action)}>
|
||||
{log.action}
|
||||
@ -353,30 +436,38 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Timestamp:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{formatDate(log.created_at)}</p>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{formatDate(log.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Module:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{log.module?.name || 'Platform'}</p>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{log.module?.name || "Platform"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">User:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">
|
||||
{log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')}
|
||||
{log.user
|
||||
? `${log.user.first_name} ${log.user.last_name}`
|
||||
: log.user_email || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Method:</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getMethodVariant(log.request_method)}>
|
||||
{log.request_method ? log.request_method.toUpperCase() : 'N/A'}
|
||||
{log.request_method ? log.request_method.toUpperCase() : "N/A"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Status:</span>
|
||||
<p className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}>
|
||||
{log.response_status || 'N/A'}
|
||||
<p
|
||||
className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}
|
||||
>
|
||||
{log.response_status || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -393,7 +484,7 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Global Search */}
|
||||
<SearchBox
|
||||
<SearchBox
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search logs & metadata..."
|
||||
@ -406,14 +497,14 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
<FilterDropdown
|
||||
label="Action"
|
||||
options={[
|
||||
{ value: 'LOGIN', label: 'LOGIN' },
|
||||
{ value: 'LOGOUT', label: 'LOGOUT' },
|
||||
{ value: 'CREATE', label: 'CREATE' },
|
||||
{ value: 'UPDATE', label: 'UPDATE' },
|
||||
{ value: 'DELETE', label: 'DELETE' },
|
||||
{ value: 'SUBMIT', label: 'SUBMIT' },
|
||||
{ value: 'APPROVE', label: 'APPROVE' },
|
||||
{ value: 'REJECT', label: 'REJECT' },
|
||||
{ value: "LOGIN", label: "LOGIN" },
|
||||
{ value: "LOGOUT", label: "LOGOUT" },
|
||||
{ value: "CREATE", label: "CREATE" },
|
||||
{ value: "UPDATE", label: "UPDATE" },
|
||||
{ value: "DELETE", label: "DELETE" },
|
||||
{ value: "SUBMIT", label: "SUBMIT" },
|
||||
{ value: "APPROVE", label: "APPROVE" },
|
||||
{ value: "REJECT", label: "REJECT" },
|
||||
// {value: 'PUBLISH', label: 'PUBLISH'},
|
||||
// {value: 'ARCHIVE', label: 'ARCHIVE'},
|
||||
// {value: 'CHECKOUT', label: 'CHECKOUT'},
|
||||
@ -467,10 +558,13 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
<FilterDropdown
|
||||
label="Sort by"
|
||||
options={[
|
||||
{ value: ['created_at', 'desc'], label: 'Newest First' },
|
||||
{ value: ['created_at', 'asc'], label: 'Oldest First' },
|
||||
{ value: ['action', 'asc'], label: 'Action (A-Z)' },
|
||||
{ value: ['resource_type', 'asc'], label: 'Resource Type (A-Z)' },
|
||||
{ value: ["created_at", "desc"], label: "Newest First" },
|
||||
{ value: ["created_at", "asc"], label: "Oldest First" },
|
||||
{ value: ["action", "asc"], label: "Action (A-Z)" },
|
||||
{
|
||||
value: ["resource_type", "asc"],
|
||||
label: "Resource Type (A-Z)",
|
||||
},
|
||||
]}
|
||||
value={orderBy}
|
||||
onChange={(value) => {
|
||||
@ -502,8 +596,8 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
<div className="flex flex-wrap items-center gap-3 md:gap-6 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-[#475569]">From:</span>
|
||||
<input
|
||||
type="date"
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => {
|
||||
setStartDate(e.target.value);
|
||||
@ -514,7 +608,7 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-[#475569]">To:</span>
|
||||
<input
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => {
|
||||
@ -524,16 +618,22 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
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>
|
||||
{(startDate || endDate || actionFilter || resourceTypeFilter || methodFilter || moduleIdFilter || search) && (
|
||||
<button
|
||||
{(startDate ||
|
||||
endDate ||
|
||||
actionFilter ||
|
||||
resourceTypeFilter ||
|
||||
methodFilter ||
|
||||
moduleIdFilter ||
|
||||
search) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setActionFilter(null);
|
||||
setResourceTypeFilter(null);
|
||||
setMethodFilter(null);
|
||||
setModuleIdFilter(null);
|
||||
setSearch('');
|
||||
setSearch("");
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-xs hover:underline decoration-offset-2"
|
||||
@ -553,7 +653,16 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage={isTenantAdmin ? "No audit logs found matching your criteria" : "No activity recorded for your account yet"}
|
||||
emptyMessage={
|
||||
isTenantAdmin
|
||||
? "No audit logs found matching your criteria"
|
||||
: "No activity recorded for your account yet"
|
||||
}
|
||||
expandableRows
|
||||
isRowExpanded={(row) => expandedId === row.id}
|
||||
onRowExpandToggle={(row) => toggleExpand(row.id)}
|
||||
renderExpandedRow={renderExpanded}
|
||||
onRowClick={(row) => toggleExpand(row.id)}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
@ -595,10 +704,10 @@ const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}):
|
||||
<Layout
|
||||
currentPage="Audit Logs"
|
||||
pageHeader={{
|
||||
title: isTenantAdmin ? 'System Audit Logs' : 'My Activity',
|
||||
description: isTenantAdmin
|
||||
? 'Monitor all activities and changes across the quality platform.'
|
||||
: 'View a chronological history of your own actions on the platform.',
|
||||
title: isTenantAdmin ? "System Audit Logs" : "My Activity",
|
||||
description: isTenantAdmin
|
||||
? "Monitor all activities and changes across the quality platform."
|
||||
: "View a chronological history of your own actions on the platform.",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
@ -181,6 +181,10 @@ const CompletionCreate = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Create Completion"
|
||||
breadcrumbs={[
|
||||
{ label: "Completions", path: "/tenant/ai/completions" },
|
||||
{ label: "Create Completion" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: "Completions Playground",
|
||||
description:
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useEffect, useState, type ReactElement, type ReactNode } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import { PrimaryButton, SecondaryButton, StatusBadge } from "@/components/shared";
|
||||
import { PrimaryButton, StatusBadge } from "@/components/shared";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import type { AICompletion } from "@/types/ai";
|
||||
import { showToast } from "@/utils/toast";
|
||||
@ -57,16 +56,14 @@ const CompletionDetail = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Completion History"
|
||||
currentPage="Completion Details"
|
||||
breadcrumbs={[
|
||||
{ label: "Completions", path: "/tenant/ai/completions" },
|
||||
{ label: "Completion details" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: "Completion details",
|
||||
description: "Full request, response, and usage for this completion.",
|
||||
action: (
|
||||
<SecondaryButton type="button" onClick={() => navigate("/tenant/ai/completions")}>
|
||||
<ArrowLeft className="w-4 h-4 mr-1.5 inline" />
|
||||
Back to list
|
||||
</SecondaryButton>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
@ -165,15 +162,6 @@ const CompletionDetail = (): ReactElement => {
|
||||
</pre>
|
||||
</section>
|
||||
)} */}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<PrimaryButton type="button" onClick={() => navigate("/tenant/ai/completions")}>
|
||||
Back to list
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type="button" onClick={() => navigate("/tenant/ai/completions/create")}>
|
||||
New completion
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
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";
|
||||
@ -12,7 +18,10 @@ import {
|
||||
} from "@/components/shared";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import { tenantService, type TenantUserDropdownItem } from "@/services/tenant-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";
|
||||
@ -37,7 +46,9 @@ const CompletionHistory = (): ReactElement => {
|
||||
const [isMetaLoading, setIsMetaLoading] = useState<boolean>(true);
|
||||
|
||||
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
|
||||
const [models, setModels] = useState<Array<{ id: string; provider: string; isDefault?: boolean }>>([]);
|
||||
const [models, setModels] = useState<
|
||||
Array<{ id: string; provider: string; isDefault?: boolean }>
|
||||
>([]);
|
||||
const [modules, setModules] = useState<MyModule[]>([]);
|
||||
const [tenantUsers, setTenantUsers] = useState<TenantUserDropdownItem[]>([]);
|
||||
|
||||
@ -67,7 +78,8 @@ const CompletionHistory = (): ReactElement => {
|
||||
});
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => providers.map((p) => ({ value: p.name, label: p.displayName || p.name })),
|
||||
() =>
|
||||
providers.map((p) => ({ value: p.name, label: p.displayName || p.name })),
|
||||
[providers],
|
||||
);
|
||||
|
||||
@ -107,20 +119,21 @@ const CompletionHistory = (): ReactElement => {
|
||||
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(),
|
||||
]);
|
||||
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";
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || "Failed to load filter options";
|
||||
showToast.error(msg);
|
||||
} finally {
|
||||
setIsMetaLoading(false);
|
||||
@ -152,8 +165,9 @@ const CompletionHistory = (): ReactElement => {
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error
|
||||
?.message || "Failed to load completion history";
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message ||
|
||||
"Failed to load completion history";
|
||||
setError(msg);
|
||||
showToast.error(msg);
|
||||
} finally {
|
||||
@ -199,10 +213,13 @@ const CompletionHistory = (): ReactElement => {
|
||||
|
||||
const renderExpanded = (row: AICompletion): ReactElement => {
|
||||
const total =
|
||||
row.usage?.total_tokens ?? row.total_tokens ?? (row.prompt_tokens ?? 0) + (row.completion_tokens ?? 0);
|
||||
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">
|
||||
// <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>
|
||||
@ -214,7 +231,9 @@ const CompletionHistory = (): ReactElement => {
|
||||
{row.correlation_id || "—"}
|
||||
</p> */}
|
||||
<p>
|
||||
<span className="font-semibold text-[#475569]">Tokens / cost / latency — </span>
|
||||
<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 && (
|
||||
@ -230,8 +249,12 @@ const CompletionHistory = (): ReactElement => {
|
||||
</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>
|
||||
<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"
|
||||
@ -241,7 +264,8 @@ const CompletionHistory = (): ReactElement => {
|
||||
>
|
||||
Open full detail view →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
// </div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -249,19 +273,31 @@ const CompletionHistory = (): ReactElement => {
|
||||
{
|
||||
key: "date",
|
||||
label: "Date",
|
||||
render: (row) => <span className="whitespace-nowrap">{formatListDate(row.created_at)}</span>,
|
||||
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>,
|
||||
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>,
|
||||
render: (row) => (
|
||||
<span className="line-clamp-2">{row.user_name || "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
render: (row) => row.provider || "—",
|
||||
},
|
||||
{ key: "provider", label: "Provider", render: (row) => row.provider || "—" },
|
||||
{
|
||||
key: "model",
|
||||
label: "Model",
|
||||
@ -271,7 +307,9 @@ const CompletionHistory = (): ReactElement => {
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (row) => (
|
||||
<StatusBadge variant={row.status === "completed" ? "success" : "failure"}>
|
||||
<StatusBadge
|
||||
variant={row.status === "completed" ? "success" : "failure"}
|
||||
>
|
||||
{row.status || "unknown"}
|
||||
</StatusBadge>
|
||||
),
|
||||
@ -304,7 +342,9 @@ const CompletionHistory = (): ReactElement => {
|
||||
description:
|
||||
"Track provider/model/token usage for persisted completions.",
|
||||
action: (
|
||||
<PrimaryButton onClick={() => navigate("/tenant/ai/completions/create")}>
|
||||
<PrimaryButton
|
||||
onClick={() => navigate("/tenant/ai/completions/create")}
|
||||
>
|
||||
Create Completion
|
||||
</PrimaryButton>
|
||||
),
|
||||
@ -327,7 +367,10 @@ const CompletionHistory = (): ReactElement => {
|
||||
placeholder="All providers"
|
||||
onChange={(value) => {
|
||||
setPage(1);
|
||||
setFilters((prev) => ({ ...prev, provider: value as string | null }));
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
provider: value as string | null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<FilterDropdown
|
||||
@ -337,7 +380,10 @@ const CompletionHistory = (): ReactElement => {
|
||||
placeholder="All models"
|
||||
onChange={(value) => {
|
||||
setPage(1);
|
||||
setFilters((prev) => ({ ...prev, model: value as string | null }));
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
model: value as string | null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<FilterDropdown
|
||||
@ -351,7 +397,10 @@ const CompletionHistory = (): ReactElement => {
|
||||
placeholder="All statuses"
|
||||
onChange={(value) => {
|
||||
setPage(1);
|
||||
setFilters((prev) => ({ ...prev, status: value as string | null }));
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: value as string | null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -366,7 +415,10 @@ const CompletionHistory = (): ReactElement => {
|
||||
)}
|
||||
style={
|
||||
showMoreFilters || hasExtraFilters
|
||||
? { borderColor: `${primaryColor}55`, color: primaryColor }
|
||||
? {
|
||||
borderColor: `${primaryColor}55`,
|
||||
color: primaryColor,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@ -400,7 +452,10 @@ const CompletionHistory = (): ReactElement => {
|
||||
placeholder="All modules"
|
||||
onChange={(value) => {
|
||||
setPage(1);
|
||||
setFilters((prev) => ({ ...prev, moduleId: value as string | null }));
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
moduleId: value as string | null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<FilterDropdown
|
||||
@ -411,31 +466,44 @@ const CompletionHistory = (): ReactElement => {
|
||||
isSearchable
|
||||
onChange={(value) => {
|
||||
setPage(1);
|
||||
setFilters((prev) => ({ ...prev, userId: value as string | null }));
|
||||
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>
|
||||
<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 }));
|
||||
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>
|
||||
<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 }));
|
||||
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"
|
||||
/>
|
||||
@ -446,7 +514,9 @@ const CompletionHistory = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{isMetaLoading && (
|
||||
<p className="text-xs text-[#64748b] mt-2">Loading filter options…</p>
|
||||
<p className="text-xs text-[#64748b] mt-2">
|
||||
Loading filter options…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -565,14 +565,14 @@ const DocumentCategories = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-4 border-t border-gray-100">
|
||||
{/* <div className="flex justify-end pt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setIsViewModalOpen(false)}
|
||||
className="px-6 py-2 bg-[#0f1724] rounded-md text-sm font-bold text-white hover:bg-black transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
@ -160,6 +160,10 @@ const PromptCreate = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Create Prompt"
|
||||
breadcrumbs={[
|
||||
{ label: "AI Prompts", path: "/tenant/ai/prompts" },
|
||||
{ label: "Create Prompt" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: (
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
FormSlider,
|
||||
FormTagInput
|
||||
} from "@/components/shared";
|
||||
import { Plus, Trash2, ArrowLeft } from "lucide-react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { useForm, useFieldArray, Controller } from "react-hook-form";
|
||||
@ -201,6 +201,10 @@ const PromptEdit = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Edit Prompt"
|
||||
breadcrumbs={[
|
||||
{ label: "AI Prompts", path: "/tenant/ai/prompts" },
|
||||
{ label: "Edit Prompt" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: "Edit Prompt",
|
||||
description: "Modify prompt template and configuration settings.",
|
||||
@ -216,16 +220,6 @@ const PromptEdit = (): ReactElement => {
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => navigate("/tenant/ai/prompts")}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to list
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="flex items-start gap-6"
|
||||
|
||||
@ -229,7 +229,7 @@ export const PromptTestCaseCreate = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Input Variables Section */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden select-none">
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-5 overflow-hidden select-none">
|
||||
<div className="p-4 bg-white flex items-center justify-between border-b border-slate-100">
|
||||
<h2 className="text-base font-semibold text-slate-800 select-none">
|
||||
Input Variables
|
||||
@ -248,7 +248,7 @@ export const PromptTestCaseCreate = (): ReactElement => {
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-start gap-4 pb-1 last:pb-0"
|
||||
className="flex items-start gap-4 pb-1 last:pb-0 border-b"
|
||||
>
|
||||
<div className="flex-1 max-w-[32%] h-11 flex items-center">
|
||||
<span className="text-sm font-semibold text-slate-700">
|
||||
|
||||
@ -3,7 +3,6 @@ import { useNavigate, useSearchParams, useParams } from "react-router-dom";
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
@ -228,6 +227,10 @@ const PromptTestCases = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Prompt Management"
|
||||
breadcrumbs={[
|
||||
{ label: "AI Prompts", path: "/tenant/ai/prompts" },
|
||||
{ label: "Prompt Test Cases" },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: "Prompt Test Cases",
|
||||
description: "Manage and execute test cases for your prompt templates.",
|
||||
@ -243,16 +246,6 @@ const PromptTestCases = (): ReactElement => {
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Back navigation link */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => navigate("/tenant/ai/prompts")}
|
||||
className="flex items-center gap-1 text-xs text-[#64748b] hover:text-[#112868] transition-colors cursor-pointer font-medium select-none"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
Back to Prompts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden select-none">
|
||||
<DataTable
|
||||
@ -268,7 +261,7 @@ const PromptTestCases = (): ReactElement => {
|
||||
showExpandColumn={true}
|
||||
expandedColSpan={columns.length + 1}
|
||||
renderExpandedRow={(item: any) => (
|
||||
<div className="bg-slate-50/70 border border-slate-200/50 p-4 rounded-xl text-xs text-slate-800 select-all flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide mb-2 block select-none">
|
||||
Input Variables
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
Modal,
|
||||
FormField,
|
||||
PrimaryButton,
|
||||
GradientStatCard,
|
||||
// SecondaryButton,
|
||||
} from "@/components/shared";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
@ -199,7 +200,6 @@ const StorageDashboard = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Storage Dashboard"
|
||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||
pageHeader={{
|
||||
title: "Storage Dashboard",
|
||||
description:
|
||||
@ -247,76 +247,27 @@ const StorageDashboard = (): ReactElement => {
|
||||
<div className="space-y-8 animate-in fade-in duration-300">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${primaryColor}10` }}
|
||||
>
|
||||
<HardDrive
|
||||
className="w-4 h-4"
|
||||
style={{ color: primaryColor }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||
Usage
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xl font-black text-[#0e1b2a]">
|
||||
{stats.quota.usage_percent}%{" "}
|
||||
<span className="text-[10px] text-[#9aa6b2] font-medium uppercase">
|
||||
capacity
|
||||
</span>
|
||||
</p>
|
||||
<div className="mt-2 w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${stats.quota.usage_percent}%`,
|
||||
backgroundColor: primaryColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-emerald-50 rounded-lg">
|
||||
<Files className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||
Total Files
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xl font-black text-[#0e1b2a]">
|
||||
{stats.files.total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-orange-50 rounded-lg">
|
||||
<ImageIcon className="w-4 h-4 text-orange-500" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||
Images
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xl font-black text-[#0e1b2a]">
|
||||
{stats.files.images}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-red-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||
DOCs / PDFs
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xl font-black text-[#0e1b2a]">
|
||||
{stats.files.pdfs + stats.files.documents}
|
||||
</p>
|
||||
</div>
|
||||
<GradientStatCard
|
||||
icon={HardDrive}
|
||||
value={`${stats.quota.usage_percent}%`}
|
||||
label="Storage Usage"
|
||||
badge={{ text: "Capacity", variant: "info" }}
|
||||
/>
|
||||
<GradientStatCard
|
||||
icon={Files}
|
||||
value={stats.files.total}
|
||||
label="Total Files"
|
||||
/>
|
||||
<GradientStatCard
|
||||
icon={ImageIcon}
|
||||
value={stats.files.images}
|
||||
label="Images"
|
||||
/>
|
||||
<GradientStatCard
|
||||
icon={FileText}
|
||||
value={stats.files.pdfs + stats.files.documents}
|
||||
label="DOCs / PDFs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tables Grid */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user