PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
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.14.2, at resources/js/pages/Enquiries.tsx

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