PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
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 / Items.tsx

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

966 lines 29.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Items Page (Item Subtypes)
3 * Manage specific items under item types (Hiking, Lunch, Bus, etc.)
4 */
5
6 import React, { useState, useMemo, useEffect } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 Plus,
10 Search,
11 X,
12 ArrowUpDown,
13 ArrowUp,
14 ArrowDown,
15 Edit,
16 Trash2,
17 } from "lucide-react";
18 import { __ } from "../lib/i18n";
19 import { usePermissions } from "../hooks/usePermissions";
20 import { useToast } from "../components/ui/toast";
21 import { apiClient } from "../lib/api-client";
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 { ConditionalRender } from "../components/ui/conditional-render";
28 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
29 import { IconSelector } from "../components/ui/icon-selector";
30 import { Badge } from "../components/ui/badge";
31 import { Table as SharedTable } from "../components/shared/Table";
32 import { Pagination } from "../components/shared/Pagination";
33 import { BulkActionToolbar } from "../components/shared/BulkActionToolbar";
34 import { getErrorContext } from "../lib/errors";
35
36 interface Item {
37 id: number;
38 name: string;
39 slug: string;
40 description: string;
41 type_id: number;
42 type_name?: string;
43 type_icon?: string;
44 type_icon_provider?: "yatra" | "fa-solid" | "fa-regular";
45 status: "draft" | "publish" | "trash";
46 usage_count?: number;
47 created_at: string;
48 updated_at: string;
49 created_by: number; // user_id
50 updated_by: number; // user_id
51 created_by_name?: string; // Optional: user name from API
52 updated_by_name?: string; // Optional: user name from API
53 }
54
55 const Items: React.FC = () => {
56 const [searchTerm, setSearchTerm] = useState("");
57 const [typeFilter, setTypeFilter] = useState("all");
58 const [statusFilter, setStatusFilter] = useState("all");
59 const [sortBy, setSortBy] = useState("id");
60 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
61 const [page, setPage] = useState(1);
62 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
63 const [bulkAction, setBulkAction] = useState("");
64 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
65 const [bulkMutationPending, setBulkMutationPending] = useState(false);
66 const [deleteConfirm, setDeleteConfirm] = useState<{
67 isOpen: boolean;
68 item: Item | null;
69 }>({
70 isOpen: false,
71 item: null,
72 });
73 const [statusActionConfirm, setStatusActionConfirm] = useState<{
74 isOpen: boolean;
75 item: Item | null;
76 action: "publish" | "draft" | "trash" | "restore" | null;
77 }>({
78 isOpen: false,
79 item: null,
80 action: null,
81 });
82
83 const updateItemStatus = async (
84 item: Item,
85 status: "draft" | "publish" | "trash",
86 ) => {
87 await apiClient.put(`/items/${item.id}`, {
88 // Required for validation
89 name: item.name,
90 type_id: item.type_id,
91 // Preserve current slug and description so status change is safe
92 slug: item.slug,
93 description: item.description,
94 status,
95 preserve_slug: true,
96 });
97 };
98 const queryClient = useQueryClient();
99 const { can } = usePermissions();
100 const { showToast } = useToast();
101
102 const baseAdminUrl = (window as any).yatraAdmin?.adminUrl || "";
103
104 const queryParams = useMemo(() => {
105 const params: Record<string, any> = {
106 page,
107 per_page: 10,
108 orderby: sortBy,
109 order: sortOrder,
110 };
111
112 if (searchTerm) {
113 params.search = searchTerm;
114 }
115
116 if (typeFilter !== "all") {
117 params.type_id = typeFilter;
118 }
119
120 if (statusFilter !== "all") {
121 params.status = statusFilter;
122 }
123
124 return params;
125 }, [searchTerm, typeFilter, statusFilter, sortBy, sortOrder, page]);
126
127 // Fetch stable status counts from API (independent of filters)
128 const { data: statsData } = useQuery({
129 queryKey: ["items-stats"],
130 queryFn: async () => {
131 try {
132 const response = await apiClient.get("/items/stats");
133 return response;
134 } catch (error: any) {
135 return { all: 0, publish: 0, draft: 0, trash: 0 };
136 }
137 },
138 enabled: can("yatra_view_trips"),
139 });
140
141 // State for available types (populated from items API response)
142 const [availableTypes, setAvailableTypes] = useState<
143 Array<{ id: number; name: string }>
144 >([]);
145
146 const { data, isLoading, error } = useQuery({
147 queryKey: ["items", queryParams],
148 queryFn: async () => {
149 try {
150 const response = await apiClient.get("/items", { params: queryParams });
151 return response;
152 } catch (error: any) {
153 showToast(
154 error?.message || __("Failed to load items", "yatra"),
155 "error",
156 );
157 throw error;
158 }
159 },
160 enabled: can("yatra_view_trips"),
161 });
162
163 const deleteMutation = useMutation({
164 mutationFn: async (id: number) => {
165 return await apiClient.delete(`/items/${id}`);
166 },
167 onSuccess: () => {
168 queryClient.invalidateQueries({ queryKey: ["items"] });
169 queryClient.invalidateQueries({ queryKey: ["items-stats"] });
170 showToast(__("Item deleted successfully", "yatra"), "success");
171 setDeleteConfirm({ isOpen: false, item: null });
172 },
173 onError: (error: any) => {
174 showToast(
175 error?.message || __("Failed to delete item", "yatra"),
176 "error",
177 );
178 },
179 });
180
181 const items = data?.data || [];
182 const total = data?.total || 0;
183 const totalPages = Math.ceil(total / 10);
184 const errorContext = getErrorContext(error);
185 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
186 const derivedErrorDetails =
187 errorContext.details ||
188 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
189 (error ? String(error?.message || error) : undefined);
190 const isItemsError = !!error || !!apiErrorMessage;
191
192 // Extract available types from API response meta
193 useEffect(() => {
194 if (data?.meta?.available_types) {
195 setAvailableTypes(data.meta.available_types);
196 }
197 }, [data]);
198
199 const types = availableTypes;
200
201 const formatDate = (dateString: string) => {
202 if (!dateString) return __("N/A", "yatra");
203 try {
204 const date = new Date(dateString);
205 return date.toLocaleDateString("en-US", {
206 month: "short",
207 day: "numeric",
208 year: "numeric",
209 hour: "2-digit",
210 minute: "2-digit",
211 });
212 } catch (e) {
213 return dateString;
214 }
215 };
216
217 const getStatusBadge = (status: string) => {
218 const statusMap: Record<string, { className: string; label: string }> = {
219 publish: {
220 className:
221 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
222 label: __("Publish", "yatra"),
223 },
224 draft: {
225 className:
226 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
227 label: __("Draft", "yatra"),
228 },
229 trash: {
230 className:
231 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
232 label: __("Trash", "yatra"),
233 },
234 };
235
236 const statusInfo = statusMap[status] || {
237 className:
238 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
239 label: status,
240 };
241
242 return (
243 <span
244 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
245 >
246 {statusInfo.label}
247 </span>
248 );
249 };
250
251 const formatUser = (userId: number, userName?: string) => {
252 if (userName) {
253 return userName;
254 }
255 return `User #${userId}`;
256 };
257
258 const handleCreate = () => {
259 window.location.href = `${baseAdminUrl}?page=yatra&subpage=itinerary&tab=items&action=create`;
260 };
261
262 const handleEdit = (item: Item) => {
263 window.location.href = `${baseAdminUrl}?page=yatra&subpage=itinerary&tab=items&action=edit&id=${item.id}`;
264 };
265
266 const handleDelete = (item: Item) => {
267 setDeleteConfirm({ isOpen: true, item });
268 };
269
270 const confirmDelete = () => {
271 if (deleteConfirm.item) {
272 deleteMutation.mutate(deleteConfirm.item.id);
273 }
274 };
275
276 const handleResetFilters = () => {
277 setSearchTerm("");
278 setTypeFilter("all");
279 setStatusFilter("all");
280 setSortBy("name");
281 setSortOrder("asc");
282 setPage(1);
283 };
284
285 const handleSort = (field: string) => {
286 if (sortBy === field) {
287 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
288 } else {
289 setSortBy(field);
290 setSortOrder("asc");
291 }
292 };
293
294 const getSortIcon = (field: string) => {
295 if (sortBy !== field) {
296 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
297 }
298 return sortOrder === "asc" ? (
299 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
300 ) : (
301 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
302 );
303 };
304
305 const hasFilters =
306 searchTerm ||
307 typeFilter !== "all" ||
308 statusFilter !== "all" ||
309 sortBy !== "name" ||
310 sortOrder !== "asc";
311
312 const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
313 {
314 item: true,
315 type: true,
316 description: true,
317 status: true,
318 usage: true,
319 date: true,
320 author: true,
321 },
322 );
323
324 const toggleColumn = (columnKey: string) => {
325 setVisibleColumns((prev) => ({
326 ...prev,
327 [columnKey]: !prev[columnKey],
328 }));
329 };
330
331 const columnOptions = [
332 { key: "item", label: __("Item", "yatra"), visible: visibleColumns.item },
333 { key: "type", label: __("Type", "yatra"), visible: visibleColumns.type },
334 {
335 key: "description",
336 label: __("Description", "yatra"),
337 visible: visibleColumns.description,
338 },
339 {
340 key: "status",
341 label: __("Status", "yatra"),
342 visible: visibleColumns.status,
343 },
344 {
345 key: "usage",
346 label: __("Usage", "yatra"),
347 visible: visibleColumns.usage,
348 },
349 { key: "date", label: __("Date", "yatra"), visible: visibleColumns.date },
350 {
351 key: "author",
352 label: __("Author", "yatra"),
353 visible: visibleColumns.author,
354 },
355 ];
356
357 const isAllSelected = items.length > 0 && selectedIds.length === items.length;
358
359 const handleSelectItem = (id: string | number, checked: boolean) => {
360 setSelectedIds((prev) =>
361 checked ? [...prev, id] : prev.filter((existingId) => existingId !== id),
362 );
363 };
364
365 const handleSelectAll = (checked: boolean) => {
366 if (checked) {
367 setSelectedIds(items.map((item: Item) => item.id));
368 } else {
369 setSelectedIds([]);
370 }
371 };
372
373 const handleClearSelection = () => {
374 setSelectedIds([]);
375 };
376
377 // Status counts from stats API (stable across filters)
378 const statusCounts = useMemo(() => {
379 if (statsData) {
380 return {
381 all: statsData.all ?? 0,
382 publish: statsData.publish ?? 0,
383 draft: statsData.draft ?? 0,
384 trash: statsData.trash ?? 0,
385 };
386 }
387
388 return {
389 all: 0,
390 publish: 0,
391 draft: 0,
392 trash: 0,
393 };
394 }, [statsData]);
395
396 const statusOptions = [
397 { key: "all", label: __("All", "yatra"), count: statusCounts.all },
398 { key: "draft", label: __("Draft", "yatra"), count: statusCounts.draft },
399 {
400 key: "publish",
401 label: __("Publish", "yatra"),
402 count: statusCounts.publish,
403 },
404 { key: "trash", label: __("Trash", "yatra"), count: statusCounts.trash },
405 ];
406
407 const bulkActionOptions = useMemo(() => {
408 if (statusFilter === "trash") {
409 return [
410 { value: "restore", label: __("Restore to Draft", "yatra") },
411 { value: "delete", label: __("Delete Permanently", "yatra") },
412 ];
413 }
414
415 return [
416 { value: "publish", label: __("Mark as Published", "yatra") },
417 { value: "draft", label: __("Mark as Draft", "yatra") },
418 { value: "trash", label: __("Move to Trash", "yatra") },
419 { value: "delete", label: __("Delete Permanently", "yatra") },
420 ];
421 }, [statusFilter]);
422
423 const handleBulkApply = async () => {
424 if (!bulkAction || selectedIds.length === 0) {
425 showToast(
426 __("Please select items and a bulk action first.", "yatra"),
427 "error",
428 );
429 return;
430 }
431
432 try {
433 setBulkMutationPending(true);
434
435 const selectedItems = items.filter((item: Item) =>
436 selectedIds.includes(item.id),
437 );
438
439 if (bulkAction === "delete") {
440 await Promise.all(
441 selectedIds.map((id) => apiClient.delete(`/items/${id}`)),
442 );
443 showToast(__("Items deleted successfully", "yatra"), "success");
444 } else if (bulkAction === "publish") {
445 await Promise.all(
446 selectedItems.map((item: Item) => updateItemStatus(item, "publish")),
447 );
448 showToast(__("Items marked as published", "yatra"), "success");
449 } else if (bulkAction === "draft") {
450 const targetStatus: "draft" = "draft";
451 await Promise.all(
452 selectedItems.map((item: Item) =>
453 updateItemStatus(item, targetStatus),
454 ),
455 );
456 showToast(
457 statusFilter === "trash"
458 ? __("Items restored to draft", "yatra")
459 : __("Items marked as draft", "yatra"),
460 "success",
461 );
462 } else if (bulkAction === "trash") {
463 await Promise.all(
464 selectedItems.map((item: Item) => updateItemStatus(item, "trash")),
465 );
466 showToast(__("Items moved to trash", "yatra"), "success");
467 } else if (bulkAction === "restore") {
468 await Promise.all(
469 selectedItems.map((item: Item) => updateItemStatus(item, "draft")),
470 );
471 showToast(__("Items restored to draft", "yatra"), "success");
472 }
473
474 await queryClient.invalidateQueries({ queryKey: ["items"] });
475 await queryClient.invalidateQueries({ queryKey: ["items-stats"] });
476 setSelectedIds([]);
477 setBulkAction("");
478 } catch (error) {
479 showToast(__("Bulk action failed", "yatra"), "error");
480 } finally {
481 setBulkMutationPending(false);
482 }
483 };
484
485 const columns = [
486 {
487 key: "item",
488 label: __("Item", "yatra"),
489 sortable: true,
490 width: "w-[200px]",
491 visible: visibleColumns.item,
492 render: (item: Item) => (
493 <div>
494 <div className="font-medium text-gray-900 dark:text-white">
495 <a
496 href={`${baseAdminUrl}?page=yatra&subpage=itinerary&tab=items&action=edit&id=${item.id}`}
497 className="hover:underline underline-offset-2 text-blue-600 dark:text-blue-400"
498 >
499 {item.name}
500 </a>
501 </div>
502 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
503 {item.slug}
504 </div>
505 </div>
506 ),
507 },
508 {
509 key: "type",
510 label: __("Type", "yatra"),
511 sortable: true,
512 width: "w-[150px]",
513 visible: visibleColumns.type,
514 render: (item: Item) => (
515 <div className="flex items-center gap-2">
516 {item.type_icon && (
517 <div className="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
518 <IconSelector
519 iconName={item.type_icon as any}
520 provider={item.type_icon_provider ?? "yatra"}
521 className="w-4 h-4 text-gray-700 dark:text-gray-300"
522 />
523 </div>
524 )}
525 <span className="text-sm text-gray-600 dark:text-gray-400">
526 {item.type_name || __("Unknown", "yatra")}
527 </span>
528 </div>
529 ),
530 },
531 {
532 key: "description",
533 label: __("Description", "yatra"),
534 sortable: false,
535 width: "w-[200px]",
536 visible: visibleColumns.description,
537 render: (item: Item) => (
538 <span className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2">
539 {item.description || __("No description", "yatra")}
540 </span>
541 ),
542 },
543 {
544 key: "status",
545 label: __("Status", "yatra"),
546 sortable: true,
547 width: "w-[100px]",
548 visible: visibleColumns.status,
549 render: (item: Item) => getStatusBadge(item.status),
550 },
551 {
552 key: "usage",
553 label: __("Usage", "yatra"),
554 sortable: false,
555 width: "w-[100px]",
556 visible: visibleColumns.usage,
557 render: (item: Item) => (
558 <div className="text-center">
559 <Badge variant="outline" className="font-medium">
560 {item.usage_count ?? 0}
561 </Badge>
562 </div>
563 ),
564 },
565 {
566 key: "date",
567 label: __("Date", "yatra"),
568 sortable: true,
569 width: "w-[150px]",
570 visible: visibleColumns.date,
571 render: (item: Item) => (
572 <div className="text-gray-500 dark:text-gray-400 text-sm">
573 <div>{formatDate(item.created_at)}</div>
574 <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
575 {__("Updated", "yatra")}: {formatDate(item.updated_at)}
576 </div>
577 </div>
578 ),
579 },
580 {
581 key: "author",
582 label: __("Author", "yatra"),
583 sortable: false,
584 width: "w-[150px]",
585 visible: visibleColumns.author,
586 render: (item: Item) => (
587 <div className="text-gray-600 dark:text-gray-400 text-sm">
588 <div>{formatUser(item.created_by, item.created_by_name)}</div>
589 <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
590 {__("Updated by", "yatra")}:{" "}
591 {formatUser(item.updated_by, item.updated_by_name)}
592 </div>
593 </div>
594 ),
595 },
596 ];
597
598 const tableActions = useMemo(() => {
599 const actions: any[] = [];
600
601 if (can("yatra_edit_trips")) {
602 actions.push({
603 key: "edit",
604 label: __("Edit", "yatra"),
605 icon: <Edit className="w-4 h-4" />,
606 onClick: (item: Item) => handleEdit(item),
607 });
608
609 actions.push({
610 key: "publish",
611 label: __("Mark as Published", "yatra"),
612 icon: <ArrowUp className="w-4 h-4" />,
613 onClick: (item: Item) => {
614 setStatusActionConfirm({ isOpen: true, item, action: "publish" });
615 },
616 condition: (item: Item) =>
617 item.status !== "publish" && item.status !== "trash",
618 });
619
620 actions.push({
621 key: "draft",
622 label: __("Mark as Draft", "yatra"),
623 icon: <ArrowDown className="w-4 h-4" />,
624 onClick: (item: Item) => {
625 setStatusActionConfirm({ isOpen: true, item, action: "draft" });
626 },
627 condition: (item: Item) =>
628 item.status !== "draft" && item.status !== "trash",
629 });
630
631 actions.push({
632 key: "trash",
633 label: __("Move to Trash", "yatra"),
634 icon: <Trash2 className="w-4 h-4" />,
635 onClick: (item: Item) => {
636 setStatusActionConfirm({ isOpen: true, item, action: "trash" });
637 },
638 condition: (item: Item) => item.status !== "trash",
639 });
640
641 actions.push({
642 key: "restore",
643 label: __("Restore to Draft", "yatra"),
644 icon: <ArrowUp className="w-4 h-4" />,
645 onClick: (item: Item) => {
646 setStatusActionConfirm({ isOpen: true, item, action: "restore" });
647 },
648 condition: (item: Item) => item.status === "trash",
649 });
650 }
651
652 if (can("yatra_delete_trips")) {
653 actions.push({
654 key: "delete",
655 label: __("Delete Permanently", "yatra"),
656 icon: <Trash2 className="w-4 h-4" />,
657 onClick: (item: Item) => handleDelete(item),
658 variant: "destructive" as const,
659 condition: (item: Item) => item.status === "trash",
660 });
661 }
662
663 return actions;
664 }, [can, queryClient, showToast]);
665
666 return (
667 <div className="space-y-3">
668 <ConfirmationDialog
669 isOpen={deleteConfirm.isOpen}
670 onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
671 onConfirm={confirmDelete}
672 title={__("Delete Item", "yatra")}
673 message={
674 deleteConfirm.item
675 ? __(
676 'Are you sure you want to delete "{name}"? This action cannot be undone.',
677 "yatra",
678 ).replace("{name}", deleteConfirm.item.name)
679 : __(
680 "Are you sure you want to delete this item? This action cannot be undone.",
681 "yatra",
682 )
683 }
684 confirmText={__("Delete", "yatra")}
685 cancelText={__("Cancel", "yatra")}
686 variant="danger"
687 isLoading={deleteMutation.isPending}
688 />
689
690 <ConfirmationDialog
691 isOpen={statusActionConfirm.isOpen}
692 onClose={() =>
693 setStatusActionConfirm({ isOpen: false, item: null, action: null })
694 }
695 onConfirm={async () => {
696 const { item, action } = statusActionConfirm;
697 if (!item || !action) {
698 return;
699 }
700
701 const targetStatus = action === "restore" ? "draft" : action;
702
703 await updateItemStatus(
704 item,
705 targetStatus as "draft" | "publish" | "trash",
706 );
707
708 const successMessage =
709 action === "publish"
710 ? __("Item marked as published.", "yatra")
711 : action === "draft" || action === "restore"
712 ? __("Item marked as draft.", "yatra")
713 : __("Item moved to trash.", "yatra");
714
715 showToast(successMessage, "success");
716 queryClient.invalidateQueries({ queryKey: ["items"] });
717 queryClient.invalidateQueries({ queryKey: ["items-stats"] });
718 setStatusActionConfirm({ isOpen: false, item: null, action: null });
719 }}
720 title={(() => {
721 switch (statusActionConfirm.action) {
722 case "publish":
723 return __("Publish Item", "yatra");
724 case "draft":
725 return __("Mark as Draft", "yatra");
726 case "trash":
727 return __("Move to Trash", "yatra");
728 case "restore":
729 return __("Restore Item", "yatra");
730 default:
731 return __("Confirm Action", "yatra");
732 }
733 })()}
734 message={(() => {
735 const name = statusActionConfirm.item?.name || "";
736 switch (statusActionConfirm.action) {
737 case "publish":
738 return __(
739 'Are you sure you want to publish "{name}"?',
740 "yatra",
741 ).replace("{name}", name);
742 case "draft":
743 return __(
744 'Are you sure you want to mark "{name}" as draft?',
745 "yatra",
746 ).replace("{name}", name);
747 case "trash":
748 return __(
749 'Are you sure you want to move "{name}" to trash?',
750 "yatra",
751 ).replace("{name}", name);
752 case "restore":
753 return __(
754 'Are you sure you want to restore "{name}" to draft?',
755 "yatra",
756 ).replace("{name}", name);
757 default:
758 return __(
759 "Are you sure you want to perform this action?",
760 "yatra",
761 );
762 }
763 })()}
764 confirmText={(() => {
765 switch (statusActionConfirm.action) {
766 case "publish":
767 return __("Publish", "yatra");
768 case "draft":
769 case "restore":
770 return __("Mark as Draft", "yatra");
771 case "trash":
772 return __("Move to Trash", "yatra");
773 default:
774 return __("Confirm", "yatra");
775 }
776 })()}
777 cancelText={__("Cancel", "yatra")}
778 variant="warning"
779 isLoading={false}
780 />
781
782 <PageHeader
783 title={__("Items", "yatra")}
784 description={__(
785 "Manage specific items (subtypes) under item types. Examples: Hiking (under Activity), Lunch (under Meal), Bus (under Transportation).",
786 "yatra",
787 )}
788 actionCapability="yatra_edit_trips"
789 actions={
790 <Button onClick={handleCreate} className="flex items-center gap-2">
791 <Plus className="w-4 h-4" />
792 {__("Add New Item", "yatra")}
793 </Button>
794 }
795 />
796
797 <Card>
798 <CardContent className="p-3">
799 <div className="flex flex-col md:flex-row gap-2 items-stretch md:items-center">
800 <div className="flex-1 relative">
801 <Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
802 <Input
803 type="text"
804 placeholder={__("Search items...", "yatra")}
805 value={searchTerm}
806 onChange={(e) => setSearchTerm(e.target.value)}
807 className="pl-9"
808 />
809 </div>
810
811 <Select
812 value={typeFilter}
813 onChange={(e) => setTypeFilter(e.target.value)}
814 className="w-full md:w-48"
815 >
816 <option value="all">{__("All Types", "yatra")}</option>
817 {types.map((type: any) => (
818 <option key={type.id} value={type.id}>
819 {type.name}
820 </option>
821 ))}
822 </Select>
823
824 <Select
825 value={statusFilter}
826 onChange={(e) => setStatusFilter(e.target.value)}
827 className="w-full md:w-40"
828 >
829 <option value="all">{__("All Status", "yatra")}</option>
830 <option value="draft">{__("Draft", "yatra")}</option>
831 <option value="publish">{__("Publish", "yatra")}</option>
832 <option value="trash">{__("Trash", "yatra")}</option>
833 </Select>
834
835 <Select
836 value={sortBy}
837 onChange={(e) => setSortBy(e.target.value)}
838 className="w-full md:w-40"
839 >
840 <option value="name">{__("Name", "yatra")}</option>
841 <option value="type_id">{__("Type", "yatra")}</option>
842 <option value="status">{__("Status", "yatra")}</option>
843 <option value="created_at">{__("Created At", "yatra")}</option>
844 <option value="updated_at">{__("Updated At", "yatra")}</option>
845 </Select>
846
847 <Button
848 variant="outline"
849 onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
850 className="px-3 flex items-center gap-1.5"
851 title={
852 sortOrder === "asc"
853 ? __("Ascending", "yatra")
854 : __("Descending", "yatra")
855 }
856 >
857 {sortOrder === "asc" ? (
858 <ArrowUp className="w-4 h-4" />
859 ) : (
860 <ArrowDown className="w-4 h-4" />
861 )}
862 <span className="text-xs">
863 {sortOrder === "asc" ? __("Asc", "yatra") : __("Desc", "yatra")}
864 </span>
865 </Button>
866
867 {hasFilters && (
868 <Button
869 variant="outline"
870 onClick={handleResetFilters}
871 className="flex items-center gap-2"
872 >
873 <X className="w-4 h-4" />
874 {__("Reset", "yatra")}
875 </Button>
876 )}
877 </div>
878 </CardContent>
879 </Card>
880
881 <ConditionalRender capability="yatra_view_trips">
882 <>
883 {!isItemsError && (
884 <BulkActionToolbar
885 selectedIds={selectedIds}
886 bulkAction={bulkAction}
887 setBulkAction={setBulkAction}
888 onApply={handleBulkApply}
889 onClearSelection={handleClearSelection}
890 statusFilter={statusFilter}
891 setStatusFilter={setStatusFilter}
892 statusOptions={statusOptions}
893 showColumnsDropdown={showColumnsDropdown}
894 setShowColumnsDropdown={setShowColumnsDropdown}
895 columnOptions={columnOptions}
896 onToggleColumn={toggleColumn}
897 bulkMutationPending={bulkMutationPending}
898 totalItems={total}
899 bulkActionOptions={bulkActionOptions}
900 />
901 )}
902
903 <Card>
904 <CardContent className="p-0">
905 <SharedTable
906 data={items}
907 columns={columns}
908 actions={tableActions}
909 isLoading={isLoading}
910 isError={isItemsError}
911 errorText={__("Error loading items", "yatra")}
912 errorDescription={__(
913 "We couldn't connect to the items service. Please refresh or try again shortly.",
914 "We couldn't connect to the items service. Please refresh or try again shortly.",
915 )}
916 errorDetails={derivedErrorDetails}
917 errorRequestInfo={errorContext.requestInfo}
918 onRetry={() =>
919 queryClient.invalidateQueries({ queryKey: ["items"] })
920 }
921 emptyText={__("No items found", "yatra")}
922 emptyDescription={
923 hasFilters
924 ? __(
925 "Try adjusting your filters to see more results.",
926 "yatra",
927 )
928 : __("Get started by creating your first item.", "yatra")
929 }
930 onCreateClick={
931 can("yatra_edit_trips") ? handleCreate : undefined
932 }
933 onSort={handleSort}
934 getSortIcon={getSortIcon}
935 selectedItemIds={selectedIds}
936 onSelectItem={handleSelectItem}
937 onSelectAll={handleSelectAll}
938 isAllSelected={isAllSelected}
939 getItemId={(item: Item) => item.id}
940 getItemStatus={(item: Item) => item.status}
941 statusFilter={statusFilter}
942 capability="yatra_view_trips"
943 />
944 </CardContent>
945 </Card>
946
947 {total > 0 && (
948 <div className="mt-4">
949 <Pagination
950 currentPage={page}
951 totalPages={totalPages}
952 totalItems={total}
953 itemsPerPage={10}
954 onPageChange={setPage}
955 itemName={__("items", "yatra")}
956 />
957 </div>
958 )}
959 </>
960 </ConditionalRender>
961 </div>
962 );
963 };
964
965 export default Items;
966