1788 lines
78 KiB
TypeScript
1788 lines
78 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
import { Plus, Search, RefreshCw, Folder, ChevronDown, ChevronUp, ChevronRight, Link as LinkIcon, Tag, Trash2, Filter, X, Box, FileSpreadsheet, FileText, Columns3, GripVertical, ArrowUpDown, Calendar, AlertTriangle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { format, subDays } from 'date-fns';
|
|
import { AnimatePresence, motion } from '@/lib/motion';
|
|
import { catalogService, CategoryResponse } from '@/services/api/catalogService';
|
|
import { getAccessToken } from '@/services/api/client';
|
|
import ImageUpload from '@/components/ui/ImageUpload';
|
|
import { SlideOver } from '@/components/ui/SlideOver';
|
|
import { RowActionsMenu } from '@/components/ui/RowActionsMenu';
|
|
import { ViewModeToggle } from '@/components/ui/ViewModeToggle';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
import { CustomSelect } from '@/components/ui/CustomSelect';
|
|
|
|
function normalizeId(value: unknown): string {
|
|
if (value == null) return '';
|
|
return String(value).trim();
|
|
}
|
|
|
|
function isRootParentId(value: unknown): boolean {
|
|
const id = normalizeId(value).toLowerCase();
|
|
return id === '' || id === 'null' || id === 'undefined' || id === '0' || id === 'root' || id === '- root -' || id === 'none';
|
|
}
|
|
|
|
function coerceFlag(value: unknown): boolean {
|
|
if (typeof value === 'boolean') return value;
|
|
if (typeof value === 'number') return value !== 0;
|
|
if (value == null) return false;
|
|
const normalized = String(value).trim().toLowerCase();
|
|
if (['true', '1', 'yes', 'enabled', 'active'].includes(normalized)) return true;
|
|
if (['false', '0', 'no', 'disabled', 'inactive', '', 'null', 'undefined'].includes(normalized)) return false;
|
|
return false;
|
|
}
|
|
|
|
function idsEqual(a: unknown, b: unknown): boolean {
|
|
const left = normalizeId(a);
|
|
const right = normalizeId(b);
|
|
return left !== '' && right !== '' && left === right;
|
|
}
|
|
|
|
function dayStartMs(isoDate: string): number {
|
|
const [year, month, day] = isoDate.split('-').map(Number);
|
|
if (!year || !month || !day) return NaN;
|
|
return new Date(year, month - 1, day, 0, 0, 0, 0).getTime();
|
|
}
|
|
|
|
function dayEndMs(isoDate: string): number {
|
|
const [year, month, day] = isoDate.split('-').map(Number);
|
|
if (!year || !month || !day) return NaN;
|
|
return new Date(year, month - 1, day, 23, 59, 59, 999).getTime();
|
|
}
|
|
|
|
type StatusFilter = 'active' | 'inactive';
|
|
type FeatureFilter = 'enabled' | 'disabled';
|
|
type SortField = 'name' | 'slug' | 'parent' | 'sortOrder' | 'status';
|
|
type SortDir = 'asc' | 'desc';
|
|
type SortConfig = { field: SortField; dir: SortDir };
|
|
type FilterSection = 'name' | 'parent' | 'feature' | 'status';
|
|
|
|
type CategoryFilters = {
|
|
categoryIds: string[];
|
|
parentIds: string[];
|
|
features: FeatureFilter[];
|
|
statuses: StatusFilter[];
|
|
};
|
|
|
|
const EMPTY_FILTERS: CategoryFilters = {
|
|
categoryIds: [],
|
|
parentIds: [],
|
|
features: [],
|
|
statuses: [],
|
|
};
|
|
|
|
const FILTER_PAGE_SIZE = 5;
|
|
|
|
const EMPTY_COLUMNS = {
|
|
categoryName: true,
|
|
slug: true,
|
|
parentCategory: true,
|
|
parentFeature: true,
|
|
sortOrder: true,
|
|
status: true,
|
|
actions: true,
|
|
};
|
|
|
|
const COLUMN_OPTIONS = [
|
|
{ key: 'categoryName' as const, label: 'Category Name', locked: true },
|
|
{ key: 'slug' as const, label: 'Slug / Path', locked: false },
|
|
{ key: 'parentCategory' as const, label: 'Parent Category', locked: false },
|
|
{ key: 'parentFeature' as const, label: 'Parent Feature', locked: false },
|
|
{ key: 'sortOrder' as const, label: 'Sort Order', locked: false },
|
|
{ key: 'status' as const, label: 'Status', locked: false },
|
|
{ key: 'actions' as const, label: 'Actions', locked: false },
|
|
];
|
|
|
|
const SORT_OPTIONS: { field: SortField; dir: SortDir; label: string }[] = [
|
|
{ field: 'name', dir: 'asc', label: 'Name A-Z' },
|
|
{ field: 'name', dir: 'desc', label: 'Name Z-A' },
|
|
{ field: 'slug', dir: 'asc', label: 'Slug A-Z' },
|
|
{ field: 'slug', dir: 'desc', label: 'Slug Z-A' },
|
|
{ field: 'parent', dir: 'asc', label: 'Parent A-Z' },
|
|
{ field: 'parent', dir: 'desc', label: 'Parent Z-A' },
|
|
{ field: 'sortOrder', dir: 'asc', label: 'Sort Order Ascending' },
|
|
{ field: 'sortOrder', dir: 'desc', label: 'Sort Order Descending' },
|
|
{ field: 'status', dir: 'asc', label: 'Status Active first' },
|
|
{ field: 'status', dir: 'desc', label: 'Status Inactive first' },
|
|
];
|
|
|
|
const DEFAULT_SORT: SortConfig = { field: 'sortOrder', dir: 'asc' };
|
|
const DEFAULT_DATE_FROM = format(subDays(new Date(), 30), 'yyyy-MM-dd');
|
|
const DEFAULT_DATE_TO = format(new Date(), 'yyyy-MM-dd');
|
|
|
|
export default function CategoriesPage() {
|
|
|
|
const [categories, setCategories] = useState<CategoryResponse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
|
|
const [openActionId, setOpenActionId] = useState<string | null>(null);
|
|
const [showFilterPanel, setShowFilterPanel] = useState(false);
|
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
|
const [showColumnsPanel, setShowColumnsPanel] = useState(false);
|
|
const [showSortPanel, setShowSortPanel] = useState(false);
|
|
const [showDatePanel, setShowDatePanel] = useState(false);
|
|
const [draftFilters, setDraftFilters] = useState<CategoryFilters>(EMPTY_FILTERS);
|
|
const [activeFilters, setActiveFilters] = useState<CategoryFilters>(EMPTY_FILTERS);
|
|
const [expandedFilterSections, setExpandedFilterSections] = useState<Record<FilterSection, boolean>>({
|
|
name: false,
|
|
parent: false,
|
|
feature: true,
|
|
status: false,
|
|
});
|
|
const [filterSectionSearch, setFilterSectionSearch] = useState({ name: '', parent: '' });
|
|
const [filterVisibleCounts, setFilterVisibleCounts] = useState({ name: FILTER_PAGE_SIZE, parent: FILTER_PAGE_SIZE });
|
|
const [visibleColumns, setVisibleColumns] = useState(EMPTY_COLUMNS);
|
|
const [sortConfig, setSortConfig] = useState<SortConfig>(DEFAULT_SORT);
|
|
const [dateFrom, setDateFrom] = useState(DEFAULT_DATE_FROM);
|
|
const [dateTo, setDateTo] = useState(DEFAULT_DATE_TO);
|
|
const [draftDateFrom, setDraftDateFrom] = useState(DEFAULT_DATE_FROM);
|
|
const [draftDateTo, setDraftDateTo] = useState(DEFAULT_DATE_TO);
|
|
const [dateFilterEnabled, setDateFilterEnabled] = useState(false);
|
|
const filterRef = useRef<HTMLDivElement>(null);
|
|
const exportRef = useRef<HTMLDivElement>(null);
|
|
const columnsRef = useRef<HTMLDivElement>(null);
|
|
const sortRef = useRef<HTMLDivElement>(null);
|
|
const dateRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Category Modal States
|
|
const [showCategoryModal, setShowCategoryModal] = useState(false);
|
|
const [editingCategory, setEditingCategory] = useState<CategoryResponse | null>(null);
|
|
const [categoryName, setCategoryName] = useState('');
|
|
const [parentCatId, setParentCatId] = useState('');
|
|
const [catDesc, setCatDesc] = useState('');
|
|
const [catImg, setCatImg] = useState('');
|
|
const [catSortOrder, setCatSortOrder] = useState('0');
|
|
const [catParentFeature, setCatParentFeature] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
// Flyout mega menu config
|
|
const [flyoutOpen, setFlyoutOpen] = useState(false);
|
|
const [flyoutLinks, setFlyoutLinks] = useState<{ label: string; url: string }[]>([
|
|
{ label: 'View All', url: '' },
|
|
{ label: 'Best Prices', url: '' },
|
|
{ label: 'New Releases', url: '' },
|
|
{ label: 'Featured Items', url: '' },
|
|
]);
|
|
const [flyoutBrands, setFlyoutBrands] = useState<{ label: string; url: string }[]>([
|
|
{ label: 'Apple Official', url: '/shop?brand=apple' },
|
|
{ label: 'Samsung Galaxy', url: '/shop?brand=samsung' },
|
|
{ label: 'Sony Electronics', url: '/shop?brand=sony' },
|
|
{ label: 'Dell Systems', url: '/shop?brand=dell' },
|
|
]);
|
|
const [flyoutPromoText, setFlyoutPromoText] = useState('Get 20% OFF');
|
|
const [flyoutSaving, setFlyoutSaving] = useState(false);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const fetchedCats = await catalogService.getCategories();
|
|
setCategories(fetchedCats);
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to load category master data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!showFilterPanel) return;
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (!filterRef.current?.contains(event.target as Node)) {
|
|
setShowFilterPanel(false);
|
|
}
|
|
};
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setShowFilterPanel(false);
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [showFilterPanel]);
|
|
|
|
useEffect(() => {
|
|
if (!showExportMenu) return;
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (!exportRef.current?.contains(event.target as Node)) {
|
|
setShowExportMenu(false);
|
|
}
|
|
};
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setShowExportMenu(false);
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [showExportMenu]);
|
|
|
|
useEffect(() => {
|
|
if (!showColumnsPanel) return;
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (!columnsRef.current?.contains(event.target as Node)) {
|
|
setShowColumnsPanel(false);
|
|
}
|
|
};
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setShowColumnsPanel(false);
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [showColumnsPanel]);
|
|
|
|
useEffect(() => {
|
|
if (!showSortPanel) return;
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (!sortRef.current?.contains(event.target as Node)) {
|
|
setShowSortPanel(false);
|
|
}
|
|
};
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setShowSortPanel(false);
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [showSortPanel]);
|
|
|
|
useEffect(() => {
|
|
if (!showDatePanel) return;
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (!dateRef.current?.contains(event.target as Node)) {
|
|
setShowDatePanel(false);
|
|
}
|
|
};
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setShowDatePanel(false);
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [showDatePanel]);
|
|
|
|
const closeOverlays = () => {
|
|
setShowFilterPanel(false);
|
|
setShowColumnsPanel(false);
|
|
setShowSortPanel(false);
|
|
setShowDatePanel(false);
|
|
};
|
|
|
|
const handleViewModeChange = (mode: 'table' | 'grid') => {
|
|
setViewMode(mode);
|
|
setOpenActionId(null);
|
|
closeOverlays();
|
|
};
|
|
|
|
const handleOpenCreate = () => {
|
|
setEditingCategory(null);
|
|
setCategoryName('');
|
|
setParentCatId('');
|
|
setCatDesc('');
|
|
setCatImg('');
|
|
setCatSortOrder('0');
|
|
setCatParentFeature(false);
|
|
setShowCategoryModal(true);
|
|
};
|
|
|
|
const handleOpenEdit = (c: CategoryResponse) => {
|
|
setEditingCategory(c);
|
|
setCategoryName(c.name);
|
|
setParentCatId(c.parent_category_id || '');
|
|
setCatDesc(c.description || '');
|
|
setCatImg(c.image_url || '');
|
|
setCatSortOrder(c.sort_order || '0');
|
|
setCatParentFeature(coerceFlag(c.is_parent_feature));
|
|
setFlyoutOpen(false);
|
|
// Fetch existing flyout config for this category from storefront API
|
|
const slug = c.slug;
|
|
fetch(`/api/v1/storefront/layout/home?region=category_flyout_${slug}`, { cache: 'no-store' })
|
|
.then((r) => r.ok ? r.json() : [])
|
|
.then((data: any[]) => {
|
|
if (data && data.length > 0) {
|
|
const meta = data[0]?.metadata_json || {};
|
|
if (meta.links) setFlyoutLinks(meta.links);
|
|
if (meta.brands) setFlyoutBrands(meta.brands);
|
|
if (meta.promoText) setFlyoutPromoText(meta.promoText);
|
|
} else {
|
|
// Reset to defaults with category slug filled in
|
|
setFlyoutLinks([
|
|
{ label: 'View All ' + c.name, url: `/shop?category=${slug}` },
|
|
{ label: 'Best Prices', url: `/shop?category=${slug}&sort=price_asc` },
|
|
{ label: 'New Releases', url: `/shop?category=${slug}&sort=newest` },
|
|
{ label: 'Featured Items', url: `/shop?category=${slug}&featured=true` },
|
|
]);
|
|
setFlyoutBrands([
|
|
{ label: 'Apple Official', url: '/shop?brand=apple' },
|
|
{ label: 'Samsung Galaxy', url: '/shop?brand=samsung' },
|
|
{ label: 'Sony Electronics', url: '/shop?brand=sony' },
|
|
{ label: 'Dell Systems', url: '/shop?brand=dell' },
|
|
]);
|
|
setFlyoutPromoText('Get 20% OFF ' + c.name);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
setShowCategoryModal(true);
|
|
};
|
|
|
|
const closeCategoryDrawer = () => {
|
|
if (isSubmitting) return;
|
|
setShowCategoryModal(false);
|
|
};
|
|
|
|
const handleSubmitCategory = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!categoryName.trim()) {
|
|
toast.error('Category name is required');
|
|
return;
|
|
}
|
|
setIsSubmitting(true);
|
|
try {
|
|
if (editingCategory) {
|
|
const updated = await catalogService.updateCategory(editingCategory.category_id, {
|
|
name: categoryName.trim(),
|
|
parent_category_id: parentCatId || null,
|
|
description: catDesc.trim() || '',
|
|
image_url: catImg ? catImg.trim() : '',
|
|
sort_order: catSortOrder,
|
|
is_parent_feature: catParentFeature,
|
|
});
|
|
setCategories(categories.map((c) => (c.category_id === updated.category_id ? updated : c)));
|
|
toast.success(`Category "${updated.name}" updated successfully`);
|
|
} else {
|
|
const created = await catalogService.createCategory({
|
|
name: categoryName.trim(),
|
|
parent_category_id: parentCatId || null,
|
|
description: catDesc.trim() || '',
|
|
image_url: catImg ? catImg.trim() : '',
|
|
sort_order: catSortOrder,
|
|
is_parent_feature: catParentFeature,
|
|
});
|
|
setCategories([...categories, created]);
|
|
toast.success(`Category "${created.name}" created successfully`);
|
|
}
|
|
setCategoryName('');
|
|
setParentCatId('');
|
|
setCatDesc('');
|
|
setCatImg('');
|
|
setCatSortOrder('0');
|
|
setCatParentFeature(false);
|
|
setEditingCategory(null);
|
|
setShowCategoryModal(false);
|
|
} catch (err: any) {
|
|
toast.error(err?.message || `Failed to ${editingCategory ? 'update' : 'create'} category`);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const openDeleteModal = (id: string, name: string) => {
|
|
setOpenActionId(null);
|
|
setDeleteTarget({ id, name });
|
|
};
|
|
|
|
const closeDeleteModal = () => {
|
|
if (isDeleting) return;
|
|
setDeleteTarget(null);
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
const { id, name } = deleteTarget;
|
|
setIsDeleting(true);
|
|
try {
|
|
setCategories((prev) => prev.filter((c) => c.category_id !== id));
|
|
await catalogService.deleteCategory(id);
|
|
toast.success(`Category "${name}" deleted successfully`);
|
|
setDeleteTarget(null);
|
|
fetchData();
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to delete category');
|
|
fetchData();
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveFlyout = async () => {
|
|
if (!editingCategory) return;
|
|
setFlyoutSaving(true);
|
|
try {
|
|
const token = getAccessToken();
|
|
const backendUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
await fetch(`${backendUrl}/api/v1/admin/storefront/content/bulk`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
items: [{
|
|
content_id: null,
|
|
page: 'home',
|
|
region: `category_flyout_${editingCategory.slug}`,
|
|
type: 'category_flyout',
|
|
title: `${editingCategory.name} Flyout Menu`,
|
|
subtitle: '',
|
|
display_order: 0,
|
|
metadata_json: {
|
|
links: flyoutLinks,
|
|
brands: flyoutBrands,
|
|
promoText: flyoutPromoText,
|
|
}
|
|
}]
|
|
})
|
|
});
|
|
toast.success('Category flyout menu published to the storefront');
|
|
} catch (err: any) {
|
|
toast.error('Could not save the category flyout menu');
|
|
} finally {
|
|
setFlyoutSaving(false);
|
|
}
|
|
};
|
|
|
|
const getParentLabel = (category: CategoryResponse) => {
|
|
if (isRootParentId(category.parent_category_id)) return '- Root -';
|
|
const parent = categories.find((parentCat) => idsEqual(parentCat.category_id, category.parent_category_id));
|
|
return parent?.name || '- Root -';
|
|
};
|
|
|
|
const filteredCategories = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
const fromTs = dateFilterEnabled && dateFrom ? dayStartMs(dateFrom) : null;
|
|
const toTs = dateFilterEnabled && dateTo ? dayEndMs(dateTo) : null;
|
|
|
|
const result = categories.filter((c) => {
|
|
const parent = categories.find((parentCat) => idsEqual(parentCat.category_id, c.parent_category_id));
|
|
const searchMatch =
|
|
!q ||
|
|
(c.name || '').toLowerCase().includes(q) ||
|
|
(c.slug || '').toLowerCase().includes(q) ||
|
|
(c.description || '').toLowerCase().includes(q) ||
|
|
(parent?.name || '').toLowerCase().includes(q);
|
|
|
|
const isActive = coerceFlag(c.is_active);
|
|
const statusMatch =
|
|
activeFilters.statuses.length === 0 ||
|
|
(activeFilters.statuses.includes('active') && isActive) ||
|
|
(activeFilters.statuses.includes('inactive') && !isActive);
|
|
|
|
const isRoot = isRootParentId(c.parent_category_id);
|
|
const parentMatch =
|
|
activeFilters.parentIds.length === 0 ||
|
|
(isRoot && activeFilters.parentIds.includes('root')) ||
|
|
activeFilters.parentIds.some((id) => id !== 'root' && idsEqual(c.parent_category_id, id));
|
|
|
|
const parentFeatureEnabled = coerceFlag(c.is_parent_feature);
|
|
const featureMatch =
|
|
activeFilters.features.length === 0 ||
|
|
(activeFilters.features.includes('enabled') && parentFeatureEnabled) ||
|
|
(activeFilters.features.includes('disabled') && !parentFeatureEnabled);
|
|
|
|
const categoryMatch =
|
|
activeFilters.categoryIds.length === 0 ||
|
|
activeFilters.categoryIds.includes(normalizeId(c.category_id));
|
|
|
|
let dateMatch = true;
|
|
if (fromTs != null && toTs != null && !Number.isNaN(fromTs) && !Number.isNaN(toTs)) {
|
|
const createdTs = new Date(c.created_at).getTime();
|
|
if (!Number.isNaN(createdTs)) {
|
|
dateMatch = createdTs >= fromTs && createdTs <= toTs;
|
|
}
|
|
}
|
|
|
|
return searchMatch && categoryMatch && statusMatch && parentMatch && featureMatch && dateMatch;
|
|
});
|
|
|
|
const dir = sortConfig.dir === 'asc' ? 1 : -1;
|
|
return [...result].sort((a, b) => {
|
|
if (sortConfig.field === 'sortOrder') {
|
|
return (parseInt(a.sort_order || '0', 10) - parseInt(b.sort_order || '0', 10)) * dir;
|
|
}
|
|
if (sortConfig.field === 'status') {
|
|
return (Number(coerceFlag(a.is_active)) - Number(coerceFlag(b.is_active))) * dir;
|
|
}
|
|
const parentA = getParentLabel(a);
|
|
const parentB = getParentLabel(b);
|
|
const left =
|
|
sortConfig.field === 'name' ? a.name || '' :
|
|
sortConfig.field === 'slug' ? a.slug || '' :
|
|
parentA;
|
|
const right =
|
|
sortConfig.field === 'name' ? b.name || '' :
|
|
sortConfig.field === 'slug' ? b.slug || '' :
|
|
parentB;
|
|
return left.localeCompare(right, undefined, { sensitivity: 'base' }) * dir;
|
|
});
|
|
}, [categories, searchQuery, activeFilters, dateFrom, dateTo, dateFilterEnabled, sortConfig]);
|
|
|
|
const pager = useClientPagination(filteredCategories);
|
|
|
|
const parentCategoryOptions = useMemo(() => {
|
|
const usedParentIds = new Set(
|
|
categories
|
|
.map((c) => normalizeId(c.parent_category_id))
|
|
.filter((id) => id && !isRootParentId(id))
|
|
);
|
|
|
|
const availableParents = categories.filter((c) => usedParentIds.has(normalizeId(c.category_id)));
|
|
const source = availableParents.length > 0 ? availableParents : categories;
|
|
|
|
return [...source].sort((a, b) => a.name.localeCompare(b.name));
|
|
}, [categories]);
|
|
|
|
const filtersActive =
|
|
activeFilters.categoryIds.length > 0 ||
|
|
activeFilters.parentIds.length > 0 ||
|
|
activeFilters.features.length > 0 ||
|
|
activeFilters.statuses.length > 0;
|
|
|
|
const sortedCategoryFilterOptions = useMemo(
|
|
() => [...categories].sort((a, b) => a.name.localeCompare(b.name)),
|
|
[categories]
|
|
);
|
|
|
|
const parentFilterOptions = useMemo(() => {
|
|
const rootOption = { id: 'root', name: 'Root Category' };
|
|
const parents = parentCategoryOptions.map((cat) => ({
|
|
id: normalizeId(cat.category_id),
|
|
name: cat.name,
|
|
}));
|
|
return [rootOption, ...parents];
|
|
}, [parentCategoryOptions]);
|
|
|
|
const toggleFilterSection = (section: FilterSection) => {
|
|
setExpandedFilterSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
|
};
|
|
|
|
const toggleDraftListValue = <T extends string>(
|
|
key: keyof Pick<CategoryFilters, 'categoryIds' | 'parentIds' | 'features' | 'statuses'>,
|
|
value: T
|
|
) => {
|
|
setDraftFilters((prev) => {
|
|
const current = prev[key] as T[];
|
|
return {
|
|
...prev,
|
|
[key]: current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
|
|
};
|
|
});
|
|
};
|
|
|
|
const openFilterPanel = () => {
|
|
setDraftFilters(activeFilters);
|
|
setFilterSectionSearch({ name: '', parent: '' });
|
|
setFilterVisibleCounts({ name: FILTER_PAGE_SIZE, parent: FILTER_PAGE_SIZE });
|
|
setShowColumnsPanel(false);
|
|
setShowSortPanel(false);
|
|
setShowDatePanel(false);
|
|
setShowFilterPanel(true);
|
|
};
|
|
|
|
const visibleColumnCount = Object.values(visibleColumns).filter(Boolean).length;
|
|
|
|
const toggleColumn = (key: keyof typeof EMPTY_COLUMNS) => {
|
|
if (key === 'categoryName') return;
|
|
setVisibleColumns((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
};
|
|
|
|
const handleExportExcel = () => {
|
|
if (filteredCategories.length === 0) {
|
|
toast.warning('No categories to export');
|
|
return;
|
|
}
|
|
|
|
const headers = ['S.No', 'Category Name', 'Slug / Path', 'Parent Category', 'Parent Feature', 'Sort Order', 'Status'];
|
|
const rows = filteredCategories.map((c, i) => [
|
|
i + 1,
|
|
c.name,
|
|
c.slug || '',
|
|
getParentLabel(c),
|
|
coerceFlag(c.is_parent_feature) ? 'Enabled' : 'Disabled',
|
|
c.sort_order || '0',
|
|
coerceFlag(c.is_active) ? 'Active' : 'Inactive',
|
|
]);
|
|
|
|
const csvContent = [headers.join(','), ...rows.map((row) => row.map((val) => `"${String(val).replace(/"/g, '""')}"`).join(','))].join('\n');
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `ifixkart_categories_${format(new Date(), 'yyyyMMdd')}.csv`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
setShowExportMenu(false);
|
|
toast.success(`Downloaded ${filteredCategories.length} ${filteredCategories.length === 1 ? 'category' : 'categories'} as CSV`);
|
|
};
|
|
|
|
const handleExportPDF = () => {
|
|
if (filteredCategories.length === 0) {
|
|
toast.warning('No categories to export');
|
|
return;
|
|
}
|
|
|
|
const printWindow = window.open('', '_blank');
|
|
if (!printWindow) {
|
|
toast.error('Allow pop-ups to open the categories print preview');
|
|
return;
|
|
}
|
|
|
|
const html = `
|
|
<html>
|
|
<head>
|
|
<title>iFixKart Categories PDF Export</title>
|
|
<style>
|
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; color: #1e293b; }
|
|
h2 { color: #0f172a; border-bottom: 2px solid #e2e8f0; padding-bottom: 10px; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 11px; }
|
|
th { background-color: #0f172a; color: white; padding: 10px; text-align: left; }
|
|
td { padding: 10px; border-bottom: 1px solid #e2e8f0; }
|
|
tr:nth-child(even) { background-color: #f8fafc; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2>iFixKart Categories (${filteredCategories.length} Records)</h2>
|
|
<p>Exported on: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>S.No</th>
|
|
<th>Category Name</th>
|
|
<th>Slug / Path</th>
|
|
<th>Parent Category</th>
|
|
<th>Parent Feature</th>
|
|
<th>Sort Order</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${filteredCategories.map((c, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td>${c.name}</td>
|
|
<td>${c.slug || ''}</td>
|
|
<td>${getParentLabel(c)}</td>
|
|
<td>${coerceFlag(c.is_parent_feature) ? 'Enabled' : 'Disabled'}</td>
|
|
<td>${c.sort_order || '0'}</td>
|
|
<td>${coerceFlag(c.is_active) ? 'Active' : 'Inactive'}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
<script>
|
|
window.onload = function() { window.print(); }
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
printWindow.document.write(html);
|
|
printWindow.document.close();
|
|
setShowExportMenu(false);
|
|
toast.success(`Print preview opened for ${filteredCategories.length} ${filteredCategories.length === 1 ? 'category' : 'categories'}`);
|
|
};
|
|
|
|
const dateRangeLabel = `${format(new Date(dayStartMs(dateFrom || DEFAULT_DATE_FROM)), 'd MMM yy')} - ${format(new Date(dayStartMs(dateTo || DEFAULT_DATE_TO)), 'd MMM yy')}`;
|
|
|
|
const dataCardShell = 'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
|
|
|
|
const renderSortLabel = (label: string, field?: SortField, showIcon = true) => {
|
|
const active = field != null && sortConfig.field === field;
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{label}
|
|
{showIcon && (
|
|
<ArrowUpDown
|
|
className={`w-3.5 h-3.5 shrink-0 ${active ? 'text-gray-700' : 'text-gray-400'}`}
|
|
/>
|
|
)}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const renderStatusBadge = (active: boolean) => (
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${
|
|
active ? 'bg-success text-white' : 'bg-destructive text-white'
|
|
}`}
|
|
>
|
|
{active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
);
|
|
|
|
const renderFeatureBadge = (enabled: boolean) => (
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-0.5 crm-radius-badge text-[11px] font-semibold ${
|
|
enabled ? 'bg-success text-white' : 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{enabled ? 'Enabled' : 'Disabled'}
|
|
</span>
|
|
);
|
|
|
|
const searchField = (
|
|
<div className="relative flex-1 min-w-[180px] max-w-[480px]">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full h-9 pl-9 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const addCategoryButton = (
|
|
<button
|
|
type="button"
|
|
onClick={handleOpenCreate}
|
|
className="inline-flex items-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer shrink-0"
|
|
>
|
|
<span className="w-[18px] h-[18px] rounded-full bg-white text-primary flex items-center justify-center">
|
|
<Plus className="w-3 h-3" strokeWidth={3} />
|
|
</span>
|
|
Add Category
|
|
</button>
|
|
);
|
|
|
|
const viewToggle = (
|
|
<ViewModeToggle value={viewMode} onChange={handleViewModeChange} />
|
|
);
|
|
|
|
const renderFilterControl = (align: 'left' | 'right') => {
|
|
const nameQuery = filterSectionSearch.name.trim().toLowerCase();
|
|
const parentQuery = filterSectionSearch.parent.trim().toLowerCase();
|
|
const filteredNameOptions = sortedCategoryFilterOptions.filter((cat) =>
|
|
!nameQuery || cat.name.toLowerCase().includes(nameQuery) || (cat.slug || '').toLowerCase().includes(nameQuery)
|
|
);
|
|
const filteredParentOptions = parentFilterOptions.filter((option) =>
|
|
!parentQuery || option.name.toLowerCase().includes(parentQuery)
|
|
);
|
|
const visibleNameOptions = filteredNameOptions.slice(0, filterVisibleCounts.name);
|
|
const visibleParentOptions = filteredParentOptions.slice(0, filterVisibleCounts.parent);
|
|
|
|
const renderSectionSearch = (
|
|
section: 'name' | 'parent',
|
|
value: string,
|
|
onChange: (next: string) => void
|
|
) => (
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search"
|
|
value={value}
|
|
onChange={(e) => {
|
|
onChange(e.target.value);
|
|
setFilterVisibleCounts((prev) => ({ ...prev, [section]: FILTER_PAGE_SIZE }));
|
|
}}
|
|
className="w-full h-8 pl-8 pr-3 crm-radius-control border border-border bg-card text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary"
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const renderCheckboxOption = (
|
|
checked: boolean,
|
|
onToggle: () => void,
|
|
label: string,
|
|
icon?: ReactNode
|
|
) => (
|
|
<label className="flex items-center gap-2.5 py-1.5 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={onToggle}
|
|
className="w-3.5 h-3.5 rounded-[3px] border-border text-primary focus:ring-primary cursor-pointer"
|
|
/>
|
|
{icon}
|
|
<span className="text-[13px] text-muted-foreground">{label}</span>
|
|
</label>
|
|
);
|
|
|
|
return (
|
|
<div className="relative shrink-0" ref={filterRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (showFilterPanel) {
|
|
setShowFilterPanel(false);
|
|
} else {
|
|
openFilterPanel();
|
|
}
|
|
}}
|
|
className={`inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control text-[13px] font-medium cursor-pointer transition-colors ${
|
|
showFilterPanel || filtersActive
|
|
? 'bg-primary text-white'
|
|
: 'border border-border bg-card text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
<Filter className="w-3.5 h-3.5" />
|
|
Filter
|
|
{filtersActive && !showFilterPanel && <span className="w-1.5 h-1.5 rounded-full bg-white" />}
|
|
<ChevronDown
|
|
className={`w-3.5 h-3.5 transition-transform ${showFilterPanel ? 'rotate-180' : ''} ${
|
|
showFilterPanel || filtersActive ? 'text-white/80' : 'text-muted-foreground'
|
|
}`}
|
|
/>
|
|
</button>
|
|
<AnimatePresence>
|
|
{showFilterPanel && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -6 }}
|
|
transition={{ duration: 0.16, ease: 'easeOut' }}
|
|
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} top-full mt-1.5 z-50 w-[320px] max-w-[calc(100vw-2rem)] crm-radius-card border border-border bg-card shadow-lg overflow-hidden`}
|
|
>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<span className="inline-flex items-center gap-2 text-[14px] font-semibold text-foreground">
|
|
<Filter className="w-4 h-4" />
|
|
Filter
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label="Close filters"
|
|
onClick={() => setShowFilterPanel(false)}
|
|
className="w-7 h-7 rounded-full bg-primary/10 text-primary hover:bg-primary hover:text-white cursor-pointer transition-colors flex items-center justify-center"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="max-h-[420px] overflow-y-auto">
|
|
<div className="border-b border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleFilterSection('name')}
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-[13px] font-semibold text-foreground hover:bg-muted/20 cursor-pointer"
|
|
>
|
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${expandedFilterSections.name ? 'rotate-90' : ''}`} />
|
|
Category Name
|
|
</button>
|
|
{expandedFilterSections.name && (
|
|
<div className="px-4 pb-4">
|
|
<div className="crm-radius-control border border-border bg-muted/20 p-3 space-y-2">
|
|
{renderSectionSearch('name', filterSectionSearch.name, (value) =>
|
|
setFilterSectionSearch((prev) => ({ ...prev, name: value }))
|
|
)}
|
|
<div className="space-y-0.5">
|
|
{visibleNameOptions.length === 0 ? (
|
|
<p className="py-2 text-[12px] text-muted-foreground">No categories found.</p>
|
|
) : (
|
|
visibleNameOptions.map((cat) => {
|
|
const id = normalizeId(cat.category_id);
|
|
return renderCheckboxOption(
|
|
draftFilters.categoryIds.includes(id),
|
|
() => toggleDraftListValue('categoryIds', id),
|
|
cat.name,
|
|
(
|
|
<div className="w-7 h-7 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
|
<Folder className="w-3.5 h-3.5" />
|
|
</div>
|
|
)
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
{filteredNameOptions.length > visibleNameOptions.length && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFilterVisibleCounts((prev) => ({
|
|
...prev,
|
|
name: prev.name + FILTER_PAGE_SIZE,
|
|
}))
|
|
}
|
|
className="text-[12px] font-medium text-primary underline underline-offset-2 cursor-pointer"
|
|
>
|
|
Load More
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-b border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleFilterSection('parent')}
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-[13px] font-semibold text-foreground hover:bg-muted/20 cursor-pointer"
|
|
>
|
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${expandedFilterSections.parent ? 'rotate-90' : ''}`} />
|
|
Parent Category
|
|
</button>
|
|
{expandedFilterSections.parent && (
|
|
<div className="px-4 pb-4">
|
|
<div className="crm-radius-control border border-border bg-muted/20 p-3 space-y-2">
|
|
{renderSectionSearch('parent', filterSectionSearch.parent, (value) =>
|
|
setFilterSectionSearch((prev) => ({ ...prev, parent: value }))
|
|
)}
|
|
<div className="space-y-0.5">
|
|
{visibleParentOptions.length === 0 ? (
|
|
<p className="py-2 text-[12px] text-muted-foreground">No parent categories found.</p>
|
|
) : (
|
|
visibleParentOptions.map((option) =>
|
|
renderCheckboxOption(
|
|
draftFilters.parentIds.includes(option.id),
|
|
() => toggleDraftListValue('parentIds', option.id),
|
|
option.name
|
|
)
|
|
)
|
|
)}
|
|
</div>
|
|
{filteredParentOptions.length > visibleParentOptions.length && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFilterVisibleCounts((prev) => ({
|
|
...prev,
|
|
parent: prev.parent + FILTER_PAGE_SIZE,
|
|
}))
|
|
}
|
|
className="text-[12px] font-medium text-primary underline underline-offset-2 cursor-pointer"
|
|
>
|
|
Load More
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-b border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleFilterSection('feature')}
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-[13px] font-semibold text-foreground hover:bg-muted/20 cursor-pointer"
|
|
>
|
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${expandedFilterSections.feature ? 'rotate-90' : ''}`} />
|
|
Parent Feature
|
|
</button>
|
|
{expandedFilterSections.feature && (
|
|
<div className="px-4 pb-4">
|
|
<div className="crm-radius-control border border-border bg-muted/20 p-3 space-y-1">
|
|
{renderCheckboxOption(
|
|
draftFilters.features.includes('enabled'),
|
|
() => toggleDraftListValue('features', 'enabled'),
|
|
'Enabled'
|
|
)}
|
|
{renderCheckboxOption(
|
|
draftFilters.features.includes('disabled'),
|
|
() => toggleDraftListValue('features', 'disabled'),
|
|
'Disabled'
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleFilterSection('status')}
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-[13px] font-semibold text-foreground hover:bg-muted/20 cursor-pointer"
|
|
>
|
|
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${expandedFilterSections.status ? 'rotate-90' : ''}`} />
|
|
Status
|
|
</button>
|
|
{expandedFilterSections.status && (
|
|
<div className="px-4 pb-4">
|
|
<div className="crm-radius-control border border-border bg-muted/20 p-3 space-y-1">
|
|
{renderCheckboxOption(
|
|
draftFilters.statuses.includes('active'),
|
|
() => toggleDraftListValue('statuses', 'active'),
|
|
'Active'
|
|
)}
|
|
{renderCheckboxOption(
|
|
draftFilters.statuses.includes('inactive'),
|
|
() => toggleDraftListValue('statuses', 'inactive'),
|
|
'Inactive'
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 px-4 py-3 border-t border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftFilters(EMPTY_FILTERS);
|
|
setActiveFilters(EMPTY_FILTERS);
|
|
setShowFilterPanel(false);
|
|
}}
|
|
className="flex-1 h-9 crm-radius-control border border-border bg-card text-[13px] font-medium text-foreground hover:bg-muted cursor-pointer"
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveFilters(draftFilters);
|
|
setShowFilterPanel(false);
|
|
}}
|
|
className="flex-1 h-9 crm-radius-control bg-primary text-white text-[13px] font-semibold cursor-pointer hover:bg-primary/90"
|
|
>
|
|
Filter
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const manageColumnsControl = (
|
|
<div className="relative shrink-0" ref={columnsRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = !showColumnsPanel;
|
|
setShowFilterPanel(false);
|
|
setShowSortPanel(false);
|
|
setShowDatePanel(false);
|
|
setShowColumnsPanel(next);
|
|
}}
|
|
className={`inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control text-[13px] font-medium cursor-pointer transition-colors bg-primary/10 text-primary border border-primary/25 hover:bg-primary/15`}
|
|
>
|
|
<Columns3 className="w-3.5 h-3.5" />
|
|
Manage Columns
|
|
</button>
|
|
{showColumnsPanel && (
|
|
<div className="absolute right-0 top-full mt-1.5 z-50 w-[240px] crm-radius-card border border-border bg-card shadow-lg py-1.5 overflow-hidden">
|
|
{COLUMN_OPTIONS.map((col) => (
|
|
<div key={col.key} className="flex items-center gap-2 px-3 py-1.5">
|
|
<GripVertical className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
<span className="flex-1 text-[13px] text-foreground truncate">{col.label}</span>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={visibleColumns[col.key]}
|
|
aria-label={`Toggle ${col.label}`}
|
|
disabled={col.locked}
|
|
title={col.locked ? 'Category Name stays visible' : `Toggle ${col.label}`}
|
|
onClick={() => toggleColumn(col.key)}
|
|
className={`relative w-8 h-[18px] rounded-full shrink-0 transition-colors ${
|
|
visibleColumns[col.key] ? 'bg-primary' : 'bg-muted'
|
|
} ${col.locked ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
>
|
|
<span
|
|
className={`absolute top-[2px] w-3.5 h-3.5 rounded-full bg-white shadow-xs transition-all ${
|
|
visibleColumns[col.key] ? 'left-[14px]' : 'left-[2px]'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const sortControl = (
|
|
<div className="relative shrink-0" ref={sortRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = !showSortPanel;
|
|
setShowFilterPanel(false);
|
|
setShowColumnsPanel(false);
|
|
setShowDatePanel(false);
|
|
setShowSortPanel(next);
|
|
}}
|
|
className={`inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control border border-border text-[13px] font-medium cursor-pointer transition-colors ${
|
|
showSortPanel || sortConfig.field !== DEFAULT_SORT.field || sortConfig.dir !== DEFAULT_SORT.dir
|
|
? 'bg-muted text-foreground'
|
|
: 'bg-card text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
<ArrowUpDown className="w-3.5 h-3.5" />
|
|
Sort By
|
|
<ChevronDown className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${showSortPanel ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{showSortPanel && (
|
|
<div className="absolute left-0 top-full mt-1.5 z-50 w-56 crm-radius-card border border-border bg-card shadow-lg py-1 overflow-hidden">
|
|
{SORT_OPTIONS.map((option) => {
|
|
const active = option.field === sortConfig.field && option.dir === sortConfig.dir;
|
|
return (
|
|
<button
|
|
key={`${option.field}-${option.dir}`}
|
|
type="button"
|
|
onClick={() => {
|
|
setSortConfig({ field: option.field, dir: option.dir });
|
|
setShowSortPanel(false);
|
|
}}
|
|
className={`w-full text-left px-3 py-1.5 text-[13px] cursor-pointer ${
|
|
active ? 'bg-primary/10 text-primary font-medium' : 'text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const dateControl = (
|
|
<div className="relative shrink-0" ref={dateRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = !showDatePanel;
|
|
setShowFilterPanel(false);
|
|
setShowColumnsPanel(false);
|
|
setShowSortPanel(false);
|
|
if (next) {
|
|
setDraftDateFrom(dateFrom || DEFAULT_DATE_FROM);
|
|
setDraftDateTo(dateTo || DEFAULT_DATE_TO);
|
|
setShowDatePanel(true);
|
|
} else {
|
|
setShowDatePanel(false);
|
|
}
|
|
}}
|
|
className={`inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control border border-border text-[13px] font-medium cursor-pointer transition-colors ${
|
|
showDatePanel || dateFilterEnabled
|
|
? 'bg-muted text-foreground'
|
|
: 'bg-card text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
{dateRangeLabel}
|
|
</button>
|
|
{showDatePanel && (
|
|
<div className="absolute left-0 top-full mt-1.5 z-50 w-72 crm-radius-card border border-border bg-card shadow-lg overflow-hidden">
|
|
<div className="px-3.5 py-3 space-y-2.5">
|
|
<div>
|
|
<label className="block text-[10px] font-semibold tracking-wide text-muted-foreground uppercase mb-1">From</label>
|
|
<input
|
|
type="date"
|
|
value={draftDateFrom}
|
|
onChange={(e) => setDraftDateFrom(e.target.value)}
|
|
className="w-full h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] text-foreground outline-none focus:border-primary"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] font-semibold tracking-wide text-muted-foreground uppercase mb-1">To</label>
|
|
<input
|
|
type="date"
|
|
value={draftDateTo}
|
|
onChange={(e) => setDraftDateTo(e.target.value)}
|
|
className="w-full h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] text-foreground outline-none focus:border-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2 px-3.5 py-2.5 border-t border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDateFrom(DEFAULT_DATE_FROM);
|
|
setDateTo(DEFAULT_DATE_TO);
|
|
setDraftDateFrom(DEFAULT_DATE_FROM);
|
|
setDraftDateTo(DEFAULT_DATE_TO);
|
|
setDateFilterEnabled(false);
|
|
setShowDatePanel(false);
|
|
}}
|
|
className="h-8 px-3 crm-radius-control border border-border bg-card text-[12px] font-medium text-foreground hover:bg-muted cursor-pointer"
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!draftDateFrom || !draftDateTo) {
|
|
toast.error('Choose a start date and an end date');
|
|
return;
|
|
}
|
|
if (draftDateFrom > draftDateTo) {
|
|
toast.error('Start date must be earlier than the end date');
|
|
return;
|
|
}
|
|
setDateFrom(draftDateFrom);
|
|
setDateTo(draftDateTo);
|
|
setDateFilterEnabled(true);
|
|
setShowDatePanel(false);
|
|
}}
|
|
className="h-8 px-3 crm-radius-control bg-primary text-primary-foreground text-[12px] font-medium cursor-pointer hover:bg-primary/90"
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">Categories</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
|
|
{categories.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
<div className="relative" ref={exportRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowExportMenu((open) => !open)}
|
|
className="inline-flex items-center gap-1.5 h-8 px-2.5 crm-radius-control border border-border bg-card text-[13px] font-medium text-foreground hover:bg-muted cursor-pointer"
|
|
>
|
|
<Box className="w-3.5 h-3.5" />
|
|
Export
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
{showExportMenu && (
|
|
<div className="absolute right-0 top-full mt-1.5 z-50 w-48 crm-radius-card border border-border bg-card shadow-lg py-1 overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={handleExportPDF}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
|
|
>
|
|
<FileText className="w-4 h-4 text-destructive" />
|
|
Export as PDF
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleExportExcel}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
|
|
>
|
|
<FileSpreadsheet className="w-4 h-4 text-green-600" />
|
|
Export as Excel
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={fetchData}
|
|
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
title="Reload Data"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === 'grid' ? (
|
|
<div className={dataCardShell}>
|
|
<div className="px-5 pt-5">
|
|
<div className="-mx-5 px-5 flex flex-wrap items-center justify-between gap-3 min-w-0 pb-4 border-b border-border">
|
|
<div className="flex flex-wrap items-center gap-3 min-w-0 flex-1">
|
|
{renderFilterControl('left')}
|
|
{searchField}
|
|
</div>
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
{viewToggle}
|
|
{addCategoryButton}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
|
|
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
|
<span className="text-[13px] text-muted-foreground">Loading catalog categories...</span>
|
|
</div>
|
|
) : filteredCategories.length === 0 ? (
|
|
<div className="py-12 text-center text-[13px] text-muted-foreground">
|
|
{searchQuery || filtersActive || dateFilterEnabled ? 'No categories match your search.' : 'No categories found.'}
|
|
</div>
|
|
) : (
|
|
<div className="p-5 mt-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{pager.items.map((c) => {
|
|
const parent = categories.find((parentCat) => idsEqual(parentCat.category_id, c.parent_category_id));
|
|
return (
|
|
<div
|
|
key={c.category_id}
|
|
className="relative crm-radius-section border border-border bg-card p-5 min-w-0 shadow-sm hover:shadow-md transition-shadow"
|
|
>
|
|
<div className="absolute top-4 right-4 z-10">
|
|
<RowActionsMenu
|
|
open={openActionId === c.category_id}
|
|
onOpenChange={(open) => setOpenActionId(open ? c.category_id : null)}
|
|
onEdit={() => handleOpenEdit(c)}
|
|
onDelete={() => openDeleteModal(c.category_id, c.name)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-start gap-3 pr-8 min-w-0">
|
|
<div className="w-10 h-10 crm-radius-icon bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
|
<Folder className="w-4 h-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[14px] font-semibold text-foreground truncate">{c.name}</p>
|
|
<p className="text-[12px] text-muted-foreground truncate mt-0.5">{c.slug}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 space-y-1.5 text-[12px] text-muted-foreground">
|
|
<p>Parent: {parent && !isRootParentId(c.parent_category_id) ? parent.name : '- Root -'}</p>
|
|
<p>{coerceFlag(c.is_parent_feature) ? 'Parent Feature enabled' : 'Parent Feature disabled'}</p>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
{renderStatusBadge(coerceFlag(c.is_active))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
) : (
|
|
<div className={dataCardShell}>
|
|
<div className="px-5 pt-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-4 w-full min-w-0 pb-4 border-b border-border">
|
|
{searchField}
|
|
{addCategoryButton}
|
|
</div>
|
|
<div className="flex flex-wrap lg:flex-nowrap items-center justify-between gap-3 w-full min-w-0 py-4">
|
|
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
|
{sortControl}
|
|
{dateControl}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
|
{renderFilterControl('right')}
|
|
{manageColumnsControl}
|
|
{viewToggle}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-2.5 border-t border-border">
|
|
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
|
<span className="text-[13px] text-muted-foreground">Loading catalog categories...</span>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto border-t border-border">
|
|
<table className="crm-data-table min-w-[860px]">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-y border-gray-200">
|
|
{visibleColumns.categoryName && (
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
|
|
{renderSortLabel('Category Name', 'name')}
|
|
</th>
|
|
)}
|
|
{visibleColumns.slug && (
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
|
|
{renderSortLabel('Slug / Path', 'slug')}
|
|
</th>
|
|
)}
|
|
{visibleColumns.parentCategory && (
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
|
|
{renderSortLabel('Parent Category', 'parent')}
|
|
</th>
|
|
)}
|
|
{visibleColumns.parentFeature && (
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
|
|
{renderSortLabel('Parent Feature')}
|
|
</th>
|
|
)}
|
|
{visibleColumns.sortOrder && (
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
|
|
{renderSortLabel('Sort Order', 'sortOrder')}
|
|
</th>
|
|
)}
|
|
{visibleColumns.status && (
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
|
|
{renderSortLabel('Status', 'status')}
|
|
</th>
|
|
)}
|
|
{visibleColumns.actions && (
|
|
<th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700 whitespace-nowrap">
|
|
{renderSortLabel('Action')}
|
|
</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-card">
|
|
{filteredCategories.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={Math.max(visibleColumnCount, 1)} className="text-center py-12 text-[13px] text-muted-foreground">
|
|
{searchQuery || filtersActive || dateFilterEnabled ? 'No categories match your search.' : 'No categories found.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pager.items.map((c) => {
|
|
const parent = categories.find((parentCat) => idsEqual(parentCat.category_id, c.parent_category_id));
|
|
const parentLabel = parent && !isRootParentId(c.parent_category_id) ? parent.name : '- Root -';
|
|
return (
|
|
<tr key={c.category_id} className="border-t border-border hover:bg-muted/10 transition-colors">
|
|
{visibleColumns.categoryName && (
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-9 h-9 crm-radius-icon bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
|
<Folder className="w-4 h-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[13px] font-semibold text-foreground truncate">{c.name}</p>
|
|
<p className="text-[11px] text-muted-foreground truncate">{c.slug}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
)}
|
|
{visibleColumns.slug && (
|
|
<td className="px-5 py-3.5 font-mono text-muted-foreground">
|
|
<span className="crm-cell-clip max-w-[180px]" title={c.slug}>{c.slug}</span>
|
|
</td>
|
|
)}
|
|
{visibleColumns.parentCategory && (
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{parentLabel}</td>
|
|
)}
|
|
{visibleColumns.parentFeature && (
|
|
<td className="px-5 py-3.5">
|
|
{renderFeatureBadge(coerceFlag(c.is_parent_feature))}
|
|
</td>
|
|
)}
|
|
{visibleColumns.sortOrder && (
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{c.sort_order}</td>
|
|
)}
|
|
{visibleColumns.status && (
|
|
<td className="px-5 py-3.5">{renderStatusBadge(coerceFlag(c.is_active))}</td>
|
|
)}
|
|
{visibleColumns.actions && (
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center justify-center">
|
|
<RowActionsMenu
|
|
open={openActionId === c.category_id}
|
|
onOpenChange={(open) => setOpenActionId(open ? c.category_id : null)}
|
|
onEdit={() => handleOpenEdit(c)}
|
|
onDelete={() => openDeleteModal(c.category_id, c.name)}
|
|
/>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
)}
|
|
|
|
{deleteTarget && (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
|
|
onClick={closeDeleteModal}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="delete-category-title"
|
|
className="bg-card border border-border shadow-2xl w-full max-w-sm overflow-hidden crm-radius-none"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="w-10 h-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center shrink-0">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 id="delete-category-title" className="text-[15px] font-semibold text-foreground">
|
|
Delete Category
|
|
</h3>
|
|
<p className="text-[13px] text-muted-foreground mt-1 leading-relaxed">
|
|
Are you sure you want to delete{' '}
|
|
<span className="font-semibold text-foreground">"{deleteTarget.name}"</span> and all its
|
|
sub-categories and products?
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Close delete dialog"
|
|
onClick={closeDeleteModal}
|
|
disabled={isDeleting}
|
|
className="w-8 h-8 rounded-full border border-border text-muted-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center shrink-0 disabled:opacity-50"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 px-5 pb-5 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={closeDeleteModal}
|
|
disabled={isDeleting}
|
|
className="h-9 px-4 crm-radius-control border border-border bg-card text-[13px] font-medium text-foreground hover:bg-muted cursor-pointer transition-colors disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleConfirmDelete}
|
|
disabled={isDeleting}
|
|
className="h-9 px-4 crm-radius-control bg-destructive hover:bg-destructive/90 text-white text-[13px] font-semibold cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
|
|
>
|
|
{isDeleting ? (
|
|
<>
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
Deleting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
Delete Category
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<SlideOver
|
|
open={showCategoryModal}
|
|
onClose={closeCategoryDrawer}
|
|
title={editingCategory ? 'Edit E-Commerce Category' : 'Create E-Commerce Category'}
|
|
icon={<Folder className="w-4 h-4 text-primary shrink-0" />}
|
|
>
|
|
<form onSubmit={handleSubmitCategory} className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4">
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">
|
|
Category Name <span className="text-primary">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. Screen Protectors"
|
|
value={categoryName}
|
|
onChange={(e) => setCategoryName(e.target.value)}
|
|
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary transition-colors"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Parent Category (Optional)</label>
|
|
<CustomSelect
|
|
value={parentCatId}
|
|
onChange={setParentCatId}
|
|
placeholder="- None (Root Category) -"
|
|
options={[
|
|
{ value: '', label: '- None (Root Category) -' },
|
|
...categories
|
|
.filter((cat) => cat.category_id !== editingCategory?.category_id)
|
|
.map((cat) => ({ value: cat.category_id, label: cat.name })),
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Description (Optional)</label>
|
|
<textarea
|
|
placeholder="Brief summary..."
|
|
value={catDesc}
|
|
onChange={(e) => setCatDesc(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary transition-colors resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Sort Order</label>
|
|
<input
|
|
type="number"
|
|
value={catSortOrder}
|
|
onChange={(e) => setCatSortOrder(e.target.value)}
|
|
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary transition-colors"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Category Image</label>
|
|
<ImageUpload
|
|
entityType="category"
|
|
entityId={editingCategory?.category_id || ''}
|
|
value={catImg}
|
|
onUploadSuccess={(url) => setCatImg(url)}
|
|
onClear={() => setCatImg('')}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-3 p-3 bg-muted/30 border border-border crm-radius-card">
|
|
<input
|
|
type="checkbox"
|
|
id="parentFeatureCheckbox"
|
|
checked={catParentFeature}
|
|
onChange={(e) => setCatParentFeature(e.target.checked)}
|
|
className="mt-0.5 rounded border-border text-info focus:ring-info cursor-pointer"
|
|
/>
|
|
<label htmlFor="parentFeatureCheckbox" className="cursor-pointer">
|
|
<span className="block text-[13px] font-semibold text-foreground">Parent Feature</span>
|
|
<span className="block text-[12px] text-muted-foreground mt-0.5">Display products using Brand → Series → Model navigation on the storefront.</span>
|
|
</label>
|
|
</div>
|
|
|
|
{editingCategory && (
|
|
<div className="border border-border crm-radius-card overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFlyoutOpen(!flyoutOpen)}
|
|
className="w-full flex items-center justify-between px-4 py-3 bg-muted/30 hover:bg-muted transition-colors text-left cursor-pointer"
|
|
>
|
|
<span className="text-[11px] font-bold text-purple-400 uppercase tracking-wide flex items-center gap-2">
|
|
<LinkIcon className="w-3.5 h-3.5" /> Flyout Mega Menu Config
|
|
</span>
|
|
{flyoutOpen ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
|
</button>
|
|
|
|
{flyoutOpen && (
|
|
<div className="p-4 space-y-5 bg-muted/20">
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-[11px] font-bold text-muted-foreground uppercase">Quick Links</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFlyoutLinks([...flyoutLinks, { label: 'New Link', url: '/' }])}
|
|
className="text-[10px] bg-purple-600/20 hover:bg-purple-600/40 text-purple-300 border border-purple-500/30 px-2 py-1 rounded font-bold flex items-center gap-1 cursor-pointer"
|
|
>
|
|
<Plus className="w-3 h-3" /> Add
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{flyoutLinks.map((link, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={link.label}
|
|
onChange={(e) => { const u = [...flyoutLinks]; u[i] = { ...u[i], label: e.target.value }; setFlyoutLinks(u); }}
|
|
placeholder="Label"
|
|
className="flex-1 px-2.5 py-1.5 bg-card border border-border rounded text-xs text-foreground"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={link.url}
|
|
onChange={(e) => { const u = [...flyoutLinks]; u[i] = { ...u[i], url: e.target.value }; setFlyoutLinks(u); }}
|
|
placeholder="/shop?..."
|
|
className="flex-1 px-2.5 py-1.5 bg-card border border-border rounded text-xs text-foreground font-mono"
|
|
/>
|
|
<button type="button" onClick={() => setFlyoutLinks(flyoutLinks.filter((_, j) => j !== i))} className="text-destructive hover:text-destructive/80 p-1 cursor-pointer">
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-[11px] font-bold text-muted-foreground uppercase">Popular Brands</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFlyoutBrands([...flyoutBrands, { label: 'Brand Name', url: '/shop?brand=' }])}
|
|
className="text-[10px] bg-purple-600/20 hover:bg-purple-600/40 text-purple-300 border border-purple-500/30 px-2 py-1 rounded font-bold flex items-center gap-1 cursor-pointer"
|
|
>
|
|
<Plus className="w-3 h-3" /> Add
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{flyoutBrands.map((brand, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={brand.label}
|
|
onChange={(e) => { const u = [...flyoutBrands]; u[i] = { ...u[i], label: e.target.value }; setFlyoutBrands(u); }}
|
|
placeholder="Brand Label"
|
|
className="flex-1 px-2.5 py-1.5 bg-card border border-border rounded text-xs text-foreground"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={brand.url}
|
|
onChange={(e) => { const u = [...flyoutBrands]; u[i] = { ...u[i], url: e.target.value }; setFlyoutBrands(u); }}
|
|
placeholder="/shop?brand=..."
|
|
className="flex-1 px-2.5 py-1.5 bg-card border border-border rounded text-xs text-foreground font-mono"
|
|
/>
|
|
<button type="button" onClick={() => setFlyoutBrands(flyoutBrands.filter((_, j) => j !== i))} className="text-destructive hover:text-destructive/80 p-1 cursor-pointer">
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[11px] font-bold text-muted-foreground uppercase block mb-1.5">CRM Special Promo Text</label>
|
|
<input
|
|
type="text"
|
|
value={flyoutPromoText}
|
|
onChange={(e) => setFlyoutPromoText(e.target.value)}
|
|
placeholder="e.g. Get 20% OFF Camera Lens Guards"
|
|
className="w-full px-3 py-2 bg-card border border-border rounded text-xs text-foreground"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleSaveFlyout}
|
|
disabled={flyoutSaving}
|
|
className="w-full flex items-center justify-center gap-2 py-2 bg-purple-600 hover:bg-purple-700 text-white text-xs font-bold crm-radius-control transition-colors cursor-pointer disabled:opacity-60"
|
|
>
|
|
{flyoutSaving ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
|
|
{flyoutSaving ? 'Saving...' : 'Save Flyout Menu'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
|
|
<button
|
|
type="button"
|
|
onClick={closeCategoryDrawer}
|
|
className="h-8 px-3.5 bg-card hover:bg-muted border border-border text-foreground crm-radius-control text-[13px] font-medium transition-colors cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="h-8 px-3.5 bg-primary hover:bg-primary/90 text-primary-foreground crm-radius-control text-[13px] font-medium transition-colors cursor-pointer disabled:opacity-60"
|
|
>
|
|
{editingCategory ? 'Save Changes' : 'Create Category'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SlideOver>
|
|
</div>
|
|
);
|
|
}
|