1185 lines
66 KiB
TypeScript
1185 lines
66 KiB
TypeScript
'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">"{deleteTarget.name}"</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>
|
||
);
|
||
}
|