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

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

870 lines 32.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Additional Services Page
3 *
4 * Premium module for managing trip add-ons and extras.
5 * This page only loads when Yatra Pro is active and module is enabled.
6 * Premium upgrade content is handled by premium-pages/AdditionalServices.tsx
7 *
8 * @package Yatra
9 * @since 3.0.0
10 */
11
12 import React, { useState, useMemo, useEffect } from "react";
13 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
14 import { Card, CardContent } from "../components/ui/card";
15 import { Badge } from "../components/ui/badge";
16 import {
17 Pagination,
18 SearchFilterToolbar,
19 BulkActionToolbar,
20 Table as SharedTable,
21 } from "../components/shared";
22 import { getDefaultBulkStatusOptions } from "../components/shared/bulkStatusOptions";
23 import { usePermissions } from "../hooks/usePermissions";
24 import { IconSelector } from "../components/ui/icon-selector";
25 import type { IconPickerValue } from "../components/ui/icon-picker";
26 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
27 import { ConditionalRender } from "../components/ui/conditional-render";
28 import { useToast } from "../components/ui/toast";
29 import { apiClient } from "../lib/api-client";
30 import { __ } from "../lib/i18n";
31 import { formatYatraMoney } from "../lib/currency-display";
32 import { Button } from "../components/ui/button";
33 import { PageHeader } from "../components/common/PageHeader";
34 import {
35 Edit,
36 Trash2,
37 Package,
38 CheckCircle,
39 FileText,
40 RotateCcw,
41 Plus,
42 } from "lucide-react";
43 import PremiumUpgradeCard from "./premium-pages/AdditionalServices";
44
45 // Types
46 interface TripInfo {
47 id: number;
48 title: string;
49 }
50
51 interface AdditionalService {
52 id: number;
53 name: string;
54 description: string;
55 price: number;
56 price_type: "fixed" | "percentage";
57 price_per: "person" | "booking" | "day";
58 icon?: IconPickerValue | null;
59 status: "publish" | "draft" | "trash";
60 sort_order: number;
61 applicable_to?: "all" | "specific_trips";
62 trip_ids?: number[];
63 trips?: TripInfo[];
64 trips_count: number;
65 bookings_count: number;
66 total_revenue: number;
67 is_required?: boolean | number | string;
68 created_at: string;
69 updated_at: string;
70 }
71
72 interface ServicesResponse {
73 data: AdditionalService[];
74 total: number;
75 page: number;
76 per_page: number;
77 }
78
79 // Check if module is available (Pro active)
80 const isModuleAvailable = (): boolean => {
81 const yatraAdmin = (window as any)?.yatraAdmin;
82 return Boolean(yatraAdmin?.isPro);
83 };
84
85 // Main Component
86 const AdditionalServices: React.FC = () => {
87 // Module-availability gate. Evaluated up front; the actual early
88 // return runs AFTER every hook below so React's hook-order
89 // invariant holds (rules-of-hooks).
90 const moduleAvailable = isModuleAvailable();
91
92 const [searchTerm, setSearchTerm] = useState("");
93 const [statusFilter, setStatusFilter] = useState("all");
94 const [sortBy, setSortBy] = useState("name");
95 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
96 const [page, setPage] = useState(1);
97 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
98 const [bulkAction, setBulkAction] = useState("");
99 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
100 const [permanentDeleteConfirm, setPermanentDeleteConfirm] = useState<{
101 isOpen: boolean;
102 service: AdditionalService | null;
103 }>({
104 isOpen: false,
105 service: null,
106 });
107
108 // Column visibility state with localStorage
109 const [visibleColumns, setVisibleColumns] = useState(() => {
110 const saved = localStorage.getItem("yatra-additional-services-columns");
111 return saved
112 ? JSON.parse(saved)
113 : {
114 name: true,
115 price: true,
116 price_type: true,
117 price_per: true,
118 trips: true,
119 status: true,
120 created_at: false,
121 updated_at: false,
122 };
123 });
124
125 const { showToast } = useToast();
126 const queryClient = useQueryClient();
127 const { can } = usePermissions();
128
129 // Toggle column visibility
130 const toggleColumn = (columnKey: string) => {
131 const newVisibleColumns = {
132 ...visibleColumns,
133 [columnKey]: !visibleColumns[columnKey],
134 };
135 setVisibleColumns(newVisibleColumns);
136 localStorage.setItem(
137 "yatra-additional-services-columns",
138 JSON.stringify(newVisibleColumns),
139 );
140 };
141
142 // Build query params
143 const queryParams = useMemo(() => {
144 const params: Record<string, any> = {
145 page,
146 per_page: 10,
147 orderby: sortBy,
148 order: sortOrder,
149 };
150 if (searchTerm) params.search = searchTerm;
151 if (statusFilter !== "all") params.status = statusFilter;
152 return params;
153 }, [searchTerm, statusFilter, sortBy, sortOrder, page]);
154
155 // Fetch status counts
156 const { data: statsData } = useQuery({
157 queryKey: ["additional-services-stats"],
158 queryFn: async () => {
159 try {
160 const response = await apiClient.get("/additional-services/stats");
161 return response;
162 } catch {
163 return { all: 0, publish: 0, draft: 0, trash: 0 };
164 }
165 },
166 enabled: isModuleAvailable(),
167 });
168
169 // Fetch services
170 const { data, isLoading, error } = useQuery<ServicesResponse>({
171 queryKey: ["additional-services", queryParams],
172 queryFn: async () => {
173 const response = await apiClient.get("/additional-services", {
174 params: queryParams,
175 });
176 return response;
177 },
178 enabled: isModuleAvailable(),
179 });
180
181 // eslint-disable-next-line react-hooks/exhaustive-deps
182 const services = data?.data || [];
183 const total = data?.total || 0;
184 const totalPages = Math.ceil(total / 10);
185
186 // Status counts
187 const statusCounts = useMemo(() => {
188 if (statsData) {
189 return {
190 all: statsData.all ?? 0,
191 publish: statsData.publish ?? 0,
192 draft: statsData.draft ?? 0,
193 trash: statsData.trash ?? 0,
194 };
195 }
196 return { all: 0, publish: 0, draft: 0, trash: 0 };
197 }, [statsData]);
198
199 // Bulk actions mutation
200 const bulkMutation = useMutation({
201 mutationFn: async ({
202 action,
203 ids,
204 }: {
205 action: string;
206 ids: (string | number)[];
207 }) => {
208 return await apiClient.post("/additional-services/bulk", { action, ids });
209 },
210 onSuccess: () => {
211 queryClient.invalidateQueries({ queryKey: ["additional-services"] });
212 queryClient.invalidateQueries({
213 queryKey: ["additional-services-stats"],
214 });
215 showToast(__("Action completed successfully"), "success");
216 setSelectedIds([]);
217 setBulkAction("");
218 },
219 onError: () => {
220 showToast(__("Failed to perform action"), "error");
221 },
222 });
223
224 const navigateToForm = (action: "create" | "edit", id?: number) => {
225 const params = new URLSearchParams(window.location.search);
226 params.set("subpage", "trips");
227 params.set("tab", "additional-services");
228 params.set("action", action);
229 if (id) params.set("id", id.toString());
230 window.history.pushState({}, "", `${window.location.pathname}?${params}`);
231 window.dispatchEvent(new PopStateEvent("popstate"));
232 };
233
234 const formatPrice = (
235 price: number | string,
236 priceType: string,
237 pricePer: string,
238 ) => {
239 const currency = (window as any)?.yatraAdmin?.currency || "USD";
240 const numPrice = Number(price) || 0;
241 if (priceType === "percentage") return `${numPrice}%`;
242 const perLabel =
243 { person: __("/person"), booking: __("/booking"), day: __("/day") }[
244 pricePer
245 ] || "";
246 return `${formatYatraMoney(numPrice, currency, { zeroAsUnknown: false })}${perLabel}`;
247 };
248
249 const handleEdit = (service: AdditionalService) => {
250 navigateToForm("edit", service.id);
251 };
252
253 const handlePermanentDelete = (service: AdditionalService) => {
254 setPermanentDeleteConfirm({ isOpen: true, service });
255 };
256
257 const confirmPermanentDelete = () => {
258 if (permanentDeleteConfirm.service) {
259 bulkMutation.mutate({
260 action: "delete",
261 ids: [permanentDeleteConfirm.service.id],
262 });
263 setPermanentDeleteConfirm({ isOpen: false, service: null });
264 }
265 };
266
267 const handleSort = (field: string) => {
268 if (sortBy === field) {
269 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
270 } else {
271 setSortBy(field);
272 setSortOrder("asc");
273 }
274 };
275
276 const getSortIcon = (field: string) => {
277 if (sortBy !== field) {
278 return null;
279 }
280 return sortOrder === "asc" ? "↑" : "↓";
281 };
282
283 const handleBulkApply = () => {
284 if (!bulkAction) {
285 showToast(__("Select a bulk action first."), "warning");
286 return;
287 }
288 if (selectedIds.length === 0) {
289 showToast(__("Select at least one service."), "warning");
290 return;
291 }
292 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
293 };
294
295 const handleResetFilters = () => {
296 setSearchTerm("");
297 setStatusFilter("all");
298 setSortBy("name");
299 setSortOrder("asc");
300 setPage(1);
301 setSelectedIds([]);
302 setBulkAction("");
303 };
304
305 const hasFilters =
306 searchTerm ||
307 statusFilter !== "all" ||
308 sortBy !== "name" ||
309 sortOrder !== "asc";
310
311 // Keep selection in sync with current page data
312 useEffect(() => {
313 setSelectedIds((prev) =>
314 prev.filter((id) => services.some((s: AdditionalService) => s.id === id)),
315 );
316 }, [services]);
317
318 const viewFilters = [
319 { key: "all", label: __("All"), count: statusCounts.all ?? 0 },
320 {
321 key: "publish",
322 label: __("Published"),
323 count: statusCounts.publish ?? 0,
324 },
325 { key: "draft", label: __("Draft"), count: statusCounts.draft ?? 0 },
326 { key: "trash", label: __("Trash"), count: statusCounts.trash ?? 0 },
327 ];
328
329 // Module-availability gate runs here so every hook above runs on
330 // every render — see rules-of-hooks. The PremiumUpgradeCard render
331 // matches what used to be above the hooks.
332 if (!moduleAvailable) {
333 return <PremiumUpgradeCard />;
334 }
335
336 return (
337 <div className="space-y-3">
338 <PageHeader
339 title={__("Additional Services", "yatra")}
340 description={__(
341 "Manage add-ons and extras that customers can select when booking",
342 "yatra",
343 )}
344 actionCapability="yatra_edit_trips"
345 actions={
346 <Button
347 onClick={() => navigateToForm("create")}
348 className="flex items-center gap-2"
349 >
350 <Plus className="w-4 h-4" />
351 {__("Add New Service", "yatra")}
352 </Button>
353 }
354 />
355
356 {/* Permanent Delete Confirmation Dialog */}
357 <ConfirmationDialog
358 isOpen={permanentDeleteConfirm.isOpen}
359 onClose={() =>
360 setPermanentDeleteConfirm({ isOpen: false, service: null })
361 }
362 onConfirm={confirmPermanentDelete}
363 title={__("Delete Service Permanently")}
364 message={
365 permanentDeleteConfirm.service
366 ? __(
367 'Are you sure you want to permanently delete "{name}"? This action cannot be undone.',
368 ).replace("{name}", permanentDeleteConfirm.service.name)
369 : __("Are you sure you want to permanently delete this service?")
370 }
371 confirmText={__("Delete Permanently")}
372 cancelText={__("Cancel")}
373 variant="danger"
374 isLoading={bulkMutation.isPending}
375 />
376
377 {/* Filters Card */}
378 <Card>
379 <CardContent className="p-3">
380 <SearchFilterToolbar
381 searchTerm={searchTerm}
382 onSearchChange={setSearchTerm}
383 statusFilter={statusFilter}
384 onStatusChange={(value) => {
385 setStatusFilter(value);
386 setPage(1);
387 setSelectedIds([]);
388 setBulkAction("");
389 }}
390 statusOptions={[
391 { value: "all", label: __("All Status") },
392 { value: "publish", label: __("Published") },
393 { value: "draft", label: __("Draft") },
394 { value: "trash", label: __("Trash") },
395 ]}
396 sortBy={sortBy}
397 onSortByChange={setSortBy}
398 sortOrder={sortOrder}
399 onSortOrderChange={setSortOrder}
400 sortOptions={[
401 { value: "name", label: __("Name") },
402 { value: "price", label: __("Price") },
403 { value: "price_type", label: __("Price Type") },
404 { value: "price_per", label: __("Price Per") },
405 { value: "status", label: __("Status") },
406 { value: "created_at", label: __("Created At") },
407 { value: "updated_at", label: __("Updated At") },
408 ]}
409 onResetFilters={handleResetFilters}
410 hasFilters={!!hasFilters}
411 placeholder={__("Search services...")}
412 />
413 </CardContent>
414 </Card>
415
416 <ConditionalRender capability="yatra_view_trips">
417 {error ? (
418 <Card>
419 <CardContent className="p-8 text-center text-red-500">
420 {__("Error loading services")}
421 </CardContent>
422 </Card>
423 ) : (
424 <>
425 <BulkActionToolbar
426 selectedIds={selectedIds}
427 bulkAction={bulkAction}
428 setBulkAction={setBulkAction}
429 onApply={handleBulkApply}
430 onClearSelection={() => setSelectedIds([])}
431 statusFilter={statusFilter}
432 setStatusFilter={(value) => {
433 setStatusFilter(value);
434 setPage(1);
435 setSelectedIds([]);
436 setBulkAction("");
437 }}
438 statusOptions={viewFilters}
439 showColumnsDropdown={showColumnsDropdown}
440 setShowColumnsDropdown={setShowColumnsDropdown}
441 columnOptions={[
442 {
443 key: "name",
444 label: __("Service"),
445 visible: visibleColumns.name,
446 },
447 {
448 key: "price",
449 label: __("Price"),
450 visible: visibleColumns.price,
451 },
452 {
453 key: "price_type",
454 label: __("Price Type"),
455 visible: visibleColumns.price_type,
456 },
457 {
458 key: "price_per",
459 label: __("Price Per"),
460 visible: visibleColumns.price_per,
461 },
462 {
463 key: "trips",
464 label: __("Trips"),
465 visible: visibleColumns.trips,
466 },
467 {
468 key: "status",
469 label: __("Status"),
470 visible: visibleColumns.status,
471 },
472 {
473 key: "created_at",
474 label: __("Created Date"),
475 visible: visibleColumns.created_at,
476 },
477 {
478 key: "updated_at",
479 label: __("Updated Date"),
480 visible: visibleColumns.updated_at,
481 },
482 ]}
483 onToggleColumn={toggleColumn}
484 bulkMutationPending={bulkMutation.isPending}
485 totalItems={services.length}
486 bulkActionOptions={getDefaultBulkStatusOptions(statusFilter)}
487 />
488
489 <Card className="overflow-visible">
490 <CardContent className="p-0 overflow-visible">
491 <SharedTable
492 data={services}
493 columns={[
494 {
495 key: "name",
496 label: __("Service"),
497 sortable: true,
498 visible: visibleColumns.name,
499 render: (service: AdditionalService) => (
500 <div className="flex items-center gap-3">
501 {/* Icon/Image */}
502 <div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
503 {service.icon ? (
504 service.icon.type === "image" ? (
505 <img
506 src={service.icon.value}
507 alt={service.name}
508 className="w-full h-full object-cover rounded-lg"
509 />
510 ) : (
511 <IconSelector
512 iconName={service.icon.value}
513 provider={service.icon.provider ?? "yatra"}
514 className="w-5 h-5 text-purple-600 dark:text-purple-400"
515 />
516 )
517 ) : (
518 <Package className="w-5 h-5 text-purple-600 dark:text-purple-400" />
519 )}
520 </div>
521 {/* Text */}
522 <div>
523 <div className="flex items-center gap-2">
524 <a
525 href="#"
526 onClick={(e) => {
527 e.preventDefault();
528 handleEdit(service);
529 }}
530 className="font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 hover:underline transition-colors cursor-pointer"
531 >
532 {service.name}
533 </a>
534 {(service.is_required === true ||
535 service.is_required === 1 ||
536 service.is_required === "1") && (
537 <Badge
538 variant="outline"
539 className="text-xs bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-700"
540 >
541 {__("Required")}
542 </Badge>
543 )}
544 </div>
545 <div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
546 <span className="text-[11px] text-gray-400 dark:text-gray-500">
547 ({__("ID:", "yatra")} {service.id})
548 </span>
549 </div>
550 </div>
551 </div>
552 ),
553 },
554 {
555 key: "price",
556 label: __("Price"),
557 sortable: true,
558 visible: visibleColumns.price,
559 render: (service: AdditionalService) => (
560 <span
561 className={
562 service.status === "trash"
563 ? "text-gray-400 dark:text-gray-600"
564 : "font-medium text-gray-900 dark:text-white"
565 }
566 >
567 {formatPrice(
568 service.price,
569 service.price_type,
570 service.price_per,
571 )}
572 </span>
573 ),
574 },
575 {
576 key: "price_type",
577 label: __("Price Type"),
578 sortable: true,
579 visible: visibleColumns.price_type,
580 render: (service: AdditionalService) => (
581 <span
582 className={
583 service.status === "trash"
584 ? "text-gray-400 dark:text-gray-600"
585 : "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300"
586 }
587 >
588 {service.price_type === "fixed"
589 ? __("Fixed")
590 : __("Percentage")}
591 </span>
592 ),
593 },
594 {
595 key: "price_per",
596 label: __("Price Per"),
597 sortable: true,
598 visible: visibleColumns.price_per,
599 render: (service: AdditionalService) => (
600 <span
601 className={
602 service.status === "trash"
603 ? "text-gray-400 dark:text-gray-600"
604 : "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
605 }
606 >
607 {{
608 person: __("Person"),
609 booking: __("Booking"),
610 day: __("Day"),
611 }[service.price_per] || service.price_per}
612 </span>
613 ),
614 },
615 {
616 key: "trips",
617 label: __("Trips"),
618 sortable: false,
619 visible: visibleColumns.trips,
620 render: (service: AdditionalService) => (
621 <div className="flex flex-col gap-1">
622 {service.applicable_to === "all" ? (
623 <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
624 {__("All Trips")}
625 </span>
626 ) : service.trips && service.trips.length > 0 ? (
627 <div className="flex flex-wrap gap-1">
628 {service.trips.slice(0, 2).map((trip) => (
629 <span
630 key={trip.id}
631 className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 max-w-[120px] truncate"
632 title={trip.title}
633 >
634 {trip.title}
635 </span>
636 ))}
637 {service.trips.length > 2 && (
638 <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
639 +{service.trips.length - 2} {__("more")}
640 </span>
641 )}
642 </div>
643 ) : (
644 <span className="text-gray-400 dark:text-gray-600 text-xs">
645 {__("No trips assigned")}
646 </span>
647 )}
648 </div>
649 ),
650 },
651 {
652 key: "status",
653 label: __("Status"),
654 sortable: true,
655 visible: visibleColumns.status,
656 render: (service: AdditionalService) => (
657 <span
658 className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
659 service.status === "trash"
660 ? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
661 : service.status === "publish"
662 ? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
663 : "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
664 }`}
665 >
666 {service.status === "trash"
667 ? __("Trash")
668 : service.status === "publish"
669 ? __("Published")
670 : __("Draft")}
671 </span>
672 ),
673 },
674 {
675 key: "created_at",
676 label: __("Created Date"),
677 sortable: true,
678 visible: visibleColumns.created_at,
679 render: (service: AdditionalService) => (
680 <span
681 className={
682 service.status === "trash"
683 ? "text-gray-400 dark:text-gray-600"
684 : "text-gray-600 dark:text-gray-400"
685 }
686 >
687 {new Date(service.created_at).toLocaleDateString(
688 "en-US",
689 {
690 year: "numeric",
691 month: "short",
692 day: "numeric",
693 },
694 )}
695 </span>
696 ),
697 },
698 {
699 key: "updated_at",
700 label: __("Updated Date"),
701 sortable: true,
702 visible: visibleColumns.updated_at,
703 render: (service: AdditionalService) => (
704 <span
705 className={
706 service.status === "trash"
707 ? "text-gray-400 dark:text-gray-600"
708 : "text-gray-600 dark:text-gray-400"
709 }
710 >
711 {new Date(service.updated_at).toLocaleDateString(
712 "en-US",
713 {
714 year: "numeric",
715 month: "short",
716 day: "numeric",
717 },
718 )}
719 </span>
720 ),
721 },
722 ]}
723 actions={[
724 {
725 key: "edit",
726 label: __("Edit"),
727 icon: <Edit className="w-4 h-4" />,
728 onClick: handleEdit,
729 condition: () => can("yatra_edit_trips"),
730 },
731 {
732 key: "publish",
733 label: __("Publish"),
734 icon: <CheckCircle className="w-4 h-4" />,
735 onClick: (service: AdditionalService) => {
736 bulkMutation.mutate({
737 action: "publish",
738 ids: [service.id],
739 });
740 },
741 condition: (service: AdditionalService) =>
742 service.status !== "publish" &&
743 service.status !== "trash" &&
744 can("yatra_edit_trips"),
745 },
746 {
747 key: "draft",
748 label: __("Set as Draft"),
749 icon: <FileText className="w-4 h-4" />,
750 onClick: (service: AdditionalService) => {
751 bulkMutation.mutate({
752 action: "draft",
753 ids: [service.id],
754 });
755 },
756 condition: (service: AdditionalService) =>
757 service.status !== "draft" &&
758 service.status !== "trash" &&
759 can("yatra_edit_trips"),
760 },
761 {
762 key: "restore",
763 label: __("Restore"),
764 icon: <RotateCcw className="w-4 h-4" />,
765 onClick: (service: AdditionalService) => {
766 bulkMutation.mutate({
767 action: "restore",
768 ids: [service.id],
769 });
770 },
771 condition: (service: AdditionalService) =>
772 (service.status === "trash" ||
773 statusFilter === "trash") &&
774 can("yatra_edit_trips"),
775 },
776 {
777 key: "trash",
778 label: __("Move to Trash"),
779 icon: <Trash2 className="w-4 h-4" />,
780 onClick: (service: AdditionalService) => {
781 bulkMutation.mutate({
782 action: "trash",
783 ids: [service.id],
784 });
785 },
786 condition: (service: AdditionalService) =>
787 service.status !== "trash" &&
788 statusFilter !== "trash" &&
789 can("yatra_edit_trips"),
790 },
791 {
792 key: "delete",
793 label: __("Delete Permanently"),
794 icon: <Trash2 className="w-4 h-4" />,
795 onClick: handlePermanentDelete,
796 condition: (service: AdditionalService) =>
797 (service.status === "trash" ||
798 statusFilter === "trash") &&
799 can("yatra_delete_trips"),
800 variant: "destructive",
801 },
802 ]}
803 isLoading={isLoading}
804 isError={!!error}
805 errorText={__("Error loading services")}
806 emptyText={__("No services found")}
807 emptyDescription={
808 hasFilters
809 ? __("Try adjusting your filters to see more results.")
810 : __("Get started by creating your first service.")
811 }
812 onCreateClick={
813 can("yatra_edit_trips")
814 ? () => navigateToForm("create")
815 : undefined
816 }
817 onSort={handleSort}
818 getSortIcon={getSortIcon}
819 selectedItemIds={selectedIds}
820 onSelectItem={(id: string | number, checked: boolean) => {
821 if (checked) {
822 setSelectedIds([...selectedIds, id]);
823 } else {
824 setSelectedIds(
825 selectedIds.filter((selectedId) => selectedId !== id),
826 );
827 }
828 }}
829 onSelectAll={(checked: boolean) => {
830 if (checked) {
831 setSelectedIds(
832 services.map((s: AdditionalService) => s.id),
833 );
834 } else {
835 setSelectedIds([]);
836 }
837 }}
838 isAllSelected={
839 services.length > 0 &&
840 selectedIds.length === services.length
841 }
842 getItemId={(service: AdditionalService) => service.id}
843 getItemStatus={(service: AdditionalService) => service.status}
844 statusFilter={statusFilter}
845 skeletonRows={5}
846 />
847 </CardContent>
848 </Card>
849
850 {total > 0 && (
851 <div className="mt-4">
852 <Pagination
853 currentPage={page}
854 totalPages={totalPages}
855 totalItems={total}
856 itemsPerPage={10}
857 onPageChange={(newPage) => setPage(newPage)}
858 itemName={__("services")}
859 />
860 </div>
861 )}
862 </>
863 )}
864 </ConditionalRender>
865 </div>
866 );
867 };
868
869 export default AdditionalServices;
870