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

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