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 +253 -284 2.1.12.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,
@@ -22,8 +25,12 @@
22 25 // Get current view and status filter
23 26 $view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'all';
24 27 $status_filter = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
25 28
29 +// Get client filter (from template data, falls back to GET)
30 +$client_filter = isset($client_filter) ? (int) $client_filter : (isset($_GET['client_id']) ? absint($_GET['client_id']) : 0);
31 +$clients_list = isset($clients_list) && is_array($clients_list) ? $clients_list : [];
32 +
26 33 // Define status filters
27 34 $status_filters = array(
28 35 'available' => 'Available',
29 36 'draft' => 'Draft',
@@ -53,77 +60,96 @@
53 60 );
54 61 }
55 62 }
56 63
57 -// 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).
58 68 $currency_totals = [];
59 -$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);
60 70
61 -foreach ($quotes as $quote) {
62 - if (is_object($quote)) {
63 - $currency_code = $quote->getCurrencyCode() ?: 'USD';
64 - $amount = $quote->getTotal() ?: 0;
65 -
66 - if (!isset($currency_totals[$currency_code])) {
67 - $currency_totals[$currency_code] = [
68 - 'total' => 0,
69 - 'quotes' => 0,
70 - 'quote_object' => $quote // Keep reference for formatting
71 - ];
72 - }
73 -
74 - $currency_totals[$currency_code]['total'] += floatval($amount);
75 - $currency_totals[$currency_code]['quotes']++;
76 - }
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 + ];
77 78 }
78 79
79 80 $enhanced_stats = [
80 - 'total_quotes' => $total_quotes,
81 + 'total_quotes' => $total_quotes,
81 82 'currency_totals' => $currency_totals,
82 - 'accepted_quotes' => count(array_filter($formatted_quotes, function($quote) {
83 - return $quote['status'] === 'accepted';
84 - })),
85 - 'pending_quotes' => count(array_filter($formatted_quotes, function($quote) {
86 - return $quote['status'] === 'sent' || $quote['status'] === 'draft';
87 - })),
88 - 'rejected_quotes' => count(array_filter($formatted_quotes, function($quote) {
89 - return $quote['status'] === 'rejected';
90 - }))
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),
91 86 ];
92 87
93 88 ?>
94 89 <div class="p-8">
95 - <!-- Page Header -->
96 - <div class="bg-white border-b border-gray-200 px-6 py-5" style="margin-left: -2rem; margin-top: -2rem; padding-left:2rem; padding-right:2rem; margin-right: -2rem;">
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;">
97 91 <div class="flex items-center justify-between">
98 92 <div>
99 - <h1 class="text-2xl font-bold text-gray-900"><?php _e('Quotes', 'easy-invoice'); ?></h1>
100 - <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>
101 95 </div>
102 96 <div class="flex items-center space-x-3">
103 97 <?php if (isset($_GET['rewrite_flushed'])): ?>
104 98 <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded-md text-sm">
105 - <?php _e('Rewrite rules flushed successfully!', 'easy-invoice'); ?>
99 + <?php esc_html_e('Rewrite rules flushed successfully!', 'easy-invoice'); ?>
106 100 </div>
107 101 <?php endif; ?>
108 102
109 103 <?php if (isset($_GET['post_types_registered'])): ?>
110 104 <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded-md text-sm">
111 - <?php _e('Post types registered successfully!', 'easy-invoice'); ?>
105 + <?php esc_html_e('Post types registered successfully!', 'easy-invoice'); ?>
112 106 </div>
113 107 <?php endif; ?>
114 108
115 - <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')); ?>"
116 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">
117 - <?php _e('Flush Rewrite Rules', 'easy-invoice'); ?>
125 + <?php esc_html_e('Flush Rewrite Rules', 'easy-invoice'); ?>
118 126 </a>
127 + <?php endif; ?>
119 128
120 - <?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()): ?>
121 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">
122 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">
123 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>
124 150 </svg>
125 - <?php _e('Export All', 'easy-invoice'); ?>
151 + <?php esc_html_e('Export All', 'easy-invoice'); ?>
126 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">
127 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"/>
128 154 </svg>
129 155 </button>
@@ -130,45 +156,33 @@
130 156 <script>
131 157 jQuery(document).ready(function($) {
132 158 $('#export-all-quotes-btn').on('click', function(e) {
133 159 e.preventDefault();
134 - // Show premium popup using the proper confirmation modal
135 160 if (typeof EasyInvoiceConfirmation !== 'undefined') {
136 161 EasyInvoiceConfirmation.showFeatureUpgrade('Export All Quotes', 'Export all your quotes to CSV, Excel, or PDF format for backup, analysis, or sharing with your team.');
137 162 } else {
138 - // Fallback to alert if confirmation modal is not available
139 163 alert('<?php echo esc_js(__('Exporting all quotes is a premium feature. Upgrade to Easy Invoice Pro to unlock this feature.', 'easy-invoice')); ?>');
140 164 }
141 165 });
142 166 });
143 167 </script>
144 - <?php else: ?>
145 - <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
146 - <input type="hidden" name="action" value="easy_invoice_export_all_quotes" />
147 - <input type="hidden" name="_wpnonce" value="<?php echo wp_create_nonce('easy_invoice_export_all_quotes'); ?>" />
148 - <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">
149 - <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">
150 - <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>
151 - </svg>
152 - <?php _e('Export All', 'easy-invoice'); ?>
153 - </button>
154 - </form>
155 168 <?php endif; ?>
156 - <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">
157 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">
158 172 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
159 173 </svg>
160 - <?php _e('Create New Quote', 'easy-invoice'); ?>
174 + <?php esc_html_e('Create New Quote', 'easy-invoice'); ?>
161 175 </button>
176 +<?php endif; ?>
162 177 </div>
163 178 </div>
164 179 </div>
165 180
166 - <!-- New Quote Modal -->
167 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">
168 182 <div class="relative mx-auto p-6 border w-96 shadow-xl rounded-lg bg-white my-20">
169 183 <div class="flex justify-between items-center mb-6">
170 - <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>
171 185 <button type="button" id="close-new-quote-modal" class="text-gray-400 hover:text-gray-600 transition-colors duration-200">
172 186 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
173 187 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
174 188 </svg>
@@ -183,9 +197,9 @@
183 197 id="new_quote_title"
184 198 required
185 199 placeholder="Enter quote title..."
186 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">
187 - <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>
188 202 </div>
189 203
190 204 <div class="flex justify-end space-x-3 pt-4">
191 205 <button type="button" id="cancel-new-quote"
@@ -202,9 +216,8 @@
202 216 <div id="new-quote-message" class="mt-4 hidden"></div>
203 217 </div>
204 218 </div>
205 219
206 - <!-- Logs Modal -->
207 220 <div id="logs-modal" class="fixed z-50 inset-0 overflow-y-auto hidden">
208 221 <div class="flex items-center justify-center min-h-screen px-4">
209 222 <div class="fixed inset-0 transition-opacity" aria-hidden="true">
210 223 <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
@@ -211,11 +224,11 @@
211 224 </div>
212 225 <div class="bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:max-w-2xl w-full z-50">
213 226 <div class="px-6 py-4 border-b border-gray-200">
214 227 <div class="flex justify-between items-center">
215 - <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>
216 229 <button type="button" id="close-logs-modal" class="text-gray-400 hover:text-gray-500 focus:outline-none">
217 - <span class="sr-only">Close</span>
230 + <span class="sr-only"><?php esc_html_e('Close', 'easy-invoice'); ?></span>
218 231 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
219 232 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
220 233 </svg>
221 234 </button>
@@ -221,15 +234,13 @@
221 234 </button>
222 235 </div>
223 236 </div>
224 237 <div class="px-6 py-4 modal-body">
225 - <!-- Logs content will be loaded here -->
226 238 </div>
227 239 </div>
228 240 </div>
229 241 </div>
230 242
231 - <!-- Stats Cards -->
232 243 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 mt-8">
233 244 <div class="bg-white rounded-lg shadow p-6 transition-transform duration-300 hover:shadow-md hover:-translate-y-1">
234 245 <div class="flex items-center">
235 246 <div class="rounded-full bg-indigo-100 p-3 mr-4">
@@ -237,10 +248,10 @@
237 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>
238 249 </svg>
239 250 </div>
240 251 <div>
241 - <h3 class="text-sm font-medium text-gray-500">Total Quotes</h3>
242 - <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>
243 254 </div>
244 255 </div>
245 256 </div>
246 257 <div class="bg-white rounded-lg shadow p-6 transition-transform duration-300 hover:shadow-md hover:-translate-y-1">
@@ -250,9 +261,9 @@
250 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>
251 262 </svg>
252 263 </div>
253 264 <div>
254 - <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>
255 266 <?php if (empty($enhanced_stats['currency_totals'])): ?>
256 267 <p class="mt-1 text-2xl font-bold text-gray-900">$0.00</p>
257 268 <?php else: ?>
258 269 <div class="mt-1 space-y-1">
@@ -257,14 +268,13 @@
257 268 <?php else: ?>
258 269 <div class="mt-1 space-y-1">
259 270 <?php foreach ($enhanced_stats['currency_totals'] as $currency_code => $currency_data): ?>
260 271 <?php
261 - $formatter = new \EasyInvoice\Helpers\QuoteFormatter($currency_data['quote_object']);
262 - $formatted_amount = $formatter->format($currency_data['total']);
272 + $formatted_amount = easy_invoice_format_money((float) $currency_data['total'], null, (string) $currency_code);
263 273 ?>
264 274 <div class="flex items-center justify-between">
265 - <span class="text-lg font-bold text-gray-900"><?php echo $formatted_amount; ?></span>
266 - <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>
267 277 </div>
268 278 <?php endforeach; ?>
269 279 </div>
270 280 <?php endif; ?>
@@ -278,10 +288,10 @@
278 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>
279 289 </svg>
280 290 </div>
281 291 <div>
282 - <h3 class="text-sm font-medium text-gray-500">Accepted Quotes</h3>
283 - <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>
284 294 </div>
285 295 </div>
286 296 </div>
287 297 <div class="bg-white rounded-lg shadow p-6 transition-transform duration-300 hover:shadow-md hover:-translate-y-1">
@@ -291,16 +301,15 @@
291 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>
292 302 </svg>
293 303 </div>
294 304 <div>
295 - <h3 class="text-sm font-medium text-gray-500">Pending Quotes</h3>
296 - <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>
297 307 </div>
298 308 </div>
299 309 </div>
300 310 </div>
301 311
302 - <!-- Top Pagination -->
303 312 <?php if (isset($pagination) && $pagination['total_pages'] > 1): ?>
304 313 <div class="mb-4">
305 314 <div class="px-4 py-3 flex items-center justify-between">
306 315 <div class="flex items-center space-x-2">
@@ -305,18 +314,18 @@
305 314 <div class="px-4 py-3 flex items-center justify-between">
306 315 <div class="flex items-center space-x-2">
307 316 <span class="text-sm text-gray-700">
308 317 Showing
309 - <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>
310 319 to
311 - <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>
312 321 of
313 - <span class="font-medium"><?php echo $pagination['total_quotes']; ?></span>
322 + <span class="font-medium"><?php echo esc_html($pagination['total_quotes']); ?></span>
314 323 results
315 324 </span>
316 325 </div>
317 326 <div class="flex items-center space-x-2">
318 - <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>
319 328 <div class="flex space-x-1">
320 329 <?php
321 330 $prev_page = $pagination['current_page'] > 1 ? $pagination['current_page'] - 1 : 1;
322 331 $next_page = $pagination['current_page'] < $pagination['total_pages'] ? $pagination['current_page'] + 1 : $pagination['total_pages'];
@@ -328,11 +337,11 @@
328 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>
329 338 <?php endif;
330 339 for ($i = $start_page; $i <= $end_page; $i++):
331 340 if ($i == $pagination['current_page']): ?>
332 - <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>
333 342 <?php else: ?>
334 - <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>
335 344 <?php endif;
336 345 endfor;
337 346 if ($pagination['current_page'] < $pagination['total_pages']): ?>
338 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>
@@ -342,11 +351,14 @@
342 351 </div>
343 352 </div>
344 353 <?php endif; ?>
345 354
346 - <!-- Main Content Area -->
347 - <div class="bg-white shadow rounded-lg">
348 - <!-- 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">
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,36 +426,35 @@
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>
423 435
424 - <!-- Filters and Bulk Actions with Search -->
425 436 <div class="p-4 flex flex-wrap items-center justify-between bg-white border-b border-gray-100">
426 - <!-- Bulk Actions (Left Side) -->
427 437 <div class="flex items-center space-x-2 my-2">
428 - <form id="bulk-action-form" method="post" class="flex items-center gap-2">
429 - <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')); ?>">
430 441
431 - <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">
432 - <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>
433 444 <?php if ($view === 'cancelled'): ?>
434 - <option value="restore">Restore Selected</option>
435 - <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>
436 447 <?php else: ?>
437 - <option value="delete">Delete Selected</option>
438 - <option value="trash">Move to Trash</option>
439 - <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>
440 451 <?php endif; ?>
441 - <option value="export">Export Selected</option>
452 + <option value="export" data-requires-pro="1"><?php esc_html_e('Export Selected (Pro)', 'easy-invoice'); ?></option>
442 453 </select>
443 454
444 - <button type="submit" class="inline-flex items-center justify-center h-9 px-4 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">
445 -Apply
455 + <button type="submit" class="inline-flex items-center justify-center h-9 px-4 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 whitespace-nowrap">
456 + Apply
446 457 </button>
447 458
448 459 <?php if ($view === 'cancelled' && !empty($quotes)): ?>
449 460 <button type="button" id="empty-trash-btn" class="inline-flex items-center justify-center h-9 px-6 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 whitespace-nowrap">
@@ -453,19 +464,32 @@
453 464 Empty Trash
454 465 </button>
455 466 <?php endif; ?>
456 467 </form>
468 +<?php endif; ?>
457 469 </div>
458 470
459 - <!-- Search Form (Right Side) -->
460 471 <div class="flex items-center gap-2">
461 472 <form method="get" class="flex items-center gap-2">
462 - <input type="hidden" name="page" value="easy-invoice-quotes-all">
473 + <input type="hidden" name="page" value="easy-quote-all">
463 474 <input type="hidden" name="view" value="<?php echo esc_attr($view); ?>">
464 475 <?php if (!empty($status_filter)): ?>
465 476 <input type="hidden" name="status" value="<?php echo esc_attr($status_filter); ?>">
466 477 <?php endif; ?>
467 478
479 + <?php if (!empty($clients_list)): ?>
480 + <select name="client_id" aria-label="<?php esc_attr_e('Filter by client', 'easy-invoice'); ?>"
481 + class="block px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-700 sm:text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
482 + onchange="this.form.submit()">
483 + <option value=""><?php esc_html_e('All clients', 'easy-invoice'); ?></option>
484 + <?php foreach ($clients_list as $client_option): ?>
485 + <option value="<?php echo esc_attr($client_option['id']); ?>" <?php selected((int) $client_filter, (int) $client_option['id']); ?>>
486 + <?php echo esc_html($client_option['name']); ?>
487 + </option>
488 + <?php endforeach; ?>
489 + </select>
490 + <?php endif; ?>
491 +
468 492 <div class="relative">
469 493 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
470 494 <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
471 495 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
@@ -473,19 +497,19 @@
473 497 </div>
474 498 <input type="text"
475 499 name="search"
476 500 value="<?php echo esc_attr($search_query); ?>"
477 - placeholder="<?php _e('Search quotes, numbers, or clients...', 'easy-invoice'); ?>"
501 + placeholder="<?php echo esc_js(__('Search quotes, numbers, or clients...', 'easy-invoice')); ?>"
478 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">
479 503 </div>
480 504
481 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">
482 - <?php _e('Search', 'easy-invoice'); ?>
506 + <?php esc_html_e('Search', 'easy-invoice'); ?>
483 507 </button>
484 508
485 509 <?php if (!empty($search_query)): ?>
486 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">
487 - <?php _e('Clear', 'easy-invoice'); ?>
511 + <?php esc_html_e('Clear', 'easy-invoice'); ?>
488 512 </a>
489 513 <?php endif; ?>
490 514 </form>
491 515 </div>
@@ -490,15 +514,14 @@
490 514 </form>
491 515 </div>
492 516 </div>
493 517
494 - <!-- Quotes Table -->
495 518 <div class="overflow-x-auto">
496 - <table class="min-w-full divide-y divide-gray-200">
519 + <table class="min-w-full divide-y divide-gray-200 ei-quotes-table">
497 520 <thead class="bg-gray-50">
498 521 <tr>
499 522 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
500 - <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">
501 524 </th>
502 525 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
503 526 Quote
504 527 </th>
@@ -520,9 +543,9 @@
520 543 <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
521 544 Logs
522 545 </th>
523 546 <th scope="col" class="relative px-6 py-3">
524 - <span class="sr-only">Actions</span>
547 + <span class="sr-only"><?php esc_html_e('Actions', 'easy-invoice'); ?></span>
525 548 </th>
526 549 </tr>
527 550 </thead>
528 551 <tbody class="bg-white divide-y divide-gray-200">
@@ -532,10 +555,10 @@
532 555 <div class="flex flex-col items-center">
533 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">
534 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>
535 558 </svg>
536 - <h3 class="text-lg font-medium text-gray-900 mb-2">No quotes found</h3>
537 - <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>
538 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">
539 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">
540 563 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
541 564 </svg>
@@ -602,9 +625,9 @@
602 625 $quote->ensureProperSlug();
603 626 ?>
604 627 <tr class="hover:bg-gray-50 transition-colors duration-150">
605 628 <td class="px-6 py-4 whitespace-nowrap">
606 - <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(); ?>">
607 630 </td>
608 631 <td class="px-6 py-4 whitespace-nowrap">
609 632 <div class="flex items-center">
610 633 <div class="flex-shrink-0 h-10 w-10">
@@ -615,30 +638,36 @@
615 638 </div>
616 639 </div>
617 640 <div class="ml-4">
618 641 <div class="text-sm font-medium text-gray-900">
619 - <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">
620 643 <?php echo esc_html($quote->getTitle() ?: 'Untitled Quote'); ?>
621 644 </a>
622 645 </div>
623 646 <div class="text-sm text-gray-500">
624 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; ?>
625 654 </div>
626 655 <div class="row-actions mt-1">
627 - <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>
628 - <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>
629 658 <?php if ($quote->getStatus() === 'cancelled'): ?>
630 - <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>
631 - <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>
632 661 <?php else: ?>
633 - <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>
634 - <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>
635 - <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>
636 665 <?php if (!easy_invoice_has_pro()): ?>
637 - <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>
638 667 <?php endif; ?>
639 668 <?php if (!easy_invoice_has_pro()): ?>
640 - <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>
641 670 <?php endif; ?>
642 671
643 672 <?php
644 673 // Apply filter for Pro plugin to add secure link actions
@@ -643,9 +672,9 @@
643 672 <?php
644 673 // Apply filter for Pro plugin to add secure link actions
645 674 $secure_actions = apply_filters('easy_invoice_quote_row_actions', [], $quote);
646 675 foreach ($secure_actions as $action_key => $action_html) {
647 - echo '<span class="' . esc_attr($action_key) . '">' . $action_html . '</span>';
676 + echo '<span class="' . esc_attr($action_key) . '">' . wp_kses_post($action_html) . '</span>';
648 677 }
649 678 ?>
650 679 <?php endif; ?>
651 680 </div>
@@ -683,10 +712,17 @@
683 712 </td>
684 713 <td class="px-6 py-4 whitespace-nowrap">
685 714 <?php
686 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 + }
687 722 $status_classes = [
688 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',
689 725 'sent' => 'bg-blue-100 text-blue-800 ring-1 ring-blue-300',
690 726 'accepted' => 'bg-green-100 text-green-800 ring-1 ring-green-300',
691 727 'declined' => 'bg-red-100 text-red-800 ring-1 ring-red-300',
692 728 'expired' => 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300'
@@ -692,17 +728,17 @@
692 728 'expired' => 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300'
693 729 ];
694 730 $status_class = $status_classes[$status] ?? 'bg-gray-100 text-gray-800 ring-1 ring-gray-300';
695 731 ?>
696 - <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <?php echo $status_class; ?>">
697 - <?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)); ?>
698 734 </span>
699 735 </td>
700 736 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
701 - <?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())) : '—'); ?>
702 738 </td>
703 739 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
704 - <?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())) : '—'); ?>
705 741 </td>
706 742 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
707 743 <?php
708 744 // Get all logs for this quote
@@ -713,22 +749,21 @@
713 749
714 750 if ($logs_count > 0): ?>
715 751 <button type="button"
716 752 class="view-logs-btn text-xs text-indigo-600 hover:text-indigo-800 font-medium"
717 - data-quote-id="<?php echo $quote->getId(); ?>"
753 + data-quote-id="<?php echo (int) $quote->getId(); ?>"
718 754 data-quote-title="<?php echo esc_attr($quote->getTitle() ?: 'Untitled Quote'); ?>"
719 - data-logs-count="<?php echo $logs_count; ?>">
720 - 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
721 757 </button>
722 758 <?php else: ?>
723 - <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>
724 760 <?php endif; ?>
725 761 <?php } catch (\Exception $e) { ?>
726 - <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>
727 763 <?php } ?>
728 764 </td>
729 765 <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
730 - <!-- Actions column - actions are shown in the row-actions div above -->
731 766 </td>
732 767 </tr>
733 768 <?php endforeach; ?>
734 769 <?php endif; ?>
@@ -736,9 +771,8 @@
736 771 </table>
737 772 </div>
738 773 </div>
739 774
740 - <!-- Bottom Pagination -->
741 775 <?php if (isset($pagination) && $pagination['total_pages'] > 1): ?>
742 776 <div class="mt-4">
743 777 <div class="px-4 py-3 flex items-center justify-between">
744 778 <div class="flex items-center space-x-2">
@@ -743,18 +777,18 @@
743 777 <div class="px-4 py-3 flex items-center justify-between">
744 778 <div class="flex items-center space-x-2">
745 779 <span class="text-sm text-gray-700">
746 780 Showing
747 - <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>
748 782 to
749 - <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>
750 784 of
751 - <span class="font-medium"><?php echo $pagination['total_quotes']; ?></span>
785 + <span class="font-medium"><?php echo esc_html($pagination['total_quotes']); ?></span>
752 786 results
753 787 </span>
754 788 </div>
755 789 <div class="flex items-center space-x-2">
756 - <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>
757 791 <div class="flex space-x-1">
758 792 <?php
759 793 $prev_page = $pagination['current_page'] > 1 ? $pagination['current_page'] - 1 : 1;
760 794 $next_page = $pagination['current_page'] < $pagination['total_pages'] ? $pagination['current_page'] + 1 : $pagination['total_pages'];
@@ -766,11 +800,11 @@
766 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>
767 801 <?php endif;
768 802 for ($i = $start_page; $i <= $end_page; $i++):
769 803 if ($i == $pagination['current_page']): ?>
770 - <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>
771 805 <?php else: ?>
772 - <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>
773 807 <?php endif;
774 808 endfor;
775 809 if ($pagination['current_page'] < $pagination['total_pages']): ?>
776 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>
@@ -816,9 +850,9 @@
816 850 var $message = $('#new-quote-message');
817 851 var title = $('#new_quote_title').val().trim();
818 852
819 853 if (!title) {
820 - $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>');
821 855 return;
822 856 }
823 857
824 858 // Show loading state
@@ -831,14 +865,14 @@
831 865 dataType: 'json',
832 866 data: {
833 867 action: 'easy_invoice_create_new_quote',
834 868 title: title,
835 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
869 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
836 870 },
837 871 success: function(response) {
838 872 if (response.success && response.data && response.data.quote_id) {
839 873 // Show success message below the form
840 - $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>');
841 875
842 876 // Redirect to the builder page with the new quote ID
843 877 setTimeout(function() {
844 878 window.location.href = 'admin.php?page=easy-invoice-quote-builder&id=' + response.data.quote_id;
@@ -848,105 +882,14 @@
848 882 $submitBtn.prop('disabled', false).html('Create Quote');
849 883 }
850 884 },
851 885 error: function() {
852 - $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>');
853 887 $submitBtn.prop('disabled', false).html('Create Quote');
854 888 }
855 889 });
856 890 });
857 891
858 - // Create sample quote
859 - $('#easy-quote-create-sample').on('click', function() {
860 - var $btn = $(this);
861 - var originalText = $btn.text();
862 - $btn.prop('disabled', true).text('Creating...');
863 -
864 - $.ajax({
865 - url: easyInvoice.ajaxUrl,
866 - type: 'POST',
867 - dataType: 'json',
868 - data: {
869 - action: 'easy_invoice_create_sample_quote',
870 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
871 - },
872 - success: function(response) {
873 - if (response.success && response.data && response.data.quote_id) {
874 - window.location.href = 'admin.php?page=easy-invoice-quote-builder&id=' + response.data.quote_id;
875 - } else {
876 - EasyInvoiceConfirmation.show({
877 - title: 'Error',
878 - message: response.data && response.data.message ? response.data.message : 'Failed to create sample quote.',
879 - confirmText: 'OK',
880 - confirmClass: 'bg-red-600 hover:bg-red-700'
881 - });
882 - }
883 - },
884 - error: function() {
885 - EasyInvoiceConfirmation.show({
886 - title: 'Error',
887 - message: 'Failed to create sample quote.',
888 - confirmText: 'OK',
889 - confirmClass: 'bg-red-600 hover:bg-red-700'
890 - });
891 - },
892 - complete: function() {
893 - $btn.prop('disabled', false).text(originalText);
894 - }
895 - });
896 - });
897 -
898 - // Update existing quotes
899 - $('#update-existing-quotes-btn').on('click', function() {
900 - var $btn = $(this);
901 - var originalText = $btn.text();
902 - $btn.prop('disabled', true).text('Updating...');
903 -
904 - $.ajax({
905 - url: easyInvoice.ajaxUrl,
906 - type: 'POST',
907 - dataType: 'json',
908 - data: {
909 - action: 'easy_invoice_update_existing_quotes',
910 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
911 - },
912 - success: function(response) {
913 - if (response.success) {
914 - EasyInvoiceConfirmation.show({
915 - title: 'Success',
916 - message: response.data.message,
917 - confirmText: 'OK',
918 - confirmClass: 'bg-green-600 hover:bg-green-700'
919 - });
920 - // Reload the page to show updated data
921 - setTimeout(function() {
922 - location.reload();
923 - }, 1500);
924 - } else {
925 - EasyInvoiceConfirmation.show({
926 - title: 'Error',
927 - message: response.data && response.data.message ? response.data.message : 'Failed to update quotes.',
928 - confirmText: 'OK',
929 - confirmClass: 'bg-red-600 hover:bg-red-700'
930 - });
931 - }
932 - },
933 - error: function() {
934 - EasyInvoiceConfirmation.show({
935 - title: 'Error',
936 - message: 'Failed to update quotes.',
937 - confirmText: 'OK',
938 - confirmClass: 'bg-red-600 hover:bg-red-700'
939 - });
940 - },
941 - complete: function() {
942 - $btn.prop('disabled', false).text(originalText);
943 - }
944 - });
945 - });
946 -
947 -
948 -
949 892 // Select all checkboxes
950 893 $('#select-all').on('change', function() {
951 894 $('.quote-checkbox').prop('checked', $(this).is(':checked'));
952 895 updateBulkActionButton();
@@ -1024,9 +967,9 @@
1024 967 data: {
1025 968 action: 'easy_invoice_bulk_quote_action',
1026 969 quote_ids: selectedQuotes,
1027 970 bulk_action: action,
1028 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
971 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1029 972 },
1030 973 success: function(response) {
1031 974 if (response.success) {
1032 975 location.reload();
@@ -1067,9 +1010,9 @@
1067 1010 dataType: 'json',
1068 1011 data: {
1069 1012 action: 'easy_invoice_draft_quote',
1070 1013 quote_id: quoteId,
1071 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1014 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1072 1015 },
1073 1016 success: function(response) {
1074 1017 if (response.success) {
1075 1018 location.reload();
@@ -1093,8 +1036,27 @@
1093 1036 });
1094 1037 });
1095 1038 });
1096 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 +
1097 1059 $(document).on('click', '.trash-quote', function(e) {
1098 1060 e.preventDefault();
1099 1061 const quoteId = $(this).data('quote-id');
1100 1062 const quoteNumber = $(this).data('quote-number');
@@ -1106,9 +1068,9 @@
1106 1068 dataType: 'json',
1107 1069 data: {
1108 1070 action: 'easy_invoice_trash_quote',
1109 1071 quote_id: quoteId,
1110 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1072 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1111 1073 },
1112 1074 success: function(response) {
1113 1075 if (response.success) {
1114 1076 location.reload();
@@ -1152,9 +1114,9 @@
1152 1114 type: "POST",
1153 1115 data: {
1154 1116 action: "easy_invoice_send_quote_email",
1155 1117 quote_id: quoteId,
1156 - 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')); ?>'
1157 1119 },
1158 1120 success: function(response) {
1159 1121 if (response.success) {
1160 1122 EasyInvoiceToast.success("Email sent successfully!");
@@ -1185,9 +1147,9 @@
1185 1147 type: "POST",
1186 1148 data: {
1187 1149 action: "easy_invoice_send_quote_email",
1188 1150 quote_id: quoteId,
1189 - 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')); ?>'
1190 1152 },
1191 1153 success: function(response) {
1192 1154 if (response.success) {
1193 1155 EasyInvoiceToast.success("Email sent successfully!");
@@ -1208,46 +1170,23 @@
1208 1170 }
1209 1171 }
1210 1172 });
1211 1173
1212 - $(document).on('click', '.download-pdf-quote', function(e) {
1213 - e.preventDefault();
1214 - const quoteId = $(this).data('quote-id');
1215 - 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.
1216 1188
1217 - // Show loading state
1218 - var $link = $(this);
1219 - var originalText = $link.text();
1220 - $link.text("Generating PDF...").css("opacity", "0.7");
1221 -
1222 - // Get quote data via AJAX and generate PDF
1223 - $.ajax({
1224 - url: easyInvoice.ajaxUrl,
1225 - type: "POST",
1226 - data: {
1227 - action: "easy_invoice_download_quote_pdf",
1228 - quote_id: quoteId,
1229 - nonce: easyInvoice.nonce
1230 - },
1231 - success: function(response) {
1232 - if (response.success && response.data.download_url) {
1233 - // Open the download URL in a new tab
1234 - window.open(response.data.download_url, '_blank');
1235 - EasyInvoiceToast.success("PDF download started");
1236 - } else {
1237 - EasyInvoiceToast.error(response.data.message || "Error preparing PDF data");
1238 - }
1239 - // Reset link text
1240 - $link.text(originalText).css("opacity", "1");
1241 - },
1242 - error: function() {
1243 - EasyInvoiceToast.error("Error connecting to server");
1244 - // Reset link text
1245 - $link.text(originalText).css("opacity", "1");
1246 - }
1247 - });
1248 - });
1249 -
1250 1189 // Delete quote
1251 1190 $(document).on('click', '.delete-quote-btn', function() {
1252 1191 const quoteId = $(this).data('quote-id');
1253 1192 const $row = $(this).closest('tr');
@@ -1259,9 +1198,9 @@
1259 1198 dataType: 'json',
1260 1199 data: {
1261 1200 action: 'easy_invoice_delete_quote',
1262 1201 quote_id: quoteId,
1263 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1202 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1264 1203 },
1265 1204 success: function(response) {
1266 1205 if (response.success) {
1267 1206 $row.fadeOut(300, function() {
@@ -1339,9 +1278,9 @@
1339 1278 type: 'POST',
1340 1279 dataType: 'json',
1341 1280 data: {
1342 1281 action: 'easy_invoice_empty_trash',
1343 - nonce: '<?php echo wp_create_nonce('easy_invoice_nonce'); ?>'
1282 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_nonce')); ?>'
1344 1283 },
1345 1284 success: function(response) {
1346 1285 if (response.success) {
1347 1286 EasyInvoiceToast.success('Trash emptied successfully');
@@ -1375,9 +1314,9 @@
1375 1314 dataType: 'json',
1376 1315 data: {
1377 1316 action: 'easy_invoice_restore_quote',
1378 1317 quote_id: quoteId,
1379 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1318 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1380 1319 },
1381 1320 success: function(response) {
1382 1321 if (response.success) {
1383 1322 location.reload();
@@ -1426,9 +1365,9 @@
1426 1365 dataType: 'json',
1427 1366 data: {
1428 1367 action: 'easy_invoice_duplicate_quote',
1429 1368 quote_id: quoteId,
1430 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1369 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1431 1370 },
1432 1371 success: function(response) {
1433 1372 if (response.success && response.data && response.data.quote_id) {
1434 1373 EasyInvoiceConfirmation.show({
@@ -1479,9 +1418,9 @@
1479 1418 dataType: 'json',
1480 1419 data: {
1481 1420 action: 'easy_invoice_get_quote_logs',
1482 1421 quote_id: quoteId,
1483 - nonce: '<?php echo wp_create_nonce('easy_invoice_admin_nonce'); ?>'
1422 + nonce: '<?php echo esc_attr(wp_create_nonce('easy_invoice_admin_nonce')); ?>'
1484 1423 },
1485 1424 success: function(response) {
1486 1425 if (response.success && response.data && response.data.logs) {
1487 1426 let logsHtml = '<div class="space-y-4">';
@@ -1518,13 +1457,13 @@
1518 1457
1519 1458 logsHtml += '</div>';
1520 1459 $('#logs-modal .modal-body').html(logsHtml);
1521 1460 } else {
1522 - $('#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>');
1523 1462 }
1524 1463 },
1525 1464 error: function() {
1526 - $('#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>');
1527 1466 }
1528 1467 });
1529 1468 });
1530 1469
@@ -1544,17 +1483,47 @@
1544 1483 </script>
1545 1484
1546 1485 <style>
1547 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; }
1548 1495 .row-actions {
1549 1496 visibility: hidden;
1550 1497 font-size: 12px;
1551 1498 padding-top: 4px;
1552 1499 display: flex;
1553 - gap: 8px;
1500 + flex-wrap: wrap; /* a long action list must not widen the column past the screen */
1501 + gap: 4px 8px;
1554 1502 }
1555 -tr:hover .row-actions {
1503 +tr:hover .row-actions,
1504 +tr:focus-within .row-actions {
1556 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 + }
1557 1526 }
1558 1527 .row-actions span a {
1559 1528 color: #6366f1; /* indigo-500 */
1560 1529 text-decoration: none;