ifixkart-admin/app/(admin)/models/page.tsx

1669 lines
68 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react';
import {
Smartphone,
Tag,
Plus,
Search,
RefreshCw,
ChevronDown,
ChevronRight,
Trash2,
Filter,
X,
Box,
FileSpreadsheet,
FileText,
Columns3,
GripVertical,
ArrowUpDown,
AlertTriangle,
} from 'lucide-react';
import { toast } from 'sonner';
import { format } from 'date-fns';
import { AnimatePresence, motion } from '@/lib/motion';
import {
catalogService,
BrandResponse,
DeviceSeriesResponse,
DeviceModelResponse,
} from '@/services/api/catalogService';
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 ImageUpload from '@/components/ui/ImageUpload';
import { CustomSelect } from '@/components/ui/CustomSelect';
import { getMediaUrl } from '@/services/api/config';
const DEVICE_TYPES = ['laptop', 'tablet', 'mobile'] as const;
type DeviceType = (typeof DEVICE_TYPES)[number];
type StatusFilter = 'active' | 'inactive';
type SortField = 'name' | 'slug' | 'brand' | 'series' | 'releaseYear' | 'status';
type SortDir = 'asc' | 'desc';
type SortConfig = { field: SortField; dir: SortDir };
type FilterSection = 'name' | 'brand' | 'series' | 'deviceTypes' | 'status';
type ModelFilters = {
modelIds: string[];
brandIds: string[];
seriesIds: string[];
deviceTypes: DeviceType[];
statuses: StatusFilter[];
};
const EMPTY_FILTERS: ModelFilters = {
modelIds: [],
brandIds: [],
seriesIds: [],
deviceTypes: [],
statuses: [],
};
const FILTER_PAGE_SIZE = 5;
const EMPTY_COLUMNS = {
modelName: true,
slug: true,
brand: true,
series: true,
deviceType: true,
releaseYear: true,
fullPath: true,
status: true,
actions: true,
};
const COLUMN_OPTIONS = [
{ key: 'modelName' as const, label: 'Model Name', locked: true },
{ key: 'slug' as const, label: 'Slug / Path', locked: false },
{ key: 'brand' as const, label: 'Brand', locked: false },
{ key: 'series' as const, label: 'Series', locked: false },
{ key: 'deviceType' as const, label: 'Device Type', locked: false },
{ key: 'releaseYear' as const, label: 'Release Year', locked: false },
{ key: 'fullPath' as const, label: 'Full Path', 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: 'brand', dir: 'asc', label: 'Brand A-Z' },
{ field: 'brand', dir: 'desc', label: 'Brand Z-A' },
{ field: 'series', dir: 'asc', label: 'Series A-Z' },
{ field: 'series', dir: 'desc', label: 'Series Z-A' },
{ field: 'releaseYear', dir: 'asc', label: 'Release Year Ascending' },
{ field: 'releaseYear', dir: 'desc', label: 'Release Year Descending' },
{ field: 'status', dir: 'asc', label: 'Status Active first' },
{ field: 'status', dir: 'desc', label: 'Status Inactive first' },
];
const DEFAULT_SORT: SortConfig = { field: 'name', dir: 'asc' };
function normalizeId(value: unknown): string {
if (value == null) return '';
return String(value).trim();
}
function idsEqual(a: unknown, b: unknown): boolean {
const left = normalizeId(a);
const right = normalizeId(b);
return left !== '' && right !== '' && left === right;
}
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 formatDeviceTypeLabel(value?: string | null): string {
if (!value) return '—';
return value.charAt(0).toUpperCase() + value.slice(1);
}
export default function DeviceModelsPage() {
const [brands, setBrands] = useState<BrandResponse[]>([]);
const [seriesList, setSeriesList] = useState<DeviceSeriesResponse[]>([]);
const [models, setModels] = useState<DeviceModelResponse[]>([]);
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 [draftFilters, setDraftFilters] = useState<ModelFilters>(EMPTY_FILTERS);
const [activeFilters, setActiveFilters] = useState<ModelFilters>(EMPTY_FILTERS);
const [expandedFilterSections, setExpandedFilterSections] = useState<Record<FilterSection, boolean>>({
name: false,
brand: false,
series: false,
deviceTypes: true,
status: false,
});
const [filterSectionSearch, setFilterSectionSearch] = useState({ name: '', brand: '', series: '' });
const [filterVisibleCounts, setFilterVisibleCounts] = useState({
name: FILTER_PAGE_SIZE,
brand: FILTER_PAGE_SIZE,
series: FILTER_PAGE_SIZE,
});
const [visibleColumns, setVisibleColumns] = useState(EMPTY_COLUMNS);
const [sortConfig, setSortConfig] = useState<SortConfig>(DEFAULT_SORT);
const filterRef = useRef<HTMLDivElement>(null);
const exportRef = useRef<HTMLDivElement>(null);
const columnsRef = useRef<HTMLDivElement>(null);
const sortRef = useRef<HTMLDivElement>(null);
const [showModelDrawer, setShowModelDrawer] = useState(false);
const [editingModel, setEditingModel] = useState<DeviceModelResponse | null>(null);
const [modelName, setModelName] = useState('');
const [modelSeriesId, setModelSeriesId] = useState('');
const [modelBrandId, setModelBrandId] = useState('');
const [modelReleaseYear, setModelReleaseYear] = useState('');
const [modelImg, setModelImg] = useState('');
const [modelDeviceType, setModelDeviceType] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const brandMap = useMemo(() => {
const map = new Map<string, BrandResponse>();
brands.forEach((brand) => map.set(normalizeId(brand.brand_id), brand));
return map;
}, [brands]);
const seriesMap = useMemo(() => {
const map = new Map<string, DeviceSeriesResponse>();
seriesList.forEach((s) => map.set(normalizeId(s.series_id), s));
return map;
}, [seriesList]);
const getBrandName = (brandId: string) => brandMap.get(normalizeId(brandId))?.name || '—';
const getSeriesName = (seriesId: string) => seriesMap.get(normalizeId(seriesId))?.name || '—';
const fetchData = async () => {
setLoading(true);
try {
const [fetchedBrands, fetchedSeries, fetchedModels] = await Promise.all([
catalogService.getBrands(),
catalogService.getDeviceSeries(),
catalogService.getDeviceModels(),
]);
setBrands(fetchedBrands);
setSeriesList(fetchedSeries);
setModels(fetchedModels);
} catch (err: any) {
toast.error(err?.message || 'Failed to load device models data');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
// Auto-inherit device_type from selected series
useEffect(() => {
if (modelSeriesId) {
const selectedSeries = seriesList.find((s) => idsEqual(s.series_id, modelSeriesId));
if (selectedSeries?.device_type) {
setModelDeviceType(selectedSeries.device_type);
}
}
}, [modelSeriesId, seriesList]);
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]);
const closeOverlays = () => {
setShowFilterPanel(false);
setShowColumnsPanel(false);
setShowSortPanel(false);
};
const handleViewModeChange = (mode: 'table' | 'grid') => {
setViewMode(mode);
setOpenActionId(null);
closeOverlays();
};
const selectedBrand = brands.find((b) => idsEqual(b.brand_id, modelBrandId));
const brandDeviceTypes = selectedBrand?.device_types || [];
const selectedSeries = seriesList.find((s) => idsEqual(s.series_id, modelSeriesId));
const handleOpenCreate = () => {
setEditingModel(null);
setModelName('');
setModelSeriesId('');
setModelBrandId('');
setModelReleaseYear('');
setModelImg('');
setModelDeviceType('');
setShowModelDrawer(true);
};
const handleOpenEdit = (model: DeviceModelResponse) => {
setEditingModel(model);
setModelName(model.name);
setModelBrandId(model.brand_id);
setModelSeriesId(model.series_id || '');
setModelReleaseYear(model.release_year ? model.release_year.toString() : '');
setModelImg(model.image_url || '');
setModelDeviceType(model.device_type || '');
setShowModelDrawer(true);
};
const closeModelDrawer = () => {
if (isSubmitting) return;
setShowModelDrawer(false);
};
const handleBrandChange = (brandId: string) => {
setModelBrandId(brandId);
setModelSeriesId('');
setModelDeviceType('');
};
const handleDeviceTypeChange = (deviceType: string) => {
setModelDeviceType(deviceType);
setModelSeriesId('');
};
const handleSubmitModel = async (e: React.FormEvent) => {
e.preventDefault();
if (!modelName.trim() || !modelBrandId) {
toast.error('Name and Brand are required');
return;
}
const finalDeviceType =
brandDeviceTypes.length > 1
? modelDeviceType || null
: selectedSeries?.device_type || brandDeviceTypes[0] || null;
setIsSubmitting(true);
try {
if (editingModel) {
const updated = await catalogService.updateDeviceModel(editingModel.model_id, {
series_id: modelSeriesId || undefined,
brand_id: modelBrandId,
name: modelName.trim(),
device_type: finalDeviceType,
release_year: modelReleaseYear ? parseInt(modelReleaseYear, 10) : undefined,
image_url: modelImg.trim() || undefined,
});
setModels(models.map((m) => (m.model_id === updated.model_id ? updated : m)));
toast.success(`Model "${updated.name}" updated successfully`);
} else {
const created = await catalogService.createDeviceModel({
series_id: modelSeriesId || undefined,
brand_id: modelBrandId,
name: modelName.trim(),
device_type: finalDeviceType,
release_year: modelReleaseYear ? parseInt(modelReleaseYear, 10) : undefined,
image_url: modelImg.trim() || undefined,
});
setModels([...models, created]);
toast.success(`Model "${created.name}" created successfully`);
}
setModelName('');
setModelSeriesId('');
setModelBrandId('');
setModelReleaseYear('');
setModelImg('');
setModelDeviceType('');
setEditingModel(null);
setShowModelDrawer(false);
} catch (err: any) {
toast.error(err?.message || `Failed to ${editingModel ? 'update' : 'create'} model`);
} 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 {
setModels((prev) => prev.filter((m) => m.model_id !== id));
await catalogService.deleteDeviceModel(id);
toast.success(`Device model "${name}" deleted successfully`);
setDeleteTarget(null);
fetchData();
} catch (err: any) {
toast.error(err?.message || 'Failed to delete device model');
fetchData();
} finally {
setIsDeleting(false);
}
};
const filteredModels = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const result = models.filter((m) => {
const brandName = getBrandName(m.brand_id);
const seriesName = getSeriesName(m.series_id);
const searchMatch =
!q ||
(m.name || '').toLowerCase().includes(q) ||
(m.slug || '').toLowerCase().includes(q) ||
brandName.toLowerCase().includes(q) ||
seriesName.toLowerCase().includes(q) ||
(m.device_type || '').toLowerCase().includes(q) ||
(m.full_path || '').toLowerCase().includes(q) ||
(m.release_year != null && String(m.release_year).includes(q));
const isActive = coerceFlag(m.is_active);
const statusMatch =
activeFilters.statuses.length === 0 ||
(activeFilters.statuses.includes('active') && isActive) ||
(activeFilters.statuses.includes('inactive') && !isActive);
const modelMatch =
activeFilters.modelIds.length === 0 ||
activeFilters.modelIds.includes(normalizeId(m.model_id));
const brandMatch =
activeFilters.brandIds.length === 0 ||
activeFilters.brandIds.includes(normalizeId(m.brand_id));
const seriesMatch =
activeFilters.seriesIds.length === 0 ||
activeFilters.seriesIds.includes(normalizeId(m.series_id));
const deviceMatch =
activeFilters.deviceTypes.length === 0 ||
(m.device_type != null && activeFilters.deviceTypes.includes(m.device_type as DeviceType));
return searchMatch && modelMatch && brandMatch && seriesMatch && statusMatch && deviceMatch;
});
const dir = sortConfig.dir === 'asc' ? 1 : -1;
return [...result].sort((a, b) => {
if (sortConfig.field === 'releaseYear') {
const left = a.release_year ?? -Infinity;
const right = b.release_year ?? -Infinity;
return (left - right) * dir;
}
if (sortConfig.field === 'status') {
return (Number(coerceFlag(a.is_active)) - Number(coerceFlag(b.is_active))) * dir;
}
if (sortConfig.field === 'brand') {
return getBrandName(a.brand_id).localeCompare(getBrandName(b.brand_id), undefined, {
sensitivity: 'base',
}) * dir;
}
if (sortConfig.field === 'series') {
return getSeriesName(a.series_id).localeCompare(getSeriesName(b.series_id), undefined, {
sensitivity: 'base',
}) * dir;
}
const left = sortConfig.field === 'name' ? a.name || '' : a.slug || '';
const right = sortConfig.field === 'name' ? b.name || '' : b.slug || '';
return left.localeCompare(right, undefined, { sensitivity: 'base' }) * dir;
});
}, [models, searchQuery, activeFilters, sortConfig, brandMap, seriesMap]);
const pager = useClientPagination(filteredModels);
const filtersActive =
activeFilters.modelIds.length > 0 ||
activeFilters.brandIds.length > 0 ||
activeFilters.seriesIds.length > 0 ||
activeFilters.deviceTypes.length > 0 ||
activeFilters.statuses.length > 0;
const sortedModelFilterOptions = useMemo(
() => [...models].sort((a, b) => a.name.localeCompare(b.name)),
[models]
);
const sortedBrandFilterOptions = useMemo(
() => [...brands].sort((a, b) => a.name.localeCompare(b.name)),
[brands]
);
const sortedSeriesFilterOptions = useMemo(
() => [...seriesList].sort((a, b) => a.name.localeCompare(b.name)),
[seriesList]
);
const toggleFilterSection = (section: FilterSection) => {
setExpandedFilterSections((prev) => ({ ...prev, [section]: !prev[section] }));
};
const toggleDraftListValue = <T extends string>(
key: keyof Pick<ModelFilters, 'modelIds' | 'brandIds' | 'seriesIds' | 'deviceTypes' | '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: '', brand: '', series: '' });
setFilterVisibleCounts({ name: FILTER_PAGE_SIZE, brand: FILTER_PAGE_SIZE, series: FILTER_PAGE_SIZE });
setShowColumnsPanel(false);
setShowSortPanel(false);
setShowFilterPanel(true);
};
const visibleColumnCount = Object.values(visibleColumns).filter(Boolean).length;
const toggleColumn = (key: keyof typeof EMPTY_COLUMNS) => {
if (key === 'modelName') return;
setVisibleColumns((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleExportExcel = () => {
if (filteredModels.length === 0) {
toast.warning('No device models to export');
return;
}
const headers = [
'S.No',
'Model Name',
'Slug / Path',
'Brand',
'Series',
'Device Type',
'Release Year',
'Full Path',
'Status',
];
const rows = filteredModels.map((m, i) => [
i + 1,
m.name,
m.slug || '',
getBrandName(m.brand_id),
getSeriesName(m.series_id),
formatDeviceTypeLabel(m.device_type),
m.release_year ?? '',
m.full_path || '',
coerceFlag(m.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_device_models_${format(new Date(), 'yyyyMMdd')}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setShowExportMenu(false);
toast.success(`Downloaded ${filteredModels.length} ${filteredModels.length === 1 ? 'device model' : 'device models'} as CSV`);
};
const handleExportPDF = () => {
if (filteredModels.length === 0) {
toast.warning('No device models to export');
return;
}
const printWindow = window.open('', '_blank');
if (!printWindow) {
toast.error('Allow pop-ups to open the device models print preview');
return;
}
const html = `
<html>
<head>
<title>iFixKart Device Models 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 Device Models (${filteredModels.length} Records)</h2>
<p>Exported on: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}</p>
<table>
<thead>
<tr>
<th>S.No</th>
<th>Model Name</th>
<th>Slug / Path</th>
<th>Brand</th>
<th>Series</th>
<th>Device Type</th>
<th>Release Year</th>
<th>Full Path</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${filteredModels
.map(
(m, i) => `
<tr>
<td>${i + 1}</td>
<td>${m.name}</td>
<td>${m.slug || ''}</td>
<td>${getBrandName(m.brand_id)}</td>
<td>${getSeriesName(m.series_id)}</td>
<td>${formatDeviceTypeLabel(m.device_type)}</td>
<td>${m.release_year ?? '—'}</td>
<td>${m.full_path || ''}</td>
<td>${coerceFlag(m.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 ${filteredModels.length} ${filteredModels.length === 1 ? 'device model' : 'device models'}`);
};
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 renderDeviceTypeBadge = (deviceType?: string | null) => {
if (!deviceType) {
return <span className="text-[13px] text-muted-foreground">—</span>;
}
return (
<span className="inline-flex items-center px-2 py-0.5 crm-radius-badge text-[11px] font-semibold bg-muted text-muted-foreground capitalize">
{deviceType}
</span>
);
};
const renderBrandAvatar = (brand: BrandResponse, size: 'sm' | 'md' = 'md') => {
const dim = size === 'sm' ? 'w-9 h-9' : 'w-10 h-10';
return (
<div
className={`${dim} crm-radius-icon bg-primary/10 text-primary flex items-center justify-center shrink-0 overflow-hidden`}
>
{brand.logo_url ? (
<img src={getMediaUrl(brand.logo_url)} alt={brand.name} className="w-full h-full object-contain p-1" />
) : (
<Tag className="w-4 h-4" />
)}
</div>
);
};
const renderModelAvatar = (model: DeviceModelResponse, size: 'sm' | 'md' = 'md') => {
const dim = size === 'sm' ? 'w-9 h-9' : 'w-10 h-10';
return (
<div
className={`${dim} crm-radius-icon bg-primary/10 text-primary flex items-center justify-center shrink-0 overflow-hidden`}
>
{model.image_url ? (
<img src={getMediaUrl(model.image_url)} alt={model.name} className="w-full h-full object-cover" />
) : (
<Smartphone className="w-4 h-4" />
)}
</div>
);
};
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 addModelButton = (
<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 Model
</button>
);
const viewToggle = <ViewModeToggle value={viewMode} onChange={handleViewModeChange} />;
const renderFilterControl = (align: 'left' | 'right') => {
const nameQuery = filterSectionSearch.name.trim().toLowerCase();
const brandQuery = filterSectionSearch.brand.trim().toLowerCase();
const seriesQuery = filterSectionSearch.series.trim().toLowerCase();
const filteredNameOptions = sortedModelFilterOptions.filter(
(model) =>
!nameQuery ||
model.name.toLowerCase().includes(nameQuery) ||
(model.slug || '').toLowerCase().includes(nameQuery)
);
const filteredBrandOptions = sortedBrandFilterOptions.filter(
(brand) =>
!brandQuery ||
brand.name.toLowerCase().includes(brandQuery) ||
(brand.slug || '').toLowerCase().includes(brandQuery)
);
const filteredSeriesOptions = sortedSeriesFilterOptions.filter(
(s) =>
!seriesQuery ||
s.name.toLowerCase().includes(seriesQuery) ||
(s.slug || '').toLowerCase().includes(seriesQuery)
);
const visibleNameOptions = filteredNameOptions.slice(0, filterVisibleCounts.name);
const visibleBrandOptions = filteredBrandOptions.slice(0, filterVisibleCounts.brand);
const visibleSeriesOptions = filteredSeriesOptions.slice(0, filterVisibleCounts.series);
const renderSectionSearch = (
section: 'name' | 'brand' | 'series',
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={() => (showFilterPanel ? setShowFilterPanel(false) : 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' : ''}`}
/>
Model 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 models found.</p>
) : (
visibleNameOptions.map((model) => {
const id = normalizeId(model.model_id);
return renderCheckboxOption(
draftFilters.modelIds.includes(id),
() => toggleDraftListValue('modelIds', id),
model.name,
renderModelAvatar(model, 'sm')
);
})
)}
</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('brand')}
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.brand ? 'rotate-90' : ''}`}
/>
Brand
</button>
{expandedFilterSections.brand && (
<div className="px-4 pb-4">
<div className="crm-radius-control border border-border bg-muted/20 p-3 space-y-2">
{renderSectionSearch('brand', filterSectionSearch.brand, (value) =>
setFilterSectionSearch((prev) => ({ ...prev, brand: value }))
)}
<div className="space-y-0.5">
{visibleBrandOptions.length === 0 ? (
<p className="py-2 text-[12px] text-muted-foreground">No brands found.</p>
) : (
visibleBrandOptions.map((brand) => {
const id = normalizeId(brand.brand_id);
return renderCheckboxOption(
draftFilters.brandIds.includes(id),
() => toggleDraftListValue('brandIds', id),
brand.name,
renderBrandAvatar(brand, 'sm')
);
})
)}
</div>
{filteredBrandOptions.length > visibleBrandOptions.length && (
<button
type="button"
onClick={() =>
setFilterVisibleCounts((prev) => ({
...prev,
brand: prev.brand + 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('series')}
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.series ? 'rotate-90' : ''}`}
/>
Series
</button>
{expandedFilterSections.series && (
<div className="px-4 pb-4">
<div className="crm-radius-control border border-border bg-muted/20 p-3 space-y-2">
{renderSectionSearch('series', filterSectionSearch.series, (value) =>
setFilterSectionSearch((prev) => ({ ...prev, series: value }))
)}
<div className="space-y-0.5">
{visibleSeriesOptions.length === 0 ? (
<p className="py-2 text-[12px] text-muted-foreground">No series found.</p>
) : (
visibleSeriesOptions.map((s) => {
const id = normalizeId(s.series_id);
return renderCheckboxOption(
draftFilters.seriesIds.includes(id),
() => toggleDraftListValue('seriesIds', id),
s.name,
(
<div className="w-7 h-7 crm-radius-icon bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Smartphone className="w-3.5 h-3.5" />
</div>
)
);
})
)}
</div>
{filteredSeriesOptions.length > visibleSeriesOptions.length && (
<button
type="button"
onClick={() =>
setFilterVisibleCounts((prev) => ({
...prev,
series: prev.series + 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('deviceTypes')}
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.deviceTypes ? 'rotate-90' : ''}`}
/>
Device Type
</button>
{expandedFilterSections.deviceTypes && (
<div className="px-4 pb-4">
<div className="crm-radius-control border border-border bg-muted/20 p-3 space-y-1">
{DEVICE_TYPES.map((dt) =>
renderCheckboxOption(
draftFilters.deviceTypes.includes(dt),
() => toggleDraftListValue('deviceTypes', dt),
dt.charAt(0).toUpperCase() + dt.slice(1)
)
)}
</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);
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 ? 'Model 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);
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 emptyMessage =
searchQuery || filtersActive ? 'No device models match your search.' : 'No device models found.';
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">Device Models</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">
{models.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}
{addModelButton}
</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 device models...</span>
</div>
) : filteredModels.length === 0 ? (
<div className="py-12 text-center text-[13px] text-muted-foreground">{emptyMessage}</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((m) => (
<div
key={m.model_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 === m.model_id}
onOpenChange={(open) => setOpenActionId(open ? m.model_id : null)}
onEdit={() => handleOpenEdit(m)}
onDelete={() => openDeleteModal(m.model_id, m.name)}
/>
</div>
<div className="flex items-start gap-3 pr-8 min-w-0">
{renderModelAvatar(m, 'md')}
<div className="min-w-0">
<p className="text-[14px] font-semibold text-foreground truncate">{m.name}</p>
<p className="text-[12px] text-muted-foreground truncate mt-0.5">{m.slug}</p>
</div>
</div>
<div className="mt-4 space-y-1.5 text-[12px] text-muted-foreground">
<p>Brand: {getBrandName(m.brand_id)}</p>
<p>Series: {getSeriesName(m.series_id)}</p>
{m.release_year && <p>Release Year: {m.release_year}</p>}
{m.full_path && (
<p className="font-mono text-[11px] truncate" title={m.full_path}>
{m.full_path}
</p>
)}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
{renderDeviceTypeBadge(m.device_type)}
{renderStatusBadge(coerceFlag(m.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}
{addModelButton}
</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}</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 device models...</span>
</div>
) : (
<div className="overflow-x-auto border-t border-border">
<table className="crm-data-table min-w-[1020px]">
<thead>
<tr className="bg-gray-50 border-y border-gray-200">
{visibleColumns.modelName && (
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
{renderSortLabel('Model 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.brand && (
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
{renderSortLabel('Brand', 'brand')}
</th>
)}
{visibleColumns.series && (
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
{renderSortLabel('Series', 'series')}
</th>
)}
{visibleColumns.deviceType && (
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
{renderSortLabel('Device Type')}
</th>
)}
{visibleColumns.releaseYear && (
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
{renderSortLabel('Release Year', 'releaseYear')}
</th>
)}
{visibleColumns.fullPath && (
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700 whitespace-nowrap">
{renderSortLabel('Full Path')}
</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">
{filteredModels.length === 0 ? (
<tr>
<td
colSpan={Math.max(visibleColumnCount, 1)}
className="text-center py-12 text-[13px] text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
) : (
pager.items.map((m) => (
<tr key={m.model_id} className="border-t border-border hover:bg-muted/10 transition-colors">
{visibleColumns.modelName && (
<td className="px-5 py-3.5">
<div className="flex items-center gap-3 min-w-0">
{renderModelAvatar(m, 'sm')}
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground truncate">{m.name}</p>
<p className="text-[11px] text-muted-foreground truncate">{m.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={m.slug}>{m.slug}</span>
</td>
)}
{visibleColumns.brand && (
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{getBrandName(m.brand_id)}</td>
)}
{visibleColumns.series && (
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{getSeriesName(m.series_id)}</td>
)}
{visibleColumns.deviceType && (
<td className="px-5 py-3.5">{renderDeviceTypeBadge(m.device_type)}</td>
)}
{visibleColumns.releaseYear && (
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{m.release_year ?? '—'}</td>
)}
{visibleColumns.fullPath && (
<td className="px-5 py-3.5 font-mono text-muted-foreground">
<span className="crm-cell-clip max-w-[200px]" title={m.full_path || ''}>{m.full_path || '—'}</span>
</td>
)}
{visibleColumns.status && (
<td className="px-5 py-3.5">{renderStatusBadge(coerceFlag(m.is_active))}</td>
)}
{visibleColumns.actions && (
<td className="px-5 py-3.5">
<div className="flex items-center justify-center">
<RowActionsMenu
open={openActionId === m.model_id}
onOpenChange={(open) => setOpenActionId(open ? m.model_id : null)}
onEdit={() => handleOpenEdit(m)}
onDelete={() => openDeleteModal(m.model_id, m.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-model-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-model-title" className="text-[15px] font-semibold text-foreground">
Delete Device Model
</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">&quot;{deleteTarget.name}&quot;</span> and all its
related repair services?
</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 Model
</>
)}
</button>
</div>
</div>
</div>
)}
<SlideOver
open={showModelDrawer}
onClose={closeModelDrawer}
title={editingModel ? 'Edit Device Model' : 'Create Device Model'}
icon={<Smartphone className="w-4 h-4 text-primary shrink-0" />}
>
<form onSubmit={handleSubmitModel} 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 className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">
Brand <span className="text-primary">*</span>
</label>
<CustomSelect
value={modelBrandId}
onChange={handleBrandChange}
placeholder="- Select Brand -"
options={[
{ value: '', label: '- Select Brand -' },
...brands.map((brand) => ({ value: brand.brand_id, label: brand.name })),
]}
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">
Device Type
</label>
<CustomSelect
value={modelDeviceType}
disabled={!modelBrandId}
onChange={handleDeviceTypeChange}
placeholder="- Select Device Type -"
options={[
{ value: '', label: '- Select Device Type -' },
...brandDeviceTypes.map((dt) => ({ value: dt, label: dt })),
]}
/>
</div>
</div>
{modelBrandId && modelDeviceType && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">
Series (Optional)
</label>
<CustomSelect
value={modelSeriesId}
onChange={setModelSeriesId}
placeholder="- Series (Optional) -"
options={[
{ value: '', label: '- Series (Optional) -' },
...seriesList
.filter(
(s) => idsEqual(s.brand_id, modelBrandId) && s.device_type === modelDeviceType
)
.map((s) => ({ value: s.series_id, label: s.name })),
]}
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">
Model Name <span className="text-primary">*</span>
</label>
<input
type="text"
placeholder="e.g. Galaxy S24 Ultra"
value={modelName}
onChange={(e) => setModelName(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>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">
Release Year
</label>
<input
type="number"
placeholder="2026"
value={modelReleaseYear}
onChange={(e) => setModelReleaseYear(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">
Device Image
</label>
<ImageUpload
entityType="model"
entityId={editingModel?.model_id || ''}
value={modelImg}
onUploadSuccess={(url) => setModelImg(url)}
onClear={() => setModelImg('')}
/>
</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={closeModelDrawer}
disabled={isSubmitting}
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 disabled:opacity-50"
>
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"
>
{isSubmitting ? 'Saving...' : editingModel ? 'Save Changes' : 'Create Model'}
</button>
</div>
</form>
</SlideOver>
</div>
);
}