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

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

665 lines 20.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Recurring Rules Page
3 * Manage recurring rules for trip departures
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 ArrowLeft,
10 Plus,
11 Edit,
12 Trash2,
13 AlertCircle,
14 Search,
15 Copy,
16 Clock,
17 CheckCircle,
18 XCircle,
19 } from "lucide-react";
20 import { __ } from "../lib/i18n";
21 import { toDateValue } from "../lib/dateFormat";
22 import { Button } from "../components/ui/button";
23 import { Input } from "../components/ui/input";
24 import { Select } from "../components/ui/select";
25 import { PageHeader } from "../components/common/PageHeader";
26 import { Card, CardContent } from "../components/ui/card";
27 import { Badge } from "../components/ui/badge";
28 import { apiClient } from "../lib/api-client";
29 import { useToast } from "../components/ui/toast";
30 import { Table as SharedTable } from "../components/shared";
31 import { getErrorContext } from "../lib/errors";
32 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
33 import { useNavigate } from "../hooks/useNavigate";
34
35 interface RecurringRule {
36 id: number;
37 trip_id: number;
38 name?: string;
39 rule_type: "weekly" | "monthly" | "interval";
40 days_of_week?: string;
41 days_of_week_array?: number[];
42 week_of_month?: string;
43 day_of_week?: number;
44 interval_days?: number;
45 start_date: string;
46 end_date?: string;
47 seats_total: number;
48 original_price?: number;
49 sale_price?: number;
50 pricing_type?: string;
51 status: "active" | "inactive" | "paused";
52 generated_count?: number;
53 created_at: string;
54 updated_at?: string;
55 }
56
57 const RecurringRulesPage: React.FC = () => {
58 const { navigate } = useNavigate();
59 const queryClient = useQueryClient();
60 const { showToast } = useToast();
61
62 // Get trip_id from URL
63 const urlParams = new URLSearchParams(window.location.search);
64 const tripId = urlParams.get("trip_id")
65 ? parseInt(urlParams.get("trip_id")!)
66 : null;
67
68 // Filters and search
69 const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false);
70
71 const [searchTerm, setSearchTerm] = useState("");
72 const [statusFilter, setStatusFilter] = useState("all");
73 const [page, setPage] = useState(1);
74
75 // Bulk selection
76 const [selectedIds, setSelectedIds] = useState<string[]>([]);
77 const [bulkAction, setBulkAction] = useState("");
78
79 // Confirmation dialogs
80 const [deleteConfirm, setDeleteConfirm] = useState<{
81 isOpen: boolean;
82 rule: RecurringRule | null;
83 }>({ isOpen: false, rule: null });
84 const [duplicateConfirm, setDuplicateConfirm] = useState<{
85 isOpen: boolean;
86 rule: RecurringRule | null;
87 }>({ isOpen: false, rule: null });
88
89 // Fetch trip details
90 const { data: tripData } = useQuery({
91 queryKey: ["trip", tripId],
92 queryFn: async () => {
93 if (!tripId) return null;
94 const response = await apiClient.get(`/trips/${tripId}`);
95 return response?.data || response;
96 },
97 enabled: !!tripId,
98 });
99
100 // Fetch recurring rules
101 const {
102 data: rulesResponse,
103 isLoading,
104 error,
105 } = useQuery<any>({
106 queryKey: [
107 "recurring-availability",
108 tripId,
109 statusFilter,
110 searchTerm,
111 page,
112 ],
113 queryFn: async () => {
114 if (!tripId) return { data: [], total: 0 };
115 const response = await apiClient.get("/recurring-availability", {
116 params: {
117 trip_id: tripId,
118 status: statusFilter !== "all" ? statusFilter : undefined,
119 search: searchTerm || undefined,
120 page,
121 per_page: 50,
122 },
123 });
124 return {
125 data: response?.data || [],
126 total: response?.total || 0,
127 };
128 },
129 enabled: !!tripId,
130 });
131
132 const rulesData = rulesResponse?.data || [];
133 const errorContext = getErrorContext(error);
134 const apiErrorMessage =
135 (rulesResponse as any)?.error || (rulesResponse as any)?.message;
136 const isRulesError = !!error || !!apiErrorMessage;
137
138 // Delete rule mutation
139 const deleteMutation = useMutation({
140 mutationFn: async (id: number) => {
141 await apiClient.delete(`/recurring-availability/${id}`);
142 },
143 onSuccess: () => {
144 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
145 showToast(__("Recurring rule deleted successfully", "yatra"), "success");
146 setDeleteConfirm({ isOpen: false, rule: null });
147 setSelectedIds([]);
148 },
149 onError: (error: any) => {
150 showToast(
151 error?.message || __("Failed to delete rule", "yatra"),
152 "error",
153 );
154 },
155 });
156
157 // Bulk delete mutation
158 const bulkDeleteMutation = useMutation({
159 mutationFn: async (ids: string[]) => {
160 await Promise.all(
161 ids.map((id) => apiClient.delete(`/recurring-availability/${id}`)),
162 );
163 },
164 onSuccess: () => {
165 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
166 showToast(__("Rules deleted successfully", "yatra"), "success");
167 setSelectedIds([]);
168 },
169 onError: (error: any) => {
170 showToast(
171 error?.message || __("Failed to delete rules", "yatra"),
172 "error",
173 );
174 },
175 });
176
177 // Duplicate rule mutation
178 const duplicateMutation = useMutation({
179 mutationFn: async (id: number) => {
180 const response = await apiClient.post(
181 `/recurring-availability/${id}/duplicate`,
182 );
183 return response;
184 },
185 onSuccess: () => {
186 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
187 showToast(__("Rule duplicated successfully", "yatra"), "success");
188 setDuplicateConfirm({ isOpen: false, rule: null });
189 },
190 onError: (error: any) => {
191 showToast(
192 error?.message || __("Failed to duplicate rule", "yatra"),
193 "error",
194 );
195 },
196 });
197
198 // Handle bulk actions
199 const handleBulkAction = () => {
200 if (!bulkAction || selectedIds.length === 0) return;
201
202 switch (bulkAction) {
203 case "delete":
204 // Confirmed through the shared dialog, matching the single-rule delete
205 // already used on this page.
206 setBulkDeleteConfirm(true);
207 break;
208 }
209
210 setBulkAction("");
211 };
212
213 // Handle search
214 const handleSearch = (value: string) => {
215 setSearchTerm(value);
216 setPage(1);
217 };
218
219 // Handle status filter
220 const handleStatusFilter = (value: string) => {
221 setStatusFilter(value);
222 setPage(1);
223 };
224
225 // Format helpers
226 // eslint-disable-next-line react-hooks/exhaustive-deps
227 const formatDate = (dateString: string | null | undefined): string => {
228 if (!dateString) return "--";
229 try {
230 const date = toDateValue(dateString);
231 return date.toLocaleDateString("en-US", {
232 year: "numeric",
233 month: "short",
234 day: "numeric",
235 });
236 } catch {
237 return dateString;
238 }
239 };
240
241 // eslint-disable-next-line react-hooks/exhaustive-deps
242 const getRuleTypeLabel = (type: string): string => {
243 const labels: Record<string, string> = {
244 weekly: __("Weekly", "yatra"),
245 monthly: __("Monthly", "yatra"),
246 interval: __("Interval", "yatra"),
247 };
248 return labels[type] || type;
249 };
250
251 // eslint-disable-next-line react-hooks/exhaustive-deps
252 const getWeekdaysLabel = (rule: RecurringRule): string => {
253 const weekdays =
254 rule.days_of_week_array ||
255 (rule.days_of_week ? rule.days_of_week.split(",").map(Number) : []);
256 if (!weekdays || weekdays.length === 0) return "--";
257 const dayNames = [
258 __("Sun", "yatra"),
259 __("Mon", "yatra"),
260 __("Tue", "yatra"),
261 __("Wed", "yatra"),
262 __("Thu", "yatra"),
263 __("Fri", "yatra"),
264 __("Sat", "yatra"),
265 ];
266 return weekdays.map((d) => dayNames[d]).join(", ");
267 };
268
269 // eslint-disable-next-line react-hooks/exhaustive-deps
270 const getStatusBadge = (status: string) => {
271 const statusConfig: Record<
272 string,
273 { label: string; className: string; icon: React.ReactNode }
274 > = {
275 active: {
276 label: __("Active", "yatra"),
277 className:
278 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
279 icon: <CheckCircle className="w-3 h-3" />,
280 },
281 inactive: {
282 label: __("Inactive", "yatra"),
283 className:
284 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
285 icon: <XCircle className="w-3 h-3" />,
286 },
287 paused: {
288 label: __("Paused", "yatra"),
289 className:
290 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
291 icon: <Clock className="w-3 h-3" />,
292 },
293 };
294
295 const config = statusConfig[status] || statusConfig.inactive;
296 return (
297 <Badge className={`${config.className} flex items-center gap-1`}>
298 {config.icon}
299 {config.label}
300 </Badge>
301 );
302 };
303
304 // Define table columns
305 const tableColumns = useMemo(() => {
306 return [
307 {
308 key: "name",
309 label: __("Rule Name", "yatra"),
310 render: (rule: RecurringRule) => (
311 <div className="flex flex-col">
312 <span className="text-sm font-medium text-gray-900 dark:text-white">
313 {rule.name || `Rule #${rule.id}`}
314 </span>
315 <span className="text-xs text-gray-500 dark:text-gray-400">
316 {getRuleTypeLabel(rule.rule_type)}
317 </span>
318 </div>
319 ),
320 },
321 {
322 key: "pattern",
323 label: __("Pattern", "yatra"),
324 render: (rule: RecurringRule) => (
325 <div className="text-sm text-gray-700 dark:text-gray-300">
326 {rule.rule_type === "weekly"
327 ? getWeekdaysLabel(rule)
328 : rule.rule_type === "interval"
329 ? `Every ${rule.interval_days || 7} days`
330 : rule.week_of_month
331 ? `${rule.week_of_month} week`
332 : "--"}
333 </div>
334 ),
335 },
336 {
337 key: "date_range",
338 label: __("Date Range", "yatra"),
339 render: (rule: RecurringRule) => (
340 <div className="flex flex-col text-sm">
341 <span className="text-gray-900 dark:text-white">
342 {formatDate(rule.start_date)}
343 </span>
344 {rule.end_date && (
345 <span className="text-gray-500 dark:text-gray-400">
346 to {formatDate(rule.end_date)}
347 </span>
348 )}
349 </div>
350 ),
351 },
352 {
353 key: "capacity",
354 label: __("Seats", "yatra"),
355 render: (rule: RecurringRule) => (
356 <div className="text-center">
357 <span className="text-sm font-semibold text-gray-900 dark:text-white">
358 {rule.seats_total || 0}
359 </span>
360 </div>
361 ),
362 },
363 {
364 key: "generated",
365 label: __("Generated", "yatra"),
366 render: (rule: RecurringRule) => (
367 <div className="text-center">
368 <Badge variant="default" className="text-xs">
369 {rule.generated_count || 0} {__("dates", "yatra")}
370 </Badge>
371 </div>
372 ),
373 },
374 {
375 key: "status",
376 label: __("Status", "yatra"),
377 render: (rule: RecurringRule) => getStatusBadge(rule.status),
378 },
379 ];
380 }, [formatDate, getRuleTypeLabel, getWeekdaysLabel, getStatusBadge]);
381
382 const tableActions = [
383 {
384 key: "edit",
385 label: __("Edit", "yatra"),
386 icon: <Edit className="w-4 h-4" />,
387 onClick: (rule: RecurringRule) =>
388 navigate({
389 subpage: "trips",
390 tab: "availability",
391 action: "edit-recurring",
392 id: rule.id.toString(),
393 trip_id: tripId?.toString(),
394 }),
395 },
396 {
397 key: "duplicate",
398 label: __("Duplicate", "yatra"),
399 icon: <Copy className="w-4 h-4" />,
400 onClick: (rule: RecurringRule) =>
401 setDuplicateConfirm({ isOpen: true, rule }),
402 },
403 {
404 key: "delete",
405 label: __("Delete", "yatra"),
406 icon: <Trash2 className="w-4 h-4" />,
407 onClick: (rule: RecurringRule) =>
408 setDeleteConfirm({ isOpen: true, rule }),
409 variant: "destructive" as const,
410 },
411 ];
412
413 return (
414 <div className="space-y-6">
415 <ConfirmationDialog
416 isOpen={deleteConfirm.isOpen}
417 onClose={() => setDeleteConfirm({ isOpen: false, rule: null })}
418 onConfirm={() => {
419 if (deleteConfirm.rule) {
420 deleteMutation.mutate(deleteConfirm.rule.id);
421 }
422 }}
423 title={__("Delete Recurring Rule", "yatra")}
424 message={
425 deleteConfirm.rule
426 ? __(
427 'Are you sure you want to delete the rule "{name}"? This action cannot be undone.',
428 "yatra",
429 ).replace(
430 "{name}",
431 deleteConfirm.rule.name || `Rule #${deleteConfirm.rule.id}`,
432 )
433 : __(
434 "Are you sure you want to delete this rule? This action cannot be undone.",
435 "yatra",
436 )
437 }
438 confirmText={__("Delete", "yatra")}
439 cancelText={__("Cancel", "yatra")}
440 variant="danger"
441 isLoading={deleteMutation.isPending}
442 />
443
444 <ConfirmationDialog
445 isOpen={duplicateConfirm.isOpen}
446 onClose={() => setDuplicateConfirm({ isOpen: false, rule: null })}
447 onConfirm={() => {
448 if (duplicateConfirm.rule) {
449 duplicateMutation.mutate(duplicateConfirm.rule.id);
450 }
451 }}
452 title={__("Duplicate Recurring Rule", "yatra")}
453 message={__(
454 "This will create a copy of this rule. You can edit it after creation.",
455 "yatra",
456 )}
457 confirmText={__("Duplicate", "yatra")}
458 cancelText={__("Cancel", "yatra")}
459 isLoading={duplicateMutation.isPending}
460 />
461
462 <PageHeader
463 title={__("Recurring Rules", "yatra")}
464 description={
465 tripData
466 ? `${__("Recurring availability rules for", "yatra")}: ${tripData.title}`
467 : __("Manage recurring availability rules", "yatra")
468 }
469 actions={
470 <div className="flex gap-2">
471 {tripId && (
472 <Button
473 onClick={() =>
474 navigate({
475 subpage: "trips",
476 tab: "availability",
477 action: "create-recurring",
478 trip_id: tripId.toString(),
479 })
480 }
481 variant="default"
482 >
483 <Plus className="w-4 h-4 mr-2" />
484 {__("Add Rule", "yatra")}
485 </Button>
486 )}
487 <Button
488 variant="ghost"
489 onClick={() =>
490 navigate({
491 subpage: "trips",
492 tab: "availability",
493 trip_id: tripId?.toString(),
494 })
495 }
496 >
497 <ArrowLeft className="w-4 h-4 mr-2" />
498 {__("Back", "yatra")}
499 </Button>
500 </div>
501 }
502 />
503
504 {!tripId ? (
505 <Card>
506 <CardContent className="pt-6">
507 <div className="text-center py-8">
508 <AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-4" />
509 <p className="text-gray-500">
510 {__("Trip ID is required", "yatra")}
511 </p>
512 </div>
513 </CardContent>
514 </Card>
515 ) : (
516 <>
517 {/* Filters */}
518 <Card>
519 <CardContent className="pt-6">
520 <div className="flex flex-col md:flex-row gap-4">
521 <div className="flex-1">
522 <div className="relative">
523 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
524 <Input
525 type="text"
526 placeholder={__("Search rules...", "yatra")}
527 value={searchTerm}
528 onChange={(e) => handleSearch(e.target.value)}
529 className="pl-10"
530 />
531 </div>
532 </div>
533 <div className="w-full md:w-48">
534 <Select
535 value={statusFilter}
536 onChange={(e) => handleStatusFilter(e.target.value)}
537 >
538 <option value="all">{__("All Status", "yatra")}</option>
539 <option value="active">{__("Active", "yatra")}</option>
540 <option value="inactive">{__("Inactive", "yatra")}</option>
541 <option value="paused">{__("Paused", "yatra")}</option>
542 </Select>
543 </div>
544 </div>
545 </CardContent>
546 </Card>
547
548 {/* Bulk Actions (hide when error) */}
549 {selectedIds.length > 0 && !isRulesError && (
550 <Card>
551 <CardContent className="pt-6">
552 <div className="flex items-center justify-between">
553 <div className="flex items-center gap-4">
554 <span className="text-sm text-gray-700 dark:text-gray-300">
555 {selectedIds.length} {__("selected", "yatra")}
556 </span>
557 <Button
558 variant="ghost"
559 size="sm"
560 onClick={() => setSelectedIds([])}
561 >
562 {__("Clear Selection", "yatra")}
563 </Button>
564 </div>
565 <div className="flex items-center gap-2">
566 <Select
567 value={bulkAction}
568 onChange={(e) => setBulkAction(e.target.value)}
569 className="w-48"
570 >
571 <option value="">{__("Bulk Actions", "yatra")}</option>
572 <option value="delete">{__("Delete", "yatra")}</option>
573 </Select>
574 <Button
575 onClick={handleBulkAction}
576 disabled={!bulkAction}
577 variant="default"
578 >
579 {__("Apply", "yatra")}
580 </Button>
581 </div>
582 </div>
583 </CardContent>
584 </Card>
585 )}
586
587 {/* Table */}
588 <Card>
589 <CardContent className="pt-6">
590 <SharedTable
591 data={rulesData}
592 columns={tableColumns}
593 actions={tableActions}
594 isLoading={isLoading}
595 isError={isRulesError}
596 errorText={__("Error loading recurring rules", "yatra")}
597 errorDescription={__(
598 "We couldn’t connect to the recurring rules service. Please refresh or try again shortly.",
599 "We couldn’t connect to the recurring rules service. Please refresh or try again shortly.",
600 )}
601 onRetry={() =>
602 queryClient.invalidateQueries({
603 queryKey: ["recurring-availability"],
604 })
605 }
606 errorDetails={errorContext.details || apiErrorMessage}
607 errorRequestInfo={errorContext.requestInfo}
608 selectedItemIds={selectedIds}
609 onSelectItem={(id, checked) => {
610 if (checked) {
611 setSelectedIds([...selectedIds, id.toString()]);
612 } else {
613 setSelectedIds(
614 selectedIds.filter((sid) => sid !== id.toString()),
615 );
616 }
617 }}
618 onSelectAll={(checked) => {
619 if (checked) {
620 setSelectedIds(
621 rulesData.map((r: RecurringRule) => r.id.toString()),
622 );
623 } else {
624 setSelectedIds([]);
625 }
626 }}
627 isAllSelected={
628 selectedIds.length === rulesData.length &&
629 rulesData.length > 0
630 }
631 getItemId={(rule) => rule.id}
632 emptyText={__("No recurring rules found", "yatra")}
633 emptyDescription={__(
634 "Create your first recurring rule to get started",
635 "yatra",
636 )}
637 />
638 </CardContent>
639 </Card>
640 </>
641 )}
642
643 <ConfirmationDialog
644 isOpen={bulkDeleteConfirm}
645 onClose={() => setBulkDeleteConfirm(false)}
646 onConfirm={() => {
647 bulkDeleteMutation.mutate(selectedIds);
648 setBulkDeleteConfirm(false);
649 }}
650 title={__("Delete Rules", "yatra")}
651 message={__(
652 "Are you sure you want to delete {count} rule(s)? This action cannot be undone.",
653 "yatra",
654 ).replace("{count}", selectedIds.length.toString())}
655 confirmText={__("Delete", "yatra")}
656 cancelText={__("Cancel", "yatra")}
657 variant="danger"
658 isLoading={bulkDeleteMutation.isPending}
659 />
660 </div>
661 );
662 };
663
664 export default RecurringRulesPage;
665