PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
← All changes | templates/quotes/listing.php +215 -253 2.3.6 → 2.4.0 View file →
@@ -5,8 +5,11 @@
5 5 * @package EasyInvoice
6 6 * @since 1.0.0
7 7 */
8 8
9 +if ( ! defined( 'ABSPATH' ) ) {
10 + exit;
11 +}
9 12 // Initialize variables
10 13 $quotes = $quotes ?? [];
11 14 $stats = $stats ?? [
12 15 'total' => 0,
@@ -57,42 +60,30 @@
57 60 );
58 61 }
59 62 }
60 63
61 -// Group quotes by currency for proper total calculation
64 +// Store-wide figures for the header cards. Counting the rows on screen gave
65 +// "Total Quotes 20" on a 10,000-quote store; the controller passes the real
66 +// per-status counts, and the value by currency is one SQL pass over the
67 +// stored totals (accepted + sent + draft, i.e. quotes still in play or won).
62 68 $currency_totals = [];
63 -$total_quotes = count($formatted_quotes);
69 +$total_quotes = (int) ($draft_count ?? 0) + (int) ($available_count ?? 0) + (int) ($sent_count ?? 0) + (int) ($accepted_count ?? 0) + (int) ($declined_count ?? 0) + (int) ($expired_count ?? 0) + (int) ($cancelled_count ?? 0);
64 70
65 -foreach ($quotes as $quote) {
66 - if (is_object($quote)) {
67 - $currency_code = $quote->getCurrencyCode() ?: 'USD';
68 - $amount = $quote->getTotal() ?: 0;
69 -
70 - if (!isset($currency_totals[$currency_code])) {
71 - $currency_totals[$currency_code] = [
72 - 'total' => 0,
73 - 'quotes' => 0,
74 - 'quote_object' => $quote // Keep reference for formatting
75 - ];
76 - }
77 -
78 - $currency_totals[$currency_code]['total'] += floatval($amount);
79 - $currency_totals[$currency_code]['quotes']++;
80 - }
71 +foreach (\EasyInvoice\Services\QuoteTotals::valueByCurrency() as $currency_code => $row) {
72 + $currency_totals[$currency_code] = [
73 + 'total' => (float) $row['amount'],
74 + 'quotes' => (int) $row['count'],
75 + 'quote_object' => null,
76 + 'currency' => $currency_code,
77 + ];
81 78 }
82 79
83 80 $enhanced_stats = [
84 - 'total_quotes' => $total_quotes,
81 + 'total_quotes' => $total_quotes,
85 82 'currency_totals' => $currency_totals,
86 - 'accepted_quotes' => count(array_filter($formatted_quotes, function($quote) {
87 - return $quote['status'] === 'accepted';
88 - })),
89 - 'pending_quotes' => count(array_filter($formatted_quotes, function($quote) {
90 - return $quote['status'] === 'sent' || $quote['status'] === 'draft';
91 - })),
92 - 'rejected_quotes' => count(array_filter($formatted_quotes, function($quote) {
93 - return $quote['status'] === 'rejected';
94 - }))
83 + 'accepted_quotes' => (int) ($accepted_count ?? 0),
84 + 'pending_quotes' => (int) ($sent_count ?? 0) + (int) ($draft_count ?? 0) + (int) ($available_count ?? 0),
85 + 'rejected_quotes' => (int) ($declined_count ?? 0),
95 86 ];
96 87
97 88 ?>
98 89 <div class="p-8">
@@ -98,28 +89,43 @@
98 89 <div class="p-8">
99 90 <div class="ei-app-page-header bg-white border-b border-gray-200 px-6" style="margin-left: -2rem; margin-top: -2rem; padding-left:2rem; padding-right:2rem; margin-right: -2rem;">
100 91 <div class="flex items-center justify-between">
101 92 <div>
102 - <h1 class="text-2xl font-bold text-gray-900"><?php _e('Quotes', 'easy-invoice'); ?></h1>
103 - <p class="mt-1 text-sm text-gray-500"><?php _e('Manage and track your quotes', 'easy-invoice'); ?></p>
93 + <h1 class="text-2xl font-bold text-gray-900"><?php esc_html_e('Quotes', 'easy-invoice'); ?></h1>
94 + <p class="mt-1 text-sm text-gray-500"><?php esc_html_e('Manage and track your quotes', 'easy-invoice'); ?></p>
104 95 </div>
105 96 <div class="flex items-center space-x-3">
106 97 <?php if (isset($_GET['rewrite_flushed'])): ?>
107 98 <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded-md text-sm">
108 - <?php _e('Rewrite rules flushed successfully!', 'easy-invoice'); ?>
99 + <?php esc_html_e('Rewrite rules flushed successfully!', 'easy-invoice'); ?>
109 100 </div>
110 101 <?php endif; ?>
111 102
112 103 <?php if (isset($_GET['post_types_registered'])): ?>
113 104 <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded-md text-sm">
114 - <?php _e('Post types registered successfully!', 'easy-invoice'); ?>
105 + <?php esc_html_e('Post types registered successfully!', 'easy-invoice'); ?>
115 106 </div>
116 107 <?php endif; ?>
117 108
118 - <a href="<?php echo wp_nonce_url(admin_url('admin-post.php?action=flush_easy_invoice_rewrite_rules'), 'flush_easy_invoice_rewrite_rules'); ?>"
109 + <?php
110 + // "Flush Rewrite Rules" is a troubleshooting action, not something a
111 + // user does as part of their job — but it rendered unconditionally as
112 + // a prominent button right beside the primary "Create New Quote" CTA
113 + // on every admin's screen. It is now hidden by default and shown when
114 + // WP_DEBUG is on, or when a site opts in via the filter.
115 + //
116 + // Kept rather than deleted because it is the manual escape hatch if
117 + // quote permalinks ever 404.
118 + $ei_show_maintenance_tools = apply_filters(
119 + 'easy_invoice_show_maintenance_tools',
120 + defined('WP_DEBUG') && WP_DEBUG
121 + );
122 + if ($ei_show_maintenance_tools) : ?>
123 + <a href="<?php echo esc_url(wp_nonce_url(admin_url('admin-post.php?action=flush_easy_invoice_rewrite_rules'), 'flush_easy_invoice_rewrite_rules')); ?>"
119 124 class="bg-yellow-100 text-yellow-700 hover:bg-yellow-200 px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200">
120 - <?php _e('Flush Rewrite Rules', 'easy-invoice'); ?>
125 + <?php esc_html_e('Flush Rewrite Rules', 'easy-invoice'); ?>
121 126 </a>
127 + <?php endif; ?>
122 128
123 129 <?php
124 130 // "Export All" is part of the bulk_operations addon
125 131 // (Personal tier). See templates/invoices/listing.php
@@ -128,14 +134,14 @@
128 134 ?>
129 135 <?php if ($ei_bulk_ops_loaded): ?>
130 136 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
131 137 <input type="hidden" name="action" value="easy_invoice_export_all_quotes" />
132 - <input type="hidden" name="_wpnonce" value="<?php echo wp_create_nonce('easy_invoice_export_all_quotes'); ?>" />
138 + <input type="hidden" name="_wpnonce" value="<?php echo esc_attr(wp_create_nonce('easy_invoice_export_all_quotes')); ?>" />
133 139 <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
134 140 <svg class="w-4 h-4 mr-2 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
135 141 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
136 142 </svg>
137 - <?php _e('Export All', 'easy-invoice'); ?>
143 + <?php esc_html_e('Export All', 'easy-invoice'); ?>
138 144 </button>
139 145 </form>
140 146 <?php elseif (!easy_invoice_has_pro()): ?>
141 147 <button type="button" id="export-all-quotes-btn" class="premium-export-btn inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200 relative">
@@ -141,9 +147,9 @@
141 147 <button type="button" id="export-all-quotes-btn" class="premium-export-btn inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200 relative">
142 148 <svg class="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
143 149 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
144 150 </svg>
145 - <?php _e('Export All', 'easy-invoice'); ?>
151 + <?php esc_html_e('Export All', 'easy-invoice'); ?>
146 152 <svg class="ml-2 w-5 h-5 text-purple-600 crown-icon" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
147 153 <path d="M12 8L15 13.2L18 10.5L17.3 14H6.7L6 10.5L9 13.2L12 8M12 4L8.5 10L3 5L5 16H19L21 5L15.5 10L12 4Z"/>
148 154 </svg>
149 155 </button>
@@ -159,14 +165,16 @@
159 165 });
160 166 });
161 167 </script>
162 168 <?php endif; ?>
163 - <button type="button" id="create-quote-btn" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
169 + <?php if (easy_invoice_user_can('ei_create_quote')) : ?>
170 +<button type="button" id="create-quote-btn" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
164 171 <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
165 172 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
166 173 </svg>
167 - <?php _e('Create New Quote', 'easy-invoice'); ?>
174 + <?php esc_html_e('Create New Quote', 'easy-invoice'); ?>
168 175 </button>
176 +<?php endif; ?>
169 177 </div>
170 178 </div>
171 179 </div>
172 180
@@ -172,9 +180,9 @@
172 180
173 181 <div id="new-quote-modal" class="fixed inset-0 bg-gray-600 bg-opacity-75 overflow-y-auto h-full w-full z-50 hidden">
174 182 <div class="relative mx-auto p-6 border w-96 shadow-xl rounded-lg bg-white my-20">
175 183 <div class="flex justify-between items-center mb-6">
176 - <h3 class="text-xl font-semibold text-gray-900">Create New Quote</h3>
184 + <h3 class="text-xl font-semibold text-gray-900"><?php esc_html_e('Create New Quote', 'easy-invoice'); ?></h3>
177 185 <button type="button" id="close-new-quote-modal" class="text-gray-400 hover:text-gray-600 transition-colors duration-200">
178 186 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
179 187 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
180 188 </svg>
@@ -189,9 +197,9 @@
189 197 id="new_quote_title"
190 198 required
191 199 placeholder="Enter quote title..."
192 200 class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-3 px-4 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition-colors duration-200">
193 - <p class="mt-2 text-sm text-gray-500">This will be the title of your quote</p>
201 + <p class="mt-2 text-sm text-gray-500"><?php esc_html_e('This will be the title of your quote', 'easy-invoice'); ?></p>
194 202 </div>
195 203
196 204 <div class="flex justify-end space-x-3 pt-4">
197 205 <button type="button" id="cancel-new-quote"
@@ -216,11 +224,11 @@
216 224 </div>
217 225 <div class="bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:max-w-2xl w-full z-50">
218 226 <div class="px-6 py-4 border-b border-gray-200">
219 227 <div class="flex justify-between items-center">
220 - <h3 class="text-lg font-medium text-gray-900">Quote Activity Logs</h3>
228 + <h3 class="text-lg font-medium text-gray-900"><?php esc_html_e('Quote Activity Logs', 'easy-invoice'); ?></h3>
221 229 <button type="button" id="close-logs-modal" class="text-gray-400 hover:text-gray-500 focus:outline-none">
222 - <span class="sr-only">Close</span>
230 + <span class="sr-only"><?php esc_html_e('Close', 'easy-invoice'); ?></span>
223 231 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
224 232 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
225 233 </svg>
226 234 </button>
@@ -240,10 +248,10 @@
240 248 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
241 249 </svg>
242 250 </div>
243 251 <div>
244 - <h3 class="text-sm font-medium text-gray-500">Total Quotes</h3>
245 - <p class="mt-1 text-2xl font-bold text-gray-900"><?php echo $enhanced_stats['total_quotes']; ?></p>
252 + <h3 class="text-sm font-medium text-gray-500"><?php esc_html_e('Total Quotes', 'easy-invoice'); ?></h3>
253 + <p class="mt-1 text-2xl font-bold text-gray-900"><?php echo esc_html($enhanced_stats['total_quotes']); ?></p>
246 254 </div>
247 255 </div>
248 256 </div>
249 257 <div class="bg-white rounded-lg shadow p-6 transition-transform duration-300 hover:shadow-md hover:-translate-y-1">
@@ -253,9 +261,9 @@
253 261 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
254 262 </svg>
255 263 </div>
256 264 <div>
257 - <h3 class="text-sm font-medium text-gray-500">Total Value</h3>
265 + <h3 class="text-sm font-medium text-gray-500"><?php esc_html_e('Total Value', 'easy-invoice'); ?></h3>
258 266 <?php if (empty($enhanced_stats['currency_totals'])): ?>
259 267 <p class="mt-1 text-2xl font-bold text-gray-900">$0.00</p>
260 268 <?php else: ?>
261 269 <div class="mt-1 space-y-1">
@@ -260,14 +268,13 @@
260 268 <?php else: ?>
261 269 <div class="mt-1 space-y-1">
262 270 <?php foreach ($enhanced_stats['currency_totals'] as $currency_code => $currency_data): ?>
263 271 <?php
264 - $formatter = new \EasyInvoice\Helpers\QuoteFormatter($currency_data['quote_object']);
265 - $formatted_amount = $formatter->format($currency_data['total']);
272 + $formatted_amount = easy_invoice_format_money((float) $currency_data['total'], null, (string) $currency_code);
266 273 ?>
267 274 <div class="flex items-center justify-between">
268 - <span class="text-lg font-bold text-gray-900"><?php echo $formatted_amount; ?></span>
269 - <span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded"><?php echo $currency_data['quotes']; ?> quote<?php echo $currency_data['quotes'] > 1 ? 's' : ''; ?></span>
275 + <span class="text-lg font-bold text-gray-900"><?php echo esc_html($formatted_amount); ?></span>
276 + <span class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded"><?php echo esc_html($currency_data['quotes']); ?> quote<?php echo $currency_data['quotes'] > 1 ? 's' : ''; ?></span>
270 277 </div>
271 278 <?php endforeach; ?>
272 279 </div>
273 280 <?php endif; ?>
@@ -281,10 +288,10 @@
281 288 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
282 289 </svg>
283 290 </div>
284 291 <div>
285 - <h3 class="text-sm font-medium text-gray-500">Accepted Quotes</h3>
286 - <p class="mt-1 text-2xl font-bold text-green-600"><?php echo $enhanced_stats['accepted_quotes']; ?></p>
292 + <h3 class="text-sm font-medium text-gray-500"><?php esc_html_e('Accepted Quotes', 'easy-invoice'); ?></h3>
293 + <p class="mt-1 text-2xl font-bold text-green-600"><?php echo esc_html($enhanced_stats['accepted_quotes']); ?></p>
287 294 </div>
288 295 </div>
289 296 </div>
290 297 <div class="bg-white rounded-lg shadow p-6 transition-transform duration-300 hover:shadow-md hover:-translate-y-1">
@@ -294,10 +301,10 @@
294 301 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
295 302 </svg>
296 303 </div>
297 304 <div>
298 - <h3 class="text-sm font-medium text-gray-500">Pending Quotes</h3>
299 - <p class="mt-1 text-2xl font-bold text-yellow-600"><?php echo $enhanced_stats['pending_quotes']; ?></p>
305 + <h3 class="text-sm font-medium text-gray-500"><?php esc_html_e('Pending Quotes', 'easy-invoice'); ?></h3>
306 + <p class="mt-1 text-2xl font-bold text-yellow-600"><?php echo esc_html($enhanced_stats['pending_quotes']); ?></p>
300 307 </div>
301 308 </div>
302 309 </div>
303 310 </div>
@@ -307,18 +314,18 @@
307 314 <div class="px-4 py-3 flex items-center justify-between">
308 315 <div class="flex items-center space-x-2">
309 316 <span class="text-sm text-gray-700">
310 317 Showing
311 - <span class="font-medium"><?php echo (($pagination['current_page'] - 1) * $pagination['per_page']) + 1; ?></span>
318 + <span class="font-medium"><?php echo (int) ((($pagination['current_page'] - 1) * $pagination['per_page']) + 1); ?></span>
312 319 to
313 - <span class="font-medium"><?php echo min($pagination['current_page'] * $pagination['per_page'], $pagination['total_quotes']); ?></span>
320 + <span class="font-medium"><?php echo (int) min($pagination['current_page'] * $pagination['per_page'], $pagination['total_quotes']); ?></span>
314 321 of
315 - <span class="font-medium"><?php echo $pagination['total_quotes']; ?></span>
322 + <span class="font-medium"><?php echo esc_html($pagination['total_quotes']); ?></span>
316 323 results
317 324 </span>
318 325 </div>
319 326 <div class="flex items-center space-x-2">
320 - <span class="text-sm text-gray-500">Page <?php echo $pagination['current_page']; ?> of <?php echo $pagination['total_pages']; ?></span>
327 + <span class="text-sm text-gray-500">Page <?php echo esc_html($pagination['current_page']); ?> of <?php echo esc_html($pagination['total_pages']); ?></span>
321 328 <div class="flex space-x-1">
322 329 <?php
323 330 $prev_page = $pagination['current_page'] > 1 ? $pagination['current_page'] - 1 : 1;
324 331 $next_page = $pagination['current_page'] < $pagination['total_pages'] ? $pagination['current_page'] + 1 : $pagination['total_pages'];
@@ -330,11 +337,11 @@
330 337 <a class="bg-white text-gray-500 hover:bg-gray-50 px-3 py-2 text-sm font-medium border border-gray-300 rounded-md" href="<?php echo esc_url(add_query_arg('paged', $prev_page, $current_url)); ?>">‹ Previous</a>
331 338 <?php endif;
332 339 for ($i = $start_page; $i <= $end_page; $i++):
333 340 if ($i == $pagination['current_page']): ?>
334 - <span class="px-3 py-2 text-sm font-medium border border-gray-300 rounded-md bg-indigo-50 border-indigo-500 text-indigo-600"><?php echo $i; ?></span>
341 + <span class="px-3 py-2 text-sm font-medium border border-gray-300 rounded-md bg-indigo-50 border-indigo-500 text-indigo-600"><?php echo esc_html($i); ?></span>
335 342 <?php else: ?>
336 - <a class="px-3 py-2 text-sm font-medium border border-gray-300 bg-white text-gray-500 hover:bg-gray-50 rounded-md" href="<?php echo esc_url(add_query_arg('paged', $i, $current_url)); ?>"><?php echo $i; ?></a>
343 + <a class="px-3 py-2 text-sm font-medium border border-gray-300 bg-white text-gray-500 hover:bg-gray-50 rounded-md" href="<?php echo esc_url(add_query_arg('paged', $i, $current_url)); ?>"><?php echo esc_html($i); ?></a>
337 344 <?php endif;
338 345 endfor;
339 346 if ($pagination['current_page'] < $pagination['total_pages']): ?>
340 347 <a class="bg-white text-gray-500 hover:bg-gray-50 px-3 py-2 text-sm font-medium border border-gray-300 rounded-md" href="<?php echo esc_url(add_query_arg('paged', $next_page, $current_url)); ?>">Next ›</a>
@@ -344,9 +351,14 @@
344 351 </div>
345 352 </div>
346 353 <?php endif; ?>
347 354
348 - <div class="bg-white shadow rounded-lg">
355 + <?php // `overflow-hidden` matters here, it is not just for rounding the corners.
356 + // The quotes table is wider than this card (9 columns vs the invoice list's 6)
357 + // and scrolls inside its own `overflow-x-auto` wrapper. Without this, that
358 + // overflow escaped every ancestor and gave the whole admin page a horizontal
359 + // scrollbar. The invoice listing has always had it — see invoices/listing.php. ?>
360 + <div class="bg-white shadow rounded-lg overflow-hidden">
349 361 <div class="border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-white">
350 362 <nav class="flex">
351 363 <?php
352 364 // Use the counts passed from the controller
@@ -380,9 +392,9 @@
380 392 ? 'border-b-2 border-indigo-500 text-indigo-600 bg-white'
381 393 : 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
382 394 ?>
383 395 <a href="<?php echo esc_url(add_query_arg('view', $tab_key, remove_query_arg('status'))); ?>"
384 - class="flex items-center whitespace-nowrap py-4 px-6 font-medium text-sm transition-colors duration-200 <?php echo $class; ?>">
396 + class="flex items-center whitespace-nowrap py-4 px-6 font-medium text-sm transition-colors duration-200 <?php echo esc_attr($class); ?>">
385 397 <?php if ($tab_key === 'all'): ?>
386 398 <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
387 399 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
388 400 </svg>
@@ -414,9 +426,9 @@
414 426 <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
415 427 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
416 428 </svg>
417 429 <?php endif; ?>
418 - <?php echo $tab_label; ?> (<?php echo $count; ?>)
430 + <?php echo esc_html($tab_label); ?> (<?php echo esc_html($count); ?>)
419 431 </a>
420 432 <?php endforeach; ?>
421 433 </nav>
422 434 </div>
@@ -422,20 +434,21 @@
422 434 </div>
423 435
424 436 <div class="p-4 flex flex-wrap items-center justify-between bg-white border-b border-gray-100">
425 437 <div class="flex items-center space-x-2 my-2">
426 - <form id="bulk-action-form" method="post" class="flex items-center gap-2">
427 - <input type="hidden" name="nonce" value="<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>">
438 + <?php if (easy_invoice_user_can('ei_create_quote')) : ?>
439 +<form id="bulk-action-form" method="post" class="flex items-center gap-2">
440 + <input type="hidden" name="nonce" value="<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>">
428 441
429 - <select name="bulk_action" class="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white text-gray-700 placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
430 - <option value="">Bulk Actions</option>
442 + <select name="bulk_action" aria-label="Bulk action for selected quotes" class="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white text-gray-700 placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
443 + <option value=""><?php esc_html_e('Bulk Actions', 'easy-invoice'); ?></option>
431 444 <?php if ($view === 'cancelled'): ?>
432 - <option value="restore">Restore Selected</option>
433 - <option value="delete">Delete Permanently</option>
445 + <option value="restore"><?php esc_html_e('Restore Selected', 'easy-invoice'); ?></option>
446 + <option value="delete"><?php esc_html_e('Delete Permanently', 'easy-invoice'); ?></option>
434 447 <?php else: ?>
435 - <option value="delete">Delete Selected</option>
436 - <option value="trash">Move to Trash</option>
437 - <option value="draft">Move to Draft</option>
448 + <option value="delete"><?php esc_html_e('Delete Selected', 'easy-invoice'); ?></option>
449 + <option value="trash"><?php esc_html_e('Move to Trash', 'easy-invoice'); ?></option>
450 + <option value="draft"><?php esc_html_e('Move to Draft', 'easy-invoice'); ?></option>
438 451 <?php endif; ?>
439 452 <option value="export" data-requires-pro="1"><?php esc_html_e('Export Selected (Pro)', 'easy-invoice'); ?></option>
440 453 </select>
441 454
@@ -451,8 +464,9 @@
451 464 Empty Trash
452 465 </button>
453 466 <?php endif; ?>
454 467 </form>
468 +<?php endif; ?>
455 469 </div>
456 470
457 471 <div class="flex items-center gap-2">
458 472 <form method="get" class="flex items-center gap-2">
@@ -483,19 +497,19 @@
483 497 </div>
484 498 <input type="text"
485 499 name="search"
486 500 value="<?php echo esc_attr($search_query); ?>"
487 - placeholder="<?php _e('Search quotes, numbers, or clients...', 'easy-invoice'); ?>"
501 + placeholder="<?php echo esc_js(__('Search quotes, numbers, or clients...', 'easy-invoice')); ?>"
488 502 class="block w-64 pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
489 503 </div>
490 504
491 505 <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
492 - <?php _e('Search', 'easy-invoice'); ?>
506 + <?php esc_html_e('Search', 'easy-invoice'); ?>
493 507 </button>
494 508
495 509 <?php if (!empty($search_query)): ?>
496 510 <a href="<?php echo esc_url(remove_query_arg('search', $_SERVER['REQUEST_URI'])); ?>" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
497 - <?php _e('Clear', 'easy-invoice'); ?>
511 + <?php esc_html_e('Clear', 'easy-invoice'); ?>
498 512 </a>
499 513 <?php endif; ?>
500 514 </form>
501 515 </div>
@@ -501,13 +515,13 @@
501 515 </div>
502 516 </div>
503 517
504 518 <div class="overflow-x-auto">
505 - <table class="min-w-full divide-y divide-gray-200">
519 + <table class="min-w-full divide-y divide-gray-200 ei-quotes-table">
506 520 <thead class="bg-gray-50">
507 521 <tr>
508 522 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
509 - <input type="checkbox" id="select-all" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
523 + <input type="checkbox" id="select-all" aria-label="Select all quotes on this page" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
510 524 </th>
511 525 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
512 526 Quote
513 527 </th>
@@ -529,9 +543,9 @@
529 543 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
530 544 Logs
531 545 </th>
532 546 <th scope="col" class="relative px-6 py-3">
533 - <span class="sr-only">Actions</span>
547 + <span class="sr-only"><?php esc_html_e('Actions', 'easy-invoice'); ?></span>
534 548 </th>
535 549 </tr>
536 550 </thead>
537 551 <tbody class="bg-white divide-y divide-gray-200">
@@ -541,10 +555,10 @@
541 555 <div class="flex flex-col items-center">
542 556 <svg class="w-12 h-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
543 557 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
544 558 </svg>
545 - <h3 class="text-lg font-medium text-gray-900 mb-2">No quotes found</h3>
546 - <p class="text-gray-500 mb-4">Get started by creating your first quote.</p>
559 + <h3 class="text-lg font-medium text-gray-900 mb-2"><?php esc_html_e('No quotes found', 'easy-invoice'); ?></h3>
560 + <p class="text-gray-500 mb-4"><?php esc_html_e('Get started by creating your first quote.', 'easy-invoice'); ?></p>
547 561 <button type="button" id="create-first-quote-btn" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
548 562 <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
549 563 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
550 564 </svg>
@@ -611,9 +625,9 @@
611 625 $quote->ensureProperSlug();
612 626 ?>
613 627 <tr class="hover:bg-gray-50 transition-colors duration-150">
614 628 <td class="px-6 py-4 whitespace-nowrap">
615 - <input type="checkbox" class="quote-checkbox rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" value="<?php echo $quote->getId(); ?>">
629 + <input type="checkbox" aria-label="<?php /* translators: %s: document number. */ echo esc_attr(sprintf(__('Select quote %s', 'easy-invoice'), $quote->getNumber())); ?>" class="quote-checkbox rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" value="<?php echo (int) $quote->getId(); ?>">
616 630 </td>
617 631 <td class="px-6 py-4 whitespace-nowrap">
618 632 <div class="flex items-center">
619 633 <div class="flex-shrink-0 h-10 w-10">
@@ -624,30 +638,36 @@
624 638 </div>
625 639 </div>
626 640 <div class="ml-4">
627 641 <div class="text-sm font-medium text-gray-900">
628 - <a href="<?php echo admin_url('admin.php?page=easy-invoice-quote-builder&id=' . $quote->getId()); ?>" class="text-indigo-600 hover:text-indigo-900 transition-colors duration-150">
642 + <a href="<?php echo esc_url(admin_url('admin.php?page=easy-invoice-quote-builder&id=' . $quote->getId())); ?>" class="text-indigo-600 hover:text-indigo-900 transition-colors duration-150">
629 643 <?php echo esc_html($quote->getTitle() ?: 'Untitled Quote'); ?>
630 644 </a>
631 645 </div>
632 646 <div class="text-sm text-gray-500">
633 647 <?php echo esc_html($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>
648 + <?php if (!in_array((string) $quote->getStatus(), ['draft'], true)) : ?>
649 + <span class="ei-viewed ml-2 inline-flex items-center gap-1 <?php echo \EasyInvoice\Services\DocumentViews::summary((int) $quote->getId())['count'] ? 'text-emerald-600' : 'text-gray-400'; ?>">
650 + <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
651 + <?php echo esc_html(\EasyInvoice\Services\DocumentViews::label((int) $quote->getId())); ?>
652 + </span>
653 + <?php endif; ?>
634 654 </div>
635 655 <div class="row-actions mt-1">
636 - <span class="view"><a href="<?php echo apply_filters('easy_invoice_quote_view_url', get_permalink($quote->getId()), $quote->getId()); ?>" target="_blank"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>View</a></span>
637 - <span class="edit"><a href="<?php echo admin_url('admin.php?page=easy-invoice-quote-builder&id=' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>Edit</a></span>
656 + <span class="view"><a href="<?php echo esc_url(apply_filters('easy_invoice_quote_view_url', get_permalink($quote->getId()), $quote->getId())); ?>" target="_blank"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>View</a></span>
657 + <span class="edit"><a href="<?php echo esc_url(admin_url('admin.php?page=easy-invoice-quote-builder&id=' . $quote->getId())); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>Edit</a></span>
638 658 <?php if ($quote->getStatus() === 'cancelled'): ?>
639 - <span class="restore"><a href="#" class="restore-quote text-green-600" data-quote-id="<?php echo $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582M20 20v-5h-.581M5 9a7 7 0 0114 0c0 3.866-3.134 7-7 7a7 7 0 01-7-7zm7 7v4m0 0h-2m2 0h2"/></svg>Restore</a></span>
640 - <span class="delete"><a href="#" class="delete-quote-btn text-red-600" data-quote-id="<?php echo $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>Delete Permanently</a></span>
659 + <span class="restore"><a href="#" class="restore-quote text-green-600" data-quote-id="<?php echo (int) $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582M20 20v-5h-.581M5 9a7 7 0 0114 0c0 3.866-3.134 7-7 7a7 7 0 01-7-7zm7 7v4m0 0h-2m2 0h2"/></svg>Restore</a></span>
660 + <span class="delete"><a href="#" class="delete-quote-btn text-red-600" data-quote-id="<?php echo (int) $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>Delete Permanently</a></span>
641 661 <?php else: ?>
642 - <span class="trash"><a href="#" class="trash-quote text-red-600" data-quote-id="<?php echo $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>Trash</a></span>
643 - <span class="email"><a href="#" class="email-quote" data-quote-id="<?php echo $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>Send</a></span>
644 - <span class="pdf"><a href="<?php echo get_permalink($quote->getId()); ?>?auto_download_pdf=1" target="_blank" class="download-pdf-quote" data-quote-id="<?php echo $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>Download</a></span>
662 + <span class="trash"><a href="#" class="trash-quote text-red-600" data-quote-id="<?php echo (int) $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>Trash</a></span>
663 + <span class="email"><a href="#" class="email-quote" data-quote-id="<?php echo (int) $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>Send</a></span>
664 + <span class="pdf"><a href="<?php echo esc_url(get_permalink($quote->getId())); ?>?auto_download_pdf=1" target="_blank" class="download-pdf-quote" data-quote-id="<?php echo (int) $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>Download</a></span>
645 665 <?php if (!easy_invoice_has_pro()): ?>
646 - <span class="export premium-feature"><a href="#" class="export-quote-pro-placeholder text-yellow-600 font-semibold" data-quote-id="<?php echo $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>Export</a></span>
666 + <span class="export premium-feature"><a href="#" class="export-quote-pro-placeholder text-yellow-600 font-semibold" data-quote-id="<?php echo (int) $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>Export</a></span>
647 667 <?php endif; ?>
648 668 <?php if (!easy_invoice_has_pro()): ?>
649 - <span class="duplicate premium-feature"><a href="#" class="duplicate-quote-pro-placeholder text-yellow-600 font-semibold" data-quote-id="<?php echo $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 7l2 4 4 .5-3 2.5.5 4-3.5-2-3.5 2 .5-4-3-2.5 4-.5 2-4z"/></svg>Duplicate</a></span>
669 + <span class="duplicate premium-feature"><a href="#" class="duplicate-quote-pro-placeholder text-yellow-600 font-semibold" data-quote-id="<?php echo (int) $quote->getId(); ?>" data-quote-number="<?php echo esc_attr($quote->getNumber() ?: 'QT-' . $quote->getId()); ?>"><svg class="w-3.5 h-3.5 mr-1 inline-block text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 7l2 4 4 .5-3 2.5.5 4-3.5-2-3.5 2 .5-4-3-2.5 4-.5 2-4z"/></svg>Duplicate</a></span>
650 670 <?php endif; ?>
651 671
652 672 <?php
653 673 // Apply filter for Pro plugin to add secure link actions
@@ -652,9 +672,9 @@
652 672 <?php
653 673 // Apply filter for Pro plugin to add secure link actions
654 674 $secure_actions = apply_filters('easy_invoice_quote_row_actions', [], $quote);
655 675 foreach ($secure_actions as $action_key => $action_html) {
656 - echo '<span class="' . esc_attr($action_key) . '">' . $action_html . '</span>';
676 + echo '<span class="' . esc_attr($action_key) . '">' . wp_kses_post($action_html) . '</span>';
657 677 }
658 678 ?>
659 679 <?php endif; ?>
660 680 </div>
@@ -692,10 +712,17 @@
692 712 </td>
693 713 <td class="px-6 py-4 whitespace-nowrap">
694 714 <?php
695 715 $status = $quote->getStatus() ?: 'draft';
716 + // An open quote past its expiry date reads as expired even
717 + // before the daily expiry check has flipped its status.
718 + if (in_array($status, ['available', 'sent'], true) && $quote->getExpiryDate()
719 + && strtotime($quote->getExpiryDate()) && gmdate('Y-m-d', strtotime($quote->getExpiryDate())) < gmdate('Y-m-d', current_time('timestamp'))) {
720 + $status = 'expired';
721 + }
696 722 $status_classes = [
697 723 'draft' => 'bg-gray-100 text-gray-800 ring-1 ring-gray-300',
724 + 'available' => 'bg-blue-100 text-blue-800 ring-1 ring-blue-300',
698 725 'sent' => 'bg-blue-100 text-blue-800 ring-1 ring-blue-300',
699 726 'accepted' => 'bg-green-100 text-green-800 ring-1 ring-green-300',
700 727 'declined' => 'bg-red-100 text-red-800 ring-1 ring-red-300',
701 728 'expired' => 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300'
@@ -701,17 +728,17 @@
701 728 'expired' => 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300'
702 729 ];
703 730 $status_class = $status_classes[$status] ?? 'bg-gray-100 text-gray-800 ring-1 ring-gray-300';
704 731 ?>
705 - <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <?php echo $status_class; ?>">
706 - <?php echo ucfirst($status); ?>
732 + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <?php echo esc_attr($status_class); ?>">
733 + <?php echo esc_html(ucfirst($status)); ?>
707 734 </span>
708 735 </td>
709 736 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
710 - <?php echo $quote->getIssueDate() ? date('M j, Y', strtotime($quote->getIssueDate())) : '—'; ?>
737 + <?php echo esc_html($quote->getIssueDate() ? gmdate('M j, Y', strtotime($quote->getIssueDate())) : '—'); ?>
711 738 </td>
712 739 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
713 - <?php echo $quote->getExpiryDate() ? date('M j, Y', strtotime($quote->getExpiryDate())) : '—'; ?>
740 + <?php echo esc_html($quote->getExpiryDate() ? gmdate('M j, Y', strtotime($quote->getExpiryDate())) : '—'); ?>
714 741 </td>
715 742 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
716 743 <?php
717 744 // Get all logs for this quote
@@ -722,18 +749,18 @@
722 749
723 750 if ($logs_count > 0): ?>
724 751 <button type="button"
725 752 class="view-logs-btn text-xs text-indigo-600 hover:text-indigo-800 font-medium"
726 - data-quote-id="<?php echo $quote->getId(); ?>"
753 + data-quote-id="<?php echo (int) $quote->getId(); ?>"
727 754 data-quote-title="<?php echo esc_attr($quote->getTitle() ?: 'Untitled Quote'); ?>"
728 - data-logs-count="<?php echo $logs_count; ?>">
729 - View all <?php echo $logs_count; ?> logs
755 + data-logs-count="<?php echo esc_attr($logs_count); ?>">
756 + View all <?php echo esc_html($logs_count); ?> logs
730 757 </button>
731 758 <?php else: ?>
732 - <span class="text-gray-400 text-xs">No activity</span>
759 + <span class="text-gray-400 text-xs"><?php esc_html_e('No activity', 'easy-invoice'); ?></span>
733 760 <?php endif; ?>
734 761 <?php } catch (\Exception $e) { ?>
735 - <span class="text-gray-400 text-xs">Error loading logs</span>
762 + <span class="text-gray-400 text-xs"><?php esc_html_e('Error loading logs', 'easy-invoice'); ?></span>
736 763 <?php } ?>
737 764 </td>
738 765 <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
739 766 </td>
@@ -750,18 +777,18 @@
750 777 <div class="px-4 py-3 flex items-center justify-between">
751 778 <div class="flex items-center space-x-2">
752 779 <span class="text-sm text-gray-700">
753 780 Showing
754 - <span class="font-medium"><?php echo (($pagination['current_page'] - 1) * $pagination['per_page']) + 1; ?></span>
781 + <span class="font-medium"><?php echo (int) ((($pagination['current_page'] - 1) * $pagination['per_page']) + 1); ?></span>
755 782 to
756 - <span class="font-medium"><?php echo min($pagination['current_page'] * $pagination['per_page'], $pagination['total_quotes']); ?></span>
783 + <span class="font-medium"><?php echo (int) min($pagination['current_page'] * $pagination['per_page'], $pagination['total_quotes']); ?></span>
757 784 of
758 - <span class="font-medium"><?php echo $pagination['total_quotes']; ?></span>
785 + <span class="font-medium"><?php echo esc_html($pagination['total_quotes']); ?></span>
759 786 results
760 787 </span>
761 788 </div>
762 789 <div class="flex items-center space-x-2">
763 - <span class="text-sm text-gray-500">Page <?php echo $pagination['current_page']; ?> of <?php echo $pagination['total_pages']; ?></span>
790 + <span class="text-sm text-gray-500">Page <?php echo esc_html($pagination['current_page']); ?> of <?php echo esc_html($pagination['total_pages']); ?></span>
764 791 <div class="flex space-x-1">
765 792 <?php
766 793 $prev_page = $pagination['current_page'] > 1 ? $pagination['current_page'] - 1 : 1;
767 794 $next_page = $pagination['current_page'] < $pagination['total_pages'] ? $pagination['current_page'] + 1 : $pagination['total_pages'];
@@ -773,11 +800,11 @@
773 800 <a class="bg-white text-gray-500 hover:bg-gray-50 px-3 py-2 text-sm font-medium border border-gray-300 rounded-md" href="<?php echo esc_url(add_query_arg('paged', $prev_page, $current_url)); ?>">‹ Previous</a>
774 801 <?php endif;
775 802 for ($i = $start_page; $i <= $end_page; $i++):
776 803 if ($i == $pagination['current_page']): ?>
777 - <span class="px-3 py-2 text-sm font-medium border border-gray-300 rounded-md bg-indigo-50 border-indigo-500 text-indigo-600"><?php echo $i; ?></span>
804 + <span class="px-3 py-2 text-sm font-medium border border-gray-300 rounded-md bg-indigo-50 border-indigo-500 text-indigo-600"><?php echo esc_html($i); ?></span>
778 805 <?php else: ?>
779 - <a class="px-3 py-2 text-sm font-medium border border-gray-300 bg-white text-gray-500 hover:bg-gray-50 rounded-md" href="<?php echo esc_url(add_query_arg('paged', $i, $current_url)); ?>"><?php echo $i; ?></a>
806 + <a class="px-3 py-2 text-sm font-medium border border-gray-300 bg-white text-gray-500 hover:bg-gray-50 rounded-md" href="<?php echo esc_url(add_query_arg('paged', $i, $current_url)); ?>"><?php echo esc_html($i); ?></a>
780 807 <?php endif;
781 808 endfor;
782 809 if ($pagination['current_page'] < $pagination['total_pages']): ?>
783 810 <a class="bg-white text-gray-500 hover:bg-gray-50 px-3 py-2 text-sm font-medium border border-gray-300 rounded-md" href="<?php echo esc_url(add_query_arg('paged', $next_page, $current_url)); ?>">Next ›</a>
@@ -823,9 +850,9 @@
823 850 var $message = $('#new-quote-message');
824 851 var title = $('#new_quote_title').val().trim();
825 852
826 853 if (!title) {
827 - $message.removeClass('hidden').html('<div class="text-red-600 text-sm">Please enter a quote title.</div>');
854 + $message.removeClass('hidden').html('<div class="text-red-600 text-sm"><?php esc_html_e('Please enter a quote title.', 'easy-invoice'); ?></div>');
828 855 return;
829 856 }
830 857
831 858 // Show loading state
@@ -838,14 +865,14 @@
838 865 dataType: 'json',
839 866 data: {
840 867 action: 'easy_invoice_create_new_quote',
841 868 title: title,
842 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
869 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
843 870 },
844 871 success: function(response) {
845 872 if (response.success && response.data && response.data.quote_id) {
846 873 // Show success message below the form
847 - $message.removeClass('hidden').html('<div class="text-green-600 text-sm">Quote created successfully! Redirecting...</div>');
874 + $message.removeClass('hidden').html('<div class="text-green-600 text-sm"><?php esc_html_e('Quote created successfully! Redirecting...', 'easy-invoice'); ?></div>');
848 875
849 876 // Redirect to the builder page with the new quote ID
850 877 setTimeout(function() {
851 878 window.location.href = 'admin.php?page=easy-invoice-quote-builder&id=' + response.data.quote_id;
@@ -855,105 +882,14 @@
855 882 $submitBtn.prop('disabled', false).html('Create Quote');
856 883 }
857 884 },
858 885 error: function() {
859 - $message.removeClass('hidden').html('<div class="text-red-600 text-sm">Error connecting to server. Please try again.</div>');
886 + $message.removeClass('hidden').html('<div class="text-red-600 text-sm"><?php esc_html_e('Error connecting to server. Please try again.', 'easy-invoice'); ?></div>');
860 887 $submitBtn.prop('disabled', false).html('Create Quote');
861 888 }
862 889 });
863 890 });
864 891
865 - // Create sample quote
866 - $('#easy-quote-create-sample').on('click', function() {
867 - var $btn = $(this);
868 - var originalText = $btn.text();
869 - $btn.prop('disabled', true).text('Creating...');
870 -
871 - $.ajax({
872 - url: easyInvoice.ajaxUrl,
873 - type: 'POST',
874 - dataType: 'json',
875 - data: {
876 - action: 'easy_invoice_create_sample_quote',
877 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
878 - },
879 - success: function(response) {
880 - if (response.success && response.data && response.data.quote_id) {
881 - window.location.href = 'admin.php?page=easy-invoice-quote-builder&id=' + response.data.quote_id;
882 - } else {
883 - EasyInvoiceConfirmation.show({
884 - title: 'Error',
885 - message: response.data && response.data.message ? response.data.message : 'Failed to create sample quote.',
886 - confirmText: 'OK',
887 - confirmClass: 'bg-red-600 hover:bg-red-700'
888 - });
889 - }
890 - },
891 - error: function() {
892 - EasyInvoiceConfirmation.show({
893 - title: 'Error',
894 - message: 'Failed to create sample quote.',
895 - confirmText: 'OK',
896 - confirmClass: 'bg-red-600 hover:bg-red-700'
897 - });
898 - },
899 - complete: function() {
900 - $btn.prop('disabled', false).text(originalText);
901 - }
902 - });
903 - });
904 -
905 - // Update existing quotes
906 - $('#update-existing-quotes-btn').on('click', function() {
907 - var $btn = $(this);
908 - var originalText = $btn.text();
909 - $btn.prop('disabled', true).text('Updating...');
910 -
911 - $.ajax({
912 - url: easyInvoice.ajaxUrl,
913 - type: 'POST',
914 - dataType: 'json',
915 - data: {
916 - action: 'easy_invoice_update_existing_quotes',
917 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
918 - },
919 - success: function(response) {
920 - if (response.success) {
921 - EasyInvoiceConfirmation.show({
922 - title: 'Success',
923 - message: response.data.message,
924 - confirmText: 'OK',
925 - confirmClass: 'bg-green-600 hover:bg-green-700'
926 - });
927 - // Reload the page to show updated data
928 - setTimeout(function() {
929 - location.reload();
930 - }, 1500);
931 - } else {
932 - EasyInvoiceConfirmation.show({
933 - title: 'Error',
934 - message: response.data && response.data.message ? response.data.message : 'Failed to update quotes.',
935 - confirmText: 'OK',
936 - confirmClass: 'bg-red-600 hover:bg-red-700'
937 - });
938 - }
939 - },
940 - error: function() {
941 - EasyInvoiceConfirmation.show({
942 - title: 'Error',
943 - message: 'Failed to update quotes.',
944 - confirmText: 'OK',
945 - confirmClass: 'bg-red-600 hover:bg-red-700'
946 - });
947 - },
948 - complete: function() {
949 - $btn.prop('disabled', false).text(originalText);
950 - }
951 - });
952 - });
953 -
954 -
955 -
956 892 // Select all checkboxes
957 893 $('#select-all').on('change', function() {
958 894 $('.quote-checkbox').prop('checked', $(this).is(':checked'));
959 895 updateBulkActionButton();
@@ -1031,9 +967,9 @@
1031 967 data: {
1032 968 action: 'easy_invoice_bulk_quote_action',
1033 969 quote_ids: selectedQuotes,
1034 970 bulk_action: action,
1035 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
971 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1036 972 },
1037 973 success: function(response) {
1038 974 if (response.success) {
1039 975 location.reload();
@@ -1074,9 +1010,9 @@
1074 1010 dataType: 'json',
1075 1011 data: {
1076 1012 action: 'easy_invoice_draft_quote',
1077 1013 quote_id: quoteId,
1078 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1014 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1079 1015 },
1080 1016 success: function(response) {
1081 1017 if (response.success) {
1082 1018 location.reload();
@@ -1100,8 +1036,27 @@
1100 1036 });
1101 1037 });
1102 1038 });
1103 1039
1040 + // Convert to invoice: a confirmation, then the draft opens in the builder.
1041 + $(document).on('click', '.convert-quote', function(e) {
1042 + e.preventDefault();
1043 + var quoteId = $(this).data('quote-id'), quoteNumber = $(this).data('quote-number');
1044 + var run = function() {
1045 + $.post(ajaxurl, { action: 'easy_invoice_convert_quote', quote_id: quoteId, nonce: '<?php echo esc_js(wp_create_nonce('easy_invoice_admin_nonce')); ?>' }, function(response) {
1046 + if (response && response.success) {
1047 + if (typeof EasyInvoiceToast !== 'undefined') { EasyInvoiceToast.success(response.data.message); }
1048 + window.location.href = response.data.redirect || ('<?php echo esc_url(admin_url('admin.php?page=easy-invoice-builder&invoice_id=')); ?>' + response.data.invoice_id);
1049 + } else if (typeof EasyInvoiceToast !== 'undefined') {
1050 + EasyInvoiceToast.error((response && response.data && response.data.message) || '<?php echo esc_js(__('The invoice could not be created.', 'easy-invoice')); ?>');
1051 + }
1052 + });
1053 + };
1054 + if (typeof EasyInvoiceConfirmation !== 'undefined') {
1055 + EasyInvoiceConfirmation.show({ title: '<?php echo esc_js(__('Convert to invoice', 'easy-invoice')); ?>', message: '<?php echo esc_js(__('Create a draft invoice from quote %s? The quote is marked accepted.', 'easy-invoice')); ?>'.replace('%s', quoteNumber), confirmText: '<?php echo esc_js(__('Create invoice', 'easy-invoice')); ?>', onConfirm: run });
1056 + } else if (window.confirm('<?php echo esc_js(__('Create a draft invoice from this quote?', 'easy-invoice')); ?>')) { run(); }
1057 + });
1058 +
1104 1059 $(document).on('click', '.trash-quote', function(e) {
1105 1060 e.preventDefault();
1106 1061 const quoteId = $(this).data('quote-id');
1107 1062 const quoteNumber = $(this).data('quote-number');
@@ -1113,9 +1068,9 @@
1113 1068 dataType: 'json',
1114 1069 data: {
1115 1070 action: 'easy_invoice_trash_quote',
1116 1071 quote_id: quoteId,
1117 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1072 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1118 1073 },
1119 1074 success: function(response) {
1120 1075 if (response.success) {
1121 1076 location.reload();
@@ -1159,9 +1114,9 @@
1159 1114 type: "POST",
1160 1115 data: {
1161 1116 action: "easy_invoice_send_quote_email",
1162 1117 quote_id: quoteId,
1163 - nonce: '<?php echo wp_create_nonce('easy_invoice_send_quote_email'); ?>'
1118 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_send_quote_email')); ?>'
1164 1119 },
1165 1120 success: function(response) {
1166 1121 if (response.success) {
1167 1122 EasyInvoiceToast.success("Email sent successfully!");
@@ -1192,9 +1147,9 @@
1192 1147 type: "POST",
1193 1148 data: {
1194 1149 action: "easy_invoice_send_quote_email",
1195 1150 quote_id: quoteId,
1196 - nonce: '<?php echo wp_create_nonce('easy_invoice_send_quote_email'); ?>'
1151 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_send_quote_email')); ?>'
1197 1152 },
1198 1153 success: function(response) {
1199 1154 if (response.success) {
1200 1155 EasyInvoiceToast.success("Email sent successfully!");
@@ -1215,46 +1170,23 @@
1215 1170 }
1216 1171 }
1217 1172 });
1218 1173
1219 - $(document).on('click', '.download-pdf-quote', function(e) {
1220 - e.preventDefault();
1221 - const quoteId = $(this).data('quote-id');
1222 - const quoteNumber = $(this).data('quote-number');
1174 + // Download PDF: let the anchor's native href do the job.
1175 + //
1176 + // Historically this handler intercepted the click and did an
1177 + // admin-ajax round-trip that minted a per-user nonce and opened
1178 + // the resulting admin-ajax URL in a new tab. Same failure modes
1179 + // as the invoice listing — see that template for the full
1180 + // rationale. Removing the interceptor collapses three server
1181 + // round-trips into one and sidesteps every failure mode. The
1182 + // `easy_invoice_download_quote_pdf` / `easy_invoice_generate_quote_pdf`
1183 + // handlers are still registered on the server for any external
1184 + // integration that used them (they now have their own robust
1185 + // 3-path auth + JS redirect fallback — see
1186 + // EasyInvoiceAjax::generateQuotePdf), but this button no longer
1187 + // depends on them.
1223 1188
1224 - // Show loading state
1225 - var $link = $(this);
1226 - var originalText = $link.text();
1227 - $link.text("Generating PDF...").css("opacity", "0.7");
1228 -
1229 - // Get quote data via AJAX and generate PDF
1230 - $.ajax({
1231 - url: easyInvoice.ajaxUrl,
1232 - type: "POST",
1233 - data: {
1234 - action: "easy_invoice_download_quote_pdf",
1235 - quote_id: quoteId,
1236 - nonce: easyInvoice.nonce
1237 - },
1238 - success: function(response) {
1239 - if (response.success && response.data.download_url) {
1240 - // Open the download URL in a new tab
1241 - window.open(response.data.download_url, '_blank');
1242 - EasyInvoiceToast.success("PDF download started");
1243 - } else {
1244 - EasyInvoiceToast.error(response.data.message || "Error preparing PDF data");
1245 - }
1246 - // Reset link text
1247 - $link.text(originalText).css("opacity", "1");
1248 - },
1249 - error: function() {
1250 - EasyInvoiceToast.error("Error connecting to server");
1251 - // Reset link text
1252 - $link.text(originalText).css("opacity", "1");
1253 - }
1254 - });
1255 - });
1256 -
1257 1189 // Delete quote
1258 1190 $(document).on('click', '.delete-quote-btn', function() {
1259 1191 const quoteId = $(this).data('quote-id');
1260 1192 const $row = $(this).closest('tr');
@@ -1266,9 +1198,9 @@
1266 1198 dataType: 'json',
1267 1199 data: {
1268 1200 action: 'easy_invoice_delete_quote',
1269 1201 quote_id: quoteId,
1270 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1202 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1271 1203 },
1272 1204 success: function(response) {
1273 1205 if (response.success) {
1274 1206 $row.fadeOut(300, function() {
@@ -1346,9 +1278,9 @@
1346 1278 type: 'POST',
1347 1279 dataType: 'json',
1348 1280 data: {
1349 1281 action: 'easy_invoice_empty_trash',
1350 - nonce: '<?php echo wp_create_nonce('easy_invoice_nonce'); ?>'
1282 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_nonce')); ?>'
1351 1283 },
1352 1284 success: function(response) {
1353 1285 if (response.success) {
1354 1286 EasyInvoiceToast.success('Trash emptied successfully');
@@ -1382,9 +1314,9 @@
1382 1314 dataType: 'json',
1383 1315 data: {
1384 1316 action: 'easy_invoice_restore_quote',
1385 1317 quote_id: quoteId,
1386 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1318 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1387 1319 },
1388 1320 success: function(response) {
1389 1321 if (response.success) {
1390 1322 location.reload();
@@ -1433,9 +1365,9 @@
1433 1365 dataType: 'json',
1434 1366 data: {
1435 1367 action: 'easy_invoice_duplicate_quote',
1436 1368 quote_id: quoteId,
1437 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1369 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1438 1370 },
1439 1371 success: function(response) {
1440 1372 if (response.success && response.data && response.data.quote_id) {
1441 1373 EasyInvoiceConfirmation.show({
@@ -1486,9 +1418,9 @@
1486 1418 dataType: 'json',
1487 1419 data: {
1488 1420 action: 'easy_invoice_get_quote_logs',
1489 1421 quote_id: quoteId,
1490 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1422 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1491 1423 },
1492 1424 success: function(response) {
1493 1425 if (response.success && response.data && response.data.logs) {
1494 1426 let logsHtml = '<div class="space-y-4">';
@@ -1525,13 +1457,13 @@
1525 1457
1526 1458 logsHtml += '</div>';
1527 1459 $('#logs-modal .modal-body').html(logsHtml);
1528 1460 } else {
1529 - $('#logs-modal .modal-body').html('<div class="text-center py-8 text-gray-500">No logs found for this quote.</div>');
1461 + $('#logs-modal .modal-body').html('<div class="text-center py-8 text-gray-500"><?php esc_html_e('No logs found for this quote.', 'easy-invoice'); ?></div>');
1530 1462 }
1531 1463 },
1532 1464 error: function() {
1533 - $('#logs-modal .modal-body').html('<div class="text-center py-8 text-red-500">Error loading logs. Please try again.</div>');
1465 + $('#logs-modal .modal-body').html('<div class="text-center py-8 text-red-500"><?php esc_html_e('Error loading logs. Please try again.', 'easy-invoice'); ?></div>');
1534 1466 }
1535 1467 });
1536 1468 });
1537 1469
@@ -1551,17 +1483,47 @@
1551 1483 </script>
1552 1484
1553 1485 <style>
1554 1486 /* Modern row actions styling (copied from invoice listing) */
1487 +/* Team roles: controls a member may not use are not drawn (the endpoints check again). */
1488 +<?php if (!easy_invoice_user_can('ei_create_quote')) : ?>.row-actions .edit, .row-actions .duplicate, .row-actions .convert { display: none; }<?php endif; ?>
1489 +<?php if (!easy_invoice_user_can('ei_delete_quote')) : ?>.row-actions .trash, .row-actions .delete, .row-actions .restore { display: none; }<?php endif; ?>
1490 +<?php if (!easy_invoice_user_can('ei_send_invoice')) : ?>.row-actions .email { display: none; }<?php endif; ?>
1491 +/* Nine columns: the title cell may wrap, and the dates sit closer together. */
1492 +.ei-quotes-table td:nth-child(2) { white-space: normal; }
1493 +.ei-quotes-table td:nth-child(2) .ei-viewed { white-space: nowrap; }
1494 +.ei-quotes-table th, .ei-quotes-table td { padding-left: 1rem; padding-right: 1rem; }
1555 1495 .row-actions {
1556 1496 visibility: hidden;
1557 1497 font-size: 12px;
1558 1498 padding-top: 4px;
1559 1499 display: flex;
1560 - gap: 8px;
1500 + flex-wrap: wrap; /* a long action list must not widen the column past the screen */
1501 + gap: 4px 8px;
1561 1502 }
1562 -tr:hover .row-actions {
1503 +tr:hover .row-actions,
1504 +tr:focus-within .row-actions {
1563 1505 visibility: visible;
1506 +}
1507 +
1508 +/*
1509 + * Row actions were revealed on `tr:hover` only, which made them unreachable for
1510 + * two whole groups of users:
1511 + *
1512 + * - Keyboard users. `visibility: hidden` also removes elements from the tab
1513 + * order, so View / Edit / Trash / Send Email / Download PDF / Duplicate could
1514 + * never be focused at all. `:focus-within` above fixes that — tabbing into the
1515 + * row now reveals them.
1516 + * - Touch users. There is no hover on a phone or tablet, so every row action was
1517 + * permanently invisible and there was no other way to reach them.
1518 + *
1519 + * On pointer-less devices, and on the narrow widths WordPress itself treats as
1520 + * mobile admin, the actions are simply always visible.
1521 + */
1522 +@media (hover: none), (max-width: 782px) {
1523 + .row-actions {
1524 + visibility: visible;
1525 + }
1564 1526 }
1565 1527 .row-actions span a {
1566 1528 color: #6366f1; /* indigo-500 */
1567 1529 text-decoration: none;