fixes
This commit is contained in:
@@ -73,7 +73,6 @@ const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPa
|
||||
// TeamManagementPage - Now integrated as tab in AccountSettingsPage
|
||||
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
|
||||
const UsageDashboardPage = lazy(() => import("./pages/account/UsageDashboardPage"));
|
||||
const UsageLogsPage = lazy(() => import("./pages/account/UsageLogsPage"));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/account/ContentSettingsPage"));
|
||||
const NotificationsPage = lazy(() => import("./pages/account/NotificationsPage"));
|
||||
|
||||
@@ -241,14 +240,13 @@ export default function App() {
|
||||
<Route path="/account/plans/history" element={<PlansAndBillingPage />} />
|
||||
<Route path="/account/purchase-credits" element={<Navigate to="/account/plans" replace />} />
|
||||
|
||||
{/* Usage Dashboard - Single comprehensive page */}
|
||||
{/* Usage Dashboard - Single comprehensive page with integrated logs */}
|
||||
<Route path="/account/usage" element={<UsageDashboardPage />} />
|
||||
{/* Usage Logs - Detailed operation history */}
|
||||
<Route path="/account/usage/logs" element={<UsageLogsPage />} />
|
||||
{/* Legacy routes redirect to dashboard */}
|
||||
<Route path="/account/usage/credits" element={<UsageDashboardPage />} />
|
||||
<Route path="/account/usage/insights" element={<UsageDashboardPage />} />
|
||||
<Route path="/account/usage/activity" element={<UsageDashboardPage />} />
|
||||
<Route path="/account/usage/logs" element={<Navigate to="/account/usage" replace />} />
|
||||
<Route path="/account/usage/credits" element={<Navigate to="/account/usage" replace />} />
|
||||
<Route path="/account/usage/insights" element={<Navigate to="/account/usage" replace />} />
|
||||
<Route path="/account/usage/activity" element={<Navigate to="/account/usage" replace />} />
|
||||
|
||||
{/* Content Settings - with sub-routes for sidebar navigation */}
|
||||
<Route path="/account/content-settings" element={<ContentSettingsPage />} />
|
||||
|
||||
@@ -398,7 +398,6 @@ const SEARCH_ITEMS: SearchResult[] = [
|
||||
keywords: ['usage', 'analytics', 'stats', 'consumption', 'credits spent', 'reports', 'metrics'],
|
||||
content: 'View detailed credit usage analytics. Charts and graphs showing daily/weekly/monthly consumption. Filter by action type (content generation, images, clustering). Export usage reports.',
|
||||
quickActions: [
|
||||
{ label: 'View Logs', path: '/account/usage/logs' },
|
||||
{ label: 'Plans & Billing', path: '/account/plans' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -142,10 +142,13 @@ export default function WordPressIntegrationForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-test connection when API key changes
|
||||
// DON'T auto-test - only test when user clicks Test button
|
||||
// Just set status based on whether key exists
|
||||
useEffect(() => {
|
||||
if (apiKey && siteUrl) {
|
||||
testConnection();
|
||||
// Key exists - show as configured, but not yet tested/connected
|
||||
setConnectionStatus('unknown');
|
||||
setConnectionMessage('Click Test to verify connection');
|
||||
} else {
|
||||
setConnectionStatus('unknown');
|
||||
setConnectionMessage('');
|
||||
@@ -278,42 +281,42 @@ export default function WordPressIntegrationForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
{/* Connection Status & Test Button */}
|
||||
{apiKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status Indicator - Uses theme colors from design-system */}
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${
|
||||
connectionStatus === 'connected'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
? 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800'
|
||||
: connectionStatus === 'testing'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
? 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800'
|
||||
: connectionStatus === 'api_key_pending'
|
||||
? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
|
||||
? 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800'
|
||||
: connectionStatus === 'plugin_missing'
|
||||
? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
|
||||
? 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800'
|
||||
: connectionStatus === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
? 'bg-error-50 dark:bg-error-900/20 border-error-200 dark:border-error-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
||||
}`}>
|
||||
{connectionStatus === 'connected' && (
|
||||
<><CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">Connected</span></>
|
||||
<><CheckCircleIcon className="w-4 h-4 text-success-600 dark:text-success-400" />
|
||||
<span className="text-sm font-medium text-success-700 dark:text-success-300">Connected</span></>
|
||||
)}
|
||||
{connectionStatus === 'testing' && (
|
||||
<><RefreshCwIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Testing...</span></>
|
||||
<><RefreshCwIcon className="w-4 h-4 text-brand-600 dark:text-brand-400 animate-spin" />
|
||||
<span className="text-sm font-medium text-brand-700 dark:text-brand-300">Testing...</span></>
|
||||
)}
|
||||
{connectionStatus === 'api_key_pending' && (
|
||||
<><AlertIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">Pending Setup</span></>
|
||||
<><AlertIcon className="w-4 h-4 text-warning-600 dark:text-warning-400" />
|
||||
<span className="text-sm font-medium text-warning-700 dark:text-warning-300">Pending Setup</span></>
|
||||
)}
|
||||
{connectionStatus === 'plugin_missing' && (
|
||||
<><AlertIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">Plugin Missing</span></>
|
||||
<><AlertIcon className="w-4 h-4 text-warning-600 dark:text-warning-400" />
|
||||
<span className="text-sm font-medium text-warning-700 dark:text-warning-300">Plugin Missing</span></>
|
||||
)}
|
||||
{connectionStatus === 'error' && (
|
||||
<><AlertIcon className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">Error</span></>
|
||||
<><AlertIcon className="w-4 h-4 text-error-600 dark:text-error-400" />
|
||||
<span className="text-sm font-medium text-error-700 dark:text-error-300">Error</span></>
|
||||
)}
|
||||
{connectionStatus === 'unknown' && (
|
||||
<><InfoIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
@@ -321,16 +324,15 @@ export default function WordPressIntegrationForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Connection Button */}
|
||||
<Button
|
||||
{/* Test Connection Button - IconButton only */}
|
||||
<IconButton
|
||||
onClick={testConnection}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
tone="brand"
|
||||
disabled={testingConnection || !apiKey}
|
||||
startIcon={<RefreshCwIcon className={`w-4 h-4 ${testingConnection ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
title="Test Connection"
|
||||
icon={<RefreshCwIcon className={`w-4 h-4 ${testingConnection ? 'animate-spin' : ''}`} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -217,10 +217,7 @@ const AppSidebar: React.FC = () => {
|
||||
{
|
||||
icon: <PieChartIcon />,
|
||||
name: "Usage",
|
||||
subItems: [
|
||||
{ name: "Dashboard", path: "/account/usage" },
|
||||
{ name: "Usage Logs", path: "/account/usage/logs" },
|
||||
],
|
||||
path: "/account/usage",
|
||||
},
|
||||
{
|
||||
icon: <PlugInIcon />,
|
||||
|
||||
@@ -547,12 +547,14 @@ export default function SiteList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Welcome Guide - Shows when button clicked OR when no sites exist */}
|
||||
{/* Welcome Guide - Shows when user clicks Add New Website button OR when no sites exist */}
|
||||
{(isGuideVisible || sites.length === 0) && (
|
||||
<div className="mb-6">
|
||||
<WorkflowGuide onSiteAdded={() => {
|
||||
loadSites();
|
||||
}} />
|
||||
<WorkflowGuide
|
||||
onSiteAdded={() => {
|
||||
loadSites();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -688,7 +688,7 @@ export default function SiteSettings() {
|
||||
{/* Integration Status Indicator - Larger */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 flex-shrink-0">
|
||||
<span
|
||||
className={`inline-block w-4 h-4 rounded-full ${
|
||||
className={`inline-block w-3 h-3 rounded-full ${
|
||||
integrationStatus === 'connected' ? 'bg-success-500' :
|
||||
integrationStatus === 'configured' ? 'bg-brand-500' : 'bg-gray-300'
|
||||
}`}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Replaces the 4-tab structure with a clean, organized dashboard
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Chart from 'react-apexcharts';
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import {
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ImageIcon,
|
||||
RefreshCwIcon,
|
||||
ChevronDownIcon,
|
||||
ArrowRightIcon,
|
||||
} from '../../icons';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
@@ -34,11 +33,16 @@ import {
|
||||
type UsageSummary,
|
||||
type LimitUsage,
|
||||
getCreditUsageSummary,
|
||||
getCreditUsage,
|
||||
type CreditUsageLog,
|
||||
} from '../../services/billing.api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||
import Input from '../../components/form/input/InputField';
|
||||
import { Pagination } from '../../components/ui/pagination/Pagination';
|
||||
|
||||
// User-friendly operation names - no model/token details
|
||||
const OPERATION_LABELS: Record<string, string> = {
|
||||
@@ -76,6 +80,28 @@ const OPERATION_UNITS: Record<string, string> = {
|
||||
linking: 'Links',
|
||||
};
|
||||
|
||||
// Operation icons for usage logs table
|
||||
const OPERATION_ICONS: Record<string, React.ReactNode> = {
|
||||
content_generation: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
image_generation: <ImageIcon className="w-3.5 h-3.5" />,
|
||||
image_prompt_extraction: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
keyword_clustering: <TagIcon className="w-3.5 h-3.5" />,
|
||||
clustering: <TagIcon className="w-3.5 h-3.5" />,
|
||||
idea_generation: <ZapIcon className="w-3.5 h-3.5" />,
|
||||
content_analysis: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
linking: <TagIcon className="w-3.5 h-3.5" />,
|
||||
};
|
||||
|
||||
// Operation type options for filter
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: '', label: 'All Operations' },
|
||||
{ value: 'content_generation', label: 'Content Writing' },
|
||||
{ value: 'image_generation', label: 'Image Creation' },
|
||||
{ value: 'image_prompt_extraction', label: 'Image Prompts' },
|
||||
{ value: 'keyword_clustering', label: 'Keyword Clustering' },
|
||||
{ value: 'idea_generation', label: 'Content Ideas' },
|
||||
];
|
||||
|
||||
export default function UsageDashboardPage() {
|
||||
const toast = useToast();
|
||||
const [analytics, setAnalytics] = useState<UsageAnalytics | null>(null);
|
||||
@@ -84,6 +110,16 @@ export default function UsageDashboardPage() {
|
||||
const [creditConsumption, setCreditConsumption] = useState<Record<string, { credits: number; cost: number; count: number }>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [period, setPeriod] = useState(30);
|
||||
|
||||
// Usage Logs State
|
||||
const [logs, setLogs] = useState<CreditUsageLog[]>([]);
|
||||
const [logsLoading, setLogsLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(15);
|
||||
const [operationFilter, setOperationFilter] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
@@ -117,6 +153,86 @@ export default function UsageDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Load usage logs with filters
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
setLogsLoading(true);
|
||||
const params: Record<string, string | number> = {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
};
|
||||
|
||||
if (operationFilter) params.operation_type = operationFilter;
|
||||
if (startDate) params.start_date = startDate;
|
||||
if (endDate) params.end_date = endDate;
|
||||
|
||||
const data = await getCreditUsage(params);
|
||||
setLogs(data.results || []);
|
||||
setTotalCount(data.count || 0);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load usage logs: ${error.message}`);
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load logs when page or filters change
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [currentPage, operationFilter, startDate, endDate]);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [operationFilter, startDate, endDate]);
|
||||
|
||||
// Calculate total pages for pagination
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
setOperationFilter('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = operationFilter || startDate || endDate;
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Format cost in USD - using client_cost (client-facing price)
|
||||
const formatCost = (cost: string | null | undefined) => {
|
||||
if (!cost) return '$0.00';
|
||||
const num = parseFloat(cost);
|
||||
if (isNaN(num)) return '$0.00';
|
||||
return `$${num.toFixed(4)}`;
|
||||
};
|
||||
|
||||
// Get operation display info
|
||||
const getOperationDisplay = (type: string) => {
|
||||
return {
|
||||
label: OPERATION_LABELS[type] || type.replace(/_/g, ' '),
|
||||
icon: OPERATION_ICONS[type] || <ZapIcon className="w-3.5 h-3.5" />,
|
||||
};
|
||||
};
|
||||
|
||||
// Summary stats for logs table
|
||||
const logsSummaryStats = useMemo(() => {
|
||||
const totalCredits = logs.reduce((sum, log) => sum + log.credits_used, 0);
|
||||
const totalCost = logs.reduce((sum, log) => sum + (parseFloat(log.client_cost || '0') || 0), 0);
|
||||
return { totalCredits, totalCost };
|
||||
}, [logs]);
|
||||
|
||||
// Calculate credit usage percentage
|
||||
const creditPercentage = creditBalance && creditBalance.plan_credits_per_month > 0
|
||||
? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100)
|
||||
@@ -583,29 +699,166 @@ export default function UsageDashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* SECTION 4: Quick Link to Detailed Logs */}
|
||||
<Card className="p-6 bg-gradient-to-r from-gray-50 to-brand-50 dark:from-gray-800 dark:to-brand-900/20 border-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<FileTextIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||
{/* SECTION 4: Usage Logs Table - Full Width */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col gap-4 mb-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<FileTextIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Usage Logs</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Complete history of all AI operations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Need More Details?</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
View complete history of all AI operations with filters, dates, and USD costs
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{totalCount.toLocaleString()} total operations
|
||||
</span>
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-brand-600 dark:text-brand-400">
|
||||
{logsSummaryStats.totalCredits.toLocaleString()} credits ({formatCost(logsSummaryStats.totalCost.toString())})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/account/usage/logs">
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="brand"
|
||||
endIcon={<ArrowRightIcon className="w-4 h-4" />}
|
||||
>
|
||||
View Usage Logs
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Filters - Single Row */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="w-44">
|
||||
<SelectDropdown
|
||||
options={OPERATION_OPTIONS}
|
||||
value={operationFilter}
|
||||
onChange={setOperationFilter}
|
||||
placeholder="All Operations"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">From</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
placeholder="Start Date"
|
||||
className="h-9 text-sm w-36"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">To</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
placeholder="End Date"
|
||||
className="h-9 text-sm w-36"
|
||||
/>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table - Full Width - Simplified for users (no model/tokens) */}
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="igny8-table-compact min-w-full w-full">
|
||||
<thead className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Date</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Operation</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Site</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">Credits</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">Cost (USD)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||
{logsLoading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="igny8-skeleton-row">
|
||||
<td className="px-4 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-28" /></td>
|
||||
<td className="px-4 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-24" /></td>
|
||||
<td className="px-4 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-20" /></td>
|
||||
<td className="px-4 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-12 mx-auto" /></td>
|
||||
<td className="px-4 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-16 mx-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<div className="text-center py-12">
|
||||
<FileTextIcon className="w-10 h-10 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-1">
|
||||
No usage logs found
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{hasActiveFilters
|
||||
? 'Try adjusting your filters to see more results.'
|
||||
: 'Your AI operation history will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
const operationDisplay = getOperationDisplay(log.operation_type);
|
||||
return (
|
||||
<tr key={log.id} className="igny8-data-row">
|
||||
<td className="px-4 py-2.5 text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
{operationDisplay.icon}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{operationDisplay.label}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||
{log.site_name || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center font-medium text-gray-900 dark:text-white">
|
||||
{log.credits_used.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center text-gray-600 dark:text-gray-400">
|
||||
{formatCost(log.client_cost)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-white/[0.05] flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount.toLocaleString()}
|
||||
</span>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
variant="icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
/**
|
||||
* Usage Logs Page - Detailed AI Operation Logs
|
||||
* Shows a filterable, paginated table of all credit usage
|
||||
* Consistent layout with Planner/Writer table pages
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
FileTextIcon,
|
||||
ImageIcon,
|
||||
TagIcon,
|
||||
ZapIcon,
|
||||
RefreshCwIcon,
|
||||
DollarSignIcon,
|
||||
TrendingUpIcon,
|
||||
InfoIcon,
|
||||
} from '../../icons';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||
import Input from '../../components/form/input/InputField';
|
||||
import { Pagination } from '../../components/ui/pagination/Pagination';
|
||||
import { getCreditUsage, getCreditUsageSummary, type CreditUsageLog } from '../../services/billing.api';
|
||||
|
||||
// User-friendly operation names (no model/token details)
|
||||
const OPERATION_LABELS: Record<string, string> = {
|
||||
content_generation: 'Content Writing',
|
||||
image_generation: 'Image Creation',
|
||||
image_prompt_extraction: 'Image Prompts',
|
||||
keyword_clustering: 'Keyword Clustering',
|
||||
clustering: 'Keyword Clustering',
|
||||
idea_generation: 'Content Ideas',
|
||||
content_analysis: 'Content Analysis',
|
||||
linking: 'Internal Linking',
|
||||
};
|
||||
|
||||
// Operation icons
|
||||
const OPERATION_ICONS: Record<string, React.ReactNode> = {
|
||||
content_generation: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
image_generation: <ImageIcon className="w-3.5 h-3.5" />,
|
||||
image_prompt_extraction: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
keyword_clustering: <TagIcon className="w-3.5 h-3.5" />,
|
||||
clustering: <TagIcon className="w-3.5 h-3.5" />,
|
||||
idea_generation: <ZapIcon className="w-3.5 h-3.5" />,
|
||||
content_analysis: <FileTextIcon className="w-3.5 h-3.5" />,
|
||||
linking: <TagIcon className="w-3.5 h-3.5" />,
|
||||
};
|
||||
|
||||
// Operation type options for filter (only enabled operations)
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: '', label: 'All Operations' },
|
||||
{ value: 'content_generation', label: 'Content Writing' },
|
||||
{ value: 'image_generation', label: 'Image Creation' },
|
||||
{ value: 'image_prompt_extraction', label: 'Image Prompts' },
|
||||
{ value: 'keyword_clustering', label: 'Keyword Clustering' },
|
||||
{ value: 'idea_generation', label: 'Content Ideas' },
|
||||
];
|
||||
|
||||
export default function UsageLogsPage() {
|
||||
const toast = useToast();
|
||||
|
||||
// Data state
|
||||
const [logs, setLogs] = useState<CreditUsageLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
// Filter state
|
||||
const [operationFilter, setOperationFilter] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// Calculate total pages
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
// Load usage logs
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params: any = {};
|
||||
|
||||
if (operationFilter) {
|
||||
params.operation_type = operationFilter;
|
||||
}
|
||||
if (startDate) {
|
||||
params.start_date = startDate;
|
||||
}
|
||||
if (endDate) {
|
||||
params.end_date = endDate;
|
||||
}
|
||||
|
||||
// Add pagination params
|
||||
params.page = currentPage;
|
||||
params.page_size = pageSize;
|
||||
|
||||
const data = await getCreditUsage(params);
|
||||
setLogs(data.results || []);
|
||||
setTotalCount(data.count || 0);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load usage logs: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load on mount and when filters change
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [currentPage, operationFilter, startDate, endDate]);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [operationFilter, startDate, endDate]);
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Format cost in USD
|
||||
const formatCost = (cost: string | null | undefined) => {
|
||||
if (!cost) return '$0.00';
|
||||
const num = parseFloat(cost);
|
||||
if (isNaN(num)) return '$0.00';
|
||||
return `$${num.toFixed(4)}`;
|
||||
};
|
||||
|
||||
// Get operation display info
|
||||
const getOperationDisplay = (type: string) => {
|
||||
return {
|
||||
label: OPERATION_LABELS[type] || type.replace(/_/g, ' '),
|
||||
icon: OPERATION_ICONS[type] || <ZapIcon className="w-3.5 h-3.5" />,
|
||||
};
|
||||
};
|
||||
|
||||
// Summary stats - calculate from all loaded logs
|
||||
const summaryStats = useMemo(() => {
|
||||
const totalCredits = logs.reduce((sum, log) => sum + log.credits_used, 0);
|
||||
const totalCost = logs.reduce((sum, log) => sum + (parseFloat(log.cost_usd || '0') || 0), 0);
|
||||
const avgCreditsPerOp = logs.length > 0 ? Math.round(totalCredits / logs.length) : 0;
|
||||
|
||||
// Count by operation type
|
||||
const byOperation = logs.reduce((acc, log) => {
|
||||
const op = log.operation_type;
|
||||
acc[op] = (acc[op] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const topOperation = Object.entries(byOperation).sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
return { totalCredits, totalCost, avgCreditsPerOp, topOperation };
|
||||
}, [logs]);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
setOperationFilter('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = operationFilter || startDate || endDate;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Usage Logs" description="Detailed log of all AI operations" />
|
||||
<PageHeader
|
||||
title="Usage Logs"
|
||||
description="Detailed history of all your AI operations and credit usage"
|
||||
badge={{ icon: <FileTextIcon className="w-4 h-4" />, color: 'purple' }}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/account/usage">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
startIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
tone="neutral"
|
||||
onClick={loadLogs}
|
||||
startIcon={<RefreshCwIcon className="w-4 h-4" />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Summary Cards - 5 metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<ZapIcon className="w-4 h-4 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{summaryStats.totalCredits.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Credits Used
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<DollarSignIcon className="w-4 h-4 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCost(summaryStats.totalCost.toString())}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Total Cost
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<CalendarIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{totalCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Operations
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
|
||||
<TrendingUpIcon className="w-4 h-4 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{summaryStats.avgCreditsPerOp}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Avg/Operation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-info-100 dark:bg-info-900/30 rounded-lg">
|
||||
<FileTextIcon className="w-4 h-4 text-info-600 dark:text-info-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white truncate">
|
||||
{summaryStats.topOperation ? OPERATION_LABELS[summaryStats.topOperation[0]] || summaryStats.topOperation[0] : '-'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Top Operation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters - Inline style like Planner pages */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="w-44">
|
||||
<SelectDropdown
|
||||
options={OPERATION_OPTIONS}
|
||||
value={operationFilter}
|
||||
onChange={setOperationFilter}
|
||||
placeholder="All Operations"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
placeholder="Start Date"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
placeholder="End Date"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table - Half width on large screens */}
|
||||
<div className="lg:w-1/2">
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="igny8-table-compact min-w-full w-full">
|
||||
<thead className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Date</th>
|
||||
<th className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">Operation</th>
|
||||
<th className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">Credits</th>
|
||||
<th className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">Cost (USD)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||
{loading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<tr key={i} className="igny8-skeleton-row">
|
||||
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-28" /></td>
|
||||
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-24" /></td>
|
||||
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-12 mx-auto" /></td>
|
||||
<td className="px-5 py-2.5"><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-16 mx-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<div className="text-center py-12">
|
||||
<FileTextIcon className="w-10 h-10 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-1">
|
||||
No usage logs found
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{hasActiveFilters
|
||||
? 'Try adjusting your filters to see more results.'
|
||||
: 'Your AI operation history will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
const operationDisplay = getOperationDisplay(log.operation_type);
|
||||
return (
|
||||
<tr key={log.id} className="igny8-data-row">
|
||||
<td className="px-5 py-2.5 text-gray-600 dark:text-gray-400">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
{operationDisplay.icon}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{operationDisplay.label}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-center font-medium text-gray-900 dark:text-white">
|
||||
{log.credits_used.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-center text-gray-600 dark:text-gray-400">
|
||||
{formatCost(log.cost_usd)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-5 py-3 border-t border-gray-100 dark:border-white/[0.05] flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount.toLocaleString()}
|
||||
</span>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
variant="icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,9 +35,13 @@ export interface CreditUsageLog {
|
||||
operation_type: string;
|
||||
credits_used: number;
|
||||
cost_usd: string;
|
||||
client_cost?: string; // Client-facing cost (credits * price_per_credit)
|
||||
model_used?: string;
|
||||
tokens_input?: number;
|
||||
tokens_output?: number;
|
||||
related_object_type?: string;
|
||||
related_object_id?: number;
|
||||
site_name?: string;
|
||||
created_at: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user