PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.4
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.4
2.4.3 2.4.4 2.4.2 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 All 60 releases
← All changes | templates/quotes/listing.php +231 -279 2.2.0 → 2.4.4 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,77 +60,96 @@
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">
99 - <!-- Page Header -->
100 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;">
101 91 <div class="flex items-center justify-between">
102 92 <div>
103 - <h1 class="text-2xl font-bold text-gray-900"><?php _e('Quotes', 'easy-invoice'); ?></h1>
104 - <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>
105 95 </div>
106 96 <div class="flex items-center space-x-3">
107 97 <?php if (isset($_GET['rewrite_flushed'])): ?>
108 98 <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded-md text-sm">
109 - <?php _e('Rewrite rules flushed successfully!', 'easy-invoice'); ?>
99 + <?php esc_html_e('Rewrite rules flushed successfully!', 'easy-invoice'); ?>
110 100 </div>
111 101 <?php endif; ?>
112 102
113 103 <?php if (isset($_GET['post_types_registered'])): ?>
114 104 <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded-md text-sm">
115 - <?php _e('Post types registered successfully!', 'easy-invoice'); ?>
105 + <?php esc_html_e('Post types registered successfully!', 'easy-invoice'); ?>
116 106 </div>
117 107 <?php endif; ?>
118 108
119 - <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')); ?>"
120 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">
121 - <?php _e('Flush Rewrite Rules', 'easy-invoice'); ?>
125 + <?php esc_html_e('Flush Rewrite Rules', 'easy-invoice'); ?>
122 126 </a>
127 + <?php endif; ?>
123 128
124 - <?php if (!easy_invoice_has_pro()): ?>
129 + <?php
130 + // "Export All" is part of the bulk_operations addon
131 + // (Personal tier). See templates/invoices/listing.php
132 + // for the canonical three-state gate pattern.
133 + $ei_bulk_ops_loaded = \EasyInvoice\Addons\AddonManager::shouldLoad('bulk_operations');
134 + ?>
135 + <?php if ($ei_bulk_ops_loaded): ?>
136 + <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
137 + <input type="hidden" name="action" value="easy_invoice_export_all_quotes" />
138 + <input type="hidden" name="_wpnonce" value="<?php echo esc_attr(wp_create_nonce('easy_invoice_export_all_quotes')); ?>" />
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">
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">
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>
142 + </svg>
143 + <?php esc_html_e('Export All', 'easy-invoice'); ?>
144 + </button>
145 + </form>
146 + <?php elseif (!easy_invoice_has_pro()): ?>
125 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">
126 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">
127 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>
128 150 </svg>
129 - <?php _e('Export All', 'easy-invoice'); ?>
151 + <?php esc_html_e('Export All', 'easy-invoice'); ?>
130 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">
131 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"/>
132 154 </svg>
133 155 </button>
@@ -134,45 +156,33 @@
134 156 <script>
135 157 jQuery(document).ready(function($) {
136 158 $('#export-all-quotes-btn').on('click', function(e) {
137 159 e.preventDefault();
138 - // Show premium popup using the proper confirmation modal
139 160 if (typeof EasyInvoiceConfirmation !== 'undefined') {
140 161 EasyInvoiceConfirmation.showFeatureUpgrade('Export All Quotes', 'Export all your quotes to CSV, Excel, or PDF format for backup, analysis, or sharing with your team.');
141 162 } else {
142 - // Fallback to alert if confirmation modal is not available
143 163 alert('<?php echo esc_js(__('Exporting all quotes is a premium feature. Upgrade to Easy Invoice Pro to unlock this feature.', 'easy-invoice')); ?>');
144 164 }
145 165 });
146 166 });
147 167 </script>
148 - <?php else: ?>
149 - <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
150 - <input type="hidden" name="action" value="easy_invoice_export_all_quotes" />
151 - <input type="hidden" name="_wpnonce" value="<?php echo wp_create_nonce('easy_invoice_export_all_quotes'); ?>" />
152 - <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">
153 - <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">
154 - <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>
155 - </svg>
156 - <?php _e('Export All', 'easy-invoice'); ?>
157 - </button>
158 - </form>
159 168 <?php endif; ?>
160 - <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">
161 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">
162 172 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
163 173 </svg>
164 - <?php _e('Create New Quote', 'easy-invoice'); ?>
174 + <?php esc_html_e('Create New Quote', 'easy-invoice'); ?>
165 175 </button>
176 +<?php endif; ?>
166 177 </div>
167 178 </div>
168 179 </div>
169 180
170 - <!-- New Quote Modal -->
171 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">
172 182 <div class="relative mx-auto p-6 border w-96 shadow-xl rounded-lg bg-white my-20">
173 183 <div class="flex justify-between items-center mb-6">
174 - <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>
175 185 <button type="button" id="close-new-quote-modal" class="text-gray-400 hover:text-gray-600 transition-colors duration-200">
176 186 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
177 187 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
178 188 </svg>
@@ -187,9 +197,9 @@
187 197 id="new_quote_title"
188 198 required
189 199 placeholder="Enter quote title..."
190 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">
191 - <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>
192 202 </div>
193 203
194 204 <div class="flex justify-end space-x-3 pt-4">
195 205 <button type="button" id="cancel-new-quote"
@@ -206,9 +216,8 @@
206 216 <div id="new-quote-message" class="mt-4 hidden"></div>
207 217 </div>
208 218 </div>
209 219
210 - <!-- Logs Modal -->
211 220 <div id="logs-modal" class="fixed z-50 inset-0 overflow-y-auto hidden">
212 221 <div class="flex items-center justify-center min-h-screen px-4">
213 222 <div class="fixed inset-0 transition-opacity" aria-hidden="true">
214 223 <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
@@ -215,11 +224,11 @@
215 224 </div>
216 225 <div class="bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:max-w-2xl w-full z-50">
217 226 <div class="px-6 py-4 border-b border-gray-200">
218 227 <div class="flex justify-between items-center">
219 - <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>
220 229 <button type="button" id="close-logs-modal" class="text-gray-400 hover:text-gray-500 focus:outline-none">
221 - <span class="sr-only">Close</span>
230 + <span class="sr-only"><?php esc_html_e('Close', 'easy-invoice'); ?></span>
222 231 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
223 232 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
224 233 </svg>
225 234 </button>
@@ -225,15 +234,13 @@
225 234 </button>
226 235 </div>
227 236 </div>
228 237 <div class="px-6 py-4 modal-body">
229 - <!-- Logs content will be loaded here -->
230 238 </div>
231 239 </div>
232 240 </div>
233 241 </div>
234 242
235 - <!-- Stats Cards -->
236 243 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 mt-8">
237 244 <div class="bg-white rounded-lg shadow p-6 transition-transform duration-300 hover:shadow-md hover:-translate-y-1">
238 245 <div class="flex items-center">
239 246 <div class="rounded-full bg-indigo-100 p-3 mr-4">
@@ -241,10 +248,10 @@
241 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>
242 249 </svg>
243 250 </div>
244 251 <div>
245 - <h3 class="text-sm font-medium text-gray-500">Total Quotes</h3>
246 - <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>
247 254 </div>
248 255 </div>
249 256 </div>
250 257 <div class="bg-white rounded-lg shadow p-6 transition-transform duration-300 hover:shadow-md hover:-translate-y-1">
@@ -254,9 +261,9 @@
254 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>
255 262 </svg>
256 263 </div>
257 264 <div>
258 - <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>
259 266 <?php if (empty($enhanced_stats['currency_totals'])): ?>
260 267 <p class="mt-1 text-2xl font-bold text-gray-900">$0.00</p>
261 268 <?php else: ?>
262 269 <div class="mt-1 space-y-1">
@@ -261,14 +268,13 @@
261 268 <?php else: ?>
262 269 <div class="mt-1 space-y-1">
263 270 <?php foreach ($enhanced_stats['currency_totals'] as $currency_code => $currency_data): ?>
264 271 <?php
265 - $formatter = new \EasyInvoice\Helpers\QuoteFormatter($currency_data['quote_object']);
266 - $formatted_amount = $formatter->format($currency_data['total']);
272 + $formatted_amount = easy_invoice_format_money((float) $currency_data['total'], null, (string) $currency_code);
267 273 ?>
268 274 <div class="flex items-center justify-between">
269 - <span class="text-lg font-bold text-gray-900"><?php echo $formatted_amount; ?></span>
270 - <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>
271 277 </div>
272 278 <?php endforeach; ?>
273 279 </div>
274 280 <?php endif; ?>
@@ -282,10 +288,10 @@
282 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>
283 289 </svg>
284 290 </div>
285 291 <div>
286 - <h3 class="text-sm font-medium text-gray-500">Accepted Quotes</h3>
287 - <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>
288 294 </div>
289 295 </div>
290 296 </div>
291 297 <div class="bg-white rounded-lg shadow p-6 transition-transform duration-300 hover:shadow-md hover:-translate-y-1">
@@ -295,16 +301,15 @@
295 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>
296 302 </svg>
297 303 </div>
298 304 <div>
299 - <h3 class="text-sm font-medium text-gray-500">Pending Quotes</h3>
300 - <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>
301 307 </div>
302 308 </div>
303 309 </div>
304 310 </div>
305 311
306 - <!-- Top Pagination -->
307 312 <?php if (isset($pagination) && $pagination['total_pages'] > 1): ?>
308 313 <div class="mb-4">
309 314 <div class="px-4 py-3 flex items-center justify-between">
310 315 <div class="flex items-center space-x-2">
@@ -309,18 +314,18 @@
309 314 <div class="px-4 py-3 flex items-center justify-between">
310 315 <div class="flex items-center space-x-2">
311 316 <span class="text-sm text-gray-700">
312 317 Showing
313 - <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>
314 319 to
315 - <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>
316 321 of
317 - <span class="font-medium"><?php echo $pagination['total_quotes']; ?></span>
322 + <span class="font-medium"><?php echo esc_html($pagination['total_quotes']); ?></span>
318 323 results
319 324 </span>
320 325 </div>
321 326 <div class="flex items-center space-x-2">
322 - <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>
323 328 <div class="flex space-x-1">
324 329 <?php
325 330 $prev_page = $pagination['current_page'] > 1 ? $pagination['current_page'] - 1 : 1;
326 331 $next_page = $pagination['current_page'] < $pagination['total_pages'] ? $pagination['current_page'] + 1 : $pagination['total_pages'];
@@ -332,11 +337,11 @@
332 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>
333 338 <?php endif;
334 339 for ($i = $start_page; $i <= $end_page; $i++):
335 340 if ($i == $pagination['current_page']): ?>
336 - <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>
337 342 <?php else: ?>
338 - <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>
339 344 <?php endif;
340 345 endfor;
341 346 if ($pagination['current_page'] < $pagination['total_pages']): ?>
342 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>
@@ -346,11 +351,14 @@
346 351 </div>
347 352 </div>
348 353 <?php endif; ?>
349 354
350 - <!-- Main Content Area -->
351 - <div class="bg-white shadow rounded-lg">
352 - <!-- Navigation Tabs -->
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">
353 361 <div class="border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-white">
354 362 <nav class="flex">
355 363 <?php
356 364 // Use the counts passed from the controller
@@ -384,9 +392,9 @@
384 392 ? 'border-b-2 border-indigo-500 text-indigo-600 bg-white'
385 393 : 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
386 394 ?>
387 395 <a href="<?php echo esc_url(add_query_arg('view', $tab_key, remove_query_arg('status'))); ?>"
388 - 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); ?>">
389 397 <?php if ($tab_key === 'all'): ?>
390 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">
391 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>
392 400 </svg>
@@ -418,30 +426,29 @@
418 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">
419 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>
420 428 </svg>
421 429 <?php endif; ?>
422 - <?php echo $tab_label; ?> (<?php echo $count; ?>)
430 + <?php echo esc_html($tab_label); ?> (<?php echo esc_html($count); ?>)
423 431 </a>
424 432 <?php endforeach; ?>
425 433 </nav>
426 434 </div>
427 435
428 - <!-- Filters and Bulk Actions with Search -->
429 436 <div class="p-4 flex flex-wrap items-center justify-between bg-white border-b border-gray-100">
430 - <!-- Bulk Actions (Left Side) -->
431 437 <div class="flex items-center space-x-2 my-2">
432 - <form id="bulk-action-form" method="post" class="flex items-center gap-2">
433 - <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')); ?>">
434 441
435 - <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">
436 - <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>
437 444 <?php if ($view === 'cancelled'): ?>
438 - <option value="restore">Restore Selected</option>
439 - <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>
440 447 <?php else: ?>
441 - <option value="delete">Delete Selected</option>
442 - <option value="trash">Move to Trash</option>
443 - <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>
444 451 <?php endif; ?>
445 452 <option value="export" data-requires-pro="1"><?php esc_html_e('Export Selected (Pro)', 'easy-invoice'); ?></option>
446 453 </select>
447 454
@@ -457,11 +464,11 @@
457 464 Empty Trash
458 465 </button>
459 466 <?php endif; ?>
460 467 </form>
468 +<?php endif; ?>
461 469 </div>
462 470
463 - <!-- Search Form (Right Side) -->
464 471 <div class="flex items-center gap-2">
465 472 <form method="get" class="flex items-center gap-2">
466 473 <input type="hidden" name="page" value="easy-quote-all">
467 474 <input type="hidden" name="view" value="<?php echo esc_attr($view); ?>">
@@ -490,19 +497,19 @@
490 497 </div>
491 498 <input type="text"
492 499 name="search"
493 500 value="<?php echo esc_attr($search_query); ?>"
494 - placeholder="<?php _e('Search quotes, numbers, or clients...', 'easy-invoice'); ?>"
501 + placeholder="<?php echo esc_js(__('Search quotes, numbers, or clients...', 'easy-invoice')); ?>"
495 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">
496 503 </div>
497 504
498 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">
499 - <?php _e('Search', 'easy-invoice'); ?>
506 + <?php esc_html_e('Search', 'easy-invoice'); ?>
500 507 </button>
501 508
502 509 <?php if (!empty($search_query)): ?>
503 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">
504 - <?php _e('Clear', 'easy-invoice'); ?>
511 + <?php esc_html_e('Clear', 'easy-invoice'); ?>
505 512 </a>
506 513 <?php endif; ?>
507 514 </form>
508 515 </div>
@@ -507,15 +514,14 @@
507 514 </form>
508 515 </div>
509 516 </div>
510 517
511 - <!-- Quotes Table -->
512 518 <div class="overflow-x-auto">
513 - <table class="min-w-full divide-y divide-gray-200">
519 + <table class="min-w-full divide-y divide-gray-200 ei-quotes-table">
514 520 <thead class="bg-gray-50">
515 521 <tr>
516 522 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
517 - <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">
518 524 </th>
519 525 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
520 526 Quote
521 527 </th>
@@ -537,9 +543,9 @@
537 543 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
538 544 Logs
539 545 </th>
540 546 <th scope="col" class="relative px-6 py-3">
541 - <span class="sr-only">Actions</span>
547 + <span class="sr-only"><?php esc_html_e('Actions', 'easy-invoice'); ?></span>
542 548 </th>
543 549 </tr>
544 550 </thead>
545 551 <tbody class="bg-white divide-y divide-gray-200">
@@ -549,10 +555,10 @@
549 555 <div class="flex flex-col items-center">
550 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">
551 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>
552 558 </svg>
553 - <h3 class="text-lg font-medium text-gray-900 mb-2">No quotes found</h3>
554 - <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>
555 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">
556 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">
557 563 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
558 564 </svg>
@@ -619,9 +625,9 @@
619 625 $quote->ensureProperSlug();
620 626 ?>
621 627 <tr class="hover:bg-gray-50 transition-colors duration-150">
622 628 <td class="px-6 py-4 whitespace-nowrap">
623 - <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(); ?>">
624 630 </td>
625 631 <td class="px-6 py-4 whitespace-nowrap">
626 632 <div class="flex items-center">
627 633 <div class="flex-shrink-0 h-10 w-10">
@@ -632,30 +638,36 @@
632 638 </div>
633 639 </div>
634 640 <div class="ml-4">
635 641 <div class="text-sm font-medium text-gray-900">
636 - <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">
637 643 <?php echo esc_html($quote->getTitle() ?: 'Untitled Quote'); ?>
638 644 </a>
639 645 </div>
640 646 <div class="text-sm text-gray-500">
641 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; ?>
642 654 </div>
643 655 <div class="row-actions mt-1">
644 - <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>
645 - <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>
646 658 <?php if ($quote->getStatus() === 'cancelled'): ?>
647 - <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>
648 - <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>
649 661 <?php else: ?>
650 - <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>
651 - <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>
652 - <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>
653 665 <?php if (!easy_invoice_has_pro()): ?>
654 - <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>
655 667 <?php endif; ?>
656 668 <?php if (!easy_invoice_has_pro()): ?>
657 - <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>
658 670 <?php endif; ?>
659 671
660 672 <?php
661 673 // Apply filter for Pro plugin to add secure link actions
@@ -660,9 +672,9 @@
660 672 <?php
661 673 // Apply filter for Pro plugin to add secure link actions
662 674 $secure_actions = apply_filters('easy_invoice_quote_row_actions', [], $quote);
663 675 foreach ($secure_actions as $action_key => $action_html) {
664 - echo '<span class="' . esc_attr($action_key) . '">' . $action_html . '</span>';
676 + echo '<span class="' . esc_attr($action_key) . '">' . wp_kses_post($action_html) . '</span>';
665 677 }
666 678 ?>
667 679 <?php endif; ?>
668 680 </div>
@@ -700,10 +712,17 @@
700 712 </td>
701 713 <td class="px-6 py-4 whitespace-nowrap">
702 714 <?php
703 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 + }
704 722 $status_classes = [
705 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',
706 725 'sent' => 'bg-blue-100 text-blue-800 ring-1 ring-blue-300',
707 726 'accepted' => 'bg-green-100 text-green-800 ring-1 ring-green-300',
708 727 'declined' => 'bg-red-100 text-red-800 ring-1 ring-red-300',
709 728 'expired' => 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300'
@@ -709,17 +728,17 @@
709 728 'expired' => 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300'
710 729 ];
711 730 $status_class = $status_classes[$status] ?? 'bg-gray-100 text-gray-800 ring-1 ring-gray-300';
712 731 ?>
713 - <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <?php echo $status_class; ?>">
714 - <?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)); ?>
715 734 </span>
716 735 </td>
717 736 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
718 - <?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())) : '—'); ?>
719 738 </td>
720 739 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
721 - <?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())) : '—'); ?>
722 741 </td>
723 742 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
724 743 <?php
725 744 // Get all logs for this quote
@@ -730,22 +749,21 @@
730 749
731 750 if ($logs_count > 0): ?>
732 751 <button type="button"
733 752 class="view-logs-btn text-xs text-indigo-600 hover:text-indigo-800 font-medium"
734 - data-quote-id="<?php echo $quote->getId(); ?>"
753 + data-quote-id="<?php echo (int) $quote->getId(); ?>"
735 754 data-quote-title="<?php echo esc_attr($quote->getTitle() ?: 'Untitled Quote'); ?>"
736 - data-logs-count="<?php echo $logs_count; ?>">
737 - 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
738 757 </button>
739 758 <?php else: ?>
740 - <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>
741 760 <?php endif; ?>
742 761 <?php } catch (\Exception $e) { ?>
743 - <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>
744 763 <?php } ?>
745 764 </td>
746 765 <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
747 - <!-- Actions column - actions are shown in the row-actions div above -->
748 766 </td>
749 767 </tr>
750 768 <?php endforeach; ?>
751 769 <?php endif; ?>
@@ -753,9 +771,8 @@
753 771 </table>
754 772 </div>
755 773 </div>
756 774
757 - <!-- Bottom Pagination -->
758 775 <?php if (isset($pagination) && $pagination['total_pages'] > 1): ?>
759 776 <div class="mt-4">
760 777 <div class="px-4 py-3 flex items-center justify-between">
761 778 <div class="flex items-center space-x-2">
@@ -760,18 +777,18 @@
760 777 <div class="px-4 py-3 flex items-center justify-between">
761 778 <div class="flex items-center space-x-2">
762 779 <span class="text-sm text-gray-700">
763 780 Showing
764 - <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>
765 782 to
766 - <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>
767 784 of
768 - <span class="font-medium"><?php echo $pagination['total_quotes']; ?></span>
785 + <span class="font-medium"><?php echo esc_html($pagination['total_quotes']); ?></span>
769 786 results
770 787 </span>
771 788 </div>
772 789 <div class="flex items-center space-x-2">
773 - <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>
774 791 <div class="flex space-x-1">
775 792 <?php
776 793 $prev_page = $pagination['current_page'] > 1 ? $pagination['current_page'] - 1 : 1;
777 794 $next_page = $pagination['current_page'] < $pagination['total_pages'] ? $pagination['current_page'] + 1 : $pagination['total_pages'];
@@ -783,11 +800,11 @@
783 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>
784 801 <?php endif;
785 802 for ($i = $start_page; $i <= $end_page; $i++):
786 803 if ($i == $pagination['current_page']): ?>
787 - <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>
788 805 <?php else: ?>
789 - <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>
790 807 <?php endif;
791 808 endfor;
792 809 if ($pagination['current_page'] < $pagination['total_pages']): ?>
793 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>
@@ -833,9 +850,9 @@
833 850 var $message = $('#new-quote-message');
834 851 var title = $('#new_quote_title').val().trim();
835 852
836 853 if (!title) {
837 - $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>');
838 855 return;
839 856 }
840 857
841 858 // Show loading state
@@ -848,14 +865,14 @@
848 865 dataType: 'json',
849 866 data: {
850 867 action: 'easy_invoice_create_new_quote',
851 868 title: title,
852 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
869 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
853 870 },
854 871 success: function(response) {
855 872 if (response.success && response.data && response.data.quote_id) {
856 873 // Show success message below the form
857 - $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>');
858 875
859 876 // Redirect to the builder page with the new quote ID
860 877 setTimeout(function() {
861 878 window.location.href = 'admin.php?page=easy-invoice-quote-builder&id=' + response.data.quote_id;
@@ -865,105 +882,14 @@
865 882 $submitBtn.prop('disabled', false).html('Create Quote');
866 883 }
867 884 },
868 885 error: function() {
869 - $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>');
870 887 $submitBtn.prop('disabled', false).html('Create Quote');
871 888 }
872 889 });
873 890 });
874 891
875 - // Create sample quote
876 - $('#easy-quote-create-sample').on('click', function() {
877 - var $btn = $(this);
878 - var originalText = $btn.text();
879 - $btn.prop('disabled', true).text('Creating...');
880 -
881 - $.ajax({
882 - url: easyInvoice.ajaxUrl,
883 - type: 'POST',
884 - dataType: 'json',
885 - data: {
886 - action: 'easy_invoice_create_sample_quote',
887 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
888 - },
889 - success: function(response) {
890 - if (response.success && response.data && response.data.quote_id) {
891 - window.location.href = 'admin.php?page=easy-invoice-quote-builder&id=' + response.data.quote_id;
892 - } else {
893 - EasyInvoiceConfirmation.show({
894 - title: 'Error',
895 - message: response.data && response.data.message ? response.data.message : 'Failed to create sample quote.',
896 - confirmText: 'OK',
897 - confirmClass: 'bg-red-600 hover:bg-red-700'
898 - });
899 - }
900 - },
901 - error: function() {
902 - EasyInvoiceConfirmation.show({
903 - title: 'Error',
904 - message: 'Failed to create sample quote.',
905 - confirmText: 'OK',
906 - confirmClass: 'bg-red-600 hover:bg-red-700'
907 - });
908 - },
909 - complete: function() {
910 - $btn.prop('disabled', false).text(originalText);
911 - }
912 - });
913 - });
914 -
915 - // Update existing quotes
916 - $('#update-existing-quotes-btn').on('click', function() {
917 - var $btn = $(this);
918 - var originalText = $btn.text();
919 - $btn.prop('disabled', true).text('Updating...');
920 -
921 - $.ajax({
922 - url: easyInvoice.ajaxUrl,
923 - type: 'POST',
924 - dataType: 'json',
925 - data: {
926 - action: 'easy_invoice_update_existing_quotes',
927 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
928 - },
929 - success: function(response) {
930 - if (response.success) {
931 - EasyInvoiceConfirmation.show({
932 - title: 'Success',
933 - message: response.data.message,
934 - confirmText: 'OK',
935 - confirmClass: 'bg-green-600 hover:bg-green-700'
936 - });
937 - // Reload the page to show updated data
938 - setTimeout(function() {
939 - location.reload();
940 - }, 1500);
941 - } else {
942 - EasyInvoiceConfirmation.show({
943 - title: 'Error',
944 - message: response.data && response.data.message ? response.data.message : 'Failed to update quotes.',
945 - confirmText: 'OK',
946 - confirmClass: 'bg-red-600 hover:bg-red-700'
947 - });
948 - }
949 - },
950 - error: function() {
951 - EasyInvoiceConfirmation.show({
952 - title: 'Error',
953 - message: 'Failed to update quotes.',
954 - confirmText: 'OK',
955 - confirmClass: 'bg-red-600 hover:bg-red-700'
956 - });
957 - },
958 - complete: function() {
959 - $btn.prop('disabled', false).text(originalText);
960 - }
961 - });
962 - });
963 -
964 -
965 -
966 892 // Select all checkboxes
967 893 $('#select-all').on('change', function() {
968 894 $('.quote-checkbox').prop('checked', $(this).is(':checked'));
969 895 updateBulkActionButton();
@@ -1041,9 +967,9 @@
1041 967 data: {
1042 968 action: 'easy_invoice_bulk_quote_action',
1043 969 quote_ids: selectedQuotes,
1044 970 bulk_action: action,
1045 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
971 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1046 972 },
1047 973 success: function(response) {
1048 974 if (response.success) {
1049 975 location.reload();
@@ -1084,9 +1010,9 @@
1084 1010 dataType: 'json',
1085 1011 data: {
1086 1012 action: 'easy_invoice_draft_quote',
1087 1013 quote_id: quoteId,
1088 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1014 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1089 1015 },
1090 1016 success: function(response) {
1091 1017 if (response.success) {
1092 1018 location.reload();
@@ -1110,8 +1036,27 @@
1110 1036 });
1111 1037 });
1112 1038 });
1113 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 +
1114 1059 $(document).on('click', '.trash-quote', function(e) {
1115 1060 e.preventDefault();
1116 1061 const quoteId = $(this).data('quote-id');
1117 1062 const quoteNumber = $(this).data('quote-number');
@@ -1123,9 +1068,9 @@
1123 1068 dataType: 'json',
1124 1069 data: {
1125 1070 action: 'easy_invoice_trash_quote',
1126 1071 quote_id: quoteId,
1127 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1072 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1128 1073 },
1129 1074 success: function(response) {
1130 1075 if (response.success) {
1131 1076 location.reload();
@@ -1169,9 +1114,9 @@
1169 1114 type: "POST",
1170 1115 data: {
1171 1116 action: "easy_invoice_send_quote_email",
1172 1117 quote_id: quoteId,
1173 - 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')); ?>'
1174 1119 },
1175 1120 success: function(response) {
1176 1121 if (response.success) {
1177 1122 EasyInvoiceToast.success("Email sent successfully!");
@@ -1202,9 +1147,9 @@
1202 1147 type: "POST",
1203 1148 data: {
1204 1149 action: "easy_invoice_send_quote_email",
1205 1150 quote_id: quoteId,
1206 - 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')); ?>'
1207 1152 },
1208 1153 success: function(response) {
1209 1154 if (response.success) {
1210 1155 EasyInvoiceToast.success("Email sent successfully!");
@@ -1225,46 +1170,23 @@
1225 1170 }
1226 1171 }
1227 1172 });
1228 1173
1229 - $(document).on('click', '.download-pdf-quote', function(e) {
1230 - e.preventDefault();
1231 - const quoteId = $(this).data('quote-id');
1232 - 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.
1233 1188
1234 - // Show loading state
1235 - var $link = $(this);
1236 - var originalText = $link.text();
1237 - $link.text("Generating PDF...").css("opacity", "0.7");
1238 -
1239 - // Get quote data via AJAX and generate PDF
1240 - $.ajax({
1241 - url: easyInvoice.ajaxUrl,
1242 - type: "POST",
1243 - data: {
1244 - action: "easy_invoice_download_quote_pdf",
1245 - quote_id: quoteId,
1246 - nonce: easyInvoice.nonce
1247 - },
1248 - success: function(response) {
1249 - if (response.success && response.data.download_url) {
1250 - // Open the download URL in a new tab
1251 - window.open(response.data.download_url, '_blank');
1252 - EasyInvoiceToast.success("PDF download started");
1253 - } else {
1254 - EasyInvoiceToast.error(response.data.message || "Error preparing PDF data");
1255 - }
1256 - // Reset link text
1257 - $link.text(originalText).css("opacity", "1");
1258 - },
1259 - error: function() {
1260 - EasyInvoiceToast.error("Error connecting to server");
1261 - // Reset link text
1262 - $link.text(originalText).css("opacity", "1");
1263 - }
1264 - });
1265 - });
1266 -
1267 1189 // Delete quote
1268 1190 $(document).on('click', '.delete-quote-btn', function() {
1269 1191 const quoteId = $(this).data('quote-id');
1270 1192 const $row = $(this).closest('tr');
@@ -1276,9 +1198,9 @@
1276 1198 dataType: 'json',
1277 1199 data: {
1278 1200 action: 'easy_invoice_delete_quote',
1279 1201 quote_id: quoteId,
1280 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1202 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1281 1203 },
1282 1204 success: function(response) {
1283 1205 if (response.success) {
1284 1206 $row.fadeOut(300, function() {
@@ -1356,9 +1278,9 @@
1356 1278 type: 'POST',
1357 1279 dataType: 'json',
1358 1280 data: {
1359 1281 action: 'easy_invoice_empty_trash',
1360 - nonce: '<?php echo wp_create_nonce('easy_invoice_nonce'); ?>'
1282 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_nonce')); ?>'
1361 1283 },
1362 1284 success: function(response) {
1363 1285 if (response.success) {
1364 1286 EasyInvoiceToast.success('Trash emptied successfully');
@@ -1392,9 +1314,9 @@
1392 1314 dataType: 'json',
1393 1315 data: {
1394 1316 action: 'easy_invoice_restore_quote',
1395 1317 quote_id: quoteId,
1396 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1318 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1397 1319 },
1398 1320 success: function(response) {
1399 1321 if (response.success) {
1400 1322 location.reload();
@@ -1443,9 +1365,9 @@
1443 1365 dataType: 'json',
1444 1366 data: {
1445 1367 action: 'easy_invoice_duplicate_quote',
1446 1368 quote_id: quoteId,
1447 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1369 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1448 1370 },
1449 1371 success: function(response) {
1450 1372 if (response.success && response.data && response.data.quote_id) {
1451 1373 EasyInvoiceConfirmation.show({
@@ -1496,9 +1418,9 @@
1496 1418 dataType: 'json',
1497 1419 data: {
1498 1420 action: 'easy_invoice_get_quote_logs',
1499 1421 quote_id: quoteId,
1500 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1422 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1501 1423 },
1502 1424 success: function(response) {
1503 1425 if (response.success && response.data && response.data.logs) {
1504 1426 let logsHtml = '<div class="space-y-4">';
@@ -1535,13 +1457,13 @@
1535 1457
1536 1458 logsHtml += '</div>';
1537 1459 $('#logs-modal .modal-body').html(logsHtml);
1538 1460 } else {
1539 - $('#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>');
1540 1462 }
1541 1463 },
1542 1464 error: function() {
1543 - $('#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>');
1544 1466 }
1545 1467 });
1546 1468 });
1547 1469
@@ -1561,17 +1483,47 @@
1561 1483 </script>
1562 1484
1563 1485 <style>
1564 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; }
1565 1495 .row-actions {
1566 1496 visibility: hidden;
1567 1497 font-size: 12px;
1568 1498 padding-top: 4px;
1569 1499 display: flex;
1570 - gap: 8px;
1500 + flex-wrap: wrap; /* a long action list must not widen the column past the screen */
1501 + gap: 4px 8px;
1571 1502 }
1572 -tr:hover .row-actions {
1503 +tr:hover .row-actions,
1504 +tr:focus-within .row-actions {
1573 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 + }
1574 1526 }
1575 1527 .row-actions span a {
1576 1528 color: #6366f1; /* indigo-500 */
1577 1529 text-decoration: none;