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

647 lines 19.9 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 const formatDate = (dateString: string | null | undefined): string => {
231 if (!dateString) return "--";
232 try {
233 const date = new Date(dateString);
234 return date.toLocaleDateString("en-US", {
235 year: "numeric",
236 month: "short",
237 day: "numeric",
238 });
239 } catch {
240 return dateString;
241 }
242 };
243
244 const getRuleTypeLabel = (type: string): string => {
245 const labels: Record<string, string> = {
246 weekly: __("Weekly", "yatra"),
247 monthly: __("Monthly", "yatra"),
248 interval: __("Interval", "yatra"),
249 };
250 return labels[type] || type;
251 };
252
253 const getWeekdaysLabel = (rule: RecurringRule): string => {
254 const weekdays =
255 rule.days_of_week_array ||
256 (rule.days_of_week ? rule.days_of_week.split(",").map(Number) : []);
257 if (!weekdays || weekdays.length === 0) return "--";
258 const dayNames = [
259 __("Sun", "yatra"),
260 __("Mon", "yatra"),
261 __("Tue", "yatra"),
262 __("Wed", "yatra"),
263 __("Thu", "yatra"),
264 __("Fri", "yatra"),
265 __("Sat", "yatra"),
266 ];
267 return weekdays.map((d) => dayNames[d]).join(", ");
268 };
269
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 </div>
643 );
644 };
645
646 export default RecurringRulesPage;
647