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

1185 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Smartphone,
Plus,
Search,
Layers,
RefreshCw,
X,
Wrench,
Package,
Cpu,
Trash2,
ChevronDown,
Box,
FileSpreadsheet,
Columns3,
GripVertical,
ArrowUpDown,
Calendar,
AlertTriangle,
} from 'lucide-react';
import { toast } from 'sonner';
import { format, subDays } from 'date-fns';
import {
catalogService,
BrandResponse,
DeviceSeriesResponse,
DeviceModelResponse,
ServiceTypeResponse,
RepairServiceResponse,
RepairVariantResponse,
PartResponse,
} 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 { CustomSelect } from '@/components/ui/CustomSelect';
import { getMediaUrl } from '@/services/api/config';
type ActiveTab = 'models' | 'services' | 'variants';
type StatusFilter = 'active' | 'inactive';
type ModelSortField = 'name' | 'brand' | 'series' | 'releaseYear' | 'status';
type ServiceSortField = 'model' | 'type' | 'path';
type VariantSortField = 'name' | 'price' | 'status';
type SortDir = 'asc' | 'desc';
const FILTER_PAGE_SIZE = 5;
const DEFAULT_DATE_FROM = format(subDays(new Date(), 30), 'yyyy-MM-dd');
const DEFAULT_DATE_TO = format(new Date(), 'yyyy-MM-dd');
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 MODEL_COLUMNS = {
modelName: true,
brand: true,
series: true,
fullPath: true,
releaseYear: true,
status: true,
actions: true,
};
const MODEL_COLUMN_OPTIONS = [
{ key: 'modelName' as const, label: 'Model Name', locked: true },
{ key: 'brand' as const, label: 'Brand', locked: false },
{ key: 'series' as const, label: 'Series', locked: false },
{ key: 'fullPath' as const, label: 'Full Path', locked: false },
{ key: 'releaseYear' as const, label: 'Release Year', locked: false },
{ key: 'status' as const, label: 'Status', locked: false },
{ key: 'actions' as const, label: 'Actions', locked: false },
];
const MODEL_SORT_OPTIONS: { field: ModelSortField; dir: SortDir; label: string }[] = [
{ field: 'name', dir: 'asc', label: 'Name A-Z' },
{ field: 'name', dir: 'desc', label: 'Name Z-A' },
{ field: 'brand', dir: 'asc', label: 'Brand A-Z' },
{ field: 'series', dir: 'asc', label: 'Series A-Z' },
{ field: 'releaseYear', dir: 'desc', label: 'Release Year (newest)' },
{ field: 'status', dir: 'asc', label: 'Status Active first' },
];
function normalizeId(value: unknown): string {
if (value == null) return '';
return String(value).trim();
}
function coerceFlag(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (value == null) return false;
const n = String(value).trim().toLowerCase();
if (['true', '1', 'yes', 'active'].includes(n)) return true;
if (['false', '0', 'no', 'inactive', ''].includes(n)) return false;
return false;
}
function dayStartMs(isoDate: string): number {
const [y, m, d] = isoDate.split('-').map(Number);
if (!y || !m || !d) return NaN;
return new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
}
function dayEndMs(isoDate: string): number {
const [y, m, d] = isoDate.split('-').map(Number);
if (!y || !m || !d) return NaN;
return new Date(y, m - 1, d, 23, 59, 59, 999).getTime();
}
export default function DevicesPage() {
const [activeTab, setActiveTab] = useState<ActiveTab>('models');
const [brands, setBrands] = useState<BrandResponse[]>([]);
const [series, setSeries] = useState<DeviceSeriesResponse[]>([]);
const [models, setModels] = useState<DeviceModelResponse[]>([]);
const [serviceTypes, setServiceTypes] = useState<ServiceTypeResponse[]>([]);
const [repairServices, setRepairServices] = useState<RepairServiceResponse[]>([]);
const [variants, setVariants] = useState<RepairVariantResponse[]>([]);
const [parts, setParts] = useState<PartResponse[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
const [openActionId, setOpenActionId] = useState<string | null>(null);
const [showExportMenu, setShowExportMenu] = useState(false);
const [showFilterPanel, setShowFilterPanel] = useState(false);
const [showColumnsPanel, setShowColumnsPanel] = useState(false);
const [showSortPanel, setShowSortPanel] = useState(false);
const [showDatePanel, setShowDatePanel] = useState(false);
const [visibleColumns, setVisibleColumns] = useState(MODEL_COLUMNS);
const [modelSort, setModelSort] = useState<{ field: ModelSortField; dir: SortDir }>({ field: 'name', dir: 'asc' });
const [serviceSort, setServiceSort] = useState<{ field: ServiceSortField; dir: SortDir }>({ field: 'model', dir: 'asc' });
const [variantSort, setVariantSort] = useState<{ field: VariantSortField; dir: SortDir }>({ field: 'name', dir: 'asc' });
const [draftModelFilters, setDraftModelFilters] = useState({ modelIds: [] as string[], brandIds: [] as string[], seriesIds: [] as string[], statuses: [] as StatusFilter[] });
const [activeModelFilters, setActiveModelFilters] = useState({ modelIds: [] as string[], brandIds: [] as string[], seriesIds: [] as string[], statuses: [] as StatusFilter[] });
const [draftServiceFilters, setDraftServiceFilters] = useState({ modelIds: [] as string[], typeIds: [] as string[] });
const [activeServiceFilters, setActiveServiceFilters] = useState({ modelIds: [] as string[], typeIds: [] as string[] });
const [draftVariantFilters, setDraftVariantFilters] = useState({ statuses: [] as string[] });
const [activeVariantFilters, setActiveVariantFilters] = useState({ statuses: [] as string[] });
const [expandedFilterSections, setExpandedFilterSections] = useState<Record<string, boolean>>({ name: false, brand: false, series: false, status: true, model: false, type: 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 [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 exportRef = useRef<HTMLDivElement>(null);
const filterRef = useRef<HTMLDivElement>(null);
const columnsRef = useRef<HTMLDivElement>(null);
const sortRef = useRef<HTMLDivElement>(null);
const dateRef = useRef<HTMLDivElement>(null);
const [showSeriesModal, setShowSeriesModal] = useState(false);
const [seriesName, setSeriesName] = useState('');
const [seriesBrandId, setSeriesBrandId] = useState('');
const [isSubmittingSeries, setIsSubmittingSeries] = useState(false);
const [showModelModal, setShowModelModal] = 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 [isSubmittingModel, setIsSubmittingModel] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [showTypeModal, setShowTypeModal] = useState(false);
const [typeName, setTypeName] = useState('');
const [typeDesc, setTypeDesc] = useState('');
const [typeIcon, setTypeIcon] = useState('');
const [showServiceModal, setShowServiceModal] = useState(false);
const [serviceModelId, setServiceModelId] = useState('');
const [serviceTypeId, setServiceTypeId] = useState('');
const [serviceDesc, setServiceDesc] = useState('');
const [showVariantModal, setShowVariantModal] = useState(false);
const [variantServiceId, setVariantServiceId] = useState('');
const [variantName, setVariantName] = useState('');
const [variantPrice, setVariantPrice] = useState('');
const [variantCost, setVariantCost] = useState('');
const [variantDuration, setVariantDuration] = useState('45');
const [variantWarranty, setVariantWarranty] = useState('90');
const [bomParts, setBomParts] = useState<Array<{ part_id: string; quantity: number }>>([]);
const fetchData = async () => {
setLoading(true);
try {
const [
fetchedBrands,
fetchedSeries,
fetchedModels,
fetchedTypes,
fetchedServices,
fetchedVariants,
fetchedParts,
] = await Promise.all([
catalogService.getBrands(),
catalogService.getDeviceSeries(),
catalogService.getDeviceModels(),
catalogService.getServiceTypes(),
catalogService.getRepairServices(),
catalogService.getRepairVariants(),
catalogService.getParts(),
]);
setBrands(fetchedBrands);
setSeries(fetchedSeries);
setModels(fetchedModels);
setServiceTypes(fetchedTypes);
setRepairServices(fetchedServices);
setVariants(fetchedVariants);
setParts(fetchedParts);
} catch (err: any) {
toast.error(err?.message || 'Failed to load catalog datasets');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
const refs = [
{ open: showFilterPanel, ref: filterRef, close: () => setShowFilterPanel(false) },
{ open: showExportMenu, ref: exportRef, close: () => setShowExportMenu(false) },
{ open: showColumnsPanel, ref: columnsRef, close: () => setShowColumnsPanel(false) },
{ open: showSortPanel, ref: sortRef, close: () => setShowSortPanel(false) },
{ open: showDatePanel, ref: dateRef, close: () => setShowDatePanel(false) },
];
const active = refs.filter((r) => r.open);
if (active.length === 0) return;
const onPointerDown = (e: MouseEvent) => {
if (active.some((r) => r.ref.current?.contains(e.target as Node))) return;
active.forEach((r) => r.close());
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') active.forEach((r) => r.close());
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [showFilterPanel, showExportMenu, showColumnsPanel, showSortPanel, showDatePanel]);
const getBrandName = (id: string) => brands.find((b) => b.brand_id === id)?.name || 'Unknown';
const getSeriesName = (id: string) => series.find((s) => s.series_id === id)?.name || 'Unknown';
const getModelName = (id: string) => models.find((m) => m.model_id === id)?.name || 'Unknown';
const getTypeName = (id: string) => serviceTypes.find((t) => t.service_type_id === id)?.name || 'Unknown';
const modelFiltersActive =
activeModelFilters.modelIds.length > 0 ||
activeModelFilters.brandIds.length > 0 ||
activeModelFilters.seriesIds.length > 0 ||
activeModelFilters.statuses.length > 0;
const serviceFiltersActive =
activeServiceFilters.modelIds.length > 0 || activeServiceFilters.typeIds.length > 0;
const variantFiltersActive = activeVariantFilters.statuses.length > 0;
const filteredModels = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const dir = modelSort.dir === 'asc' ? 1 : -1;
const result = models.filter((m) => {
const searchMatch = !q || m.name.toLowerCase().includes(q) || m.full_path.toLowerCase().includes(q);
const brandMatch = activeModelFilters.brandIds.length === 0 || activeModelFilters.brandIds.includes(m.brand_id);
const seriesMatch = activeModelFilters.seriesIds.length === 0 || activeModelFilters.seriesIds.includes(m.series_id);
const modelMatch = activeModelFilters.modelIds.length === 0 || activeModelFilters.modelIds.includes(m.model_id);
const isActive = coerceFlag(m.is_active);
const statusMatch =
activeModelFilters.statuses.length === 0 ||
(activeModelFilters.statuses.includes('active') && isActive) ||
(activeModelFilters.statuses.includes('inactive') && !isActive);
return searchMatch && brandMatch && seriesMatch && modelMatch && statusMatch;
});
return [...result].sort((a, b) => {
if (modelSort.field === 'status') return (Number(coerceFlag(a.is_active)) - Number(coerceFlag(b.is_active))) * dir;
if (modelSort.field === 'releaseYear') return ((a.release_year || 0) - (b.release_year || 0)) * dir;
if (modelSort.field === 'brand') return getBrandName(a.brand_id).localeCompare(getBrandName(b.brand_id)) * dir;
if (modelSort.field === 'series') return getSeriesName(a.series_id).localeCompare(getSeriesName(b.series_id)) * dir;
return a.name.localeCompare(b.name) * dir;
});
}, [models, searchQuery, activeModelFilters, modelSort, brands, series]);
const filteredServices = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const dir = serviceSort.dir === 'asc' ? 1 : -1;
const result = repairServices.filter((rs) => {
const modelName = getModelName(rs.model_id);
const typeName = getTypeName(rs.service_type_id);
const searchMatch = !q || modelName.toLowerCase().includes(q) || typeName.toLowerCase().includes(q) || rs.full_path.toLowerCase().includes(q);
const modelMatch = activeServiceFilters.modelIds.length === 0 || activeServiceFilters.modelIds.includes(rs.model_id);
const typeMatch = activeServiceFilters.typeIds.length === 0 || activeServiceFilters.typeIds.includes(rs.service_type_id);
return searchMatch && modelMatch && typeMatch;
});
return [...result].sort((a, b) => {
if (serviceSort.field === 'type') return getTypeName(a.service_type_id).localeCompare(getTypeName(b.service_type_id)) * dir;
if (serviceSort.field === 'path') return a.full_path.localeCompare(b.full_path) * dir;
return getModelName(a.model_id).localeCompare(getModelName(b.model_id)) * dir;
});
}, [repairServices, searchQuery, activeServiceFilters, serviceSort, models, serviceTypes]);
const filteredVariants = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const dir = variantSort.dir === 'asc' ? 1 : -1;
const fromTs = dateFilterEnabled && dateFrom ? dayStartMs(dateFrom) : null;
const toTs = dateFilterEnabled && dateTo ? dayEndMs(dateTo) : null;
const result = variants.filter((v) => {
const svc = repairServices.find((s) => s.repair_service_id === v.repair_service_id);
const modelName = svc ? getModelName(svc.model_id) : '';
const typeName = svc ? getTypeName(svc.service_type_id) : '';
const searchMatch = !q || v.name.toLowerCase().includes(q) || modelName.toLowerCase().includes(q) || typeName.toLowerCase().includes(q);
const statusMatch = activeVariantFilters.statuses.length === 0 || activeVariantFilters.statuses.includes(v.status);
let dateMatch = true;
if (fromTs != null && toTs != null && !Number.isNaN(fromTs) && !Number.isNaN(toTs)) {
const ts = new Date(v.created_at).getTime();
if (!Number.isNaN(ts)) dateMatch = ts >= fromTs && ts <= toTs;
}
return searchMatch && statusMatch && dateMatch;
});
return [...result].sort((a, b) => {
if (variantSort.field === 'price') return (a.price - b.price) * dir;
if (variantSort.field === 'status') return a.status.localeCompare(b.status) * dir;
return a.name.localeCompare(b.name) * dir;
});
}, [variants, searchQuery, activeVariantFilters, variantSort, dateFilterEnabled, dateFrom, dateTo, repairServices, models, serviceTypes]);
const modelsPager = useClientPagination(filteredModels);
const servicesPager = useClientPagination(filteredServices);
const variantsPager = useClientPagination(filteredVariants);
const tabCount = activeTab === 'models' ? models.length : activeTab === 'services' ? repairServices.length : variants.length;
const dateRangeLabel = `${format(new Date(dayStartMs(dateFrom)), 'd MMM yy')} - ${format(new Date(dayStartMs(dateTo)), 'd MMM yy')}`;
const visibleColumnCount = Object.values(visibleColumns).filter(Boolean).length;
const filtersActive = activeTab === 'models' ? modelFiltersActive : activeTab === 'services' ? serviceFiltersActive : variantFiltersActive;
const closeOverlays = () => {
setShowFilterPanel(false);
setShowColumnsPanel(false);
setShowSortPanel(false);
setShowDatePanel(false);
};
const switchTab = (tab: ActiveTab) => {
setActiveTab(tab);
setSearchQuery('');
setOpenActionId(null);
closeOverlays();
if (tab === 'models') setVisibleColumns(MODEL_COLUMNS);
};
const handleViewModeChange = (mode: 'table' | 'grid') => {
setViewMode(mode);
setOpenActionId(null);
closeOverlays();
};
const renderSortLabel = (label: string, active = false) => (
<span className="inline-flex items-center gap-1.5">
{label}
<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 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 handleOpenCreateModel = () => {
setEditingModel(null);
setModelName('');
setModelSeriesId('');
setModelBrandId('');
setModelReleaseYear('');
setModelImg('');
setShowModelModal(true);
};
const handleOpenEditModel = (model: DeviceModelResponse) => {
setEditingModel(model);
setModelName(model.name);
setModelSeriesId(model.series_id);
setModelBrandId(model.brand_id);
setModelReleaseYear(model.release_year?.toString() || '');
setModelImg(model.image_url || '');
setShowModelModal(true);
};
const openDeleteModal = (id: string, name: string) => {
setOpenActionId(null);
setDeleteTarget({ id, name });
};
const handleConfirmDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
setModels((prev) => prev.filter((m) => m.model_id !== deleteTarget.id));
await catalogService.deleteDeviceModel(deleteTarget.id);
toast.success(`Model "${deleteTarget.name}" deleted successfully`);
setDeleteTarget(null);
fetchData();
} catch (err: any) {
toast.error(err?.message || 'Failed to delete model');
fetchData();
} finally {
setIsDeleting(false);
}
};
const handleAddSeries = async (e: React.FormEvent) => {
e.preventDefault();
if (!seriesName.trim() || !seriesBrandId) {
toast.error('Name and Brand are required');
return;
}
setIsSubmittingSeries(true);
try {
const created = await catalogService.createDeviceSeries({ brand_id: seriesBrandId, name: seriesName.trim() });
setSeries([...series, created]);
toast.success(`Series "${created.name}" created successfully`);
setSeriesName('');
setSeriesBrandId('');
setShowSeriesModal(false);
} catch (err: any) {
toast.error(err?.message || 'Failed to create series');
} finally {
setIsSubmittingSeries(false);
}
};
const handleAddModel = async (e: React.FormEvent) => {
e.preventDefault();
if (!modelName.trim() || !modelSeriesId || !modelBrandId) {
toast.error('Name, Brand, and Series are required');
return;
}
setIsSubmittingModel(true);
try {
const payload = {
series_id: modelSeriesId,
brand_id: modelBrandId,
name: modelName.trim(),
release_year: modelReleaseYear ? parseInt(modelReleaseYear) : undefined,
image_url: modelImg.trim() || undefined,
};
if (editingModel) {
const updated = await catalogService.updateDeviceModel(editingModel.model_id, payload);
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(payload);
setModels([...models, created]);
toast.success(`Model "${created.name}" created successfully`);
}
setModelName('');
setModelSeriesId('');
setModelBrandId('');
setModelReleaseYear('');
setModelImg('');
setEditingModel(null);
setShowModelModal(false);
} catch (err: any) {
toast.error(err?.message || `Failed to ${editingModel ? 'update' : 'create'} model`);
} finally {
setIsSubmittingModel(false);
}
};
const handleAddServiceType = async (e: React.FormEvent) => {
e.preventDefault();
if (!typeName.trim()) {
toast.error('Service type name is required');
return;
}
try {
const created = await catalogService.createServiceType({
name: typeName.trim(),
icon_url: typeIcon.trim() || undefined,
description: typeDesc.trim() || undefined,
});
setServiceTypes([...serviceTypes, created]);
toast.success(`Service Type "${created.name}" created successfully`);
setTypeName('');
setTypeIcon('');
setTypeDesc('');
setShowTypeModal(false);
} catch (err: any) {
toast.error(err?.message || 'Failed to create service type');
}
};
const handleAddRepairService = async (e: React.FormEvent) => {
e.preventDefault();
if (!serviceModelId || !serviceTypeId) {
toast.error('Device Model and Service Type are required');
return;
}
try {
const created = await catalogService.createRepairService({
model_id: serviceModelId,
service_type_id: serviceTypeId,
description: serviceDesc.trim() || undefined,
});
setRepairServices([...repairServices, created]);
toast.success(`Repair service mapped to the selected model`);
setServiceModelId('');
setServiceTypeId('');
setServiceDesc('');
setShowServiceModal(false);
} catch (err: any) {
toast.error(err?.message || 'Failed to map repair service');
}
};
const handleAddVariant = async (e: React.FormEvent) => {
e.preventDefault();
if (!variantServiceId || !variantName.trim() || !variantPrice || !variantCost) {
toast.error('Mapping Service, Name, Price, and Cost are required');
return;
}
try {
const created = await catalogService.createRepairVariant({
repair_service_id: variantServiceId,
name: variantName.trim(),
price: parseFloat(variantPrice),
cost: parseFloat(variantCost),
duration_minutes: variantDuration ? parseInt(variantDuration) : undefined,
warranty_days: variantWarranty ? parseInt(variantWarranty) : undefined,
parts: bomParts.filter((p) => p.part_id !== ''),
});
setVariants([...variants, created]);
toast.success(`Repair variant "${created.name}" created successfully`);
setVariantServiceId('');
setVariantName('');
setVariantPrice('');
setVariantCost('');
setVariantDuration('45');
setVariantWarranty('90');
setBomParts([]);
setShowVariantModal(false);
} catch (err: any) {
toast.error(err?.message || 'Failed to create repair variant');
}
};
const addBomPartRow = () => {
setBomParts([...bomParts, { part_id: '', quantity: 1 }]);
};
const updateBomPartRow = (index: number, field: 'part_id' | 'quantity', value: string) => {
const updated = [...bomParts];
if (field === 'part_id') {
updated[index].part_id = value;
} else {
updated[index].quantity = parseInt(value) || 1;
}
setBomParts(updated);
};
const removeBomPartRow = (index: number) => {
setBomParts(bomParts.filter((_, i) => i !== index));
};
const handleExportExcel = () => {
const rows =
activeTab === 'models'
? filteredModels.map((m, i) => [i + 1, m.name, getBrandName(m.brand_id), getSeriesName(m.series_id), m.full_path, m.release_year || '', coerceFlag(m.is_active) ? 'Active' : 'Inactive'])
: activeTab === 'services'
? filteredServices.map((rs, i) => [i + 1, getModelName(rs.model_id), getTypeName(rs.service_type_id), rs.full_path, rs.description || ''])
: filteredVariants.map((v, i) => {
const svc = repairServices.find((s) => s.repair_service_id === v.repair_service_id);
return [i + 1, v.name, svc ? getModelName(svc.model_id) : '', svc ? getTypeName(svc.service_type_id) : '', v.price, v.cost, v.status];
});
if (rows.length === 0) {
toast.warning(
activeTab === 'models'
? 'No device models to export'
: activeTab === 'services'
? 'No repair services to export'
: 'No repair variants to export'
);
return;
}
const headers =
activeTab === 'models'
? ['S.No', 'Model Name', 'Brand', 'Series', 'Full Path', 'Release Year', 'Status']
: activeTab === 'services'
? ['S.No', 'Device Model', 'Service Type', 'Route Path', 'Description']
: ['S.No', 'Variant Name', 'Device Model', 'Service Type', 'Price', 'Cost', 'Status'];
const csv = [headers.join(','), ...rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `ifixkart_devices_${activeTab}_${format(new Date(), 'yyyyMMdd')}.csv`;
link.click();
setShowExportMenu(false);
toast.success(
activeTab === 'models'
? `Downloaded ${rows.length} ${rows.length === 1 ? 'device model' : 'device models'} as CSV`
: activeTab === 'services'
? `Downloaded ${rows.length} ${rows.length === 1 ? 'repair service' : 'repair services'} as CSV`
: `Downloaded ${rows.length} ${rows.length === 1 ? 'repair variant' : 'repair variants'} as CSV`
);
};
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 viewToggle = <ViewModeToggle value={viewMode} onChange={handleViewModeChange} />;
const tabActions = (
<>
{activeTab === 'models' && (
<>
<button type="button" onClick={() => setShowSeriesModal(true)} className="inline-flex items-center gap-2 h-9 px-3 crm-radius-control border border-border bg-card text-[13px] font-medium text-foreground hover:bg-muted cursor-pointer shrink-0">
<Plus className="w-3.5 h-3.5" /> Add Series
</button>
<button type="button" onClick={handleOpenCreateModel} 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>
</>
)}
{activeTab === 'services' && (
<>
<button type="button" onClick={() => setShowTypeModal(true)} className="inline-flex items-center gap-2 h-9 px-3 crm-radius-control border border-border bg-card text-[13px] font-medium text-foreground hover:bg-muted cursor-pointer shrink-0">
<Plus className="w-3.5 h-3.5" /> Add Service Type
</button>
<button type="button" onClick={() => setShowServiceModal(true)} 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>
Map Service
</button>
</>
)}
{activeTab === 'variants' && (
<button type="button" onClick={() => setShowVariantModal(true)} 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 Price Variant
</button>
)}
</>
);
const emptyMessage = searchQuery || filtersActive || (activeTab === 'variants' && dateFilterEnabled) ? 'No records match your search.' : 'No records 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">Devices</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">{tabCount}</span>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="relative" ref={exportRef}>
<button type="button" onClick={() => setShowExportMenu((o) => !o)} 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={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 flex items-center justify-center" title="Reload Data">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className={dataCardShell}>
<div className="px-5 pt-4 border-b border-border">
<div className="flex flex-wrap gap-2 pb-4">
{(['models', 'services', 'variants'] as ActiveTab[]).map((tab) => (
<button
key={tab}
type="button"
onClick={() => switchTab(tab)}
className={`h-8 px-3 crm-radius-control text-[13px] font-medium cursor-pointer transition-colors ${
activeTab === tab ? 'bg-primary text-white' : 'border border-border bg-card text-foreground hover:bg-muted'
}`}
>
{tab === 'models' ? `Models (${models.length})` : tab === 'services' ? `Mapped Services (${repairServices.length})` : `Pricing Variants (${variants.length})`}
</button>
))}
</div>
</div>
<div className="px-5 pt-5">
{viewMode === 'grid' && activeTab !== 'services' ? (
<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">{searchField}</div>
<div className="flex items-center gap-3 shrink-0">{viewToggle}{tabActions}</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center justify-between gap-4 w-full min-w-0 pb-4 border-b border-border">
{searchField}
{tabActions}
</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">
<div className="relative shrink-0" ref={sortRef}>
<button type="button" onClick={() => { closeOverlays(); setShowSortPanel(!showSortPanel); }} className="inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control border border-border bg-card text-[13px] font-medium cursor-pointer 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">
{(activeTab === 'models' ? MODEL_SORT_OPTIONS : activeTab === 'services' ? [{ field: 'model', dir: 'asc', label: 'Model A-Z' }, { field: 'type', dir: 'asc', label: 'Service Type A-Z' }] : [{ field: 'name', dir: 'asc', label: 'Name A-Z' }, { field: 'price', dir: 'asc', label: 'Price Low-High' }]).map((opt: any) => (
<button key={`${opt.field}-${opt.dir}`} type="button" onClick={() => { if (activeTab === 'models') setModelSort(opt); else if (activeTab === 'services') setServiceSort(opt); else setVariantSort(opt); setShowSortPanel(false); }} className="w-full text-left px-3 py-1.5 text-[13px] text-foreground hover:bg-muted cursor-pointer">{opt.label}</button>
))}
</div>
)}
</div>
{activeTab === 'variants' && (
<div className="relative shrink-0" ref={dateRef}>
<button type="button" onClick={() => { closeOverlays(); setShowDatePanel(!showDatePanel); if (!showDatePanel) { setDraftDateFrom(dateFrom); setDraftDateTo(dateTo); } }} className={`inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control border border-border text-[13px] font-medium cursor-pointer ${dateFilterEnabled ? 'bg-muted' : 'bg-card 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 p-3 space-y-2">
<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 text-[13px]" />
<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 text-[13px]" />
<div className="flex gap-2 pt-1">
<button type="button" onClick={() => { setDateFilterEnabled(false); setShowDatePanel(false); }} className="flex-1 h-8 crm-radius-control border border-border text-[12px]">Clear</button>
<button type="button" onClick={() => { setDateFrom(draftDateFrom); setDateTo(draftDateTo); setDateFilterEnabled(true); setShowDatePanel(false); }} className="flex-1 h-8 crm-radius-control bg-primary text-white text-[12px]">Apply</button>
</div>
</div>
)}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
{activeTab === 'models' && (
<div className="relative shrink-0" ref={columnsRef}>
<button type="button" onClick={() => { closeOverlays(); setShowColumnsPanel(!showColumnsPanel); }} className="inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control text-[13px] font-medium cursor-pointer 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">
{MODEL_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" />
<span className="flex-1 text-[13px] truncate">{col.label}</span>
<button type="button" disabled={col.locked} onClick={() => !col.locked && setVisibleColumns((p) => ({ ...p, [col.key]: !p[col.key] }))} className={`relative w-8 h-[18px] rounded-full ${visibleColumns[col.key] ? 'bg-primary' : 'bg-muted'} ${col.locked ? 'opacity-60' : 'cursor-pointer'}`}>
<span className={`absolute top-[2px] w-3.5 h-3.5 rounded-full bg-white transition-all ${visibleColumns[col.key] ? 'left-[14px]' : 'left-[2px]'}`} />
</button>
</div>
))}
</div>
)}
</div>
)}
{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 assets...</span>
</div>
) : activeTab === 'models' ? (
viewMode === 'grid' ? (
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">
{modelsPager.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={() => handleOpenEditModel(m)} onDelete={() => openDeleteModal(m.model_id, m.name)} />
</div>
<div className="flex items-start gap-3 pr-8 min-w-0">
{renderModelAvatar(m)}
<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">{getBrandName(m.brand_id)} · {getSeriesName(m.series_id)}</p>
</div>
</div>
<p className="mt-4 text-[11px] font-mono text-muted-foreground truncate">{m.full_path}</p>
<div className="mt-4">{renderStatusBadge(coerceFlag(m.is_active))}</div>
</div>
))}
</div>
</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.modelName && <th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Model Name', modelSort.field === 'name')}</th>}
{visibleColumns.brand && <th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Brand', modelSort.field === 'brand')}</th>}
{visibleColumns.series && <th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Series', modelSort.field === 'series')}</th>}
{visibleColumns.fullPath && <th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Full Path')}</th>}
{visibleColumns.releaseYear && <th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Release Year', modelSort.field === 'releaseYear')}</th>}
{visibleColumns.status && <th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Status', modelSort.field === 'status')}</th>}
{visibleColumns.actions && <th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700">{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>
) : modelsPager.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.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.fullPath && (
<td className="px-5 py-3.5 font-mono text-muted-foreground">
<span className="crm-cell-clip max-w-[220px]" title={m.full_path}>{m.full_path}</span>
</td>
)}
{visibleColumns.releaseYear && <td className="px-5 py-3.5 text-[13px] text-muted-foreground">{m.release_year || '—'}</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 justify-center"><RowActionsMenu open={openActionId === m.model_id} onOpenChange={(open) => setOpenActionId(open ? m.model_id : null)} onEdit={() => handleOpenEditModel(m)} onDelete={() => openDeleteModal(m.model_id, m.name)} /></div></td>
)}
</tr>
))}
</tbody>
</table>
</div>
)
) : activeTab === 'services' ? (
<div className="overflow-x-auto border-t border-border">
<table className="crm-data-table min-w-[720px]">
<thead>
<tr className="bg-gray-50 border-y border-gray-200">
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Device Model', serviceSort.field === 'model')}</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Service Type', serviceSort.field === 'type')}</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Route Path', serviceSort.field === 'path')}</th>
</tr>
</thead>
<tbody className="bg-card">
{filteredServices.length === 0 ? (
<tr><td colSpan={3} className="text-center py-12 text-[13px] text-muted-foreground">{emptyMessage}</td></tr>
) : servicesPager.items.map((rs) => (
<tr key={rs.repair_service_id} className="border-t border-border hover:bg-muted/10 transition-colors">
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground">{getModelName(rs.model_id)}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground"><span className="inline-flex items-center gap-2"><Wrench className="w-3.5 h-3.5 text-primary" />{getTypeName(rs.service_type_id)}</span></td>
<td className="px-5 py-3.5 text-[12px] font-mono text-muted-foreground">{rs.full_path}</td>
</tr>
))}
</tbody>
</table>
</div>
) : viewMode === 'grid' ? (
filteredVariants.length === 0 ? (
<div className="py-12 text-center text-[13px] text-muted-foreground">{emptyMessage}</div>
) : (
<div className="p-5 mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
{variantsPager.items.map((v) => {
const svc = repairServices.find((s) => s.repair_service_id === v.repair_service_id);
return (
<div key={v.variant_id} className="crm-radius-section border border-border bg-card p-5 shadow-sm">
<p className="text-[11px] text-muted-foreground uppercase">{svc ? `${getModelName(svc.model_id)} · ${getTypeName(svc.service_type_id)}` : ''}</p>
<div className="flex items-start justify-between gap-3 mt-1">
<h3 className="text-[15px] font-semibold text-foreground">{v.name}</h3>
<div className="text-right"><p className="text-[14px] font-semibold text-primary">â¹{v.price}</p><p className="text-[11px] text-muted-foreground">Cost: â¹{v.cost}</p></div>
</div>
<div className="mt-3 flex gap-4 text-[12px] text-muted-foreground"><span>{v.duration_minutes} mins</span><span>{v.warranty_days} days warranty</span></div>
<div className="mt-3"><span className="inline-flex px-2.5 py-1 crm-radius-badge text-[11px] font-semibold bg-muted text-muted-foreground capitalize">{v.status}</span></div>
</div>
);
})}
</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">
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Variant Name', variantSort.field === 'name')}</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Device Model')}</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Service Type')}</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Price', variantSort.field === 'price')}</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Cost')}</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Duration')}</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">{renderSortLabel('Status', variantSort.field === 'status')}</th>
</tr>
</thead>
<tbody className="bg-card">
{filteredVariants.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-[13px] text-muted-foreground">{emptyMessage}</td></tr>
) : variantsPager.items.map((v) => {
const svc = repairServices.find((s) => s.repair_service_id === v.repair_service_id);
return (
<tr key={v.variant_id} className="border-t border-border hover:bg-muted/10 transition-colors">
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground">{v.name}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{svc ? getModelName(svc.model_id) : '—'}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{svc ? getTypeName(svc.service_type_id) : '—'}</td>
<td className="px-5 py-3.5 text-[13px] font-semibold text-primary">â¹{v.price}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">â¹{v.cost}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{v.duration_minutes} mins</td>
<td className="px-5 py-3.5"><span className="inline-flex px-2.5 py-1 crm-radius-badge text-[11px] font-semibold bg-muted text-muted-foreground capitalize">{v.status}</span></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{activeTab === 'models' && <TablePagination {...modelsPager} />}
{activeTab === 'services' && <TablePagination {...servicesPager} />}
{activeTab === 'variants' && <TablePagination {...variantsPager} />}
</div>
{deleteTarget && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4" onClick={() => !isDeleting && setDeleteTarget(null)}>
<div role="dialog" aria-modal="true" 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 gap-3 px-5 pt-5 pb-2">
<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><h3 className="text-[15px] font-semibold text-foreground">Delete Model</h3><p className="text-[13px] text-muted-foreground mt-1">Delete <span className="font-semibold text-foreground">&quot;{deleteTarget.name}&quot;</span> and related data?</p></div>
</div>
<div className="flex justify-end gap-2 px-5 pb-5 pt-3">
<button type="button" onClick={() => setDeleteTarget(null)} disabled={isDeleting} className="h-9 px-4 crm-radius-control border border-border text-[13px]">Cancel</button>
<button type="button" onClick={handleConfirmDelete} disabled={isDeleting} className="h-9 px-4 crm-radius-control bg-destructive text-white text-[13px] font-semibold inline-flex items-center gap-2">{isDeleting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />} Delete</button>
</div>
</div>
</div>
)}
<SlideOver open={showSeriesModal} onClose={() => !isSubmittingSeries && setShowSeriesModal(false)} title="Create Device Series" icon={<Layers className="w-4 h-4 text-primary" />}>
<form onSubmit={handleAddSeries} 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">Device Brand</label>
<CustomSelect
value={seriesBrandId}
onChange={setSeriesBrandId}
placeholder="- Select Brand -"
options={[
{ value: '', label: '- Select Brand -' },
...brands.map((b) => ({ value: b.brand_id, label: b.name })),
]}
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Series Name</label>
<input type="text" placeholder="e.g. Galaxy S Series" value={seriesName} onChange={(e) => setSeriesName(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex justify-end gap-2 bg-card">
<button type="button" onClick={() => setShowSeriesModal(false)} disabled={isSubmittingSeries} className="h-8 px-3.5 crm-radius-control border border-border text-[13px]">Cancel</button>
<button type="submit" disabled={isSubmittingSeries} className="h-8 px-3.5 crm-radius-control bg-primary text-white text-[13px]">{isSubmittingSeries ? 'Saving...' : 'Create Series'}</button>
</div>
</form>
</SlideOver>
<SlideOver open={showModelModal} onClose={() => !isSubmittingModel && setShowModelModal(false)} title={editingModel ? 'Edit Device Model' : 'Create Device Model'} icon={<Smartphone className="w-4 h-4 text-primary" />}>
<form onSubmit={handleAddModel} 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-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Brand</label>
<CustomSelect
value={modelBrandId}
onChange={setModelBrandId}
placeholder="- Brand -"
options={[
{ value: '', label: '- Brand -' },
...brands.map((b) => ({ value: b.brand_id, label: b.name })),
]}
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Series</label>
<CustomSelect
value={modelSeriesId}
onChange={setModelSeriesId}
placeholder="- Series -"
options={[
{ value: '', label: '- Series -' },
...series.filter((s) => s.brand_id === modelBrandId).map((s) => ({ value: s.series_id, label: s.name })),
]}
/>
</div>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Model Name</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 crm-radius-control border border-border bg-card text-[13px]" />
</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">Release Year</label>
<input type="number" placeholder="2026" value={modelReleaseYear} onChange={(e) => setModelReleaseYear(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Image URL</label>
<input type="text" placeholder="https://..." value={modelImg} onChange={(e) => setModelImg(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</div>
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex justify-end gap-2 bg-card">
<button type="button" onClick={() => setShowModelModal(false)} disabled={isSubmittingModel} className="h-8 px-3.5 crm-radius-control border border-border text-[13px]">Cancel</button>
<button type="submit" disabled={isSubmittingModel} className="h-8 px-3.5 crm-radius-control bg-primary text-white text-[13px]">{isSubmittingModel ? 'Saving...' : editingModel ? 'Save Changes' : 'Create Model'}</button>
</div>
</form>
</SlideOver>
<SlideOver open={showTypeModal} onClose={() => setShowTypeModal(false)} title="Create Service Type" icon={<Wrench className="w-4 h-4 text-primary" />}>
<form onSubmit={handleAddServiceType} 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">Service Name</label>
<input type="text" value={typeName} onChange={(e) => setTypeName(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Description</label>
<textarea value={typeDesc} onChange={(e) => setTypeDesc(e.target.value)} rows={3} className="w-full px-3 py-2 crm-radius-control border border-border bg-card text-[13px] resize-none" />
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex justify-end gap-2 bg-card">
<button type="button" onClick={() => setShowTypeModal(false)} className="h-8 px-3.5 crm-radius-control border border-border text-[13px]">Cancel</button>
<button type="submit" className="h-8 px-3.5 crm-radius-control bg-primary text-white text-[13px]">Create Type</button>
</div>
</form>
</SlideOver>
<SlideOver open={showServiceModal} onClose={() => setShowServiceModal(false)} title="Map Device Repair Service" icon={<Cpu className="w-4 h-4 text-primary" />}>
<form onSubmit={handleAddRepairService} 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">Device Model</label>
<CustomSelect
value={serviceModelId}
onChange={setServiceModelId}
placeholder="- Select Model -"
options={[
{ value: '', label: '- Select Model -' },
...models.map((m) => ({ value: m.model_id, label: m.name })),
]}
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Service Type</label>
<CustomSelect
value={serviceTypeId}
onChange={setServiceTypeId}
placeholder="- Select Type -"
options={[
{ value: '', label: '- Select Type -' },
...serviceTypes.map((t) => ({ value: t.service_type_id, label: t.name })),
]}
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Mapping Description</label>
<textarea value={serviceDesc} onChange={(e) => setServiceDesc(e.target.value)} rows={3} className="w-full px-3 py-2 crm-radius-control border border-border bg-card text-[13px] resize-none" />
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex justify-end gap-2 bg-card">
<button type="button" onClick={() => setShowServiceModal(false)} className="h-8 px-3.5 crm-radius-control border border-border text-[13px]">Cancel</button>
<button type="submit" className="h-8 px-3.5 crm-radius-control bg-primary text-white text-[13px]">Map Service</button>
</div>
</form>
</SlideOver>
<SlideOver open={showVariantModal} onClose={() => setShowVariantModal(false)} title="Create Price Variant (BOM)" icon={<Package className="w-4 h-4 text-primary" />}>
<form onSubmit={handleAddVariant} 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">Mapped Repair Service</label>
<CustomSelect
value={variantServiceId}
onChange={setVariantServiceId}
placeholder="- Select Service -"
options={[
{ value: '', label: '- Select Service -' },
...repairServices.map((rs) => {
const mo = models.find((m) => m.model_id === rs.model_id);
const ty = serviceTypes.find((t) => t.service_type_id === rs.service_type_id);
return { value: rs.repair_service_id, label: `${mo?.name} - ${ty?.name}` };
}),
]}
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Variant Name</label>
<input type="text" value={variantName} onChange={(e) => setVariantName(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Sale Price (â¹)</label>
<input type="number" value={variantPrice} onChange={(e) => setVariantPrice(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Estimated Cost (â¹)</label>
<input type="number" value={variantCost} onChange={(e) => setVariantCost(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</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">Duration (Mins)</label>
<input type="number" value={variantDuration} onChange={(e) => setVariantDuration(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Warranty (Days)</label>
<input type="number" value={variantWarranty} onChange={(e) => setVariantWarranty(e.target.value)} className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px]" />
</div>
</div>
<div className="border-t border-border pt-4">
<div className="flex justify-between items-center mb-3">
<h4 className="text-[11px] font-semibold text-muted-foreground uppercase">Bill of Materials (BOM)</h4>
<button type="button" onClick={addBomPartRow} className="text-[12px] text-primary font-medium cursor-pointer inline-flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> Add Part</button>
</div>
<div className="space-y-2">
{bomParts.length === 0 ? <p className="text-[12px] text-muted-foreground italic">No parts added yet.</p> : bomParts.map((row, idx) => (
<div key={idx} className="flex gap-2 items-center">
<CustomSelect
value={row.part_id}
onChange={(value) => updateBomPartRow(idx, 'part_id', value)}
size="sm"
className="flex-1"
placeholder="- Select Part -"
options={[
{ value: '', label: '- Select Part -' },
...parts.map((p) => ({ value: p.part_id, label: `${p.name} (${p.sku})` })),
]}
/>
<input type="number" value={row.quantity} onChange={(e) => updateBomPartRow(idx, 'quantity', e.target.value)} className="w-16 h-8 px-2 crm-radius-control border border-border bg-card text-[12px]" />
<button type="button" onClick={() => removeBomPartRow(idx)} className="w-8 h-8 crm-radius-control border border-border flex items-center justify-center cursor-pointer"><X className="w-3.5 h-3.5" /></button>
</div>
))}
</div>
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex justify-end gap-2 bg-card">
<button type="button" onClick={() => setShowVariantModal(false)} className="h-8 px-3.5 crm-radius-control border border-border text-[13px]">Cancel</button>
<button type="submit" className="h-8 px-3.5 crm-radius-control bg-primary text-white text-[13px]">Create Variant</button>
</div>
</form>
</SlideOver>
</div>
);
}