PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / TripConsent.tsx

TripConsent.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.3, at resources/js/pages/TripConsent.tsx

1,284 lines 43.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Trip Consent Page
3 *
4 * Premium module for managing consent forms and digital signatures.
5 * This page displays the UI shell in the free plugin with a premium gate.
6 * The actual functionality is provided by the Yatra Pro plugin.
7 *
8 * @package Yatra
9 * @since 3.0.0
10 */
11
12 import React, { useState, useMemo, useEffect } from "react";
13 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
14 import { Card, CardContent } from "../components/ui/card";
15 import { Button } from "../components/ui/button";
16 import { Badge } from "../components/ui/badge";
17 import { PageHeader } from "../components/common/PageHeader";
18 import {
19 Pagination,
20 SearchFilterToolbar,
21 BulkActionToolbar,
22 Table as SharedTable,
23 } from "../components/shared";
24 import { getDefaultBulkStatusOptions } from "../components/shared/bulkStatusOptions";
25 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
26 import { ConditionalRender } from "../components/ui/conditional-render";
27 import { useToast } from "../components/ui/toast";
28 import { Modal } from "../components/ui/modal";
29 import { apiClient } from "../lib/api-client";
30 import { apiService } from "../lib/api-client";
31 import { __ } from "../lib/i18n";
32 import PremiumUpgradeCard from "./premium-pages/TripConsent";
33 import {
34 Plus,
35 Edit,
36 Trash2,
37 CheckCircle,
38 Copy,
39 Clock,
40 Eye,
41 Download,
42 Mail,
43 Calendar,
44 RotateCcw,
45 FileSignature,
46 } from "lucide-react";
47
48 // Types
49 interface ConsentForm {
50 id: number;
51 name: string;
52 description: string;
53 form_config: FormConfig;
54 status: "publish" | "draft" | "archived" | "trash";
55 version: number;
56 require_signature: boolean;
57 require_initials: boolean;
58 applicable_to: "all" | "specific_trips";
59 send_to: "all_travelers" | "lead_traveler" | "primary_contact";
60 trip_ids: number[];
61 send_before_days: number;
62 reminder_days: string;
63 expiry_hours: number | null;
64 created_by: number;
65 created_at: string;
66 updated_at: string;
67 signed_count?: number;
68 pending_count?: number;
69 }
70
71 interface FormConfig {
72 title: string;
73 description: string;
74 enabled: boolean;
75 fields: FormField[];
76 content_blocks?: ContentBlock[];
77 }
78
79 interface FormField {
80 id: string;
81 type: string;
82 label: string;
83 placeholder?: string;
84 required: boolean;
85 enabled: boolean;
86 order: number;
87 width?: string;
88 section?: string;
89 options?: { value: string; label: string }[];
90 locked?: boolean;
91 }
92
93 interface ContentBlock {
94 id: string;
95 type: string;
96 title: string;
97 content: string;
98 order: number;
99 }
100
101 interface ConsentFormsResponse {
102 data: ConsentForm[];
103 meta: {
104 total: number;
105 per_page: number;
106 current_page: number;
107 total_pages: number;
108 };
109 }
110
111 interface SignedConsent {
112 id: number;
113 form_id: number;
114 form_name?: string;
115 form_version: number;
116 booking_id: number;
117 traveler_index: number;
118 signer_name: string;
119 signer_email: string;
120 form_data: Record<string, any>;
121 signature_data: string | null;
122 initials_data: string | null;
123 ip_address: string;
124 user_agent: string;
125 signed_at: string;
126 status: "signed" | "expired" | "revoked";
127 }
128
129 interface SignedConsentsResponse {
130 data: SignedConsent[];
131 meta: {
132 total: number;
133 per_page: number;
134 current_page: number;
135 };
136 }
137
138 // Check if module is available (Pro active)
139 const isModuleAvailable = (): boolean => {
140 const yatraAdmin = (window as any)?.yatraAdmin;
141 return Boolean(yatraAdmin?.isPro);
142 };
143
144 // Consent Forms List Component (shown when Pro is active)
145 const ConsentFormsList: React.FC = () => {
146 const queryClient = useQueryClient();
147 const { showToast } = useToast();
148
149 // State
150 const [searchTerm, setSearchTerm] = useState("");
151 const [statusFilter, setStatusFilter] = useState("all");
152 const [sortBy, setSortBy] = useState("name");
153 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
154 const [page, setPage] = useState(1);
155 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
156 const [bulkAction, setBulkAction] = useState("");
157 const [deleteConfirm, setDeleteConfirm] = useState<{
158 isOpen: boolean;
159 form: ConsentForm | null;
160 }>({
161 isOpen: false,
162 form: null,
163 });
164 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
165
166 // Column visibility state
167 const [visibleColumns, setVisibleColumns] = useState(() => {
168 const saved = localStorage.getItem("yatra-consent-forms-columns");
169 return saved
170 ? JSON.parse(saved)
171 : {
172 id: true,
173 name: true,
174 status: true,
175 applicable_to: true,
176 signatures: true,
177 version: true,
178 };
179 });
180
181 const toggleColumn = (columnKey: string) => {
182 const newVisibleColumns = {
183 ...visibleColumns,
184 [columnKey]: !visibleColumns[columnKey],
185 };
186 setVisibleColumns(newVisibleColumns);
187 localStorage.setItem(
188 "yatra-consent-forms-columns",
189 JSON.stringify(newVisibleColumns),
190 );
191 };
192
193 // Build query params
194 const queryParams = useMemo(() => {
195 const params: Record<string, any> = {
196 page,
197 per_page: 10,
198 orderby: sortBy,
199 order: sortOrder,
200 };
201 if (searchTerm) params.search = searchTerm;
202 if (statusFilter !== "all") params.status = statusFilter;
203 return params;
204 }, [searchTerm, statusFilter, sortBy, sortOrder, page]);
205
206 // Fetch consent forms
207 const {
208 data: formsData,
209 isLoading,
210 error,
211 } = useQuery<ConsentFormsResponse>({
212 queryKey: ["consent-forms", queryParams],
213 queryFn: async () => {
214 const response = await apiClient.get("/consent-forms", {
215 params: queryParams,
216 });
217 return response.data || response;
218 },
219 enabled: isModuleAvailable(),
220 });
221
222 // Handle both array response and wrapped response
223 const forms = Array.isArray(formsData) ? formsData : formsData?.data || [];
224 const total = Array.isArray(formsData)
225 ? formsData.length
226 : formsData?.meta?.total || 0;
227 const totalPages = Math.ceil(total / 10);
228
229 // Delete mutation
230 const deleteMutation = useMutation({
231 mutationFn: async (id: number) => {
232 await apiClient.delete(`/consent-forms/${id}`);
233 },
234 onSuccess: () => {
235 queryClient.invalidateQueries({ queryKey: ["consent-forms"] });
236 showToast(__("Consent form deleted successfully"), "success");
237 setDeleteConfirm({ isOpen: false, form: null });
238 },
239 onError: () => {
240 showToast(__("Failed to delete consent form"), "error");
241 },
242 });
243
244 // Duplicate mutation
245 const duplicateMutation = useMutation({
246 mutationFn: async (id: number) => {
247 await apiClient.post(`/consent-forms/${id}/duplicate`);
248 },
249 onSuccess: () => {
250 queryClient.invalidateQueries({ queryKey: ["consent-forms"] });
251 showToast(__("Consent form duplicated successfully"), "success");
252 },
253 onError: () => {
254 showToast(__("Failed to duplicate consent form"), "error");
255 },
256 });
257
258 // Bulk actions mutation
259 const bulkMutation = useMutation({
260 mutationFn: async ({
261 action,
262 ids,
263 }: {
264 action: string;
265 ids: (string | number)[];
266 }) => {
267 return await apiClient.post("/consent-forms/bulk", { action, ids });
268 },
269 onSuccess: () => {
270 queryClient.invalidateQueries({ queryKey: ["consent-forms"] });
271 showToast(__("Action completed successfully"), "success");
272 setSelectedIds([]);
273 setBulkAction("");
274 },
275 onError: () => {
276 showToast(__("Failed to perform action"), "error");
277 },
278 });
279
280 const navigateToForm = (action: "create" | "edit", id?: number) => {
281 const params = new URLSearchParams(window.location.search);
282 params.set("subpage", "trips");
283 params.set("tab", "trip-consent");
284 params.set("action", action);
285 if (id) params.set("id", id.toString());
286 window.history.pushState({}, "", `${window.location.pathname}?${params}`);
287 window.dispatchEvent(new PopStateEvent("popstate"));
288 };
289
290 const handleEdit = (form: ConsentForm) => navigateToForm("edit", form.id);
291
292 const handleBulkApply = () => {
293 if (!bulkAction || selectedIds.length === 0) return;
294 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
295 };
296
297 const handleResetFilters = () => {
298 setSearchTerm("");
299 setStatusFilter("all");
300 setSortBy("name");
301 setSortOrder("asc");
302 setPage(1);
303 };
304
305 const hasFilters =
306 searchTerm ||
307 statusFilter !== "all" ||
308 sortBy !== "name" ||
309 sortOrder !== "asc";
310
311 // Keep selection in sync with current page data
312 useEffect(() => {
313 setSelectedIds((prev) =>
314 prev.filter((id) => forms.some((f: ConsentForm) => f.id === id)),
315 );
316 }, [forms]);
317
318 // Calculate status counts from forms data
319 const statusCounts = useMemo(() => {
320 const counts = {
321 all: total,
322 publish: 0,
323 draft: 0,
324 archived: 0,
325 trash: 0,
326 };
327
328 forms.forEach((form: ConsentForm) => {
329 if (counts[form.status] !== undefined) {
330 counts[form.status]++;
331 }
332 });
333
334 return counts;
335 }, [forms, total]);
336
337 const viewFilters = [
338 { key: "all", label: __("All"), count: statusCounts.all },
339 { key: "publish", label: __("Published"), count: statusCounts.publish },
340 { key: "draft", label: __("Draft"), count: statusCounts.draft },
341 { key: "archived", label: __("Archived"), count: statusCounts.archived },
342 { key: "trash", label: __("Trash"), count: statusCounts.trash },
343 ];
344
345 return (
346 <div className="space-y-3">
347 {/* Delete Confirmation Dialog */}
348 <ConfirmationDialog
349 isOpen={deleteConfirm.isOpen}
350 onClose={() => setDeleteConfirm({ isOpen: false, form: null })}
351 onConfirm={() =>
352 deleteConfirm.form && deleteMutation.mutate(deleteConfirm.form.id)
353 }
354 title={__("Delete Consent Form")}
355 message={
356 deleteConfirm.form
357 ? __(
358 'Are you sure you want to delete "{name}"? Forms with signed consents will be archived instead.',
359 ).replace("{name}", deleteConfirm.form.name)
360 : __("Are you sure you want to delete this consent form?")
361 }
362 confirmText={__("Delete")}
363 cancelText={__("Cancel")}
364 variant="danger"
365 isLoading={deleteMutation.isPending}
366 />
367
368 {/* Filters Card */}
369 <Card>
370 <CardContent className="p-3">
371 <SearchFilterToolbar
372 searchTerm={searchTerm}
373 onSearchChange={setSearchTerm}
374 statusFilter={statusFilter}
375 onStatusChange={(value) => {
376 setStatusFilter(value);
377 setPage(1);
378 setSelectedIds([]);
379 setBulkAction("");
380 }}
381 statusOptions={[
382 { value: "all", label: __("All Status") },
383 { value: "publish", label: __("Published") },
384 { value: "draft", label: __("Draft") },
385 { value: "archived", label: __("Archived") },
386 { value: "trash", label: __("Trash") },
387 ]}
388 sortBy={sortBy}
389 onSortByChange={setSortBy}
390 sortOrder={sortOrder}
391 onSortOrderChange={setSortOrder}
392 sortOptions={[
393 { value: "name", label: __("Name") },
394 { value: "status", label: __("Status") },
395 { value: "created_at", label: __("Created At") },
396 { value: "updated_at", label: __("Updated At") },
397 ]}
398 onResetFilters={handleResetFilters}
399 hasFilters={!!hasFilters}
400 placeholder={__("Search consent forms...")}
401 />
402 </CardContent>
403 </Card>
404
405 <ConditionalRender capability="yatra_view_trips">
406 {error ? (
407 <Card>
408 <CardContent className="p-8 text-center text-red-500">
409 {__("Error loading consent forms")}
410 </CardContent>
411 </Card>
412 ) : (
413 <>
414 <BulkActionToolbar
415 selectedIds={selectedIds}
416 bulkAction={bulkAction}
417 setBulkAction={setBulkAction}
418 onApply={handleBulkApply}
419 onClearSelection={() => setSelectedIds([])}
420 statusFilter={statusFilter}
421 setStatusFilter={(value) => {
422 setStatusFilter(value);
423 setPage(1);
424 setSelectedIds([]);
425 setBulkAction("");
426 }}
427 statusOptions={viewFilters}
428 showColumnsDropdown={showColumnsDropdown}
429 setShowColumnsDropdown={setShowColumnsDropdown}
430 columnOptions={[
431 { key: "id", label: __("Form ID"), visible: visibleColumns.id },
432 {
433 key: "name",
434 label: __("Form Name"),
435 visible: visibleColumns.name,
436 },
437 {
438 key: "status",
439 label: __("Status"),
440 visible: visibleColumns.status,
441 },
442 {
443 key: "applicable_to",
444 label: __("Applies To"),
445 visible: visibleColumns.applicable_to,
446 },
447 {
448 key: "signatures",
449 label: __("Signatures"),
450 visible: visibleColumns.signatures,
451 },
452 {
453 key: "version",
454 label: __("Version"),
455 visible: visibleColumns.version,
456 },
457 ]}
458 onToggleColumn={toggleColumn}
459 bulkMutationPending={bulkMutation.isPending}
460 totalItems={forms.length}
461 bulkActionOptions={getDefaultBulkStatusOptions(statusFilter)}
462 />
463
464 <Card className="overflow-visible">
465 <CardContent className="p-0 overflow-visible">
466 <SharedTable
467 data={forms}
468 columns={[
469 {
470 key: "id",
471 label: __("Form ID"),
472 sortable: true,
473 visible: visibleColumns.id,
474 render: (form: ConsentForm) => (
475 <span className="text-sm font-mono text-gray-500 dark:text-gray-400">
476 #{form.id}
477 </span>
478 ),
479 },
480 {
481 key: "name",
482 label: __("Form Name"),
483 sortable: true,
484 visible: visibleColumns.name,
485 render: (form: ConsentForm) => (
486 <div className="flex items-center gap-3">
487 <div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
488 <FileSignature className="w-5 h-5 text-blue-600 dark:text-blue-400" />
489 </div>
490 <div>
491 <a
492 href="#"
493 onClick={(e) => {
494 e.preventDefault();
495 handleEdit(form);
496 }}
497 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors cursor-pointer"
498 >
499 {form.name}
500 </a>
501 {form.description && (
502 <div className="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
503 {form.description}
504 </div>
505 )}
506 </div>
507 </div>
508 ),
509 },
510 {
511 key: "status",
512 label: __("Status"),
513 sortable: true,
514 visible: visibleColumns.status,
515 render: (form: ConsentForm) => {
516 const statusColors: Record<string, string> = {
517 publish:
518 "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
519 draft:
520 "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
521 archived:
522 "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400",
523 trash:
524 "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
525 };
526 return (
527 <Badge className={statusColors[form.status] || ""}>
528 {form.status.charAt(0).toUpperCase() +
529 form.status.slice(1)}
530 </Badge>
531 );
532 },
533 },
534 {
535 key: "applicable_to",
536 label: __("Applies To"),
537 sortable: false,
538 visible: visibleColumns.applicable_to,
539 render: (form: ConsentForm) => (
540 <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
541 {form.applicable_to === "all"
542 ? __("All Trips")
543 : `${form.trip_ids?.length || 0} ${__("Trips")}`}
544 </span>
545 ),
546 },
547 {
548 key: "signatures",
549 label: __("Signatures"),
550 sortable: false,
551 visible: visibleColumns.signatures,
552 render: (form: ConsentForm) => {
553 const signedCount = form.signed_count || 0;
554 const pendingCount = form.pending_count || 0;
555
556 return (
557 <div className="flex items-center gap-3">
558 <div className="flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400">
559 <CheckCircle className="w-4 h-4" />
560 <span>{signedCount}</span>
561 </div>
562 <div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
563 <Clock className="w-4 h-4" />
564 <span>{pendingCount}</span>
565 </div>
566 </div>
567 );
568 },
569 },
570 {
571 key: "version",
572 label: __("Version"),
573 sortable: false,
574 visible: visibleColumns.version,
575 render: (form: ConsentForm) => (
576 <span className="text-sm text-gray-600 dark:text-gray-400">
577 v{form.version}
578 </span>
579 ),
580 },
581 ]}
582 actions={[
583 {
584 key: "edit",
585 label: __("Edit"),
586 icon: <Edit className="w-4 h-4" />,
587 onClick: handleEdit,
588 condition: (form: ConsentForm) => form.status !== "trash",
589 },
590 {
591 key: "duplicate",
592 label: __("Duplicate"),
593 icon: <Copy className="w-4 h-4" />,
594 onClick: (form: ConsentForm) =>
595 duplicateMutation.mutate(form.id),
596 condition: (form: ConsentForm) => form.status !== "trash",
597 },
598 {
599 key: "restore",
600 label: __("Restore"),
601 icon: <RotateCcw className="w-4 h-4" />,
602 onClick: (form: ConsentForm) =>
603 bulkMutation.mutate({
604 action: "restore",
605 ids: [form.id],
606 }),
607 condition: (form: ConsentForm) => form.status === "trash",
608 },
609 {
610 key: "trash",
611 label: __("Move to Trash"),
612 icon: <Trash2 className="w-4 h-4" />,
613 onClick: (form: ConsentForm) =>
614 bulkMutation.mutate({
615 action: "trash",
616 ids: [form.id],
617 }),
618 condition: (form: ConsentForm) => form.status !== "trash",
619 },
620 {
621 key: "delete",
622 label: __("Delete Permanently"),
623 icon: <Trash2 className="w-4 h-4" />,
624 onClick: (form: ConsentForm) =>
625 setDeleteConfirm({ isOpen: true, form }),
626 variant: "destructive",
627 condition: (form: ConsentForm) => form.status === "trash",
628 },
629 ]}
630 selectedItemIds={selectedIds}
631 onSelectItem={(id, checked) => {
632 if (checked) {
633 setSelectedIds([...selectedIds, id]);
634 } else {
635 setSelectedIds(selectedIds.filter((i) => i !== id));
636 }
637 }}
638 onSelectAll={(checked) => {
639 if (checked) {
640 setSelectedIds(forms.map((f) => f.id));
641 } else {
642 setSelectedIds([]);
643 }
644 }}
645 isAllSelected={
646 forms.length > 0 && selectedIds.length === forms.length
647 }
648 getItemId={(form) => form.id}
649 isLoading={isLoading}
650 />
651
652 {total > 10 && (
653 <div className="border-t border-gray-200 dark:border-gray-700 p-4">
654 <Pagination
655 currentPage={page}
656 totalPages={totalPages}
657 onPageChange={setPage}
658 totalItems={total}
659 itemsPerPage={10}
660 itemName={__("forms")}
661 />
662 </div>
663 )}
664 </CardContent>
665 </Card>
666 </>
667 )}
668 </ConditionalRender>
669 </div>
670 );
671 };
672
673 // Signed Consents List Component
674 const SignedConsentsList: React.FC = () => {
675 const [page, setPage] = useState(1);
676 const [selectedConsent, setSelectedConsent] = useState<SignedConsent | null>(
677 null,
678 );
679 const [isModalOpen, setIsModalOpen] = useState(false);
680 const [isDetailLoading, setIsDetailLoading] = useState(false);
681 const [detailError, setDetailError] = useState<string | null>(null);
682 const [searchTerm, setSearchTerm] = useState("");
683 const [sortBy, setSortBy] = useState("signed_at");
684 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
685 const [statusFilter, setStatusFilter] = useState("all");
686 const { showToast } = useToast();
687
688 // Debug modal state changes
689 useEffect(() => {}, [isModalOpen]);
690
691 useEffect(() => {}, [selectedConsent]);
692
693 // Fetch signed consents
694 const {
695 data: consentsData,
696 isLoading,
697 error,
698 } = useQuery<SignedConsentsResponse>({
699 queryKey: [
700 "signed-consents",
701 page,
702 searchTerm,
703 sortBy,
704 sortOrder,
705 statusFilter,
706 ],
707 queryFn: async () => {
708 const params: Record<string, any> = {
709 page,
710 per_page: 15,
711 sort_by: sortBy,
712 sort_order: sortOrder,
713 };
714 if (searchTerm) params.search = searchTerm;
715 if (statusFilter !== "all") params.status = statusFilter;
716 const response = await apiClient.get("/signed-consents", { params });
717 return response;
718 },
719 enabled: isModuleAvailable(),
720 });
721
722 const consents = consentsData?.data || [];
723 const total = consentsData?.meta?.total || 0;
724 const totalPages = Math.ceil(total / 15);
725
726 const formatDate = (dateString: string) => {
727 return new Date(dateString).toLocaleDateString("en-US", {
728 year: "numeric",
729 month: "short",
730 day: "numeric",
731 hour: "2-digit",
732 minute: "2-digit",
733 });
734 };
735
736 const handleView = async (consent: SignedConsent) => {
737 setIsModalOpen(true);
738 setIsDetailLoading(true);
739 setDetailError(null);
740 setSelectedConsent(null);
741
742 try {
743 const response = await apiClient.get(`/signed-consents/${consent.id}`);
744
745 // Handle nested data structure from API
746 let consentData = response;
747 if (response.data) {
748 consentData = response.data;
749 }
750
751 setSelectedConsent(consentData);
752 } catch (error) {
753 console.error("Error fetching consent details:", error);
754 setDetailError(__("Failed to load consent details"));
755 } finally {
756 setIsDetailLoading(false);
757 }
758 };
759
760 const handleCloseModal = () => {
761 setIsModalOpen(false);
762 setDetailError(null);
763 setSelectedConsent(null);
764 };
765
766 const handleDownloadPdf = async (consent: SignedConsent) => {
767 try {
768 const data = await apiService.downloadSignedConsentPdf(consent.id);
769
770 if (data?.success && data?.data?.download_url) {
771 window.open(data.data.download_url, "_blank");
772 } else {
773 showToast(
774 __("Failed to prepare PDF download. Please try again.", "yatra"),
775 "error",
776 );
777 }
778 } catch (err: any) {
779 console.error("PDF download error:", err);
780 showToast(err?.message || __("Failed to download PDF", "yatra"), "error");
781 }
782 };
783
784 const handleResetFilters = () => {
785 setSearchTerm("");
786 setStatusFilter("all");
787 setSortBy("signed_at");
788 setSortOrder("desc");
789 setPage(1);
790 };
791
792 const hasFilters =
793 searchTerm ||
794 statusFilter !== "all" ||
795 sortBy !== "signed_at" ||
796 sortOrder !== "desc";
797
798 return (
799 <div className="space-y-3">
800 {/* Search and Filters */}
801 <Card>
802 <CardContent className="p-3">
803 <SearchFilterToolbar
804 searchTerm={searchTerm}
805 onSearchChange={(value) => {
806 setSearchTerm(value);
807 setPage(1);
808 }}
809 statusFilter={statusFilter}
810 onStatusChange={(value) => {
811 setStatusFilter(value);
812 setPage(1);
813 }}
814 statusOptions={[
815 { value: "all", label: __("All Status") },
816 { value: "signed", label: __("Signed") },
817 { value: "pending", label: __("Pending") },
818 ]}
819 sortBy={sortBy}
820 onSortByChange={setSortBy}
821 sortOrder={sortOrder}
822 onSortOrderChange={setSortOrder}
823 sortOptions={[
824 { value: "signed_at", label: __("Signed Date") },
825 { value: "signer_name", label: __("Signer Name") },
826 { value: "form_name", label: __("Form Name") },
827 ]}
828 onResetFilters={handleResetFilters}
829 hasFilters={!!hasFilters}
830 placeholder={__("Search by name or email...")}
831 />
832 </CardContent>
833 </Card>
834
835 {/* Consents Table */}
836 <Card className="overflow-visible">
837 <CardContent className="p-0 overflow-visible">
838 <SharedTable
839 data={consents}
840 columns={[
841 {
842 key: "signer",
843 label: __("Signer"),
844 sortable: true,
845 visible: true,
846 render: (consent: SignedConsent) => (
847 <button
848 type="button"
849 onClick={() => handleView(consent)}
850 className="flex items-center gap-3 w-full text-left group focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded-lg"
851 >
852 <div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-300 font-semibold">
853 {consent.signer_name?.charAt(0)?.toUpperCase() || "?"}
854 </div>
855 <div className="flex flex-col">
856 <div className="font-medium text-gray-900 dark:text-white">
857 {consent.signer_name}
858 </div>
859 <div className="text-sm text-gray-500 flex items-center gap-1">
860 <Mail className="w-3 h-3" />
861 {consent.signer_email}
862 </div>
863 <span className="text-xs text-blue-600 dark:text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity">
864 {__("View details")}
865 </span>
866 </div>
867 </button>
868 ),
869 },
870 {
871 key: "form",
872 label: __("Form"),
873 sortable: true,
874 visible: true,
875 render: (consent: SignedConsent) => (
876 <div>
877 <div className="text-sm text-gray-900 dark:text-white">
878 {consent.form_name || `Form #${consent.form_id}`}
879 </div>
880 <div className="text-xs text-gray-500">
881 v{consent.form_version}
882 </div>
883 </div>
884 ),
885 },
886 {
887 key: "booking",
888 label: __("Booking"),
889 sortable: false,
890 visible: true,
891 render: (consent: SignedConsent) => (
892 <span className="text-sm text-gray-600 dark:text-gray-400">
893 {consent.booking_id > 0 ? (
894 <>
895 #{consent.booking_id}
896 {consent.traveler_index > 0 && (
897 <span className="ml-1 text-xs text-gray-400">
898 (Traveler {consent.traveler_index})
899 </span>
900 )}
901 </>
902 ) : (
903 <span className="text-xs text-gray-400 italic">
904 {__("Preview")}
905 </span>
906 )}
907 </span>
908 ),
909 },
910 {
911 key: "signed_at",
912 label: __("Signed At"),
913 sortable: true,
914 visible: true,
915 render: (consent: SignedConsent) => (
916 <div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
917 <Calendar className="w-3 h-3" />
918 {formatDate(consent.signed_at)}
919 </div>
920 ),
921 },
922 {
923 key: "status",
924 label: __("Status"),
925 sortable: true,
926 visible: true,
927 render: (consent: SignedConsent) => (
928 <Badge
929 variant={
930 consent.status === "signed" ? "success" : "default"
931 }
932 >
933 {consent.status === "signed"
934 ? __("Signed")
935 : consent.status}
936 </Badge>
937 ),
938 },
939 ]}
940 actions={[
941 {
942 key: "view",
943 label: __("View Details"),
944 icon: <Eye className="w-4 h-4" />,
945 onClick: handleView,
946 },
947 {
948 key: "download",
949 label: __("Download PDF"),
950 icon: <Download className="w-4 h-4" />,
951 onClick: handleDownloadPdf,
952 },
953 ]}
954 isLoading={isLoading}
955 isError={!!error}
956 errorText={__("Error loading signed consents")}
957 emptyText={__("No signed consents yet")}
958 emptyDescription={__(
959 "Signed consent forms will appear here once travelers submit them.",
960 )}
961 getItemId={(consent: SignedConsent) => consent.id}
962 />
963 </CardContent>
964 </Card>
965
966 {/* Pagination */}
967 {total > 15 && (
968 <Pagination
969 currentPage={page}
970 totalPages={totalPages}
971 onPageChange={setPage}
972 totalItems={total}
973 itemsPerPage={15}
974 itemName={__("consents")}
975 />
976 )}
977
978 {/* View Consent Modal */}
979 <Modal
980 isOpen={isModalOpen}
981 onClose={handleCloseModal}
982 title={__("Signed Consent Details")}
983 size="xl"
984 loading={isDetailLoading}
985 error={detailError}
986 loadingSkeleton={
987 <div className="space-y-6 animate-pulse">
988 {/* Signer Info Skeleton */}
989 <div className="grid grid-cols-2 gap-4">
990 <div>
991 <div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
992 <div className="h-5 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div>
993 </div>
994 <div>
995 <div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
996 <div className="h-5 w-40 bg-gray-300 dark:bg-gray-600 rounded"></div>
997 </div>
998 <div>
999 <div className="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
1000 <div className="h-5 w-36 bg-gray-300 dark:bg-gray-600 rounded"></div>
1001 </div>
1002 <div>
1003 <div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
1004 <div className="h-5 w-28 bg-gray-300 dark:bg-gray-600 rounded"></div>
1005 </div>
1006 </div>
1007
1008 {/* Form Responses Skeleton */}
1009 <div>
1010 <div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-3"></div>
1011 <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-3">
1012 <div className="flex justify-between pb-2 border-b border-gray-200 dark:border-gray-700">
1013 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
1014 <div className="h-4 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div>
1015 </div>
1016 <div className="flex justify-between pb-2 border-b border-gray-200 dark:border-gray-700">
1017 <div className="h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded"></div>
1018 <div className="h-4 w-24 bg-gray-300 dark:bg-gray-600 rounded"></div>
1019 </div>
1020 <div className="flex justify-between">
1021 <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
1022 <div className="h-4 w-36 bg-gray-300 dark:bg-gray-600 rounded"></div>
1023 </div>
1024 </div>
1025 </div>
1026
1027 {/* Signature Skeleton */}
1028 <div>
1029 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-3"></div>
1030 <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
1031 <div className="h-24 w-48 bg-gray-200 dark:bg-gray-700 rounded mx-auto"></div>
1032 </div>
1033 </div>
1034 </div>
1035 }
1036 footer={
1037 selectedConsent && !detailError && !isDetailLoading ? (
1038 <div className="flex justify-end gap-3">
1039 <Button
1040 variant="outline"
1041 onClick={() => handleDownloadPdf(selectedConsent)}
1042 >
1043 <Download className="w-4 h-4 mr-2" />
1044 {__("Download PDF")}
1045 </Button>
1046 <Button onClick={handleCloseModal}>{__("Close")}</Button>
1047 </div>
1048 ) : undefined
1049 }
1050 >
1051 {selectedConsent && (
1052 <>
1053 <div className="grid grid-cols-2 gap-4 mb-6">
1054 <div>
1055 <label className="text-xs text-gray-500 uppercase">
1056 {__("Signer Name")}
1057 </label>
1058 <p className="font-medium text-gray-900 dark:text-white">
1059 {selectedConsent.signer_name}
1060 </p>
1061 </div>
1062 <div>
1063 <label className="text-xs text-gray-500 uppercase">
1064 {__("Email")}
1065 </label>
1066 <p className="font-medium text-gray-900 dark:text-white">
1067 {selectedConsent.signer_email}
1068 </p>
1069 </div>
1070 <div>
1071 <label className="text-xs text-gray-500 uppercase">
1072 {__("Signed At")}
1073 </label>
1074 <p className="font-medium text-gray-900 dark:text-white">
1075 {formatDate(selectedConsent.signed_at)}
1076 </p>
1077 </div>
1078 <div>
1079 <label className="text-xs text-gray-500 uppercase">
1080 {__("IP Address")}
1081 </label>
1082 <p className="font-medium text-gray-900 dark:text-white">
1083 {selectedConsent.ip_address}
1084 </p>
1085 </div>
1086 </div>
1087
1088 <div className="mb-6">
1089 <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase">
1090 {__("Form Responses")}
1091 </h3>
1092 <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-3">
1093 {selectedConsent.form_data &&
1094 Object.entries(selectedConsent.form_data).map(
1095 ([key, value]) => (
1096 <div
1097 key={key}
1098 className="flex justify-between border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0 last:pb-0"
1099 >
1100 <span className="text-sm text-gray-600 dark:text-gray-400">
1101 {key}
1102 </span>
1103 <span className="text-sm font-medium text-gray-900 dark:text-white">
1104 {typeof value === "boolean"
1105 ? value
1106 ? "✓ Yes"
1107 : "✗ No"
1108 : String(value)}
1109 </span>
1110 </div>
1111 ),
1112 )}
1113 {(!selectedConsent.form_data ||
1114 Object.keys(selectedConsent.form_data).length === 0) && (
1115 <p className="text-sm text-gray-500">
1116 {__("No form data recorded")}
1117 </p>
1118 )}
1119 </div>
1120 </div>
1121
1122 {selectedConsent.signature_data && (
1123 <div className="mb-6">
1124 <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase">
1125 {__("Signature")}
1126 </h3>
1127 <div className="bg-white border border-gray-200 dark:border-gray-700 rounded-lg p-4">
1128 <img
1129 src={selectedConsent.signature_data}
1130 alt="Signature"
1131 className="max-h-24 mx-auto"
1132 />
1133 </div>
1134 </div>
1135 )}
1136
1137 {selectedConsent.initials_data && (
1138 <div className="mb-2">
1139 <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 uppercase">
1140 {__("Initials")}
1141 </h3>
1142 <div className="bg-white border border-gray-200 dark:border-gray-700 rounded-lg p-4">
1143 <img
1144 src={selectedConsent.initials_data}
1145 alt="Initials"
1146 className="max-h-16 mx-auto"
1147 />
1148 </div>
1149 </div>
1150 )}
1151 </>
1152 )}
1153 </Modal>
1154 </div>
1155 );
1156 };
1157
1158 // Main Component
1159 const TripConsent: React.FC = () => {
1160 // Read subtab from URL and sync with state
1161 const getSubtabFromUrl = useMemo(() => {
1162 const params = new URLSearchParams(window.location.search);
1163 return params.get("subtab") || "forms";
1164 }, []);
1165
1166 const [activeTab, setActiveTab] = useState<"forms" | "signed">(
1167 getSubtabFromUrl as "forms" | "signed",
1168 );
1169
1170 // Update URL when tab changes
1171 const updateTabInUrl = (tab: "forms" | "signed") => {
1172 const params = new URLSearchParams(window.location.search);
1173 params.set("subpage", "trips");
1174 params.set("tab", "trip-consent");
1175 params.set("subtab", tab);
1176 params.delete("action"); // Remove action when switching tabs
1177 params.delete("id"); // Remove id when switching tabs
1178 window.history.pushState({}, "", `${window.location.pathname}?${params}`);
1179 };
1180
1181 // Handle tab changes
1182 const handleTabChange = (tab: "forms" | "signed") => {
1183 setActiveTab(tab);
1184 updateTabInUrl(tab);
1185 };
1186
1187 // Listen for URL changes
1188 useEffect(() => {
1189 const handleLocationChange = () => {
1190 const newSubtab = getSubtabFromUrl;
1191 if (newSubtab !== activeTab) {
1192 setActiveTab(newSubtab as "forms" | "signed");
1193 }
1194 };
1195
1196 // Listen for popstate (back/forward button)
1197 window.addEventListener("popstate", handleLocationChange);
1198
1199 // Also check periodically (fallback for direct navigation)
1200 const interval = setInterval(() => {
1201 const currentSearch = window.location.search;
1202 if (currentSearch !== (window as any).__lastSearch) {
1203 (window as any).__lastSearch = currentSearch;
1204 handleLocationChange();
1205 }
1206 }, 100);
1207
1208 return () => {
1209 window.removeEventListener("popstate", handleLocationChange);
1210 clearInterval(interval);
1211 };
1212 }, [getSubtabFromUrl, activeTab]);
1213
1214 if (!isModuleAvailable()) {
1215 return <PremiumUpgradeCard />;
1216 }
1217
1218 return (
1219 <div className="space-y-6">
1220 <PageHeader
1221 title={__("Trip Consent Forms")}
1222 description={__(
1223 "Manage consent forms, waivers, and digital signatures for your trips.",
1224 )}
1225 actions={
1226 activeTab === "forms" ? (
1227 <Button
1228 onClick={() => {
1229 const params = new URLSearchParams(window.location.search);
1230 params.set("subpage", "trips");
1231 params.set("tab", "trip-consent");
1232 params.set("action", "create");
1233 window.history.pushState(
1234 {},
1235 "",
1236 `${window.location.pathname}?${params}`,
1237 );
1238 window.dispatchEvent(new PopStateEvent("popstate"));
1239 }}
1240 className="bg-blue-600 hover:bg-blue-700"
1241 >
1242 <Plus className="w-4 h-4 mr-2" />
1243 {__("Create Form")}
1244 </Button>
1245 ) : null
1246 }
1247 />
1248
1249 {/* Tabs */}
1250 <div className="border-b border-gray-200 dark:border-gray-700">
1251 <nav className="-mb-px flex space-x-8">
1252 <button
1253 onClick={() => handleTabChange("forms")}
1254 className={`py-3 px-1 border-b-2 font-medium text-sm ${
1255 activeTab === "forms"
1256 ? "border-blue-500 text-blue-600 dark:text-blue-400"
1257 : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
1258 }`}
1259 >
1260 {__("Consent Forms")}
1261 </button>
1262 <button
1263 onClick={() => handleTabChange("signed")}
1264 className={`py-3 px-1 border-b-2 font-medium text-sm ${
1265 activeTab === "signed"
1266 ? "border-blue-500 text-blue-600 dark:text-blue-400"
1267 : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
1268 }`}
1269 >
1270 {__("Signed Consents")}
1271 </button>
1272 </nav>
1273 </div>
1274
1275 {/* Tab Content - with gap */}
1276 <div className="mt-4">
1277 {activeTab === "forms" ? <ConsentFormsList /> : <SignedConsentsList />}
1278 </div>
1279 </div>
1280 );
1281 };
1282
1283 export default TripConsent;
1284