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

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

967 lines 29.9 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 // eslint-disable-next-line react-hooks/exhaustive-deps
665 }, [can, queryClient, showToast]);
666
667 return (
668 <div className="space-y-3">
669 <ConfirmationDialog
670 isOpen={deleteConfirm.isOpen}
671 onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
672 onConfirm={confirmDelete}
673 title={__("Delete Item", "yatra")}
674 message={
675 deleteConfirm.item
676 ? __(
677 'Are you sure you want to delete "{name}"? This action cannot be undone.',
678 "yatra",
679 ).replace("{name}", deleteConfirm.item.name)
680 : __(
681 "Are you sure you want to delete this item? This action cannot be undone.",
682 "yatra",
683 )
684 }
685 confirmText={__("Delete", "yatra")}
686 cancelText={__("Cancel", "yatra")}
687 variant="danger"
688 isLoading={deleteMutation.isPending}
689 />
690
691 <ConfirmationDialog
692 isOpen={statusActionConfirm.isOpen}
693 onClose={() =>
694 setStatusActionConfirm({ isOpen: false, item: null, action: null })
695 }
696 onConfirm={async () => {
697 const { item, action } = statusActionConfirm;
698 if (!item || !action) {
699 return;
700 }
701
702 const targetStatus = action === "restore" ? "draft" : action;
703
704 await updateItemStatus(
705 item,
706 targetStatus as "draft" | "publish" | "trash",
707 );
708
709 const successMessage =
710 action === "publish"
711 ? __("Item marked as published.", "yatra")
712 : action === "draft" || action === "restore"
713 ? __("Item marked as draft.", "yatra")
714 : __("Item moved to trash.", "yatra");
715
716 showToast(successMessage, "success");
717 queryClient.invalidateQueries({ queryKey: ["items"] });
718 queryClient.invalidateQueries({ queryKey: ["items-stats"] });
719 setStatusActionConfirm({ isOpen: false, item: null, action: null });
720 }}
721 title={(() => {
722 switch (statusActionConfirm.action) {
723 case "publish":
724 return __("Publish Item", "yatra");
725 case "draft":
726 return __("Mark as Draft", "yatra");
727 case "trash":
728 return __("Move to Trash", "yatra");
729 case "restore":
730 return __("Restore Item", "yatra");
731 default:
732 return __("Confirm Action", "yatra");
733 }
734 })()}
735 message={(() => {
736 const name = statusActionConfirm.item?.name || "";
737 switch (statusActionConfirm.action) {
738 case "publish":
739 return __(
740 'Are you sure you want to publish "{name}"?',
741 "yatra",
742 ).replace("{name}", name);
743 case "draft":
744 return __(
745 'Are you sure you want to mark "{name}" as draft?',
746 "yatra",
747 ).replace("{name}", name);
748 case "trash":
749 return __(
750 'Are you sure you want to move "{name}" to trash?',
751 "yatra",
752 ).replace("{name}", name);
753 case "restore":
754 return __(
755 'Are you sure you want to restore "{name}" to draft?',
756 "yatra",
757 ).replace("{name}", name);
758 default:
759 return __(
760 "Are you sure you want to perform this action?",
761 "yatra",
762 );
763 }
764 })()}
765 confirmText={(() => {
766 switch (statusActionConfirm.action) {
767 case "publish":
768 return __("Publish", "yatra");
769 case "draft":
770 case "restore":
771 return __("Mark as Draft", "yatra");
772 case "trash":
773 return __("Move to Trash", "yatra");
774 default:
775 return __("Confirm", "yatra");
776 }
777 })()}
778 cancelText={__("Cancel", "yatra")}
779 variant="warning"
780 isLoading={false}
781 />
782
783 <PageHeader
784 title={__("Items", "yatra")}
785 description={__(
786 "Manage specific items (subtypes) under item types. Examples: Hiking (under Activity), Lunch (under Meal), Bus (under Transportation).",
787 "yatra",
788 )}
789 actionCapability="yatra_edit_trips"
790 actions={
791 <Button onClick={handleCreate} className="flex items-center gap-2">
792 <Plus className="w-4 h-4" />
793 {__("Add New Item", "yatra")}
794 </Button>
795 }
796 />
797
798 <Card>
799 <CardContent className="p-3">
800 <div className="flex flex-col md:flex-row gap-2 items-stretch md:items-center">
801 <div className="flex-1 relative">
802 <Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
803 <Input
804 type="text"
805 placeholder={__("Search items...", "yatra")}
806 value={searchTerm}
807 onChange={(e) => setSearchTerm(e.target.value)}
808 className="pl-9"
809 />
810 </div>
811
812 <Select
813 value={typeFilter}
814 onChange={(e) => setTypeFilter(e.target.value)}
815 className="w-full md:w-48"
816 >
817 <option value="all">{__("All Types", "yatra")}</option>
818 {types.map((type: any) => (
819 <option key={type.id} value={type.id}>
820 {type.name}
821 </option>
822 ))}
823 </Select>
824
825 <Select
826 value={statusFilter}
827 onChange={(e) => setStatusFilter(e.target.value)}
828 className="w-full md:w-40"
829 >
830 <option value="all">{__("All Status", "yatra")}</option>
831 <option value="draft">{__("Draft", "yatra")}</option>
832 <option value="publish">{__("Publish", "yatra")}</option>
833 <option value="trash">{__("Trash", "yatra")}</option>
834 </Select>
835
836 <Select
837 value={sortBy}
838 onChange={(e) => setSortBy(e.target.value)}
839 className="w-full md:w-40"
840 >
841 <option value="name">{__("Name", "yatra")}</option>
842 <option value="type_id">{__("Type", "yatra")}</option>
843 <option value="status">{__("Status", "yatra")}</option>
844 <option value="created_at">{__("Created At", "yatra")}</option>
845 <option value="updated_at">{__("Updated At", "yatra")}</option>
846 </Select>
847
848 <Button
849 variant="outline"
850 onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
851 className="px-3 flex items-center gap-1.5"
852 title={
853 sortOrder === "asc"
854 ? __("Ascending", "yatra")
855 : __("Descending", "yatra")
856 }
857 >
858 {sortOrder === "asc" ? (
859 <ArrowUp className="w-4 h-4" />
860 ) : (
861 <ArrowDown className="w-4 h-4" />
862 )}
863 <span className="text-xs">
864 {sortOrder === "asc" ? __("Asc", "yatra") : __("Desc", "yatra")}
865 </span>
866 </Button>
867
868 {hasFilters && (
869 <Button
870 variant="outline"
871 onClick={handleResetFilters}
872 className="flex items-center gap-2"
873 >
874 <X className="w-4 h-4" />
875 {__("Reset", "yatra")}
876 </Button>
877 )}
878 </div>
879 </CardContent>
880 </Card>
881
882 <ConditionalRender capability="yatra_view_trips">
883 <>
884 {!isItemsError && (
885 <BulkActionToolbar
886 selectedIds={selectedIds}
887 bulkAction={bulkAction}
888 setBulkAction={setBulkAction}
889 onApply={handleBulkApply}
890 onClearSelection={handleClearSelection}
891 statusFilter={statusFilter}
892 setStatusFilter={setStatusFilter}
893 statusOptions={statusOptions}
894 showColumnsDropdown={showColumnsDropdown}
895 setShowColumnsDropdown={setShowColumnsDropdown}
896 columnOptions={columnOptions}
897 onToggleColumn={toggleColumn}
898 bulkMutationPending={bulkMutationPending}
899 totalItems={total}
900 bulkActionOptions={bulkActionOptions}
901 />
902 )}
903
904 <Card>
905 <CardContent className="p-0">
906 <SharedTable
907 data={items}
908 columns={columns}
909 actions={tableActions}
910 isLoading={isLoading}
911 isError={isItemsError}
912 errorText={__("Error loading items", "yatra")}
913 errorDescription={__(
914 "We couldn't connect to the items service. Please refresh or try again shortly.",
915 "We couldn't connect to the items service. Please refresh or try again shortly.",
916 )}
917 errorDetails={derivedErrorDetails}
918 errorRequestInfo={errorContext.requestInfo}
919 onRetry={() =>
920 queryClient.invalidateQueries({ queryKey: ["items"] })
921 }
922 emptyText={__("No items found", "yatra")}
923 emptyDescription={
924 hasFilters
925 ? __(
926 "Try adjusting your filters to see more results.",
927 "yatra",
928 )
929 : __("Get started by creating your first item.", "yatra")
930 }
931 onCreateClick={
932 can("yatra_edit_trips") ? handleCreate : undefined
933 }
934 onSort={handleSort}
935 getSortIcon={getSortIcon}
936 selectedItemIds={selectedIds}
937 onSelectItem={handleSelectItem}
938 onSelectAll={handleSelectAll}
939 isAllSelected={isAllSelected}
940 getItemId={(item: Item) => item.id}
941 getItemStatus={(item: Item) => item.status}
942 statusFilter={statusFilter}
943 capability="yatra_view_trips"
944 />
945 </CardContent>
946 </Card>
947
948 {total > 0 && (
949 <div className="mt-4">
950 <Pagination
951 currentPage={page}
952 totalPages={totalPages}
953 totalItems={total}
954 itemsPerPage={10}
955 onPageChange={setPage}
956 itemName={__("items", "yatra")}
957 />
958 </div>
959 )}
960 </>
961 </ConditionalRender>
962 </div>
963 );
964 };
965
966 export default Items;
967