PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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.13, at resources/js/pages/Items.tsx

973 lines 30.2 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 // Report the input that is actually missing. The Apply button is disabled
425 // whenever nothing is selected, so this can only be reached with a non-empty
426 // selection — the combined message blamed the selection and left operators
427 // re-selecting rows that were already ticked.
428 if (selectedIds.length === 0) {
429 showToast(__("Please select at least one item.", "yatra"), "error");
430 return;
431 }
432
433 if (!bulkAction) {
434 showToast(__("Please choose a bulk action to apply.", "yatra"), "error");
435 return;
436 }
437
438 try {
439 setBulkMutationPending(true);
440
441 const selectedItems = items.filter((item: Item) =>
442 selectedIds.includes(item.id),
443 );
444
445 if (bulkAction === "delete") {
446 await Promise.all(
447 selectedIds.map((id) => apiClient.delete(`/items/${id}`)),
448 );
449 showToast(__("Items deleted successfully", "yatra"), "success");
450 } else if (bulkAction === "publish") {
451 await Promise.all(
452 selectedItems.map((item: Item) => updateItemStatus(item, "publish")),
453 );
454 showToast(__("Items marked as published", "yatra"), "success");
455 } else if (bulkAction === "draft") {
456 const targetStatus: "draft" = "draft";
457 await Promise.all(
458 selectedItems.map((item: Item) =>
459 updateItemStatus(item, targetStatus),
460 ),
461 );
462 showToast(
463 statusFilter === "trash"
464 ? __("Items restored to draft", "yatra")
465 : __("Items marked as draft", "yatra"),
466 "success",
467 );
468 } else if (bulkAction === "trash") {
469 await Promise.all(
470 selectedItems.map((item: Item) => updateItemStatus(item, "trash")),
471 );
472 showToast(__("Items moved to trash", "yatra"), "success");
473 } else if (bulkAction === "restore") {
474 await Promise.all(
475 selectedItems.map((item: Item) => updateItemStatus(item, "draft")),
476 );
477 showToast(__("Items restored to draft", "yatra"), "success");
478 }
479
480 await queryClient.invalidateQueries({ queryKey: ["items"] });
481 await queryClient.invalidateQueries({ queryKey: ["items-stats"] });
482 setSelectedIds([]);
483 setBulkAction("");
484 } catch (error) {
485 showToast(__("Bulk action failed", "yatra"), "error");
486 } finally {
487 setBulkMutationPending(false);
488 }
489 };
490
491 const columns = [
492 {
493 key: "item",
494 label: __("Item", "yatra"),
495 sortable: true,
496 width: "w-[200px]",
497 visible: visibleColumns.item,
498 render: (item: Item) => (
499 <div>
500 <div className="font-medium text-gray-900 dark:text-white">
501 <a
502 href={`${baseAdminUrl}?page=yatra&subpage=itinerary&tab=items&action=edit&id=${item.id}`}
503 className="hover:underline underline-offset-2 text-blue-600 dark:text-blue-400"
504 >
505 {item.name}
506 </a>
507 </div>
508 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
509 {item.slug}
510 </div>
511 </div>
512 ),
513 },
514 {
515 key: "type",
516 label: __("Type", "yatra"),
517 sortable: true,
518 width: "w-[150px]",
519 visible: visibleColumns.type,
520 render: (item: Item) => (
521 <div className="flex items-center gap-2">
522 {item.type_icon && (
523 <div className="w-8 h-8 rounded-md bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
524 <IconSelector
525 iconName={item.type_icon as any}
526 provider={item.type_icon_provider ?? "yatra"}
527 className="w-4 h-4 text-gray-700 dark:text-gray-300"
528 />
529 </div>
530 )}
531 <span className="text-sm text-gray-600 dark:text-gray-400">
532 {item.type_name || __("Unknown", "yatra")}
533 </span>
534 </div>
535 ),
536 },
537 {
538 key: "description",
539 label: __("Description", "yatra"),
540 sortable: false,
541 width: "w-[200px]",
542 visible: visibleColumns.description,
543 render: (item: Item) => (
544 <span className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2">
545 {item.description || __("No description", "yatra")}
546 </span>
547 ),
548 },
549 {
550 key: "status",
551 label: __("Status", "yatra"),
552 sortable: true,
553 width: "w-[100px]",
554 visible: visibleColumns.status,
555 render: (item: Item) => getStatusBadge(item.status),
556 },
557 {
558 key: "usage",
559 label: __("Usage", "yatra"),
560 sortable: false,
561 width: "w-[100px]",
562 visible: visibleColumns.usage,
563 render: (item: Item) => (
564 <div className="text-center">
565 <Badge variant="outline" className="font-medium">
566 {item.usage_count ?? 0}
567 </Badge>
568 </div>
569 ),
570 },
571 {
572 key: "date",
573 label: __("Date", "yatra"),
574 sortable: true,
575 width: "w-[150px]",
576 visible: visibleColumns.date,
577 render: (item: Item) => (
578 <div className="text-gray-500 dark:text-gray-400 text-sm">
579 <div>{formatDate(item.created_at)}</div>
580 <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
581 {__("Updated", "yatra")}: {formatDate(item.updated_at)}
582 </div>
583 </div>
584 ),
585 },
586 {
587 key: "author",
588 label: __("Author", "yatra"),
589 sortable: false,
590 width: "w-[150px]",
591 visible: visibleColumns.author,
592 render: (item: Item) => (
593 <div className="text-gray-600 dark:text-gray-400 text-sm">
594 <div>{formatUser(item.created_by, item.created_by_name)}</div>
595 <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
596 {__("Updated by", "yatra")}:{" "}
597 {formatUser(item.updated_by, item.updated_by_name)}
598 </div>
599 </div>
600 ),
601 },
602 ];
603
604 const tableActions = useMemo(() => {
605 const actions: any[] = [];
606
607 if (can("yatra_edit_trips")) {
608 actions.push({
609 key: "edit",
610 label: __("Edit", "yatra"),
611 icon: <Edit className="w-4 h-4" />,
612 onClick: (item: Item) => handleEdit(item),
613 });
614
615 actions.push({
616 key: "publish",
617 label: __("Mark as Published", "yatra"),
618 icon: <ArrowUp className="w-4 h-4" />,
619 onClick: (item: Item) => {
620 setStatusActionConfirm({ isOpen: true, item, action: "publish" });
621 },
622 condition: (item: Item) =>
623 item.status !== "publish" && item.status !== "trash",
624 });
625
626 actions.push({
627 key: "draft",
628 label: __("Mark as Draft", "yatra"),
629 icon: <ArrowDown className="w-4 h-4" />,
630 onClick: (item: Item) => {
631 setStatusActionConfirm({ isOpen: true, item, action: "draft" });
632 },
633 condition: (item: Item) =>
634 item.status !== "draft" && item.status !== "trash",
635 });
636
637 actions.push({
638 key: "trash",
639 label: __("Move to Trash", "yatra"),
640 icon: <Trash2 className="w-4 h-4" />,
641 onClick: (item: Item) => {
642 setStatusActionConfirm({ isOpen: true, item, action: "trash" });
643 },
644 condition: (item: Item) => item.status !== "trash",
645 });
646
647 actions.push({
648 key: "restore",
649 label: __("Restore to Draft", "yatra"),
650 icon: <ArrowUp className="w-4 h-4" />,
651 onClick: (item: Item) => {
652 setStatusActionConfirm({ isOpen: true, item, action: "restore" });
653 },
654 condition: (item: Item) => item.status === "trash",
655 });
656 }
657
658 if (can("yatra_delete_trips")) {
659 actions.push({
660 key: "delete",
661 label: __("Delete Permanently", "yatra"),
662 icon: <Trash2 className="w-4 h-4" />,
663 onClick: (item: Item) => handleDelete(item),
664 variant: "destructive" as const,
665 condition: (item: Item) => item.status === "trash",
666 });
667 }
668
669 return actions;
670 // eslint-disable-next-line react-hooks/exhaustive-deps
671 }, [can, queryClient, showToast]);
672
673 return (
674 <div className="space-y-3">
675 <ConfirmationDialog
676 isOpen={deleteConfirm.isOpen}
677 onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
678 onConfirm={confirmDelete}
679 title={__("Delete Item", "yatra")}
680 message={
681 deleteConfirm.item
682 ? __(
683 'Are you sure you want to delete "{name}"? This action cannot be undone.',
684 "yatra",
685 ).replace("{name}", deleteConfirm.item.name)
686 : __(
687 "Are you sure you want to delete this item? This action cannot be undone.",
688 "yatra",
689 )
690 }
691 confirmText={__("Delete", "yatra")}
692 cancelText={__("Cancel", "yatra")}
693 variant="danger"
694 isLoading={deleteMutation.isPending}
695 />
696
697 <ConfirmationDialog
698 isOpen={statusActionConfirm.isOpen}
699 onClose={() =>
700 setStatusActionConfirm({ isOpen: false, item: null, action: null })
701 }
702 onConfirm={async () => {
703 const { item, action } = statusActionConfirm;
704 if (!item || !action) {
705 return;
706 }
707
708 const targetStatus = action === "restore" ? "draft" : action;
709
710 await updateItemStatus(
711 item,
712 targetStatus as "draft" | "publish" | "trash",
713 );
714
715 const successMessage =
716 action === "publish"
717 ? __("Item marked as published.", "yatra")
718 : action === "draft" || action === "restore"
719 ? __("Item marked as draft.", "yatra")
720 : __("Item moved to trash.", "yatra");
721
722 showToast(successMessage, "success");
723 queryClient.invalidateQueries({ queryKey: ["items"] });
724 queryClient.invalidateQueries({ queryKey: ["items-stats"] });
725 setStatusActionConfirm({ isOpen: false, item: null, action: null });
726 }}
727 title={(() => {
728 switch (statusActionConfirm.action) {
729 case "publish":
730 return __("Publish Item", "yatra");
731 case "draft":
732 return __("Mark as Draft", "yatra");
733 case "trash":
734 return __("Move to Trash", "yatra");
735 case "restore":
736 return __("Restore Item", "yatra");
737 default:
738 return __("Confirm Action", "yatra");
739 }
740 })()}
741 message={(() => {
742 const name = statusActionConfirm.item?.name || "";
743 switch (statusActionConfirm.action) {
744 case "publish":
745 return __(
746 'Are you sure you want to publish "{name}"?',
747 "yatra",
748 ).replace("{name}", name);
749 case "draft":
750 return __(
751 'Are you sure you want to mark "{name}" as draft?',
752 "yatra",
753 ).replace("{name}", name);
754 case "trash":
755 return __(
756 'Are you sure you want to move "{name}" to trash?',
757 "yatra",
758 ).replace("{name}", name);
759 case "restore":
760 return __(
761 'Are you sure you want to restore "{name}" to draft?',
762 "yatra",
763 ).replace("{name}", name);
764 default:
765 return __(
766 "Are you sure you want to perform this action?",
767 "yatra",
768 );
769 }
770 })()}
771 confirmText={(() => {
772 switch (statusActionConfirm.action) {
773 case "publish":
774 return __("Publish", "yatra");
775 case "draft":
776 case "restore":
777 return __("Mark as Draft", "yatra");
778 case "trash":
779 return __("Move to Trash", "yatra");
780 default:
781 return __("Confirm", "yatra");
782 }
783 })()}
784 cancelText={__("Cancel", "yatra")}
785 variant="warning"
786 isLoading={false}
787 />
788
789 <PageHeader
790 title={__("Items", "yatra")}
791 description={__(
792 "Manage specific items (subtypes) under item types. Examples: Hiking (under Activity), Lunch (under Meal), Bus (under Transportation).",
793 "yatra",
794 )}
795 actionCapability="yatra_edit_trips"
796 actions={
797 <Button onClick={handleCreate} className="flex items-center gap-2">
798 <Plus className="w-4 h-4" />
799 {__("Add New Item", "yatra")}
800 </Button>
801 }
802 />
803
804 <Card>
805 <CardContent className="p-3">
806 <div className="flex flex-col md:flex-row gap-2 items-stretch md:items-center">
807 <div className="flex-1 relative">
808 <Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
809 <Input
810 type="text"
811 placeholder={__("Search items...", "yatra")}
812 value={searchTerm}
813 onChange={(e) => setSearchTerm(e.target.value)}
814 className="pl-9"
815 />
816 </div>
817
818 <Select
819 value={typeFilter}
820 onChange={(e) => setTypeFilter(e.target.value)}
821 className="w-full md:w-48"
822 >
823 <option value="all">{__("All Types", "yatra")}</option>
824 {types.map((type: any) => (
825 <option key={type.id} value={type.id}>
826 {type.name}
827 </option>
828 ))}
829 </Select>
830
831 <Select
832 value={statusFilter}
833 onChange={(e) => setStatusFilter(e.target.value)}
834 className="w-full md:w-40"
835 >
836 <option value="all">{__("All Status", "yatra")}</option>
837 <option value="draft">{__("Draft", "yatra")}</option>
838 <option value="publish">{__("Publish", "yatra")}</option>
839 <option value="trash">{__("Trash", "yatra")}</option>
840 </Select>
841
842 <Select
843 value={sortBy}
844 onChange={(e) => setSortBy(e.target.value)}
845 className="w-full md:w-40"
846 >
847 <option value="name">{__("Name", "yatra")}</option>
848 <option value="type_id">{__("Type", "yatra")}</option>
849 <option value="status">{__("Status", "yatra")}</option>
850 <option value="created_at">{__("Created At", "yatra")}</option>
851 <option value="updated_at">{__("Updated At", "yatra")}</option>
852 </Select>
853
854 <Button
855 variant="outline"
856 onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
857 className="px-3 flex items-center gap-1.5"
858 title={
859 sortOrder === "asc"
860 ? __("Ascending", "yatra")
861 : __("Descending", "yatra")
862 }
863 >
864 {sortOrder === "asc" ? (
865 <ArrowUp className="w-4 h-4" />
866 ) : (
867 <ArrowDown className="w-4 h-4" />
868 )}
869 <span className="text-xs">
870 {sortOrder === "asc" ? __("Asc", "yatra") : __("Desc", "yatra")}
871 </span>
872 </Button>
873
874 {hasFilters && (
875 <Button
876 variant="outline"
877 onClick={handleResetFilters}
878 className="flex items-center gap-2"
879 >
880 <X className="w-4 h-4" />
881 {__("Reset", "yatra")}
882 </Button>
883 )}
884 </div>
885 </CardContent>
886 </Card>
887
888 <ConditionalRender capability="yatra_view_trips">
889 <>
890 {!isItemsError && (
891 <BulkActionToolbar
892 selectedIds={selectedIds}
893 bulkAction={bulkAction}
894 setBulkAction={setBulkAction}
895 onApply={handleBulkApply}
896 onClearSelection={handleClearSelection}
897 statusFilter={statusFilter}
898 setStatusFilter={setStatusFilter}
899 statusOptions={statusOptions}
900 showColumnsDropdown={showColumnsDropdown}
901 setShowColumnsDropdown={setShowColumnsDropdown}
902 columnOptions={columnOptions}
903 onToggleColumn={toggleColumn}
904 bulkMutationPending={bulkMutationPending}
905 totalItems={total}
906 bulkActionOptions={bulkActionOptions}
907 />
908 )}
909
910 <Card>
911 <CardContent className="p-0">
912 <SharedTable
913 data={items}
914 columns={columns}
915 actions={tableActions}
916 isLoading={isLoading}
917 isError={isItemsError}
918 errorText={__("Error loading items", "yatra")}
919 errorDescription={__(
920 "We couldn't connect to the items service. Please refresh or try again shortly.",
921 "We couldn't connect to the items service. Please refresh or try again shortly.",
922 )}
923 errorDetails={derivedErrorDetails}
924 errorRequestInfo={errorContext.requestInfo}
925 onRetry={() =>
926 queryClient.invalidateQueries({ queryKey: ["items"] })
927 }
928 emptyText={__("No items found", "yatra")}
929 emptyDescription={
930 hasFilters
931 ? __(
932 "Try adjusting your filters to see more results.",
933 "yatra",
934 )
935 : __("Get started by creating your first item.", "yatra")
936 }
937 onCreateClick={
938 can("yatra_edit_trips") ? handleCreate : undefined
939 }
940 onSort={handleSort}
941 getSortIcon={getSortIcon}
942 selectedItemIds={selectedIds}
943 onSelectItem={handleSelectItem}
944 onSelectAll={handleSelectAll}
945 isAllSelected={isAllSelected}
946 getItemId={(item: Item) => item.id}
947 getItemStatus={(item: Item) => item.status}
948 statusFilter={statusFilter}
949 capability="yatra_view_trips"
950 />
951 </CardContent>
952 </Card>
953
954 {total > 0 && (
955 <div className="mt-4">
956 <Pagination
957 currentPage={page}
958 totalPages={totalPages}
959 totalItems={total}
960 itemsPerPage={10}
961 onPageChange={setPage}
962 itemName={__("items", "yatra")}
963 />
964 </div>
965 )}
966 </>
967 </ConditionalRender>
968 </div>
969 );
970 };
971
972 export default Items;
973