PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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 / Enquiries.tsx

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

993 lines 32.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Enquiries Page
3 * Clean, minimal SaaS-style enquiries management page using shared components
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 Search,
10 X,
11 ArrowUpDown,
12 ArrowUp,
13 ArrowDown,
14 Eye,
15 Edit,
16 Trash2,
17 Mail,
18 Phone,
19 MessageSquare,
20 MapPin,
21 Send,
22 Loader2,
23 } from "lucide-react";
24 import { __ } from "../lib/i18n";
25 import { apiService } from "../lib/api-client";
26 import { formatDate as formatDateUtil } from "../lib/dateFormat";
27 import { usePermissions } from "../hooks/usePermissions";
28 import { Button } from "../components/ui/button";
29 import { Input } from "../components/ui/input";
30 import { Select } from "../components/ui/select";
31 import { PageHeader } from "../components/common/PageHeader";
32 import { Card, CardContent } from "../components/ui/card";
33 import { Badge } from "../components/ui/badge";
34 import { useNavigate } from "../hooks/useNavigate";
35 import {
36 Pagination,
37 Table as SharedTable,
38 BulkActionToolbar,
39 } from "../components/shared";
40 import { getErrorContext } from "../lib/errors";
41 import { Modal } from "../components/ui/modal";
42 import { useToast } from "../components/ui/toast";
43
44 interface Enquiry {
45 id: number;
46 name: string;
47 email: string;
48 phone: string;
49 trip_title?: string;
50 trip_id?: number;
51 message: string;
52 travelers_count?: number;
53 metadata?: any;
54 travel_date?: string;
55 status:
56 | "new"
57 | "pending"
58 | "responded"
59 | "closed"
60 | "converted"
61 | "read"
62 | "archived"
63 | "spam"
64 | "trash"
65 | "";
66 created_at: string;
67 responded_at?: string;
68 response_notes?: string;
69 }
70
71 const Enquiries: React.FC = () => {
72 const [searchTerm, setSearchTerm] = useState("");
73 const [statusFilter, setStatusFilter] = useState("all");
74 const [sortBy, setSortBy] = useState("created_at");
75 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
76 const [page, setPage] = useState(1);
77 const [respondDialogOpen, setRespondDialogOpen] = useState(false);
78 const [selectedEnquiry, setSelectedEnquiry] = useState<Enquiry | null>(null);
79 const [responseMessage, setResponseMessage] = useState("");
80 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
81 const [enquiryToDelete, setEnquiryToDelete] = useState<Enquiry | null>(null);
82 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
83 const [bulkAction, setBulkAction] = useState("");
84 const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
85 const [bulkApplying, setBulkApplying] = useState(false);
86 const [pendingBulkAction, setPendingBulkAction] = useState<string | null>(
87 null,
88 );
89 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
90 const [visibleColumns, setVisibleColumns] = useState(() => {
91 if (typeof window === "undefined") {
92 return {
93 customer: true,
94 trip: true,
95 message: true,
96 travelers: true,
97 preferred_date: true,
98 status: true,
99 date: true,
100 };
101 }
102
103 const saved = window.localStorage.getItem(
104 "yatra-enquiries-visible-columns",
105 );
106 return saved
107 ? JSON.parse(saved)
108 : {
109 customer: true,
110 trip: true,
111 message: true,
112 travelers: true,
113 preferred_date: true,
114 status: true,
115 date: true,
116 };
117 });
118
119 const bulkActionOptions = useMemo(() => {
120 if (statusFilter === "trash") {
121 return [{ value: "delete", label: __("Delete Permanently", "yatra") }];
122 }
123
124 if (statusFilter === "spam") {
125 return [
126 { value: "mark_trash", label: __("Move to Trash", "yatra") },
127 { value: "delete", label: __("Delete Permanently", "yatra") },
128 ];
129 }
130
131 return [
132 { value: "mark_spam", label: __("Mark as Spam", "yatra") },
133 { value: "mark_trash", label: __("Move to Trash", "yatra") },
134 { value: "delete", label: __("Delete Permanently", "yatra") },
135 ];
136 }, [statusFilter]);
137
138 const queryClient = useQueryClient();
139 const { can } = usePermissions();
140 const { navigate } = useNavigate();
141 const { showToast } = useToast();
142
143 // Build query params
144 const queryParams = useMemo(() => {
145 const params: Record<string, any> = {
146 page,
147 per_page: 10,
148 orderby: sortBy,
149 order: sortOrder,
150 };
151
152 if (searchTerm) {
153 params.search = searchTerm;
154 }
155
156 if (statusFilter !== "all") {
157 params.status = statusFilter;
158 }
159
160 return params;
161 }, [searchTerm, statusFilter, sortBy, sortOrder, page]);
162
163 // Enquiry stats for status tabs
164 const { data: statsData } = useQuery({
165 queryKey: ["enquiries-stats"],
166 queryFn: async () => {
167 return await apiService.getEnquiriesStats();
168 },
169 enabled: can("yatra_view_bookings"),
170 });
171
172 const statusCounts = {
173 all: (statsData as any)?.total || 0,
174 new: ((statsData as any)?.by_status?.new?.count as number) || 0,
175 pending: ((statsData as any)?.by_status?.pending?.count as number) || 0,
176 responded: ((statsData as any)?.by_status?.responded?.count as number) || 0,
177 converted: ((statsData as any)?.by_status?.converted?.count as number) || 0,
178 closed: ((statsData as any)?.by_status?.closed?.count as number) || 0,
179 spam: ((statsData as any)?.by_status?.spam?.count as number) || 0,
180 trash: ((statsData as any)?.by_status?.trash?.count as number) || 0,
181 };
182
183 // Bulk actions
184 const handleBulkApply = () => {
185 if (!bulkAction || selectedIds.length === 0) {
186 return;
187 }
188 setPendingBulkAction(bulkAction);
189 setBulkConfirmOpen(true);
190 };
191
192 const confirmBulkApply = async () => {
193 if (!pendingBulkAction || selectedIds.length === 0) {
194 setBulkConfirmOpen(false);
195 return;
196 }
197 setBulkApplying(true);
198 try {
199 if (["delete", "mark_spam", "mark_trash"].includes(pendingBulkAction)) {
200 await apiService.bulkEnquiriesAction(pendingBulkAction, selectedIds);
201 queryClient.invalidateQueries({ queryKey: ["enquiries"] });
202 }
203 } catch (error) {
204 console.error("Bulk enquiry action error", error);
205 } finally {
206 setBulkApplying(false);
207 setBulkConfirmOpen(false);
208 setPendingBulkAction(null);
209 setSelectedIds([]);
210 setBulkAction("");
211 }
212 };
213
214 const toggleColumn = (columnKey: string) => {
215 const newVisible = {
216 ...visibleColumns,
217 [columnKey]: !visibleColumns[columnKey as keyof typeof visibleColumns],
218 };
219 setVisibleColumns(newVisible);
220 if (typeof window !== "undefined") {
221 window.localStorage.setItem(
222 "yatra-enquiries-visible-columns",
223 JSON.stringify(newVisible),
224 );
225 }
226 };
227
228 const columnOptions = [
229 {
230 key: "customer",
231 label: __("Customer", "yatra"),
232 visible: visibleColumns.customer,
233 },
234 { key: "trip", label: __("Trip", "yatra"), visible: visibleColumns.trip },
235 {
236 key: "message",
237 label: __("Message", "yatra"),
238 visible: visibleColumns.message,
239 },
240 {
241 key: "travelers",
242 label: __("Travelers", "yatra"),
243 visible: visibleColumns.travelers,
244 },
245 {
246 key: "preferred_date",
247 label: __("Preferred Date", "yatra"),
248 visible: visibleColumns.preferred_date,
249 },
250 {
251 key: "status",
252 label: __("Status", "yatra"),
253 visible: visibleColumns.status,
254 },
255 { key: "date", label: __("Date", "yatra"), visible: visibleColumns.date },
256 ];
257
258 const actions = [
259 {
260 key: "view",
261 label: __("View", "yatra"),
262 icon: <Eye className="w-4 h-4" />,
263 onClick: (enquiry: Enquiry) => handleView(enquiry),
264 condition: () => can("yatra_view_bookings"),
265 },
266 {
267 key: "respond",
268 label: __("Respond", "yatra"),
269 icon: <Send className="w-4 h-4" />,
270 onClick: (enquiry: Enquiry) => handleRespond(enquiry),
271 // Only allow respond on active enquiries (not closed, spam, trash, or archived)
272 condition: (enquiry: Enquiry) =>
273 can("yatra_edit_bookings") &&
274 !["closed", "spam", "trash", "archived"].includes(enquiry.status),
275 },
276 {
277 key: "edit",
278 label: __("Edit", "yatra"),
279 icon: <Edit className="w-4 h-4" />,
280 onClick: (enquiry: Enquiry) => handleEdit(enquiry),
281 condition: () => can("yatra_edit_bookings"),
282 },
283 {
284 key: "delete",
285 label: __("Delete", "yatra"),
286 icon: <Trash2 className="w-4 h-4" />,
287 onClick: (enquiry: Enquiry) => handleDelete(enquiry),
288 variant: "destructive" as const,
289 condition: () => can("yatra_delete_bookings"),
290 },
291 ];
292
293 // Fetch enquiries from API
294 const { data, isLoading, error, refetch } = useQuery({
295 queryKey: ["enquiries", queryParams],
296 queryFn: async () => {
297 // Check URL parameter for error simulation (for testing)
298 const urlParams = new URLSearchParams(window.location.search);
299 if (urlParams.get("simulate_error") === "true") {
300 throw new Error(
301 "Simulated API error for testing error UI functionality",
302 );
303 }
304
305 const paramsObj: Record<string, any> = {};
306 Object.entries(queryParams).forEach(([key, value]) => {
307 if (value !== undefined && value !== null) {
308 paramsObj[key] = value;
309 }
310 });
311
312 const response = await apiService.getEnquiries(paramsObj);
313
314 return {
315 data: response.data || [],
316 total: response.meta?.total || response.total || 0,
317 page: response.meta?.page || response.page || 1,
318 per_page: response.meta?.per_page || response.per_page || 10,
319 };
320 },
321 enabled: can("yatra_view_bookings"),
322 });
323
324 // Delete mutation
325 const deleteMutation = useMutation({
326 mutationFn: async (id: number) => {
327 await apiService.deleteEnquiry(id);
328 },
329 onSuccess: () => {
330 queryClient.invalidateQueries({ queryKey: ["enquiries"] });
331 setDeleteDialogOpen(false);
332 setEnquiryToDelete(null);
333 },
334 });
335
336 // Respond mutation - sends email to customer
337 const respondMutation = useMutation({
338 mutationFn: async ({ id, message }: { id: number; message: string }) => {
339 return await apiService.respondToEnquiry(id, { response: message });
340 },
341 onSuccess: () => {
342 queryClient.invalidateQueries({ queryKey: ["enquiries"] });
343 setRespondDialogOpen(false);
344 setSelectedEnquiry(null);
345 setResponseMessage("");
346 showToast(__("Response sent successfully.", "yatra"), "success");
347 },
348 });
349
350 const enquiriesResponse: any = data;
351 const enquiries: Enquiry[] =
352 enquiriesResponse?.data || enquiriesResponse?.enquiries || [];
353 const total =
354 enquiriesResponse?.meta?.total ??
355 enquiriesResponse?.total ??
356 enquiries.length ??
357 0;
358 const perPage = enquiriesResponse?.meta?.per_page ?? 10;
359 const totalPages = Math.max(1, Math.ceil(total / perPage));
360
361 // Enhanced error handling
362 const errorContext = getErrorContext(error);
363 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
364 const derivedErrorDetails =
365 errorContext.details ||
366 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
367 (error ? String(error?.message || error) : undefined);
368 const isEnquiriesError = !!error || !!apiErrorMessage;
369
370 const formatDate = (dateString: string) => {
371 return formatDateUtil(dateString);
372 };
373
374 const getStatusBadge = (status: string) => {
375 const statusMap: Record<string, { className: string; label: string }> = {
376 new: {
377 className:
378 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
379 label: __("New", "yatra"),
380 },
381 pending: {
382 className:
383 "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400",
384 label: __("Pending", "yatra"),
385 },
386 responded: {
387 className:
388 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
389 label: __("Responded", "yatra"),
390 },
391 converted: {
392 className:
393 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
394 label: __("Converted", "yatra"),
395 },
396 closed: {
397 className:
398 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
399 label: __("Closed", "yatra"),
400 },
401 spam: {
402 className:
403 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
404 label: __("Spam", "yatra"),
405 },
406 trash: {
407 className:
408 "bg-gray-100 text-gray-700 dark:bg-gray-900/40 dark:text-gray-300 line-through",
409 label: __("Trash", "yatra"),
410 },
411 };
412
413 const statusInfo = statusMap[status] || {
414 className:
415 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
416 label: status,
417 };
418
419 return (
420 <Badge className={`text-xs ${statusInfo.className}`}>
421 {statusInfo.label}
422 </Badge>
423 );
424 };
425
426 const handleView = (enquiry: Enquiry) => {
427 navigate({ subpage: "enquiries", action: "view", id: enquiry.id });
428 };
429
430 const handleEdit = (enquiry: Enquiry) => {
431 navigate({ subpage: "enquiries", action: "edit", id: enquiry.id });
432 };
433
434 const handleDelete = (enquiry: Enquiry) => {
435 setEnquiryToDelete(enquiry);
436 setDeleteDialogOpen(true);
437 };
438
439 const confirmDelete = () => {
440 if (enquiryToDelete) {
441 deleteMutation.mutate(enquiryToDelete.id);
442 }
443 };
444
445 const handleRespond = (enquiry: Enquiry) => {
446 setSelectedEnquiry(enquiry);
447 setResponseMessage("");
448 setRespondDialogOpen(true);
449 };
450
451 const sendResponse = () => {
452 if (selectedEnquiry && responseMessage.trim()) {
453 respondMutation.mutate({
454 id: selectedEnquiry.id,
455 message: responseMessage,
456 });
457 }
458 };
459
460 const handleSort = (field: string) => {
461 if (sortBy === field) {
462 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
463 } else {
464 setSortBy(field);
465 setSortOrder("desc");
466 }
467 };
468
469 const getSortIcon = (field: string) => {
470 if (sortBy !== field) {
471 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
472 }
473 return sortOrder === "asc" ? (
474 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
475 ) : (
476 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
477 );
478 };
479
480 const hasFilters =
481 searchTerm ||
482 statusFilter !== "all" ||
483 sortBy !== "created_at" ||
484 sortOrder !== "desc";
485
486 const handleResetFilters = () => {
487 setSearchTerm("");
488 setStatusFilter("all");
489 setSortBy("created_at");
490 setSortOrder("desc");
491 setPage(1);
492 };
493
494 return (
495 <div className="space-y-3">
496 <PageHeader
497 title={__("Enquiries", "yatra")}
498 description={__("Manage customer enquiries and inquiries", "yatra")}
499 />
500
501 {/* Filters, Search, and Sorting - Always Visible */}
502 <Card>
503 <CardContent className="p-3">
504 <div className="flex flex-col md:flex-row gap-2 items-stretch md:items-center">
505 {/* Search */}
506 <div className="flex-1 relative">
507 <Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
508 <Input
509 type="text"
510 placeholder={__("Search enquiries...", "yatra")}
511 value={searchTerm}
512 onChange={(e) => setSearchTerm(e.target.value)}
513 className="pl-9"
514 />
515 </div>
516
517 {/* Status Filter */}
518 <Select
519 value={statusFilter}
520 onChange={(e) => setStatusFilter(e.target.value)}
521 className="w-full md:w-40"
522 >
523 <option value="all">{__("All Status", "yatra")}</option>
524 <option value="new">{__("New", "yatra")}</option>
525 <option value="pending">{__("Pending", "yatra")}</option>
526 <option value="responded">{__("Responded", "yatra")}</option>
527 <option value="converted">{__("Converted", "yatra")}</option>
528 <option value="closed">{__("Closed", "yatra")}</option>
529 <option value="spam">{__("Spam", "yatra")}</option>
530 <option value="trash">{__("Trash", "yatra")}</option>
531 </Select>
532
533 {/* Sort By */}
534 <Select
535 value={sortBy}
536 onChange={(e) => setSortBy(e.target.value)}
537 className="w-full md:w-40"
538 >
539 <option value="created_at">{__("Date", "yatra")}</option>
540 <option value="name">{__("Name", "yatra")}</option>
541 <option value="email">{__("Email", "yatra")}</option>
542 <option value="trip_title">{__("Trip", "yatra")}</option>
543 <option value="status">{__("Status", "yatra")}</option>
544 </Select>
545
546 {/* Sort Order */}
547 <Button
548 variant="outline"
549 onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
550 className="px-3 flex items-center gap-1.5"
551 title={
552 sortOrder === "asc"
553 ? __("Ascending", "yatra")
554 : __("Descending", "yatra")
555 }
556 >
557 {sortOrder === "asc" ? (
558 <ArrowUp className="w-4 h-4" />
559 ) : (
560 <ArrowDown className="w-4 h-4" />
561 )}
562 <span className="text-xs">
563 {sortOrder === "asc" ? __("Asc", "yatra") : __("Desc", "yatra")}
564 </span>
565 </Button>
566
567 {/* Reset Button */}
568 {hasFilters && (
569 <Button
570 variant="outline"
571 onClick={handleResetFilters}
572 className="flex items-center gap-2"
573 >
574 <X className="w-4 h-4" />
575 {__("Reset", "yatra")}
576 </Button>
577 )}
578 </div>
579 </CardContent>
580 </Card>
581
582 {/* Bulk actions toolbar - shared component */}
583 <BulkActionToolbar
584 selectedIds={selectedIds}
585 bulkAction={bulkAction}
586 setBulkAction={setBulkAction}
587 onApply={handleBulkApply}
588 onClearSelection={() => setSelectedIds([])}
589 statusFilter={statusFilter}
590 setStatusFilter={(value: string) => {
591 setStatusFilter(value);
592 setPage(1);
593 setSelectedIds([]);
594 setBulkAction("");
595 }}
596 statusOptions={[
597 { key: "all", label: __("All", "yatra"), count: statusCounts.all },
598 { key: "new", label: __("New", "yatra"), count: statusCounts.new },
599 {
600 key: "pending",
601 label: __("Pending", "yatra"),
602 count: statusCounts.pending,
603 },
604 {
605 key: "responded",
606 label: __("Responded", "yatra"),
607 count: statusCounts.responded,
608 },
609 {
610 key: "converted",
611 label: __("Converted", "yatra"),
612 count: statusCounts.converted,
613 },
614 {
615 key: "closed",
616 label: __("Closed", "yatra"),
617 count: statusCounts.closed,
618 },
619 { key: "spam", label: __("Spam", "yatra"), count: statusCounts.spam },
620 {
621 key: "trash",
622 label: __("Trash", "yatra"),
623 count: statusCounts.trash,
624 },
625 ]}
626 showColumnsDropdown={showColumnsDropdown}
627 setShowColumnsDropdown={setShowColumnsDropdown}
628 columnOptions={columnOptions}
629 onToggleColumn={toggleColumn}
630 bulkMutationPending={deleteMutation.isPending}
631 totalItems={enquiries.length}
632 bulkActionOptions={bulkActionOptions}
633 />
634
635 <Card>
636 <CardContent className="p-0 overflow-x-auto">
637 <SharedTable
638 data={enquiries}
639 columns={[
640 visibleColumns.customer && {
641 key: "customer",
642 label: __("Customer", "yatra"),
643 render: (enquiry: Enquiry) => (
644 <div>
645 <div className="font-medium text-gray-900 dark:text-white text-sm">
646 {enquiry.name}
647 </div>
648 <div className="text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-0.5">
649 <div className="flex items-center gap-1">
650 <Mail className="w-3 h-3" />
651 {enquiry.email}
652 </div>
653 {enquiry.phone && (
654 <div className="flex items-center gap-1">
655 <Phone className="w-3 h-3" />
656 {enquiry.phone}
657 </div>
658 )}
659 </div>
660 </div>
661 ),
662 },
663 visibleColumns.trip && {
664 key: "trip",
665 label: __("Trip", "yatra"),
666 render: (enquiry: Enquiry) =>
667 enquiry.trip_title ? (
668 <div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
669 <MapPin className="w-3.5 h-3.5" />
670 <span>{enquiry.trip_title}</span>
671 </div>
672 ) : (
673 <span className="text-sm text-gray-400 dark:text-gray-500 italic">
674 {__("General enquiry", "yatra")}
675 </span>
676 ),
677 },
678 visibleColumns.message && {
679 key: "message",
680 label: __("Message", "yatra"),
681 render: (enquiry: Enquiry) => (
682 <div className="flex items-start gap-2">
683 <MessageSquare className="w-4 h-4 text-gray-400 mt-0.5 flex-shrink-0" />
684 <p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
685 {enquiry.message}
686 </p>
687 </div>
688 ),
689 },
690 visibleColumns.travelers && {
691 key: "travelers",
692 label: __("Travelers", "yatra"),
693 render: (enquiry: Enquiry) => (
694 <span className="text-gray-600 dark:text-gray-400 text-sm">
695 {enquiry.travelers_count || "-"}
696 </span>
697 ),
698 },
699 visibleColumns.preferred_date && {
700 key: "preferred_date",
701 label: __("Preferred Date", "yatra"),
702 render: (enquiry: Enquiry) => (
703 <span className="text-gray-500 dark:text-gray-400 text-sm">
704 {enquiry.travel_date
705 ? formatDate(enquiry.travel_date)
706 : "-"}
707 </span>
708 ),
709 },
710 visibleColumns.status && {
711 key: "status",
712 label: __("Status", "yatra"),
713 render: (enquiry: Enquiry) => getStatusBadge(enquiry.status),
714 },
715 visibleColumns.date && {
716 key: "date",
717 label: __("Date", "yatra"),
718 render: (enquiry: Enquiry) => (
719 <span className="text-gray-500 dark:text-gray-400 text-sm">
720 {formatDate(enquiry.created_at)}
721 </span>
722 ),
723 },
724 ].filter(Boolean)}
725 actions={actions}
726 isLoading={isLoading}
727 isError={isEnquiriesError}
728 errorText={__("Error loading enquiries")}
729 errorDescription={__(
730 "We couldn't connect to the enquiries service. Please refresh or try again shortly.",
731 )}
732 errorDetails={derivedErrorDetails}
733 errorRequestInfo={errorContext.requestInfo}
734 onRetry={() => refetch()}
735 emptyText={__("No enquiries found")}
736 emptyDescription={
737 hasFilters
738 ? __(
739 "Try adjusting your search or filter criteria to find what you're looking for.",
740 "Try adjusting your search or filter criteria to find what you're looking for.",
741 )
742 : __(
743 "When customers submit enquiries, they will appear here.",
744 "yatra",
745 )
746 }
747 onSort={handleSort}
748 getSortIcon={getSortIcon}
749 selectedItemIds={selectedIds}
750 onSelectItem={(id: string | number, checked: boolean) => {
751 if (checked) {
752 setSelectedIds((prev) =>
753 prev.includes(id) ? prev : [...prev, id],
754 );
755 } else {
756 setSelectedIds((prev) =>
757 prev.filter((existingId) => existingId !== id),
758 );
759 }
760 }}
761 onSelectAll={(checked: boolean) => {
762 if (checked) {
763 setSelectedIds(enquiries.map((e) => e.id));
764 } else {
765 setSelectedIds([]);
766 }
767 }}
768 isAllSelected={
769 enquiries.length > 0 && selectedIds.length === enquiries.length
770 }
771 getItemId={(enquiry: Enquiry) => enquiry.id}
772 capability="yatra_view_bookings"
773 skeletonRows={5}
774 />
775 </CardContent>
776 </Card>
777
778 {/* Pagination - shared component, no extra card background */}
779 {total > 0 && (
780 <Pagination
781 currentPage={page}
782 totalPages={totalPages}
783 totalItems={total}
784 itemsPerPage={perPage}
785 onPageChange={setPage}
786 itemName={__("enquiries", "yatra")}
787 />
788 )}
789
790 {/* Bulk action confirmation modal */}
791 <Modal
792 isOpen={bulkConfirmOpen}
793 onClose={() => {
794 if (!bulkApplying) {
795 setBulkConfirmOpen(false);
796 setPendingBulkAction(null);
797 }
798 }}
799 title={__("Apply Bulk Action", "yatra")}
800 description={
801 pendingBulkAction
802 ? __(
803 `Are you sure you want to apply "${pendingBulkAction}" to the selected enquiries?`,
804 `Are you sure you want to apply "${pendingBulkAction}" to the selected enquiries?`,
805 )
806 : undefined
807 }
808 size="sm"
809 panelClassName="yatra-model-ui"
810 bodyClassName="p-0"
811 footer={
812 <div className="flex gap-2 justify-end">
813 <Button
814 variant="outline"
815 onClick={() => {
816 setBulkConfirmOpen(false);
817 setPendingBulkAction(null);
818 }}
819 disabled={bulkApplying}
820 >
821 {__("Cancel", "yatra")}
822 </Button>
823 <Button onClick={confirmBulkApply} disabled={bulkApplying}>
824 {bulkApplying ? (
825 <>
826 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
827 {__("Applying...", "yatra")}
828 </>
829 ) : (
830 __("Apply", "yatra")
831 )}
832 </Button>
833 </div>
834 }
835 >
836 <div aria-hidden="true" />
837 </Modal>
838
839 {/* Delete Confirmation Modal (shared) */}
840 <Modal
841 isOpen={deleteDialogOpen}
842 onClose={() => {
843 if (!deleteMutation.isPending) {
844 setDeleteDialogOpen(false);
845 setEnquiryToDelete(null);
846 }
847 }}
848 title={__("Delete Enquiry", "yatra")}
849 description={
850 enquiryToDelete
851 ? __(
852 `Are you sure you want to delete the enquiry from "${enquiryToDelete.name}"? This action cannot be undone.`,
853 `Are you sure you want to delete the enquiry from "${enquiryToDelete.name}"? This action cannot be undone.`,
854 )
855 : __("Are you sure you want to delete this enquiry?", "yatra")
856 }
857 size="sm"
858 panelClassName="yatra-model-ui"
859 bodyClassName="p-0"
860 footer={
861 <div className="flex gap-2 justify-end">
862 <Button
863 variant="outline"
864 onClick={() => {
865 setDeleteDialogOpen(false);
866 setEnquiryToDelete(null);
867 }}
868 disabled={deleteMutation.isPending}
869 >
870 {__("Cancel", "yatra")}
871 </Button>
872 <Button
873 variant="destructive"
874 onClick={confirmDelete}
875 disabled={deleteMutation.isPending}
876 >
877 {deleteMutation.isPending ? (
878 <>
879 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
880 {__("Deleting...", "yatra")}
881 </>
882 ) : (
883 __("Delete", "yatra")
884 )}
885 </Button>
886 </div>
887 }
888 >
889 <div />
890 </Modal>
891
892 {/* Respond Dialog via shared Modal */}
893 <Modal
894 isOpen={respondDialogOpen && !!selectedEnquiry}
895 onClose={() => {
896 if (!respondMutation.isPending) setRespondDialogOpen(false);
897 }}
898 title={__("Respond to Enquiry", "yatra")}
899 description={
900 selectedEnquiry ? (
901 <span>
902 {__("Send a response to", "yatra")}{" "}
903 <strong>{selectedEnquiry.name}</strong>
904 </span>
905 ) : null
906 }
907 size="md"
908 panelClassName="yatra-model-ui"
909 footer={
910 <div className="flex gap-2 justify-end">
911 <Button
912 variant="outline"
913 onClick={() => setRespondDialogOpen(false)}
914 disabled={respondMutation.isPending}
915 >
916 {__("Cancel", "yatra")}
917 </Button>
918 <Button
919 onClick={sendResponse}
920 disabled={respondMutation.isPending || !responseMessage.trim()}
921 >
922 {respondMutation.isPending ? (
923 <>
924 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
925 {__("Sending...", "yatra")}
926 </>
927 ) : (
928 <>
929 <Send className="w-4 h-4 mr-2" />
930 {__("Send Response", "yatra")}
931 </>
932 )}
933 </Button>
934 </div>
935 }
936 >
937 {selectedEnquiry && (
938 <div className="space-y-4">
939 {/* Customer Info */}
940 <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 space-y-2">
941 <div className="flex items-center gap-2 text-sm">
942 <Mail className="w-4 h-4 text-gray-400" />
943 <span className="text-gray-600 dark:text-gray-400">
944 {selectedEnquiry.email}
945 </span>
946 </div>
947 {selectedEnquiry.trip_title && (
948 <div className="flex items-center gap-2 text-sm">
949 <MapPin className="w-4 h-4 text-gray-400" />
950 <span className="text-gray-600 dark:text-gray-400">
951 {selectedEnquiry.trip_title}
952 </span>
953 </div>
954 )}
955 <div className="text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2 mt-2">
956 <strong>{__("Original Message", "yatra")}:</strong>
957 <p className="mt-1 text-gray-600 dark:text-gray-400">
958 {selectedEnquiry.message}
959 </p>
960 </div>
961 </div>
962
963 {/* Response Message */}
964 <div>
965 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
966 {__("Your Response", "yatra")}{" "}
967 <span className="text-red-500">*</span>
968 </label>
969 <textarea
970 value={responseMessage}
971 onChange={(e) => setResponseMessage(e.target.value)}
972 placeholder={__("Type your response here...", "yatra")}
973 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
974 rows={5}
975 disabled={respondMutation.isPending}
976 />
977 </div>
978
979 {respondMutation.isError && (
980 <div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600 dark:text-red-400">
981 {respondMutation.error?.message ||
982 __("Failed to send response. Please try again.", "yatra")}
983 </div>
984 )}
985 </div>
986 )}
987 </Modal>
988 </div>
989 );
990 };
991
992 export default Enquiries;
993