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

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