PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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.10, at resources/js/pages/TripConsent.tsx

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