| 1 |
/** |
| 2 |
* Usage Breakdown Component |
| 3 |
* |
| 4 |
* Detailed AI usage breakdown with tabular view |
| 5 |
* |
| 6 |
* @package ThinkRank |
| 7 |
* @since 1.0.0 |
| 8 |
*/ |
| 9 |
|
| 10 |
import { __ } from '@wordpress/i18n'; |
| 11 |
import { Spinner, Notice, SelectControl, Button } from '@wordpress/components'; |
| 12 |
import { useState, useEffect } from '@wordpress/element'; |
| 13 |
import apiFetch from '@wordpress/api-fetch'; |
| 14 |
import ModelBadge from '../shared/ModelBadge'; |
| 15 |
|
| 16 |
/** |
| 17 |
* Usage Breakdown Table Component |
| 18 |
*/ |
| 19 |
const UsageBreakdownTable = ({ records, isLoading }) => { |
| 20 |
if (isLoading) { |
| 21 |
return ( |
| 22 |
<div className="thinkrank-text-center thinkrank-py-8"> |
| 23 |
<Spinner /> |
| 24 |
<p className="thinkrank-mt-4 thinkrank-text-secondary"> |
| 25 |
{__('Loading usage data...', 'thinkrank')} |
| 26 |
</p> |
| 27 |
</div> |
| 28 |
); |
| 29 |
} |
| 30 |
|
| 31 |
if (!records || records.length === 0) { |
| 32 |
return ( |
| 33 |
<div className="thinkrank-text-center thinkrank-py-8"> |
| 34 |
<p className="thinkrank-text-secondary"> |
| 35 |
{__('No usage data found for the selected period.', 'thinkrank')} |
| 36 |
</p> |
| 37 |
</div> |
| 38 |
); |
| 39 |
} |
| 40 |
|
| 41 |
return ( |
| 42 |
<div className="thinkrank-overflow-x-auto"> |
| 43 |
<table className="thinkrank-table thinkrank-w-full"> |
| 44 |
<thead> |
| 45 |
<tr className="thinkrank-border-b thinkrank-border-gray-200"> |
| 46 |
<th className="thinkrank-text-left thinkrank-py-3 thinkrank-px-4 thinkrank-font-semibold thinkrank-text-primary"> |
| 47 |
{__('Date & Time', 'thinkrank')} |
| 48 |
</th> |
| 49 |
<th className="thinkrank-text-left thinkrank-py-3 thinkrank-px-4 thinkrank-font-semibold thinkrank-text-primary"> |
| 50 |
{__('Action', 'thinkrank')} |
| 51 |
</th> |
| 52 |
<th className="thinkrank-text-left thinkrank-py-3 thinkrank-px-4 thinkrank-font-semibold thinkrank-text-primary"> |
| 53 |
{__('AI Model', 'thinkrank')} |
| 54 |
</th> |
| 55 |
<th className="thinkrank-text-right thinkrank-py-3 thinkrank-px-4 thinkrank-font-semibold thinkrank-text-primary"> |
| 56 |
{__('Tokens', 'thinkrank')} |
| 57 |
</th> |
| 58 |
<th className="thinkrank-text-right thinkrank-py-3 thinkrank-px-4 thinkrank-font-semibold thinkrank-text-primary"> |
| 59 |
{__('Est. Cost', 'thinkrank')} |
| 60 |
</th> |
| 61 |
</tr> |
| 62 |
</thead> |
| 63 |
<tbody> |
| 64 |
{records.map((record) => ( |
| 65 |
<tr key={record.id} className="thinkrank-border-b thinkrank-border-gray-100 hover:thinkrank-bg-gray-50"> |
| 66 |
<td className="thinkrank-py-3 thinkrank-px-4 thinkrank-text-sm"> |
| 67 |
<div className="thinkrank-font-medium thinkrank-text-primary"> |
| 68 |
{record.formatted_date} |
| 69 |
</div> |
| 70 |
</td> |
| 71 |
<td className="thinkrank-py-3 thinkrank-px-4 thinkrank-text-sm"> |
| 72 |
<div className="thinkrank-font-medium thinkrank-text-primary"> |
| 73 |
{record.action.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()).replace(/\bSeo\b/g, 'SEO')} |
| 74 |
</div> |
| 75 |
</td> |
| 76 |
<td className="thinkrank-py-3 thinkrank-px-4"> |
| 77 |
<ModelBadge |
| 78 |
provider={record.provider} |
| 79 |
model={record.model} |
| 80 |
/> |
| 81 |
</td> |
| 82 |
<td className="thinkrank-py-3 thinkrank-px-4 thinkrank-text-sm thinkrank-text-right thinkrank-font-mono"> |
| 83 |
{record.tokens_used.toLocaleString()} |
| 84 |
</td> |
| 85 |
<td className="thinkrank-py-3 thinkrank-px-4 thinkrank-text-sm thinkrank-text-right thinkrank-font-mono"> |
| 86 |
${record.estimated_cost.toFixed(4)} |
| 87 |
</td> |
| 88 |
</tr> |
| 89 |
))} |
| 90 |
</tbody> |
| 91 |
</table> |
| 92 |
</div> |
| 93 |
); |
| 94 |
}; |
| 95 |
|
| 96 |
/** |
| 97 |
* Pagination Component |
| 98 |
*/ |
| 99 |
const Pagination = ({ currentPage, totalPages, onPageChange, isLoading }) => { |
| 100 |
if (totalPages <= 1) return null; |
| 101 |
|
| 102 |
const pages = []; |
| 103 |
const maxVisiblePages = 5; |
| 104 |
|
| 105 |
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); |
| 106 |
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); |
| 107 |
|
| 108 |
if (endPage - startPage + 1 < maxVisiblePages) { |
| 109 |
startPage = Math.max(1, endPage - maxVisiblePages + 1); |
| 110 |
} |
| 111 |
|
| 112 |
for (let i = startPage; i <= endPage; i++) { |
| 113 |
pages.push(i); |
| 114 |
} |
| 115 |
|
| 116 |
return ( |
| 117 |
<div className="thinkrank-flex thinkrank-items-center thinkrank-justify-between thinkrank-mt-6 thinkrank-pt-4 thinkrank-border-t thinkrank-border-gray-200"> |
| 118 |
<div className="thinkrank-flex thinkrank-items-center thinkrank-gap-2"> |
| 119 |
<Button |
| 120 |
variant="secondary" |
| 121 |
disabled={currentPage === 1 || isLoading} |
| 122 |
onClick={() => onPageChange(currentPage - 1)} |
| 123 |
size="small" |
| 124 |
> |
| 125 |
{__('Previous', 'thinkrank')} |
| 126 |
</Button> |
| 127 |
|
| 128 |
{pages.map(page => ( |
| 129 |
<Button |
| 130 |
key={page} |
| 131 |
variant={page === currentPage ? 'primary' : 'secondary'} |
| 132 |
disabled={isLoading} |
| 133 |
onClick={() => onPageChange(page)} |
| 134 |
size="small" |
| 135 |
> |
| 136 |
{page} |
| 137 |
</Button> |
| 138 |
))} |
| 139 |
|
| 140 |
<Button |
| 141 |
variant="secondary" |
| 142 |
disabled={currentPage === totalPages || isLoading} |
| 143 |
onClick={() => onPageChange(currentPage + 1)} |
| 144 |
size="small" |
| 145 |
> |
| 146 |
{__('Next', 'thinkrank')} |
| 147 |
</Button> |
| 148 |
</div> |
| 149 |
|
| 150 |
<div className="thinkrank-text-sm thinkrank-text-secondary"> |
| 151 |
{__('Page', 'thinkrank')} {currentPage} {__('of', 'thinkrank')} {totalPages} |
| 152 |
</div> |
| 153 |
</div> |
| 154 |
); |
| 155 |
}; |
| 156 |
|
| 157 |
/** |
| 158 |
* Main Usage Breakdown Component |
| 159 |
*/ |
| 160 |
const UsageBreakdown = () => { |
| 161 |
const [period, setPeriod] = useState('30d'); |
| 162 |
const [currentPage, setCurrentPage] = useState(1); |
| 163 |
const [perPage] = useState(20); |
| 164 |
const [data, setData] = useState(null); |
| 165 |
const [isLoading, setIsLoading] = useState(false); |
| 166 |
const [error, setError] = useState(null); |
| 167 |
|
| 168 |
// Period options |
| 169 |
const periodOptions = [ |
| 170 |
{ label: __('Last 7 days', 'thinkrank'), value: '7d' }, |
| 171 |
{ label: __('Last 30 days', 'thinkrank'), value: '30d' }, |
| 172 |
{ label: __('Last 90 days', 'thinkrank'), value: '90d' }, |
| 173 |
{ label: __('All time', 'thinkrank'), value: 'all' } |
| 174 |
]; |
| 175 |
|
| 176 |
// Fetch usage breakdown data |
| 177 |
const fetchUsageBreakdown = async (selectedPeriod = period, page = currentPage) => { |
| 178 |
setIsLoading(true); |
| 179 |
setError(null); |
| 180 |
|
| 181 |
try { |
| 182 |
const response = await apiFetch({ |
| 183 |
path: `/thinkrank/v1/analytics/usage?period=${selectedPeriod}&page=${page}&per_page=${perPage}`, |
| 184 |
method: 'GET' |
| 185 |
}); |
| 186 |
|
| 187 |
if (response.success) { |
| 188 |
setData(response.data); |
| 189 |
} else { |
| 190 |
setError(__('Failed to load usage breakdown data.', 'thinkrank')); |
| 191 |
} |
| 192 |
} catch (err) { |
| 193 |
console.error('Usage breakdown fetch error:', err); |
| 194 |
setError(__('Failed to load usage breakdown data.', 'thinkrank')); |
| 195 |
} finally { |
| 196 |
setIsLoading(false); |
| 197 |
} |
| 198 |
}; |
| 199 |
|
| 200 |
// Handle period change |
| 201 |
const handlePeriodChange = (newPeriod) => { |
| 202 |
setPeriod(newPeriod); |
| 203 |
setCurrentPage(1); |
| 204 |
fetchUsageBreakdown(newPeriod, 1); |
| 205 |
}; |
| 206 |
|
| 207 |
// Handle page change |
| 208 |
const handlePageChange = (newPage) => { |
| 209 |
setCurrentPage(newPage); |
| 210 |
fetchUsageBreakdown(period, newPage); |
| 211 |
}; |
| 212 |
|
| 213 |
// Initial data fetch |
| 214 |
useEffect(() => { |
| 215 |
fetchUsageBreakdown(); |
| 216 |
}, []); |
| 217 |
|
| 218 |
return ( |
| 219 |
<div className="thinkrank-ui"> |
| 220 |
<div className="thinkrank-card thinkrank-card--elevated thinkrank-mb-6"> |
| 221 |
<div className="thinkrank-card__header"> |
| 222 |
<div className="thinkrank-flex thinkrank-justify-between thinkrank-items-center thinkrank-flex-wrap thinkrank-gap-4"> |
| 223 |
<h2 className="thinkrank-text-2xl thinkrank-font-bold thinkrank-text-primary thinkrank-mb-0"> |
| 224 |
{__('AI Usage Breakdown', 'thinkrank')} |
| 225 |
</h2> |
| 226 |
<SelectControl |
| 227 |
label={__('Time Period', 'thinkrank')} |
| 228 |
value={period} |
| 229 |
options={periodOptions} |
| 230 |
onChange={handlePeriodChange} |
| 231 |
className="thinkrank-min-w-40" |
| 232 |
__next40pxDefaultSize={true} |
| 233 |
__nextHasNoMarginBottom={true} |
| 234 |
/> |
| 235 |
</div> |
| 236 |
</div> |
| 237 |
<div className="thinkrank-card__body"> |
| 238 |
{error && ( |
| 239 |
<Notice status="error" isDismissible={false}> |
| 240 |
{error} |
| 241 |
</Notice> |
| 242 |
)} |
| 243 |
|
| 244 |
<UsageBreakdownTable |
| 245 |
records={data?.usage_records || []} |
| 246 |
isLoading={isLoading} |
| 247 |
/> |
| 248 |
|
| 249 |
{data?.pagination && ( |
| 250 |
<Pagination |
| 251 |
currentPage={data.pagination.page} |
| 252 |
totalPages={data.pagination.total_pages} |
| 253 |
onPageChange={handlePageChange} |
| 254 |
isLoading={isLoading} |
| 255 |
/> |
| 256 |
)} |
| 257 |
|
| 258 |
{data?.pagination && ( |
| 259 |
<div className="thinkrank-mt-4 thinkrank-text-sm thinkrank-text-secondary thinkrank-text-center"> |
| 260 |
{__('Showing', 'thinkrank')} {data.pagination.total_records} {__('total records', 'thinkrank')} |
| 261 |
</div> |
| 262 |
)} |
| 263 |
</div> |
| 264 |
</div> |
| 265 |
</div> |
| 266 |
); |
| 267 |
}; |
| 268 |
|
| 269 |
export default UsageBreakdown; |
| 270 |
|