diff --git a/backend/igny8_core/modules/billing/serializers.py b/backend/igny8_core/modules/billing/serializers.py index 9a821328..d0a8afee 100644 --- a/backend/igny8_core/modules/billing/serializers.py +++ b/backend/igny8_core/modules/billing/serializers.py @@ -31,15 +31,35 @@ class CreditUsageLogSerializer(serializers.ModelSerializer): source='get_operation_type_display', 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: model = CreditUsageLog fields = [ 'id', 'operation_type', 'operation_type_display', 'credits_used', - 'cost_usd', 'model_used', 'tokens_input', 'tokens_output', - 'related_object_type', 'related_object_id', 'metadata', 'created_at' + 'cost_usd', 'client_cost', 'model_used', 'tokens_input', 'tokens_output', + 'related_object_type', 'related_object_id', 'site_name', 'metadata', 'created_at' ] 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): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a1f3ce4..83cc9e40 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> - {/* Usage Dashboard - Single comprehensive page */} + {/* Usage Dashboard - Single comprehensive page with integrated logs */} } /> - {/* Usage Logs - Detailed operation history */} - } /> {/* Legacy routes redirect to dashboard */} - } /> - } /> - } /> + } /> + } /> + } /> + } /> {/* Content Settings - with sub-routes for sidebar navigation */} } /> diff --git a/frontend/src/components/common/SearchModal.tsx b/frontend/src/components/common/SearchModal.tsx index f9581a77..3be92267 100644 --- a/frontend/src/components/common/SearchModal.tsx +++ b/frontend/src/components/common/SearchModal.tsx @@ -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' }, ] }, diff --git a/frontend/src/components/sites/WordPressIntegrationForm.tsx b/frontend/src/components/sites/WordPressIntegrationForm.tsx index 290dc345..3caa4b42 100644 --- a/frontend/src/components/sites/WordPressIntegrationForm.tsx +++ b/frontend/src/components/sites/WordPressIntegrationForm.tsx @@ -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({ - {/* Connection Status */} + {/* Connection Status & Test Button */} {apiKey && ( -
- {/* Status Badge */} +
+ {/* Status Indicator - Uses theme colors from design-system */}
{connectionStatus === 'connected' && ( - <> - Connected + <> + Connected )} {connectionStatus === 'testing' && ( - <> - Testing... + <> + Testing... )} {connectionStatus === 'api_key_pending' && ( - <> - Pending Setup + <> + Pending Setup )} {connectionStatus === 'plugin_missing' && ( - <> - Plugin Missing + <> + Plugin Missing )} {connectionStatus === 'error' && ( - <> - Error + <> + Error )} {connectionStatus === 'unknown' && ( <> @@ -321,16 +324,15 @@ export default function WordPressIntegrationForm({ )}
- {/* Test Connection Button */} - + title="Test Connection" + icon={} + />
)}
diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 8c3c4fca..01d939ac 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -217,10 +217,7 @@ const AppSidebar: React.FC = () => { { icon: , name: "Usage", - subItems: [ - { name: "Dashboard", path: "/account/usage" }, - { name: "Usage Logs", path: "/account/usage/logs" }, - ], + path: "/account/usage", }, { icon: , diff --git a/frontend/src/pages/Sites/List.tsx b/frontend/src/pages/Sites/List.tsx index 11d72980..d2c942e7 100644 --- a/frontend/src/pages/Sites/List.tsx +++ b/frontend/src/pages/Sites/List.tsx @@ -547,12 +547,14 @@ export default function SiteList() { - {/* 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) && (
- { - loadSites(); - }} /> + { + loadSites(); + }} + />
)} diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx index 1267b261..221d0187 100644 --- a/frontend/src/pages/Sites/Settings.tsx +++ b/frontend/src/pages/Sites/Settings.tsx @@ -688,7 +688,7 @@ export default function SiteSettings() { {/* Integration Status Indicator - Larger */}
= { @@ -76,6 +80,28 @@ const OPERATION_UNITS: Record = { linking: 'Links', }; +// Operation icons for usage logs table +const OPERATION_ICONS: Record = { + content_generation: , + image_generation: , + image_prompt_extraction: , + keyword_clustering: , + clustering: , + idea_generation: , + content_analysis: , + linking: , +}; + +// 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(null); @@ -84,6 +110,16 @@ export default function UsageDashboardPage() { const [creditConsumption, setCreditConsumption] = useState>({}); const [loading, setLoading] = useState(true); const [period, setPeriod] = useState(30); + + // Usage Logs State + const [logs, setLogs] = useState([]); + 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 = { + 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] || , + }; + }; + + // 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() {
- {/* SECTION 4: Quick Link to Detailed Logs */} - -
-
-
- + {/* SECTION 4: Usage Logs Table - Full Width */} + +
+ {/* Header */} +
+
+
+ +
+
+

Usage Logs

+

Complete history of all AI operations

+
-
-

Need More Details?

-

- View complete history of all AI operations with filters, dates, and USD costs -

+
+ + {totalCount.toLocaleString()} total operations + + {hasActiveFilters && ( + <> + + + {logsSummaryStats.totalCredits.toLocaleString()} credits ({formatCost(logsSummaryStats.totalCost.toString())}) + + + )}
- - - + + {/* Filters - Single Row */} +
+
+ +
+
+ From + setStartDate(e.target.value)} + placeholder="Start Date" + className="h-9 text-sm w-36" + /> +
+
+ To + setEndDate(e.target.value)} + placeholder="End Date" + className="h-9 text-sm w-36" + /> +
+ {hasActiveFilters && ( + + )} +
+
+ + {/* Table - Full Width - Simplified for users (no model/tokens) */} +
+
+ + + + + + + + + + + + {logsLoading ? ( + // Loading skeleton + Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + )) + ) : logs.length === 0 ? ( + + + + ) : ( + logs.map((log) => { + const operationDisplay = getOperationDisplay(log.operation_type); + return ( + + + + + + + + ); + }) + )} + +
DateOperationSiteCreditsCost (USD)
+
+ +

+ No usage logs found +

+

+ {hasActiveFilters + ? 'Try adjusting your filters to see more results.' + : 'Your AI operation history will appear here.'} +

+
+
+ {formatDate(log.created_at)} + +
+
+ {operationDisplay.icon} +
+ + {operationDisplay.label} + +
+
+ {log.site_name || '-'} + + {log.credits_used.toLocaleString()} + + {formatCost(log.client_cost)} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount.toLocaleString()} + + +
+ )}
diff --git a/frontend/src/pages/account/UsageLogsPage.tsx b/frontend/src/pages/account/UsageLogsPage.tsx deleted file mode 100644 index 8ce4e057..00000000 --- a/frontend/src/pages/account/UsageLogsPage.tsx +++ /dev/null @@ -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 = { - 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 = { - content_generation: , - image_generation: , - image_prompt_extraction: , - keyword_clustering: , - clustering: , - idea_generation: , - content_analysis: , - linking: , -}; - -// 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([]); - 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] || , - }; - }; - - // 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); - 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 ( - <> - - , color: 'purple' }} - actions={ -
- - - - -
- } - /> - -
- {/* Summary Cards - 5 metrics */} -
- -
-
- -
-
-
- {summaryStats.totalCredits.toLocaleString()} -
-
- Credits Used -
-
-
-
- -
-
- -
-
-
- {formatCost(summaryStats.totalCost.toString())} -
-
- Total Cost -
-
-
-
- -
-
- -
-
-
- {totalCount.toLocaleString()} -
-
- Operations -
-
-
-
- -
-
- -
-
-
- {summaryStats.avgCreditsPerOp} -
-
- Avg/Operation -
-
-
-
- -
-
- -
-
-
- {summaryStats.topOperation ? OPERATION_LABELS[summaryStats.topOperation[0]] || summaryStats.topOperation[0] : '-'} -
-
- Top Operation -
-
-
-
-
- - {/* Filters - Inline style like Planner pages */} -
-
- -
-
- setStartDate(e.target.value)} - placeholder="Start Date" - className="h-9 text-sm" - /> -
-
- setEndDate(e.target.value)} - placeholder="End Date" - className="h-9 text-sm" - /> -
- {hasActiveFilters && ( - - )} -
- - {/* Table - Half width on large screens */} -
-
-
- - - - - - - - - - - {loading ? ( - // Loading skeleton - Array.from({ length: 10 }).map((_, i) => ( - - - - - - - )) - ) : logs.length === 0 ? ( - - - - ) : ( - logs.map((log) => { - const operationDisplay = getOperationDisplay(log.operation_type); - return ( - - - - - - - ); - }) - )} - -
DateOperationCreditsCost (USD)
-
- -

- No usage logs found -

-

- {hasActiveFilters - ? 'Try adjusting your filters to see more results.' - : 'Your AI operation history will appear here.'} -

-
-
- {formatDate(log.created_at)} - -
-
- {operationDisplay.icon} -
- - {operationDisplay.label} - -
-
- {log.credits_used.toLocaleString()} - - {formatCost(log.cost_usd)} -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- - Showing {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, totalCount)} of {totalCount.toLocaleString()} - - -
- )} -
-
-
- - ); -} diff --git a/frontend/src/services/billing.api.ts b/frontend/src/services/billing.api.ts index d9f6f52f..8876ca67 100644 --- a/frontend/src/services/billing.api.ts +++ b/frontend/src/services/billing.api.ts @@ -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; }