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

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