PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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.4, at resources/js/pages/ItemTypes.tsx

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