PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / RecurringRulesPage.tsx

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

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