PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
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.8, at resources/js/pages/RecurringRulesPage.tsx

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