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

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

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