This commit is contained in:
IGNY8 VPS (Salman)
2026-01-13 01:23:54 +00:00
parent 78c9cd38e0
commit 47a5a8b1da
10 changed files with 343 additions and 488 deletions

View File

@@ -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 />} />

View File

@@ -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' },
]
},

View File

@@ -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>

View File

@@ -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 />,

View File

@@ -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>
)}

View File

@@ -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'
}`}

View File

@@ -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>

View File

@@ -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>
</>
);
}

View File

@@ -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>;
}