PluginProbe ʕ •ᴥ•ʔ
Event Tickets with Ticket Scanner / 3.1.2
Event Tickets with Ticket Scanner v3.1.2
3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.4 trunk 2.6.0 2.7.0 2.7.1 2.7.10 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.10 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7 2.9.8 2.9.9 3.0.0 3.0.1 3.0.2 3.0.3
event-tickets-with-ticket-scanner / includes / seating / class-seating-plan.php
event-tickets-with-ticket-scanner / includes / seating Last commit date
class-seating-admin.php 1 week ago class-seating-base.php 1 week ago class-seating-block.php 1 week ago class-seating-frontend.php 1 week ago class-seating-plan.php 1 week ago class-seating-seat.php 1 week ago
class-seating-plan.php
1069 lines
1 <?php
2 /**
3 * Seating Plan CRUD Class
4 *
5 * Handles all CRUD operations for seating plans.
6 *
7 * @package Event_Tickets_With_Ticket_Scanner
8 * @subpackage Seating
9 * @since 2.8.0
10 */
11
12 // Exit if accessed directly
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 require_once __DIR__ . '/class-seating-base.php';
18
19 /**
20 * Seating Plan Manager
21 *
22 * Provides CRUD operations for seatingplans table.
23 *
24 * @since 2.8.0
25 */
26 class sasoEventtickets_Seating_Plan extends sasoEventtickets_Seating_Base {
27
28 /**
29 * Table name
30 *
31 * @var string
32 */
33 private string $table = 'seatingplans';
34
35 /**
36 * Get meta object structure for seating plans
37 *
38 * @return array Meta object structure with all defaults
39 */
40 public function getMetaObject(): array {
41 $metaObj = [
42 'description' => '',
43 'total_capacity' => 0,
44 'layout_type' => self::LAYOUT_SIMPLE,
45 // Venue photo is stored in 'image_id' (set via plan edit modal)
46 // Visual Designer settings
47 'canvas_width' => 800,
48 'canvas_height' => 600,
49 'background_color' => '#ffffff',
50 'background_image' => '',
51 // Status colors (configurable)
52 'colors' => [
53 'available' => '#4CAF50', // Green
54 'reserved' => '#FFC107', // Yellow
55 'booked' => '#F44336', // Red
56 'selected' => '#2196F3' // Blue
57 ],
58 // Visual elements (stored in meta_draft/meta_published)
59 'decorations' => [], // Non-seat shapes
60 'lines' => [], // Room boundaries, aisles
61 'labels' => [] // Text labels
62 ];
63
64 // Premium hook - can add fields without basic plugin update
65 if ($this->MAIN->isPremium() && method_exists($this->MAIN->getPremiumFunctions(), 'getSeatingPlanMetaObject')) {
66 $metaObj = $this->MAIN->getPremiumFunctions()->getSeatingPlanMetaObject($metaObj);
67 }
68
69 return $metaObj;
70 }
71
72 /**
73 * Create a new seating plan
74 *
75 * @param array $data Plan data: name, aktiv, meta (optional)
76 * @return int|false Plan ID on success, false on failure
77 * @throws Exception If limit reached or validation fails
78 */
79 public function create(array $data) {
80 global $wpdb;
81
82 // Validate required fields
83 if (empty($data['name'])) {
84 throw new Exception(__('Plan name is required.', 'event-tickets-with-ticket-scanner'));
85 }
86
87 // Check free version limit via existing infrastructure
88 $currentCount = $this->getCount();
89 $maxPlans = $this->MAIN->getBase()->getMaxValue('seatingplans', 1);
90
91 // maxValue = 0 means unlimited (Premium)
92 if ($maxPlans > 0 && $currentCount >= $maxPlans) {
93 throw new Exception(
94 sprintf(
95 __('Limit reached (%d plans). Upgrade to Premium for unlimited seating plans.', 'event-tickets-with-ticket-scanner'),
96 $maxPlans
97 )
98 );
99 }
100
101 // Check name uniqueness
102 if ($this->nameExists($data['name'])) {
103 throw new Exception(__('A plan with this name already exists.', 'event-tickets-with-ticket-scanner'));
104 }
105
106 // Merge input meta with defaults
107 $meta = array_replace_recursive($this->getMetaObject(), $data['meta'] ?? []);
108 $metaJson = $this->MAIN->getCore()->json_encode_with_error_handling($meta);
109
110 // Get current user for audit trail
111 $currentUserId = get_current_user_id();
112 $now = current_time('mysql');
113
114 $result = $wpdb->insert(
115 $this->getTable($this->table),
116 [
117 'time' => $now,
118 'timezone' => wp_timezone_string(),
119 'name' => sanitize_text_field($data['name']),
120 'aktiv' => isset($data['aktiv']) ? (int) $data['aktiv'] : 0,
121 'meta' => $metaJson,
122 'layout_type' => sanitize_text_field($data['layout_type'] ?? self::LAYOUT_SIMPLE),
123 'meta_draft' => $metaJson,
124 'meta_published' => '', // Empty until first publish
125 'created_by' => $currentUserId,
126 'updated_by' => $currentUserId,
127 'created_at' => $now,
128 'updated_at' => $now
129 ],
130 ['%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s']
131 );
132
133 if ($result === false) {
134 return false;
135 }
136
137 return $wpdb->insert_id;
138 }
139
140 /**
141 * Get a seating plan by ID
142 *
143 * @param int $id Plan ID
144 * @return array|null Plan data or null if not found
145 */
146 public function getById(int $id): ?array {
147 global $wpdb;
148
149 $row = $wpdb->get_row(
150 $wpdb->prepare(
151 "SELECT * FROM {$this->getTable($this->table)} WHERE id = %d",
152 $id
153 ),
154 ARRAY_A
155 );
156
157 if ($row) {
158 $row['meta'] = $this->decodeAndMergeMeta($row['meta']);
159 }
160
161 return $row;
162 }
163
164 /**
165 * Get complete seating plan object with all data
166 *
167 * Returns the full plan including draft, published, seats, and meta info.
168 * Use this for admin/designer - it loads everything in one call.
169 *
170 * @param int $id Plan ID
171 * @return array|null Complete plan object or null if not found
172 */
173 public function getFullPlan(int $id): ?array {
174 global $wpdb;
175
176 $row = $wpdb->get_row(
177 $wpdb->prepare(
178 "SELECT * FROM {$this->getTable($this->table)} WHERE id = %d",
179 $id
180 ),
181 ARRAY_A
182 );
183
184 if (!$row) {
185 return null;
186 }
187
188 // Parse meta fields
189 $meta = $this->decodeAndMergeMeta($row['meta'] ?? '');
190 $draft = $this->decodeAndMergeMeta($row['meta_draft']);
191 $published = !empty($row['meta_published']) ? $this->decodeAndMergeMeta($row['meta_published']) : [];
192
193 // Get seats
194 $seats = $this->MAIN->getSeating()->getSeatManager()->getByPlanId($id);
195
196 // Get user names for audit info
197 $createdByName = '';
198 $updatedByName = '';
199 $publishedByName = '';
200
201 if (!empty($row['created_by'])) {
202 $user = get_userdata($row['created_by']);
203 $createdByName = $user ? $user->display_name : '';
204 }
205 if (!empty($row['updated_by'])) {
206 $user = get_userdata($row['updated_by']);
207 $updatedByName = $user ? $user->display_name : '';
208 }
209 if (!empty($row['published_by'])) {
210 $user = get_userdata($row['published_by']);
211 $publishedByName = $user ? $user->display_name : '';
212 }
213
214 // Check for unpublished changes
215 $hasUnpublishedChanges = ($row['meta_draft'] !== $row['meta_published']);
216
217 return [
218 'id' => (int) $row['id'],
219 'name' => $row['name'],
220 'aktiv' => (bool) $row['aktiv'],
221 'layout_type' => $row['layout_type'] ?? 'simple',
222 'meta' => $meta,
223 'draft' => $draft,
224 'published' => $published,
225 'seats' => $seats,
226 'has_unpublished_changes' => $hasUnpublishedChanges,
227 'publish_info' => [
228 'published_at' => $row['published_at'],
229 'published_by' => (int) $row['published_by'],
230 'published_by_name' => $publishedByName
231 ],
232 'audit_info' => [
233 'created_at' => $row['created_at'],
234 'created_by' => (int) $row['created_by'],
235 'created_by_name' => $createdByName,
236 'updated_at' => $row['updated_at'],
237 'updated_by' => (int) $row['updated_by'],
238 'updated_by_name' => $updatedByName
239 ],
240 'active_sales' => $this->getActiveSalesInfo($id)
241 ];
242 }
243
244 /**
245 * Get a seating plan by name
246 *
247 * @param string $name Plan name
248 * @return array|null Plan data or null if not found
249 */
250 public function getByName(string $name): ?array {
251 global $wpdb;
252
253 $row = $wpdb->get_row(
254 $wpdb->prepare(
255 "SELECT * FROM {$this->getTable($this->table)} WHERE name = %s",
256 $name
257 ),
258 ARRAY_A
259 );
260
261 if ($row) {
262 $row['meta'] = $this->decodeAndMergeMeta($row['meta']);
263 }
264
265 return $row;
266 }
267
268 /**
269 * Get all seating plans
270 *
271 * @param bool $activeOnly Only return active plans
272 * @return array Array of plan data
273 */
274 public function getAll(bool $activeOnly = false): array {
275 global $wpdb;
276
277 $sql = "SELECT * FROM {$this->getTable($this->table)}";
278 if ($activeOnly) {
279 $sql .= " WHERE aktiv = 1";
280 }
281 $sql .= " ORDER BY name ASC";
282
283 $rows = $wpdb->get_results($sql, ARRAY_A);
284
285 foreach ($rows as &$row) {
286 $row['meta'] = $this->decodeAndMergeMeta($row['meta']);
287 }
288
289 return $rows ?: [];
290 }
291
292 /**
293 * Update a seating plan
294 *
295 * @param int $id Plan ID
296 * @param array $data Data to update
297 * @return bool Success
298 * @throws Exception If validation fails
299 */
300 public function update(int $id, array $data): bool {
301 global $wpdb;
302
303 $existing = $this->getById($id);
304 if (!$existing) {
305 throw new Exception(__('Seating plan not found.', 'event-tickets-with-ticket-scanner'));
306 }
307
308 $updateData = [];
309 $updateFormats = [];
310
311 // Update name if provided
312 if (isset($data['name']) && $data['name'] !== $existing['name']) {
313 if ($this->nameExists($data['name'], $id)) {
314 throw new Exception(__('A plan with this name already exists.', 'event-tickets-with-ticket-scanner'));
315 }
316 $updateData['name'] = sanitize_text_field($data['name']);
317 $updateFormats[] = '%s';
318 }
319
320 // Update aktiv if provided
321 if (isset($data['aktiv'])) {
322 $updateData['aktiv'] = (int) $data['aktiv'];
323 $updateFormats[] = '%d';
324 }
325
326 // Update layout_type if provided
327 if (isset($data['layout_type'])) {
328 $validTypes = [self::LAYOUT_SIMPLE, self::LAYOUT_VISUAL];
329 $layoutType = in_array($data['layout_type'], $validTypes, true)
330 ? $data['layout_type']
331 : self::LAYOUT_SIMPLE;
332 $updateData['layout_type'] = $layoutType;
333 $updateFormats[] = '%s';
334 }
335
336 // Update meta if provided - merge recursively to preserve nested structures
337 if (isset($data['meta'])) {
338 $newMeta = array_replace_recursive($existing['meta'], $data['meta']);
339 $updateData['meta'] = $this->MAIN->getCore()->json_encode_with_error_handling($newMeta);
340 $updateFormats[] = '%s';
341 }
342
343 if (empty($updateData)) {
344 return true; // Nothing to update
345 }
346
347 // Always add audit trail
348 $updateData['updated_by'] = get_current_user_id();
349 $updateFormats[] = '%d';
350 $updateData['updated_at'] = current_time('mysql');
351 $updateFormats[] = '%s';
352
353 $result = $wpdb->update(
354 $this->getTable($this->table),
355 $updateData,
356 ['id' => $id],
357 $updateFormats,
358 ['%d']
359 );
360
361 return $result !== false;
362 }
363
364 /**
365 * Delete a seating plan
366 *
367 * @param int $id Plan ID
368 * @param bool $force Also delete associated seats and blocks
369 * @return bool Success
370 * @throws Exception If plan has seats and force is false
371 */
372 public function delete(int $id, bool $force = false): bool {
373 global $wpdb;
374
375 // Check for associated seats
376 $seatCount = $wpdb->get_var(
377 $wpdb->prepare(
378 "SELECT COUNT(*) FROM {$this->getTable('seats')} WHERE seatingplan_id = %d",
379 $id
380 )
381 );
382
383 if ($seatCount > 0 && !$force) {
384 throw new Exception(
385 sprintf(
386 __('Cannot delete plan with %d seats. Use force delete or remove seats first.', 'event-tickets-with-ticket-scanner'),
387 $seatCount
388 )
389 );
390 }
391
392 // If force, delete seats and blocks first (delegated to SeatManager)
393 if ($force) {
394 $this->MAIN->getSeating()->getSeatManager()->deleteByPlanId($id);
395 }
396
397 $result = $wpdb->delete(
398 $this->getTable($this->table),
399 ['id' => $id],
400 ['%d']
401 );
402
403 return $result !== false;
404 }
405
406 /**
407 * Get count of seating plans
408 *
409 * @return int Count
410 */
411 public function getCount(): int {
412 global $wpdb;
413
414 return (int) $wpdb->get_var(
415 "SELECT COUNT(*) FROM {$this->getTable($this->table)}"
416 );
417 }
418
419 /**
420 * Check if a plan name already exists
421 *
422 * @param string $name Plan name
423 * @param int|null $excludeId Exclude this ID from check (for updates)
424 * @return bool True if name exists
425 */
426 public function nameExists(string $name, ?int $excludeId = null): bool {
427 global $wpdb;
428
429 $sql = $wpdb->prepare(
430 "SELECT COUNT(*) FROM {$this->getTable($this->table)} WHERE name = %s",
431 $name
432 );
433
434 if ($excludeId !== null) {
435 $sql = $wpdb->prepare(
436 "SELECT COUNT(*) FROM {$this->getTable($this->table)} WHERE name = %s AND id != %d",
437 $name,
438 $excludeId
439 );
440 }
441
442 return (int) $wpdb->get_var($sql) > 0;
443 }
444
445 /**
446 * Update layout type for a plan
447 *
448 * @param int $planId Plan ID
449 * @param string $layoutType Layout type (simple|visual)
450 * @return bool Success
451 */
452 public function updateLayoutType(int $planId, string $layoutType): bool {
453 $validTypes = [self::LAYOUT_SIMPLE, self::LAYOUT_VISUAL];
454 if (!in_array($layoutType, $validTypes, true)) {
455 $layoutType = self::LAYOUT_SIMPLE;
456 }
457
458 return $this->update($planId, [
459 'meta' => ['layout_type' => $layoutType]
460 ]);
461 }
462
463 /**
464 * Get plans as dropdown options
465 *
466 * @param bool $includeEmpty Include empty option
467 * @param bool $showLayoutType Show layout type in label
468 * @param bool $showDraftStatus Show draft/published status
469 * @return array Key-value pairs for dropdown
470 */
471 public function getDropdownOptions(bool $includeEmpty = true, bool $showLayoutType = true, bool $showDraftStatus = true): array {
472 $plans = $this->getAll(true);
473 $options = [];
474
475 if ($includeEmpty) {
476 $options[''] = __('-- Select Seating Plan --', 'event-tickets-with-ticket-scanner');
477 }
478
479 foreach ($plans as $plan) {
480 $label = $plan['name'];
481
482 // Layout type (stored in separate DB column, not in meta JSON)
483 if ($showLayoutType) {
484 $layoutType = $plan['layout_type'] ?? self::LAYOUT_SIMPLE;
485 $layoutLabel = $layoutType === self::LAYOUT_VISUAL
486 ? __('Visual', 'event-tickets-with-ticket-scanner')
487 : __('Simple', 'event-tickets-with-ticket-scanner');
488 $label .= ' [' . $layoutLabel . ']';
489 }
490
491 // Draft/Published status
492 if ($showDraftStatus) {
493 $isPublished = !empty($plan['published_at']);
494 $hasChanges = !empty($plan['meta_draft']) && $plan['meta_draft'] !== ($plan['meta_published'] ?? '');
495
496 if (!$isPublished) {
497 // Never published - Draft only
498 $label .= ' ⚠️ ' . __('Not published yet', 'event-tickets-with-ticket-scanner');
499 } elseif ($hasChanges) {
500 // Published but has unpublished changes
501 $label .= ' 📝 ' . __('Unpublished changes', 'event-tickets-with-ticket-scanner');
502 }
503 }
504
505 $options[$plan['id']] = $label;
506 }
507
508 return $options;
509 }
510
511 // =========================================================================
512 // Draft/Publish Workflow Methods (Visual Designer)
513 // =========================================================================
514
515 /**
516 * Save draft changes for visual designer
517 *
518 * @param int $planId Plan ID
519 * @param array $draftData Draft data (decorations, lines, labels, colors, etc.)
520 * @return bool Success
521 * @throws Exception If plan not found
522 */
523 public function saveDraft(int $planId, array $draftData): bool {
524 global $wpdb;
525
526 $existing = $this->getById($planId);
527 if (!$existing) {
528 throw new Exception(__('Seating plan not found.', 'event-tickets-with-ticket-scanner'));
529 }
530
531 // Merge with existing meta defaults
532 $currentDraft = $this->getDraftMeta($planId);
533 $newDraft = array_replace_recursive($currentDraft, $draftData);
534
535 $draftJson = $this->MAIN->getCore()->json_encode_with_error_handling($newDraft);
536
537 $result = $wpdb->update(
538 $this->getTable($this->table),
539 [
540 'meta_draft' => $draftJson,
541 // Note: meta column contains plan config (name, layout, background_image)
542 // and should NOT be overwritten by draft designer data
543 'updated_by' => get_current_user_id(),
544 'updated_at' => current_time('mysql')
545 ],
546 ['id' => $planId],
547 ['%s', '%d', '%s'],
548 ['%d']
549 );
550
551 return $result !== false;
552 }
553
554 /**
555 * Publish draft to make it visible to customers
556 *
557 * @param int $planId Plan ID
558 * @return array Result with success status and any conflicts
559 * @throws Exception If validation fails or conflicts exist
560 */
561 public function publish(int $planId): array {
562 global $wpdb;
563
564 $existing = $this->getById($planId);
565 if (!$existing) {
566 throw new Exception(__('Seating plan not found.', 'event-tickets-with-ticket-scanner'));
567 }
568
569 // Check for conflicts: seats with sold tickets that are being deleted
570 $conflicts = $this->checkPublishConflicts($planId);
571 if (!empty($conflicts)) {
572 return [
573 'success' => false,
574 'conflicts' => $conflicts,
575 'message' => __('Cannot publish: Some seats have sold tickets.', 'event-tickets-with-ticket-scanner')
576 ];
577 }
578
579 $currentUserId = get_current_user_id();
580 $now = current_time('mysql');
581
582 // Copy draft to published
583 $result = $wpdb->update(
584 $this->getTable($this->table),
585 [
586 'meta_published' => $existing['meta_draft'] ?? '',
587 'published_at' => $now,
588 'published_by' => $currentUserId,
589 'updated_by' => $currentUserId,
590 'updated_at' => $now
591 ],
592 ['id' => $planId],
593 ['%s', '%s', '%d', '%d', '%s'],
594 ['%d']
595 );
596
597 if ($result === false) {
598 throw new Exception(__('Failed to publish seating plan.', 'event-tickets-with-ticket-scanner'));
599 }
600
601 // Sync seats table (soft-delete removed seats, create new ones)
602 $this->syncSeatsOnPublish($planId);
603
604 return [
605 'success' => true,
606 'published_at' => $now,
607 'message' => __('Seating plan published successfully.', 'event-tickets-with-ticket-scanner')
608 ];
609 }
610
611 /**
612 * Discard draft changes and revert to published version
613 *
614 * @param int $planId Plan ID
615 * @return bool Success
616 * @throws Exception If plan not found
617 */
618 public function discardDraft(int $planId): bool {
619 global $wpdb;
620
621 $existing = $this->getById($planId);
622 if (!$existing) {
623 throw new Exception(__('Seating plan not found.', 'event-tickets-with-ticket-scanner'));
624 }
625
626 // Revert draft to published version
627 $result = $wpdb->update(
628 $this->getTable($this->table),
629 [
630 'meta_draft' => $existing['meta_published'] ?? '',
631 'meta' => $existing['meta_published'] ?? '',
632 'updated_by' => get_current_user_id(),
633 'updated_at' => current_time('mysql')
634 ],
635 ['id' => $planId],
636 ['%s', '%s', '%d', '%s'],
637 ['%d']
638 );
639
640 return $result !== false;
641 }
642
643 /**
644 * Check if plan has been published at least once
645 *
646 * @param int $planId Plan ID
647 * @return bool True if plan has published version
648 */
649 public function isPublished(int $planId): bool {
650 global $wpdb;
651
652 $publishedAt = $wpdb->get_var(
653 $wpdb->prepare(
654 "SELECT published_at FROM {$this->getTable($this->table)} WHERE id = %d",
655 $planId
656 )
657 );
658
659 return !empty($publishedAt);
660 }
661
662 /**
663 * Check if plan has unpublished changes
664 *
665 * @param int $planId Plan ID
666 * @return bool True if there are unpublished changes
667 */
668 public function hasUnpublishedChanges(int $planId): bool {
669 global $wpdb;
670
671 $row = $wpdb->get_row(
672 $wpdb->prepare(
673 "SELECT meta_draft, meta_published FROM {$this->getTable($this->table)} WHERE id = %d",
674 $planId
675 ),
676 ARRAY_A
677 );
678
679 if (!$row) {
680 return false;
681 }
682
683 // If published is empty, there are definitely unpublished changes
684 if (empty($row['meta_published'])) {
685 return !empty($row['meta_draft']);
686 }
687
688 // Compare draft and published
689 return $row['meta_draft'] !== $row['meta_published'];
690 }
691
692 /**
693 * Get draft meta data for editing
694 *
695 * @param int $planId Plan ID
696 * @return array Draft meta data merged with defaults
697 */
698 public function getDraftMeta(int $planId): array {
699 global $wpdb;
700
701 $draftJson = $wpdb->get_var(
702 $wpdb->prepare(
703 "SELECT meta_draft FROM {$this->getTable($this->table)} WHERE id = %d",
704 $planId
705 )
706 );
707
708 return $this->decodeAndMergeMeta($draftJson);
709 }
710
711 /**
712 * Get published meta data for frontend display
713 *
714 * @param int $planId Plan ID
715 * @return array Published meta data merged with defaults
716 */
717 public function getPublishedMeta(int $planId): array {
718 global $wpdb;
719
720 $publishedJson = $wpdb->get_var(
721 $wpdb->prepare(
722 "SELECT meta_published FROM {$this->getTable($this->table)} WHERE id = %d",
723 $planId
724 )
725 );
726
727 return $this->decodeAndMergeMeta($publishedJson);
728 }
729
730 /**
731 * Get publish info (when and by whom)
732 *
733 * @param int $planId Plan ID
734 * @return array|null Publish info or null if never published
735 */
736 public function getPublishInfo(int $planId): ?array {
737 global $wpdb;
738
739 $row = $wpdb->get_row(
740 $wpdb->prepare(
741 "SELECT published_at, published_by FROM {$this->getTable($this->table)} WHERE id = %d",
742 $planId
743 ),
744 ARRAY_A
745 );
746
747 if (!$row || empty($row['published_at'])) {
748 return null;
749 }
750
751 $user = get_user_by('id', $row['published_by']);
752
753 return [
754 'published_at' => $row['published_at'],
755 'published_by' => $row['published_by'],
756 'published_by_name' => $user ? $user->display_name : __('Unknown', 'event-tickets-with-ticket-scanner')
757 ];
758 }
759
760 /**
761 * Get audit trail info (created/updated by whom)
762 *
763 * @param int $planId Plan ID
764 * @return array Audit info
765 */
766 public function getAuditInfo(int $planId): array {
767 global $wpdb;
768
769 $row = $wpdb->get_row(
770 $wpdb->prepare(
771 "SELECT created_by, created_at, updated_by, updated_at, published_by, published_at
772 FROM {$this->getTable($this->table)} WHERE id = %d",
773 $planId
774 ),
775 ARRAY_A
776 );
777
778 if (!$row) {
779 return [];
780 }
781
782 $createdUser = get_user_by('id', $row['created_by']);
783 $updatedUser = get_user_by('id', $row['updated_by']);
784 $publishedUser = $row['published_by'] ? get_user_by('id', $row['published_by']) : null;
785
786 return [
787 'created_at' => $row['created_at'],
788 'created_by' => $row['created_by'],
789 'created_by_name' => $createdUser ? $createdUser->display_name : __('Unknown', 'event-tickets-with-ticket-scanner'),
790 'updated_at' => $row['updated_at'],
791 'updated_by' => $row['updated_by'],
792 'updated_by_name' => $updatedUser ? $updatedUser->display_name : __('Unknown', 'event-tickets-with-ticket-scanner'),
793 'published_at' => $row['published_at'],
794 'published_by' => $row['published_by'],
795 'published_by_name' => $publishedUser ? $publishedUser->display_name : null
796 ];
797 }
798
799 /**
800 * Check for conflicts when publishing (seats with sold tickets being deleted)
801 *
802 * @param int $planId Plan ID
803 * @return array Array of conflicts (seat identifier => ticket count)
804 */
805 protected function checkPublishConflicts(int $planId): array {
806 // Get seats marked for deletion in draft that have active tickets
807 $seatManager = $this->MAIN->getSeating()->getSeatManager();
808 $conflicts = [];
809
810 // Get all seats for this plan
811 $seats = $seatManager->getByPlanId($planId, false); // Include soft-deleted
812
813 foreach ($seats as $seat) {
814 // Check if seat is marked for deletion in the new draft
815 // but has active (non-refunded) tickets
816 if (!empty($seat['is_deleted']) || $this->isSeatRemovedInDraft($planId, $seat['seat_identifier'])) {
817 $ticketCount = $this->countActiveTicketsForSeat($planId, $seat['id']);
818 if ($ticketCount > 0) {
819 $conflicts[$seat['seat_identifier']] = $ticketCount;
820 }
821 }
822 }
823
824 return $conflicts;
825 }
826
827 /**
828 * Check if a seat identifier was removed in the draft
829 *
830 * @param int $planId Plan ID
831 * @param string $identifier Seat identifier
832 * @return bool True if seat was removed in draft
833 */
834 protected function isSeatRemovedInDraft(int $planId, string $identifier): bool {
835 // This would compare draft seats with current seats
836 // For now, return false - will be implemented when Visual Designer saves seats
837 return false;
838 }
839
840 /**
841 * Count active (sold, non-refunded) tickets for a seat
842 *
843 * @param int $planId Plan ID
844 * @param int $seatId Seat ID
845 * @return int Number of active tickets
846 */
847 protected function countActiveTicketsForSeat(int $planId, int $seatId): int {
848 global $wpdb;
849
850 // Check seat_blocks for confirmed bookings
851 $count = $wpdb->get_var(
852 $wpdb->prepare(
853 "SELECT COUNT(*) FROM {$this->getTable('seat_blocks')}
854 WHERE seat_id = %d AND seatingplan_id = %d AND status = 'confirmed'",
855 $seatId,
856 $planId
857 )
858 );
859
860 return (int) $count;
861 }
862
863 /**
864 * Sync seats table after publishing
865 * Creates new seats, soft-deletes removed seats
866 *
867 * @param int $planId Plan ID
868 */
869 protected function syncSeatsOnPublish(int $planId): void {
870 // This will be called after publish to sync the seats table
871 // with the published design. New seats get created, removed
872 // seats get soft-deleted (is_deleted = 1).
873 // Implementation depends on how Visual Designer stores seat data.
874
875 // For now, this is a placeholder. The actual implementation
876 // will parse the published meta and sync seats accordingly.
877 }
878
879 /**
880 * Get products using this seating plan
881 *
882 * @param int $planId Plan ID
883 * @return array Array of product data (id, name, ticket_count)
884 */
885 public function getLinkedProducts(int $planId): array {
886 global $wpdb;
887
888 // Find WooCommerce products that use this seating plan
889 $products = $wpdb->get_results(
890 $wpdb->prepare(
891 "SELECT p.ID, p.post_title
892 FROM {$wpdb->posts} p
893 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
894 WHERE pm.meta_key = %s
895 AND pm.meta_value = %d
896 AND p.post_type IN ('product', 'product_variation')
897 AND p.post_status = 'publish'",
898 $this->getMetaProductSeatingplan(),
899 $planId
900 ),
901 ARRAY_A
902 );
903
904 $result = [];
905 foreach ($products as $product) {
906 // Count sold tickets for this product
907 $ticketCount = $this->countTicketsForProduct($product['ID']);
908 $result[] = [
909 'id' => $product['ID'],
910 'name' => $product['post_title'],
911 'ticket_count' => $ticketCount
912 ];
913 }
914
915 return $result;
916 }
917
918 /**
919 * Count sold tickets for a product
920 *
921 * @param int $productId WooCommerce product ID
922 * @return int Number of sold tickets
923 */
924 protected function countTicketsForProduct(int $productId): int {
925 global $wpdb;
926
927 // Count confirmed seat blocks for this product
928 $count = $wpdb->get_var(
929 $wpdb->prepare(
930 "SELECT COUNT(*) FROM {$this->getTable('seat_blocks')}
931 WHERE product_id = %d AND status = 'confirmed'",
932 $productId
933 )
934 );
935
936 return (int) $count;
937 }
938
939 /**
940 * Check if plan has active sales (products with sold tickets)
941 *
942 * @param int $planId Plan ID
943 * @return array Info about active sales
944 */
945 public function getActiveSalesInfo(int $planId): array {
946 $products = $this->getLinkedProducts($planId);
947 $totalTickets = 0;
948 $activeProducts = [];
949
950 foreach ($products as $product) {
951 if ($product['ticket_count'] > 0) {
952 $activeProducts[] = $product;
953 $totalTickets += $product['ticket_count'];
954 }
955 }
956
957 return [
958 'has_active_sales' => $totalTickets > 0,
959 'total_tickets' => $totalTickets,
960 'products' => $activeProducts
961 ];
962 }
963
964 /**
965 * Clone/duplicate a seating plan with all its seats
966 *
967 * Creates a copy of the plan including all seats and visual layout.
968 * The new plan will have "(Copy)" appended to the name.
969 *
970 * @param int $sourcePlanId ID of the plan to clone
971 * @param string|null $newName Optional custom name for the cloned plan
972 * @return int New plan ID
973 * @throws Exception If source plan not found or clone fails
974 * @since 2.8.2
975 */
976 public function clonePlan(int $sourcePlanId, ?string $newName = null): int {
977 global $wpdb;
978
979 // Get source plan
980 $sourcePlan = $this->getById($sourcePlanId);
981 if (!$sourcePlan) {
982 throw new Exception(__('Source seating plan not found.', 'event-tickets-with-ticket-scanner'));
983 }
984
985 // Generate unique name if not provided
986 if (empty($newName)) {
987 $newName = $this->generateUniqueCopyName($sourcePlan['name']);
988 } else {
989 // Ensure provided name is unique
990 if ($this->nameExists($newName)) {
991 $newName = $this->generateUniqueCopyName($newName);
992 }
993 }
994
995 // Create new plan with copied data
996 $newPlanId = $this->create([
997 'name' => $newName,
998 'aktiv' => 0, // Start as inactive
999 'layout_type' => $sourcePlan['layout_type'] ?? self::LAYOUT_SIMPLE,
1000 'meta' => $sourcePlan['meta']
1001 ]);
1002
1003 if (!$newPlanId) {
1004 throw new Exception(__('Failed to create cloned seating plan.', 'event-tickets-with-ticket-scanner'));
1005 }
1006
1007 // Copy draft and published meta if they exist
1008 $now = current_time('mysql');
1009 $currentUserId = get_current_user_id();
1010
1011 $wpdb->update(
1012 $this->getTable($this->table),
1013 [
1014 'meta_draft' => $sourcePlan['meta_draft'] ?? '',
1015 'meta_published' => '', // Don't copy published state - new plan starts unpublished
1016 'updated_at' => $now,
1017 'updated_by' => $currentUserId
1018 ],
1019 ['id' => $newPlanId],
1020 ['%s', '%s', '%s', '%d'],
1021 ['%d']
1022 );
1023
1024 // Copy all seats
1025 $seatManager = $this->MAIN->getSeating()->getSeatManager();
1026 $sourceSeats = $seatManager->getByPlanId($sourcePlanId);
1027
1028 foreach ($sourceSeats as $seat) {
1029 $seatManager->create($newPlanId, [
1030 'seat_identifier' => $seat['seat_identifier'],
1031 'aktiv' => $seat['aktiv'],
1032 'sort_order' => $seat['sort_order'],
1033 'meta' => $seat['meta']
1034 ]);
1035 }
1036
1037 return $newPlanId;
1038 }
1039
1040 /**
1041 * Generate a unique copy name for a plan
1042 *
1043 * Appends "(Copy)", "(Copy 2)", etc. until a unique name is found.
1044 *
1045 * @param string $baseName Original plan name
1046 * @return string Unique name
1047 */
1048 private function generateUniqueCopyName(string $baseName): string {
1049 // Remove existing "(Copy X)" suffix if present
1050 $baseName = preg_replace('/\s*\(Copy(?:\s+\d+)?\)\s*$/', '', $baseName);
1051
1052 $copyName = $baseName . ' (Copy)';
1053 $counter = 2;
1054
1055 while ($this->nameExists($copyName)) {
1056 $copyName = $baseName . ' (Copy ' . $counter . ')';
1057 $counter++;
1058
1059 // Safety limit
1060 if ($counter > 100) {
1061 $copyName = $baseName . ' (Copy ' . uniqid() . ')';
1062 break;
1063 }
1064 }
1065
1066 return $copyName;
1067 }
1068 }
1069