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

@@ -31,15 +31,35 @@ class CreditUsageLogSerializer(serializers.ModelSerializer):
source='get_operation_type_display', source='get_operation_type_display',
read_only=True read_only=True
) )
client_cost = serializers.SerializerMethodField(help_text='Client-facing cost (credits * price_per_credit)')
site_name = serializers.SerializerMethodField(help_text='Name of the associated site')
class Meta: class Meta:
model = CreditUsageLog model = CreditUsageLog
fields = [ fields = [
'id', 'operation_type', 'operation_type_display', 'credits_used', 'id', 'operation_type', 'operation_type_display', 'credits_used',
'cost_usd', 'model_used', 'tokens_input', 'tokens_output', 'cost_usd', 'client_cost', 'model_used', 'tokens_input', 'tokens_output',
'related_object_type', 'related_object_id', 'metadata', 'created_at' 'related_object_type', 'related_object_id', 'site_name', 'metadata', 'created_at'
] ]
read_only_fields = ['created_at', 'account'] read_only_fields = ['created_at', 'account']
def get_client_cost(self, obj) -> str:
"""Calculate client-facing cost from credits * default_credit_price_usd"""
from igny8_core.business.billing.models import BillingConfiguration
try:
config = BillingConfiguration.get_config()
price_per_credit = config.default_credit_price_usd
client_cost = Decimal(obj.credits_used) * price_per_credit
return str(client_cost.quantize(Decimal('0.0001')))
except Exception:
# Fallback to cost_usd if billing config unavailable
return str(obj.cost_usd) if obj.cost_usd else '0.0000'
def get_site_name(self, obj) -> Optional[str]:
"""Get the site name if available"""
if obj.site:
return obj.site.name
return None
class CreditBalanceSerializer(serializers.Serializer): class CreditBalanceSerializer(serializers.Serializer):

View File

@@ -73,7 +73,6 @@ const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPa
// TeamManagementPage - Now integrated as tab in AccountSettingsPage // TeamManagementPage - Now integrated as tab in AccountSettingsPage
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage")); const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
const UsageDashboardPage = lazy(() => import("./pages/account/UsageDashboardPage")); const UsageDashboardPage = lazy(() => import("./pages/account/UsageDashboardPage"));
const UsageLogsPage = lazy(() => import("./pages/account/UsageLogsPage"));
const ContentSettingsPage = lazy(() => import("./pages/account/ContentSettingsPage")); const ContentSettingsPage = lazy(() => import("./pages/account/ContentSettingsPage"));
const NotificationsPage = lazy(() => import("./pages/account/NotificationsPage")); 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/plans/history" element={<PlansAndBillingPage />} />
<Route path="/account/purchase-credits" element={<Navigate to="/account/plans" replace />} /> <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 />} /> <Route path="/account/usage" element={<UsageDashboardPage />} />
{/* Usage Logs - Detailed operation history */}
<Route path="/account/usage/logs" element={<UsageLogsPage />} />
{/* Legacy routes redirect to dashboard */} {/* Legacy routes redirect to dashboard */}
<Route path="/account/usage/credits" element={<UsageDashboardPage />} /> <Route path="/account/usage/logs" element={<Navigate to="/account/usage" replace />} />
<Route path="/account/usage/insights" element={<UsageDashboardPage />} /> <Route path="/account/usage/credits" element={<Navigate to="/account/usage" replace />} />
<Route path="/account/usage/activity" element={<UsageDashboardPage />} /> <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 */} {/* Content Settings - with sub-routes for sidebar navigation */}
<Route path="/account/content-settings" element={<ContentSettingsPage />} /> <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'], 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.', 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: [ quickActions: [
{ label: 'View Logs', path: '/account/usage/logs' },
{ label: 'Plans & Billing', path: '/account/plans' }, { 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(() => { useEffect(() => {
if (apiKey && siteUrl) { if (apiKey && siteUrl) {
testConnection(); // Key exists - show as configured, but not yet tested/connected
setConnectionStatus('unknown');
setConnectionMessage('Click Test to verify connection');
} else { } else {
setConnectionStatus('unknown'); setConnectionStatus('unknown');
setConnectionMessage(''); setConnectionMessage('');
@@ -278,42 +281,42 @@ export default function WordPressIntegrationForm({
</div> </div>
</div> </div>
{/* Connection Status */} {/* Connection Status & Test Button */}
{apiKey && ( {apiKey && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
{/* Status Badge */} {/* Status Indicator - Uses theme colors from design-system */}
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${ <div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${
connectionStatus === 'connected' 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' : 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' : 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' : 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' : 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' : 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
}`}> }`}>
{connectionStatus === 'connected' && ( {connectionStatus === 'connected' && (
<><CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" /> <><CheckCircleIcon className="w-4 h-4 text-success-600 dark:text-success-400" />
<span className="text-sm font-medium text-green-700 dark:text-green-300">Connected</span></> <span className="text-sm font-medium text-success-700 dark:text-success-300">Connected</span></>
)} )}
{connectionStatus === 'testing' && ( {connectionStatus === 'testing' && (
<><RefreshCwIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" /> <><RefreshCwIcon className="w-4 h-4 text-brand-600 dark:text-brand-400 animate-spin" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Testing...</span></> <span className="text-sm font-medium text-brand-700 dark:text-brand-300">Testing...</span></>
)} )}
{connectionStatus === 'api_key_pending' && ( {connectionStatus === 'api_key_pending' && (
<><AlertIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" /> <><AlertIcon className="w-4 h-4 text-warning-600 dark:text-warning-400" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">Pending Setup</span></> <span className="text-sm font-medium text-warning-700 dark:text-warning-300">Pending Setup</span></>
)} )}
{connectionStatus === 'plugin_missing' && ( {connectionStatus === 'plugin_missing' && (
<><AlertIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" /> <><AlertIcon className="w-4 h-4 text-warning-600 dark:text-warning-400" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">Plugin Missing</span></> <span className="text-sm font-medium text-warning-700 dark:text-warning-300">Plugin Missing</span></>
)} )}
{connectionStatus === 'error' && ( {connectionStatus === 'error' && (
<><AlertIcon className="w-4 h-4 text-red-600 dark:text-red-400" /> <><AlertIcon className="w-4 h-4 text-error-600 dark:text-error-400" />
<span className="text-sm font-medium text-red-700 dark:text-red-300">Error</span></> <span className="text-sm font-medium text-error-700 dark:text-error-300">Error</span></>
)} )}
{connectionStatus === 'unknown' && ( {connectionStatus === 'unknown' && (
<><InfoIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" /> <><InfoIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
@@ -321,16 +324,15 @@ export default function WordPressIntegrationForm({
)} )}
</div> </div>
{/* Test Connection Button */} {/* Test Connection Button - IconButton only */}
<Button <IconButton
onClick={testConnection} onClick={testConnection}
variant="outline" variant="outline"
size="sm" tone="brand"
disabled={testingConnection || !apiKey} disabled={testingConnection || !apiKey}
startIcon={<RefreshCwIcon className={`w-4 h-4 ${testingConnection ? 'animate-spin' : ''}`} />} title="Test Connection"
> icon={<RefreshCwIcon className={`w-4 h-4 ${testingConnection ? 'animate-spin' : ''}`} />}
Test />
</Button>
</div> </div>
)} )}
</div> </div>

View File

@@ -217,10 +217,7 @@ const AppSidebar: React.FC = () => {
{ {
icon: <PieChartIcon />, icon: <PieChartIcon />,
name: "Usage", name: "Usage",
subItems: [ path: "/account/usage",
{ name: "Dashboard", path: "/account/usage" },
{ name: "Usage Logs", path: "/account/usage/logs" },
],
}, },
{ {
icon: <PlugInIcon />, icon: <PlugInIcon />,

View File

@@ -547,12 +547,14 @@ export default function SiteList() {
</div> </div>
</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) && ( {(isGuideVisible || sites.length === 0) && (
<div className="mb-6"> <div className="mb-6">
<WorkflowGuide onSiteAdded={() => { <WorkflowGuide
loadSites(); onSiteAdded={() => {
}} /> loadSites();
}}
/>
</div> </div>
)} )}

View File

@@ -688,7 +688,7 @@ export default function SiteSettings() {
{/* Integration Status Indicator - Larger */} {/* 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"> <div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 flex-shrink-0">
<span <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 === 'connected' ? 'bg-success-500' :
integrationStatus === 'configured' ? 'bg-brand-500' : 'bg-gray-300' integrationStatus === 'configured' ? 'bg-brand-500' : 'bg-gray-300'
}`} }`}

View File

@@ -4,7 +4,7 @@
* Replaces the 4-tab structure with a clean, organized dashboard * 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 Chart from 'react-apexcharts';
import { ApexOptions } from 'apexcharts'; import { ApexOptions } from 'apexcharts';
import { import {
@@ -20,7 +20,6 @@ import {
ImageIcon, ImageIcon,
RefreshCwIcon, RefreshCwIcon,
ChevronDownIcon, ChevronDownIcon,
ArrowRightIcon,
} from '../../icons'; } from '../../icons';
import PageMeta from '../../components/common/PageMeta'; import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
@@ -34,11 +33,16 @@ import {
type UsageSummary, type UsageSummary,
type LimitUsage, type LimitUsage,
getCreditUsageSummary, getCreditUsageSummary,
getCreditUsage,
type CreditUsageLog,
} from '../../services/billing.api'; } from '../../services/billing.api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
import Button from '../../components/ui/button/Button'; import Button from '../../components/ui/button/Button';
import { Link } from 'react-router-dom'; 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 // User-friendly operation names - no model/token details
const OPERATION_LABELS: Record<string, string> = { const OPERATION_LABELS: Record<string, string> = {
@@ -76,6 +80,28 @@ const OPERATION_UNITS: Record<string, string> = {
linking: 'Links', 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() { export default function UsageDashboardPage() {
const toast = useToast(); const toast = useToast();
const [analytics, setAnalytics] = useState<UsageAnalytics | null>(null); 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 [creditConsumption, setCreditConsumption] = useState<Record<string, { credits: number; cost: number; count: number }>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState(30); 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(() => { useEffect(() => {
loadAllData(); 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 // Calculate credit usage percentage
const creditPercentage = creditBalance && creditBalance.plan_credits_per_month > 0 const creditPercentage = creditBalance && creditBalance.plan_credits_per_month > 0
? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100) ? Math.round((creditBalance.credits_used_this_month / creditBalance.plan_credits_per_month) * 100)
@@ -583,29 +699,166 @@ export default function UsageDashboardPage() {
</Card> </Card>
</div> </div>
{/* SECTION 4: Quick Link to Detailed Logs */} {/* SECTION 4: Usage Logs Table - Full Width */}
<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"> <Card className="p-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 mb-4">
<div className="flex items-center gap-4"> {/* Header */}
<div className="p-3 bg-white dark:bg-gray-800 rounded-xl shadow-sm"> <div className="flex items-center justify-between">
<FileTextIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" /> <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>
<div> <div className="flex items-center gap-2 text-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Need More Details?</h3> <span className="text-gray-600 dark:text-gray-400">
<p className="text-sm text-gray-600 dark:text-gray-400"> {totalCount.toLocaleString()} total operations
View complete history of all AI operations with filters, dates, and USD costs </span>
</p> {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>
</div> </div>
<Link to="/account/usage/logs">
<Button {/* Filters - Single Row */}
variant="primary" <div className="flex flex-wrap items-center gap-3">
tone="brand" <div className="w-44">
endIcon={<ArrowRightIcon className="w-4 h-4" />} <SelectDropdown
> options={OPERATION_OPTIONS}
View Usage Logs value={operationFilter}
</Button> onChange={setOperationFilter}
</Link> 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> </div>
</Card> </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; operation_type: string;
credits_used: number; credits_used: number;
cost_usd: string; cost_usd: string;
client_cost?: string; // Client-facing cost (credits * price_per_credit)
model_used?: string; model_used?: string;
tokens_input?: number; tokens_input?: number;
tokens_output?: number; tokens_output?: number;
related_object_type?: string;
related_object_id?: number;
site_name?: string;
created_at: string; created_at: string;
metadata?: Record<string, any>; metadata?: Record<string, any>;
} }