671 lines
22 KiB
TypeScript
Executable File
671 lines
22 KiB
TypeScript
Executable File
// components/PageCrudWrapper.tsx
|
|
import { useState, useEffect, ReactNode } from 'react';
|
|
import { PageTemplate, PageAction } from '@/components/page-template';
|
|
import { PlusIcon } from 'lucide-react';
|
|
import { router, usePage } from '@inertiajs/react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Filter, Search } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Pagination } from '@/components/ui/pagination';
|
|
import { hasPermission } from '@/utils/authorization';
|
|
import { CrudTable } from './CrudTable';
|
|
import { CrudFormModal } from './CrudFormModal';
|
|
import { CrudDeleteModal } from './CrudDeleteModal';
|
|
import { toast } from '@/components/custom-toast';
|
|
import { CrudConfig } from '@/types/crud';
|
|
import { BreadcrumbItem } from '@/types';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
export interface CrudButton {
|
|
label: string;
|
|
icon?: ReactNode;
|
|
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
|
onClick?: () => void;
|
|
permission?: string;
|
|
className?: string;
|
|
showAddButton?: boolean;
|
|
}
|
|
|
|
interface PageCrudWrapperProps {
|
|
config: CrudConfig;
|
|
title?: string;
|
|
url: string;
|
|
buttons?: CrudButton[];
|
|
breadcrumbs?: BreadcrumbItem[];
|
|
}
|
|
|
|
export function PageCrudWrapper({
|
|
config,
|
|
title,
|
|
url,
|
|
buttons = [],
|
|
breadcrumbs
|
|
}: PageCrudWrapperProps) {
|
|
const { t } = useTranslation();
|
|
const { entity, table, filters = [], form, hooks } = config;
|
|
const { auth, globalSettings, ...pageProps } = usePage().props as any;
|
|
const permissions = auth?.permissions || [];
|
|
|
|
// Get data from page props using entity name
|
|
const data = pageProps[entity.name] || { data: [], links: [] };
|
|
const pageFilters = pageProps.filters || {};
|
|
|
|
// State
|
|
const [searchTerm, setSearchTerm] = useState(pageFilters.search || '');
|
|
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
|
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
const [currentItem, setCurrentItem] = useState<any>(null);
|
|
const [formMode, setFormMode] = useState<'create' | 'edit' | 'view'>('create');
|
|
|
|
// Initialize filter values from URL
|
|
useEffect(() => {
|
|
const initialFilters: Record<string, any> = {};
|
|
filters.forEach(filter => {
|
|
const filterKey = filter.name || filter.key;
|
|
initialFilters[filterKey] = pageFilters[filterKey] || '';
|
|
});
|
|
setFilterValues(initialFilters);
|
|
}, []);
|
|
|
|
// Check if any filters are active
|
|
const hasActiveFilters = () => {
|
|
return Object.entries(filterValues).some(([key, value]) => {
|
|
return value && value !== '';
|
|
}) || searchTerm !== '';
|
|
};
|
|
|
|
// Count active filters
|
|
const activeFilterCount = () => {
|
|
return Object.entries(filterValues).filter(([key, value]) => {
|
|
return value && value !== '';
|
|
}).length + (searchTerm ? 1 : 0);
|
|
};
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
applyFilters();
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
const params: any = { page: 1 };
|
|
|
|
if (searchTerm) {
|
|
params.search = searchTerm;
|
|
}
|
|
|
|
// Add filter values to params
|
|
Object.entries(filterValues).forEach(([key, value]) => {
|
|
if (value && value !== '') {
|
|
params[key] = value;
|
|
}
|
|
});
|
|
|
|
// Add per_page if it exists
|
|
if (pageFilters.per_page) {
|
|
params.per_page = pageFilters.per_page;
|
|
}
|
|
|
|
router.get(entity.endpoint, params, { preserveState: true, preserveScroll: true });
|
|
};
|
|
|
|
const handleFilterChange = (key: string, value: any) => {
|
|
setFilterValues(prev => ({ ...prev, [key]: value }));
|
|
|
|
const params: any = { page: 1 };
|
|
|
|
if (searchTerm) {
|
|
params.search = searchTerm;
|
|
}
|
|
|
|
// Add all current filter values
|
|
const newFilters = { ...filterValues, [key]: value };
|
|
Object.entries(newFilters).forEach(([k, v]) => {
|
|
if (v && v !== '') {
|
|
params[k] = v;
|
|
}
|
|
});
|
|
|
|
// Add per_page if it exists
|
|
if (pageFilters.per_page) {
|
|
params.per_page = pageFilters.per_page;
|
|
}
|
|
|
|
router.get(entity.endpoint, params, { preserveState: true, preserveScroll: true });
|
|
};
|
|
|
|
const handleSort = (field: string) => {
|
|
const direction = pageFilters.sort_field === field && pageFilters.sort_direction === 'asc' ? 'desc' : 'asc';
|
|
|
|
const params: any = {
|
|
sort_field: field,
|
|
sort_direction: direction,
|
|
page: 1
|
|
};
|
|
|
|
// Add search and filters
|
|
if (searchTerm) {
|
|
params.search = searchTerm;
|
|
}
|
|
|
|
Object.entries(filterValues).forEach(([key, value]) => {
|
|
if (value && value !== '') {
|
|
params[key] = value;
|
|
}
|
|
});
|
|
|
|
// Add per_page if it exists
|
|
if (pageFilters.per_page) {
|
|
params.per_page = pageFilters.per_page;
|
|
}
|
|
|
|
router.get(entity.endpoint, params, { preserveState: true, preserveScroll: true });
|
|
};
|
|
|
|
const handleAction = (action: string, item: any) => {
|
|
setCurrentItem(item);
|
|
|
|
switch (action) {
|
|
case 'view':
|
|
setFormMode('view');
|
|
setIsFormModalOpen(true);
|
|
break;
|
|
case 'edit':
|
|
setFormMode('edit');
|
|
setIsFormModalOpen(true);
|
|
break;
|
|
case 'delete':
|
|
setIsDeleteModalOpen(true);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
const handleAddNew = () => {
|
|
setCurrentItem(null);
|
|
setFormMode('create');
|
|
setIsFormModalOpen(true);
|
|
};
|
|
|
|
const handleFormSubmit = (formData: any) => {
|
|
// Make a copy of the form data to avoid modifying the original
|
|
const processedFormData = { ...formData };
|
|
|
|
// For roles, create a simplified object with only the required fields
|
|
if (entity.name === 'roles') {
|
|
// Extract permission names from the permissions array if they're objects
|
|
if (processedFormData.permissions && Array.isArray(processedFormData.permissions)) {
|
|
const permissionNames = processedFormData.permissions.map(p => {
|
|
if (typeof p === 'object' && p !== null && p.name) {
|
|
return p.name;
|
|
}
|
|
return String(p);
|
|
});
|
|
processedFormData.permissions = permissionNames;
|
|
}
|
|
|
|
// Reset the object with only the fields we need
|
|
const cleanData = {
|
|
label: processedFormData.label,
|
|
description: processedFormData.description || '',
|
|
permissions: processedFormData.permissions || []
|
|
};
|
|
|
|
// Replace all properties
|
|
Object.keys(processedFormData).forEach(key => {
|
|
delete processedFormData[key];
|
|
});
|
|
|
|
Object.assign(processedFormData, cleanData);
|
|
}
|
|
// Fix permissions format for other entities
|
|
else if (processedFormData.permissions && Array.isArray(processedFormData.permissions)) {
|
|
const permissionsObj = {};
|
|
processedFormData.permissions.forEach((id, index) => {
|
|
permissionsObj[index] = String(id);
|
|
});
|
|
processedFormData.permissions = permissionsObj;
|
|
}
|
|
|
|
// Ensure we're not sending the name field for permissions as it's auto-generated
|
|
if (entity.name === 'permissions' && formMode === 'edit') {
|
|
delete processedFormData.name;
|
|
}
|
|
|
|
// Check if this entity has file uploads
|
|
const hasFileFields = form.fields.some(field => field.type === 'file');
|
|
|
|
if (hasFileFields) {
|
|
// Get file field names
|
|
const fileFields = form.fields
|
|
.filter(field => field.type === 'file')
|
|
.map(field => field.name);
|
|
|
|
// Use FormData for file uploads
|
|
const formDataObj = new FormData();
|
|
|
|
// Add all fields to FormData
|
|
Object.keys(processedFormData).forEach(key => {
|
|
// For file fields in edit mode
|
|
if (fileFields.includes(key) && formMode === 'edit') {
|
|
// Only include the file if a new one was selected
|
|
if (processedFormData[key] && typeof processedFormData[key] === 'object') {
|
|
formDataObj.append(key, processedFormData[key]);
|
|
}
|
|
// Otherwise skip this field - don't send empty file fields
|
|
return;
|
|
}
|
|
formDataObj.append(key, processedFormData[key]);
|
|
});
|
|
|
|
if (formMode === 'create') {
|
|
// Show loading toast
|
|
if (!globalSettings?.is_demo) {
|
|
toast.loading(t('Creating...'));
|
|
}
|
|
|
|
router.post(entity.endpoint, formDataObj, {
|
|
onSuccess: (page) => {
|
|
setIsFormModalOpen(false);
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (page.props.flash.success) {
|
|
toast.success(t(page.props.flash.success));
|
|
} else if (page.props.flash.error) {
|
|
toast.error(t(page.props.flash.error));
|
|
}
|
|
if (hooks?.afterCreate) {
|
|
hooks.afterCreate(formData, page.props[entity.name]);
|
|
}
|
|
},
|
|
onError: (errors) => {
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (typeof errors === 'string') {
|
|
toast.error(t(errors));
|
|
} else {
|
|
toast.error(t(`Failed to create ${entity.name.slice(0, -1)}: {{errors}}`, { errors: Object.values(errors).join(', ') }));
|
|
}
|
|
}
|
|
});
|
|
} else if (formMode === 'edit') {
|
|
// Show loading toast
|
|
if (!globalSettings?.is_demo) {
|
|
toast.loading(t('Updating...'));
|
|
}
|
|
|
|
router.post(`${entity.endpoint}/${currentItem.id}?_method=PUT`, formDataObj, {
|
|
onSuccess: (page) => {
|
|
setIsFormModalOpen(false);
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (page.props.flash.success) {
|
|
toast.success(t(page.props.flash.success));
|
|
} else if (page.props.flash.error) {
|
|
toast.error(t(page.props.flash.error));
|
|
}
|
|
if (hooks?.afterUpdate) {
|
|
hooks.afterUpdate(formData, page.props[entity.name]);
|
|
}
|
|
},
|
|
onError: (errors) => {
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (typeof errors === 'string') {
|
|
toast.error(t(errors));
|
|
} else {
|
|
toast.error(t(`Failed to update ${entity.name.slice(0, -1)}: {{errors}}`, { errors: Object.values(errors).join(', ') }));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (formMode === 'create') {
|
|
// Show loading toast
|
|
if (!globalSettings?.is_demo) {
|
|
toast.loading(t('Creating...'));
|
|
}
|
|
|
|
router.post(entity.endpoint, processedFormData, {
|
|
onSuccess: (page) => {
|
|
setIsFormModalOpen(false);
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (page.props.flash.success) {
|
|
toast.success(t(page.props.flash.success));
|
|
} else if (page.props.flash.error) {
|
|
toast.error(t(page.props.flash.error));
|
|
}
|
|
if (hooks?.afterCreate) {
|
|
hooks.afterCreate(formData, page.props[entity.name]);
|
|
}
|
|
},
|
|
onError: (errors) => {
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (typeof errors === 'string') {
|
|
toast.error(t(errors));
|
|
} else {
|
|
toast.error(t(`Failed to create ${entity.name.slice(0, -1)}: {{errors}}`, { errors: Object.values(errors).join(', ') }));
|
|
}
|
|
}
|
|
});
|
|
} else if (formMode === 'edit') {
|
|
// Show loading toast
|
|
if (!globalSettings?.is_demo) {
|
|
toast.loading(t('Updating...'));
|
|
}
|
|
|
|
router.put(`${entity.endpoint}/${currentItem.id}`, processedFormData, {
|
|
onSuccess: (page) => {
|
|
setIsFormModalOpen(false);
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (page.props.flash.success) {
|
|
toast.success(t(page.props.flash.success));
|
|
} else if (page.props.flash.error) {
|
|
toast.error(t(page.props.flash.error));
|
|
}
|
|
if (hooks?.afterUpdate) {
|
|
hooks.afterUpdate(formData, page.props[entity.name]);
|
|
}
|
|
},
|
|
onError: (errors) => {
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (typeof errors === 'string') {
|
|
toast.error(t(errors));
|
|
} else {
|
|
toast.error(t(`Failed to update ${entity.name.slice(0, -1)}: {{errors}}`, { errors: Object.values(errors).join(', ') }));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeleteConfirm = () => {
|
|
// Show loading toast
|
|
if (!globalSettings?.is_demo) {
|
|
toast.loading(t('Deleting...'));
|
|
}
|
|
|
|
router.delete(`${entity.endpoint}/${currentItem.id}`, {
|
|
onSuccess: (page) => {
|
|
setIsDeleteModalOpen(false);
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (page.props.flash.success) {
|
|
toast.success(t(page.props.flash.success));
|
|
} else if (page.props.flash.error) {
|
|
toast.error(t(page.props.flash.error));
|
|
}
|
|
if (hooks?.afterDelete) {
|
|
hooks.afterDelete(currentItem.id);
|
|
}
|
|
},
|
|
onError: (errors) => {
|
|
if (!globalSettings?.is_demo) {
|
|
toast.dismiss();
|
|
}
|
|
if (typeof errors === 'string') {
|
|
toast.error(t(errors));
|
|
} else {
|
|
toast.error(t(`Failed to delete ${entity.name.slice(0, -1)}: {{errors}}`, { errors: Object.values(errors).join(', ') }));
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleResetFilters = () => {
|
|
// Reset all filters to default values
|
|
const resetFilters: Record<string, any> = {};
|
|
filters.forEach(filter => {
|
|
resetFilters[filter.key] = filter.type === 'select' ? 'all' : '';
|
|
});
|
|
|
|
setFilterValues(resetFilters);
|
|
setSearchTerm('');
|
|
setShowFilters(false);
|
|
|
|
router.get(entity.endpoint, {
|
|
page: 1,
|
|
per_page: pageFilters.per_page
|
|
}, { preserveState: true, preserveScroll: true });
|
|
};
|
|
|
|
// Check if we should show the add button
|
|
const showAddButton = buttons.every(button => button.showAddButton !== false);
|
|
|
|
// Define page actions
|
|
const pageActions: PageAction[] = [];
|
|
|
|
// Add custom buttons with permission check
|
|
buttons.forEach(button => {
|
|
if (!button.permission || hasPermission(permissions, button.permission)) {
|
|
pageActions.push({
|
|
label: button.label,
|
|
icon: button.icon,
|
|
variant: button.variant,
|
|
onClick: button.onClick
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add the default "Add New" button if allowed and user has permission
|
|
if (showAddButton && hasPermission(permissions, entity.permissions.create)) {
|
|
pageActions.push({
|
|
label: `Add ${entity.name.slice(0, -1).charAt(0).toUpperCase() + entity.name.slice(0, -1).slice(1)}`,
|
|
icon: <PlusIcon className="h-4 w-4" />,
|
|
variant: 'default',
|
|
onClick: () => handleAddNew()
|
|
});
|
|
}
|
|
|
|
const pageTitle = title || entity.name.charAt(0).toUpperCase() + entity.name.slice(1);
|
|
|
|
// Generate default breadcrumbs if not provided
|
|
const defaultBreadcrumbs: BreadcrumbItem[] = [
|
|
{ title: t('Dashboard'), href: route('dashboard') },
|
|
{ title: pageTitle }
|
|
];
|
|
|
|
const pageBreadcrumbs = breadcrumbs || defaultBreadcrumbs;
|
|
|
|
return (
|
|
<PageTemplate
|
|
title={pageTitle}
|
|
url={url}
|
|
actions={pageActions}
|
|
breadcrumbs={pageBreadcrumbs}
|
|
noPadding
|
|
>
|
|
{/* Search and filters section */}
|
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow mb-4">
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<form onSubmit={handleSearch} className="flex gap-2">
|
|
<div className="relative w-64">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={`Search ${entity.name}...`}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-9"
|
|
/>
|
|
</div>
|
|
<Button type="submit" size="sm">
|
|
<Search className="h-4 w-4 mr-1.5" />
|
|
{t("Search")}
|
|
</Button>
|
|
</form>
|
|
|
|
{filters.length > 0 && (
|
|
<div className="ml-2">
|
|
<Button
|
|
variant={hasActiveFilters() ? "default" : "outline"}
|
|
size="sm"
|
|
className="h-8 px-2 py-1"
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
>
|
|
<Filter className="h-3.5 w-3.5 mr-1.5" />
|
|
{showFilters ? 'Hide Filters' : 'Filters'}
|
|
{hasActiveFilters() && (
|
|
<span className="ml-1 bg-primary-foreground text-primary rounded-full w-5 h-5 flex items-center justify-center text-xs">
|
|
{activeFilterCount()}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-xs text-muted-foreground">{t("Per Page")}:</Label>
|
|
<Select
|
|
value={pageFilters.per_page?.toString() || "10"}
|
|
onValueChange={(value) => {
|
|
const params: any = { page: 1, per_page: parseInt(value) };
|
|
|
|
if (searchTerm) {
|
|
params.search = searchTerm;
|
|
}
|
|
|
|
Object.entries(filterValues).forEach(([key, val]) => {
|
|
if (val && val !== '') {
|
|
params[key] = val;
|
|
}
|
|
});
|
|
|
|
router.get(entity.endpoint, params, { preserveState: true, preserveScroll: true });
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-16 h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="10">10</SelectItem>
|
|
<SelectItem value="25">25</SelectItem>
|
|
<SelectItem value="50">50</SelectItem>
|
|
<SelectItem value="100">100</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{showFilters && filters.length > 0 && (
|
|
<div className="w-full mt-3 p-4 bg-gray-50 dark:bg-gray-800 border dark:border-gray-700 rounded-md">
|
|
<div className="flex flex-wrap gap-4 items-end">
|
|
{filters.map((filter) => {
|
|
const filterKey = filter.name || filter.key;
|
|
return (
|
|
<div key={filterKey} className="space-y-2">
|
|
<Label>{filter.label}</Label>
|
|
{filter.type === 'select' && (
|
|
<Select
|
|
value={filterValues[filterKey] || ''}
|
|
onValueChange={(value) => handleFilterChange(filterKey, value)}
|
|
>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue placeholder={`All ${filter.label}`} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{filter.options?.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9"
|
|
onClick={handleResetFilters}
|
|
disabled={!hasActiveFilters()}
|
|
>
|
|
{t("Reset Filters")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table section */}
|
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
|
|
<CrudTable
|
|
columns={table.columns}
|
|
actions={table.actions}
|
|
data={data.data}
|
|
from={data.from || 1}
|
|
onAction={handleAction}
|
|
sortField={pageFilters.sort_field}
|
|
sortDirection={pageFilters.sort_direction}
|
|
onSort={handleSort}
|
|
statusColors={table.statusColors}
|
|
permissions={permissions}
|
|
entityPermissions={entity.permissions}
|
|
/>
|
|
|
|
{/* Pagination section */}
|
|
<Pagination
|
|
from={data.from || 0}
|
|
to={data.to || 0}
|
|
total={data.total}
|
|
links={data.links}
|
|
entityName={entity.name}
|
|
onPageChange={(url) => router.get(url)}
|
|
/>
|
|
</div>
|
|
|
|
<CrudFormModal
|
|
isOpen={isFormModalOpen}
|
|
onClose={() => setIsFormModalOpen(false)}
|
|
onSubmit={handleFormSubmit}
|
|
formConfig={{
|
|
...form,
|
|
modalSize: config.modalSize || form.modalSize
|
|
}}
|
|
initialData={currentItem}
|
|
title={
|
|
formMode === 'create'
|
|
? `Add New ${entity.name.slice(0, -1).charAt(0).toUpperCase() + entity.name.slice(0, -1).slice(1)}`
|
|
: formMode === 'edit'
|
|
? `Edit ${entity.name.slice(0, -1).charAt(0).toUpperCase() + entity.name.slice(0, -1).slice(1)}`
|
|
: `View ${entity.name.slice(0, -1).charAt(0).toUpperCase() + entity.name.slice(0, -1).slice(1)}`
|
|
}
|
|
mode={formMode}
|
|
description={config.description}
|
|
/>
|
|
|
|
<CrudDeleteModal
|
|
isOpen={isDeleteModalOpen}
|
|
onClose={() => setIsDeleteModalOpen(false)}
|
|
onConfirm={handleDeleteConfirm}
|
|
itemName={currentItem?.name || ''}
|
|
entityName={entity.name.slice(0, -1)}
|
|
/>
|
|
</PageTemplate>
|
|
);
|
|
} |