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 / Enquiries.tsx

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

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