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

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

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