ifixkart-admin/app/(admin)/service-catalog/page.tsx

962 lines
41 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { Plus, RefreshCw, Search, Trash2, Wrench, Tag, Smartphone } from 'lucide-react';
import { toast } from 'sonner';
import { adminService } from '@/services/api/adminService';
import { formatCurrency } from '@/lib/utils';
import { SlideOver } from '@/components/ui/SlideOver';
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
import { CustomSelect } from '@/components/ui/CustomSelect';
const CANONICAL_DEVICE_TYPES = [
{ id: 'laptop', name: 'Laptop' },
{ id: 'tablet', name: 'Tablet' },
{ id: 'mobile', name: 'Mobile' },
];
export interface VariantBatchRow {
id: string;
name: string;
price: string;
cost: string;
duration: string;
warranty: string;
}
function createDefaultBatchRows(): VariantBatchRow[] {
return [
{ id: '1', name: 'Original OLED Grade A+', price: '', cost: '', duration: '45', warranty: '180' },
{ id: '2', name: 'Compatible Premium LCD', price: '', cost: '', duration: '30', warranty: '90' },
];
}
type CatalogTab = 'types' | 'mappings' | 'variants';
function parseApiError(err: any, fallback: string) {
if (typeof err?.message === 'string' && err.message) return err.message;
if (Array.isArray(err?.detail)) return err.detail.map((d: any) => d.msg).join(', ');
return fallback;
}
export default function ServiceCatalogManagerPage() {
const [activeTab, setActiveTab] = useState<CatalogTab>('types');
const [serviceTypes, setServiceTypes] = useState<any[]>([]);
const [brands, setBrands] = useState<any[]>([]);
const [deviceSeries, setDeviceSeries] = useState<any[]>([]);
const [deviceModels, setDeviceModels] = useState<any[]>([]);
const [repairServices, setRepairServices] = useState<any[]>([]);
const [repairVariants, setRepairVariants] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showTypeModal, setShowTypeModal] = useState(false);
const [showMappingModal, setShowMappingModal] = useState(false);
const [showVariantModal, setShowVariantModal] = useState(false);
const [typeName, setTypeName] = useState('');
const [typeDesc, setTypeDesc] = useState('');
const [selectedDeviceTypes, setSelectedDeviceTypes] = useState<string[]>(['mobile']);
const [step2CategoryId, setStep2CategoryId] = useState('');
const [step2BrandId, setStep2BrandId] = useState('');
const [step2SeriesId, setStep2SeriesId] = useState('');
const [step2ModelId, setStep2ModelId] = useState('');
const [step2Desc, setStep2Desc] = useState('');
const [step3CategoryId, setStep3CategoryId] = useState('');
const [step3BrandId, setStep3BrandId] = useState('');
const [step3ModelId, setStep3ModelId] = useState('');
const [batchRows, setBatchRows] = useState<VariantBatchRow[]>(createDefaultBatchRows);
const [selectedViewBrandId, setSelectedViewBrandId] = useState('');
const [selectedViewModelId, setSelectedViewModelId] = useState('');
const [selectedViewCategoryId, setSelectedViewCategoryId] = useState('ALL');
useEffect(() => {
loadAllCatalogData();
}, []);
const loadAllCatalogData = async () => {
setLoading(true);
try {
const [sTypes, bData, seriesData, modelsData, servicesData, variantsData] = await Promise.all([
adminService.fetchServiceTypes(),
adminService.fetchBrands(),
adminService.fetchDeviceSeries(),
adminService.fetchDeviceModels(),
adminService.fetchRepairServices(),
adminService.fetchRepairVariants(),
]);
setServiceTypes(sTypes || []);
setBrands(bData || []);
setDeviceSeries(seriesData || []);
setDeviceModels(modelsData || []);
setRepairServices(servicesData || []);
setRepairVariants(variantsData || []);
} catch (err: any) {
toast.error(err.message || 'Failed to load service catalog data.');
} finally {
setLoading(false);
}
};
const modelsMap = useMemo(() => {
const map = new Map<string, any>();
deviceModels.forEach((m) => map.set(m.model_id, m));
return map;
}, [deviceModels]);
const serviceTypesMap = useMemo(() => {
const map = new Map<string, any>();
serviceTypes.forEach((st) => map.set(st.service_type_id, st));
return map;
}, [serviceTypes]);
const repairServicesMap = useMemo(() => {
const map = new Map<string, any>();
repairServices.forEach((rs) => map.set(rs.repair_service_id, rs));
return map;
}, [repairServices]);
const viewModelsList = useMemo(() => {
if (!selectedViewBrandId) return deviceModels;
return deviceModels.filter((m) => m.brand_id === selectedViewBrandId);
}, [deviceModels, selectedViewBrandId]);
const brandsMap = useMemo(() => {
const map = new Map<string, any>();
brands.forEach((b) => map.set(b.brand_id, b));
return map;
}, [brands]);
const step2SeriesList = useMemo(() => {
if (!step2BrandId) return [];
return deviceSeries.filter((s) => s.brand_id === step2BrandId);
}, [deviceSeries, step2BrandId]);
const step2ModelsList = useMemo(() => {
if (!step2BrandId) return [];
let list = deviceModels.filter((m) => m.brand_id === step2BrandId);
if (step2SeriesId) list = list.filter((m) => m.series_id === step2SeriesId);
return list;
}, [deviceModels, step2BrandId, step2SeriesId]);
const step3MappedBrandsList = useMemo(() => {
if (!step3CategoryId) return brands;
const matchingServiceMappings = repairServices.filter((rs) => rs.service_type_id === step3CategoryId);
const mappedModelIds = new Set(matchingServiceMappings.map((rs) => rs.model_id));
const mappedModels = deviceModels.filter((m) => mappedModelIds.has(m.model_id));
const mappedBrandIds = new Set(mappedModels.map((m) => m.brand_id));
if (mappedBrandIds.size > 0) return brands.filter((b) => mappedBrandIds.has(b.brand_id));
return brands;
}, [repairServices, deviceModels, brands, step3CategoryId]);
const step3MappedModelsList = useMemo(() => {
if (!step3CategoryId || !step3BrandId) return [];
const matchingServiceMappings = repairServices.filter((rs) => rs.service_type_id === step3CategoryId);
const mappedModelIds = new Set(matchingServiceMappings.map((rs) => rs.model_id));
const mapped = deviceModels.filter((m) => m.brand_id === step3BrandId && mappedModelIds.has(m.model_id));
if (mapped.length > 0) return mapped;
return deviceModels.filter((m) => m.brand_id === step3BrandId);
}, [repairServices, deviceModels, step3CategoryId, step3BrandId]);
const handleCreateServiceType = async (e: React.FormEvent) => {
e.preventDefault();
if (!typeName.trim()) {
toast.warning('Please enter a service category name.');
return;
}
setIsSubmitting(true);
try {
await adminService.createServiceType({
name: typeName.trim(),
description: typeDesc.trim() || undefined,
});
toast.success(`Service category "${typeName}" created`);
setShowTypeModal(false);
setTypeName('');
setTypeDesc('');
setSelectedDeviceTypes(['mobile']);
await loadAllCatalogData();
} catch (err: any) {
toast.error(parseApiError(err, 'Failed to create service category.'));
} finally {
setIsSubmitting(false);
}
};
const handleCreateMapping = async (e: React.FormEvent) => {
e.preventDefault();
if (!step2CategoryId || !step2ModelId) {
toast.warning('Please select both a service category and a device model.');
return;
}
setIsSubmitting(true);
try {
await adminService.createRepairService({
model_id: step2ModelId,
service_type_id: step2CategoryId,
description: step2Desc.trim() || undefined,
});
toast.success('Device model mapped to service category');
setShowMappingModal(false);
setStep2CategoryId('');
setStep2BrandId('');
setStep2SeriesId('');
setStep2ModelId('');
setStep2Desc('');
await loadAllCatalogData();
} catch (err: any) {
toast.error(parseApiError(err, 'Failed to create repair service mapping.'));
} finally {
setIsSubmitting(false);
}
};
const handleAddBatchRow = () => {
setBatchRows((prev) => [
...prev,
{ id: Date.now().toString(), name: '', price: '', cost: '', duration: '45', warranty: '180' },
]);
};
const handleRemoveBatchRow = (id: string) => {
if (batchRows.length <= 1) {
toast.warning('At least one variant row is required.');
return;
}
setBatchRows((prev) => prev.filter((r) => r.id !== id));
};
const handleBatchRowChange = (id: string, field: keyof VariantBatchRow, value: string) => {
setBatchRows((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)));
};
const handleCreateBatchVariants = async (e: React.FormEvent) => {
e.preventDefault();
if (!step3CategoryId || !step3ModelId) {
toast.warning('Please select service category, brand, and device model.');
return;
}
const validRows = batchRows.filter((r) => r.name.trim() && r.price.trim());
if (validRows.length === 0) {
toast.warning('Please enter variant name and price for at least one row.');
return;
}
setIsSubmitting(true);
try {
let targetService = repairServices.find(
(rs) => rs.model_id === step3ModelId && rs.service_type_id === step3CategoryId
);
if (!targetService) {
toast.info('Creating model to category mapping automatically...');
targetService = await adminService.createRepairService({
model_id: step3ModelId,
service_type_id: step3CategoryId,
});
}
let createdCount = 0;
for (const row of validRows) {
await adminService.createRepairVariant({
repair_service_id: targetService.repair_service_id,
name: row.name.trim(),
price: parseFloat(row.price),
cost: row.cost ? parseFloat(row.cost) : undefined,
duration_minutes: parseInt(row.duration, 10) || 45,
warranty_days: parseInt(row.warranty, 10) || 180,
});
createdCount++;
}
toast.success(`Created ${createdCount} repair variants`);
setShowVariantModal(false);
setStep3CategoryId('');
setStep3BrandId('');
setStep3ModelId('');
setBatchRows(createDefaultBatchRows());
await loadAllCatalogData();
} catch (err: any) {
toast.error(parseApiError(err, 'Failed to batch create repair variants.'));
} finally {
setIsSubmitting(false);
}
};
const modelFilteredMappings = useMemo(() => {
return repairServices.filter((rs) => {
if (selectedViewModelId) {
if (rs.model_id !== selectedViewModelId) return false;
} else if (selectedViewBrandId) {
const model = modelsMap.get(rs.model_id);
if (model?.brand_id !== selectedViewBrandId) return false;
}
return true;
});
}, [repairServices, selectedViewModelId, selectedViewBrandId, modelsMap]);
const modelFilteredVariants = useMemo(() => {
return repairVariants.filter((rv) => {
const rs = repairServicesMap.get(rv.repair_service_id);
if (!rs) return false;
if (selectedViewModelId) {
if (rs.model_id !== selectedViewModelId) return false;
} else if (selectedViewBrandId) {
const model = modelsMap.get(rs.model_id);
if (model?.brand_id !== selectedViewBrandId) return false;
}
if (selectedViewCategoryId !== 'ALL') {
if (rs.service_type_id !== selectedViewCategoryId) return false;
}
return true;
});
}, [repairVariants, repairServicesMap, modelsMap, selectedViewModelId, selectedViewBrandId, selectedViewCategoryId]);
const filteredServiceTypes = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return serviceTypes;
return serviceTypes.filter(
(st) =>
(st.name || '').toLowerCase().includes(q) ||
(st.slug || '').toLowerCase().includes(q) ||
(st.description || '').toLowerCase().includes(q)
);
}, [serviceTypes, searchQuery]);
const typesPager = useClientPagination(filteredServiceTypes);
const mappingsPager = useClientPagination(modelFilteredMappings);
const variantsPager = useClientPagination(modelFilteredVariants);
const headerCount =
activeTab === 'types'
? serviceTypes.length
: activeTab === 'mappings'
? repairServices.length
: repairVariants.length;
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 inputClass =
'w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary disabled:opacity-50';
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
const primaryButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50';
const secondaryButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50';
const openAdd = () => {
if (activeTab === 'types') {
setTypeName('');
setTypeDesc('');
setSelectedDeviceTypes(['mobile']);
setShowTypeModal(true);
} else if (activeTab === 'mappings') {
setStep2CategoryId('');
setStep2BrandId('');
setStep2SeriesId('');
setStep2ModelId('');
setStep2Desc('');
setShowMappingModal(true);
} else {
setStep3CategoryId('');
setStep3BrandId('');
setStep3ModelId('');
setBatchRows(createDefaultBatchRows());
setShowVariantModal(true);
}
};
const addButtonLabel =
activeTab === 'types' ? 'Add category' : activeTab === 'mappings' ? 'Map model' : 'Add variants';
const renderActiveBadge = (label = 'Active') => (
<span className="inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold bg-success text-white">
{label}
</span>
);
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">Service Catalog Manager</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">
{headerCount}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={loadAllCatalogData}
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
title="Reload"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={openAdd} className={primaryButton}>
<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>
{addButtonLabel}
</button>
</div>
</div>
<div className={dataCardShell}>
<div className="px-5 pt-5">
<div className="-mx-5 px-5 flex flex-wrap items-center justify-between gap-3 min-w-0 pb-4 border-b border-border">
{activeTab === 'types' ? (
<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>
) : (
<div className="flex flex-wrap items-end gap-3 flex-1 min-w-0">
<div className="min-w-[160px] flex-1 max-w-[220px]">
<label className={labelClass}>Brand</label>
<CustomSelect
value={selectedViewBrandId}
onChange={(value) => {
setSelectedViewBrandId(value);
setSelectedViewModelId('');
}}
placeholder="All brands"
options={[
{ value: '', label: 'All brands' },
...brands.map((b) => ({ value: b.brand_id, label: b.name })),
]}
/>
</div>
<div className="min-w-[160px] flex-1 max-w-[220px]">
<label className={labelClass}>Device model</label>
<CustomSelect
value={selectedViewModelId}
disabled={false}
onChange={(value) => {
setSelectedViewModelId(value);
if (value && !selectedViewBrandId) {
const m = modelsMap.get(value);
if (m?.brand_id) setSelectedViewBrandId(m.brand_id);
}
}}
placeholder="All models"
options={[
{ value: '', label: 'All models' },
...viewModelsList.map((m) => ({ value: m.model_id, label: m.name })),
]}
/>
</div>
{activeTab === 'variants' && (
<div className="min-w-[160px] flex-1 max-w-[220px]">
<label className={labelClass}>Category</label>
<CustomSelect
value={selectedViewCategoryId}
onChange={setSelectedViewCategoryId}
options={[
{ value: 'ALL', label: 'All categories' },
...serviceTypes.map((st) => ({ value: st.service_type_id, label: st.name })),
]}
/>
</div>
)}
</div>
)}
<div className="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1 shrink-0 overflow-x-auto max-w-full">
{(
[
{ id: 'types', label: `Categories (${serviceTypes.length})` },
{ id: 'mappings', label: `Mappings (${repairServices.length})` },
{ id: 'variants', label: `Variants (${repairVariants.length})` },
] as { id: CatalogTab; label: string }[]
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap ${
activeTab === tab.id ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
<span className="text-[13px] text-muted-foreground">Loading catalog...</span>
</div>
) : activeTab === 'types' ? (
<div className="overflow-x-auto">
<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">Category</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Slug</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Description</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Status</th>
</tr>
</thead>
<tbody className="bg-card">
{filteredServiceTypes.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-12 text-[13px] text-muted-foreground">
{searchQuery ? 'No categories match your search.' : 'No service categories yet.'}
</td>
</tr>
) : (
typesPager.items.map((st, idx) => (
<tr key={`st_${st.service_type_id || idx}_${idx}`} className="border-t border-border hover:bg-muted/10">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 crm-radius-icon bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Tag className="w-4 h-4" />
</div>
<span className="text-[13px] font-semibold text-foreground">{st.name}</span>
</div>
</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground font-mono">{st.slug || '—'}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{st.description || '—'}</td>
<td className="px-5 py-3.5">{renderActiveBadge()}</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : activeTab === 'mappings' ? (
<div className="overflow-x-auto">
<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">Service category</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Device model</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Brand</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Path</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Mapping ID</th>
</tr>
</thead>
<tbody className="bg-card">
{modelFilteredMappings.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-12 text-[13px] text-muted-foreground">
No service category mappings found for the selected filter.
</td>
</tr>
) : (
mappingsPager.items.map((rs, idx) => {
const sType = serviceTypesMap.get(rs.service_type_id);
const model = modelsMap.get(rs.model_id);
const brand = model ? brandsMap.get(model.brand_id) : null;
return (
<tr key={`rs_${rs.repair_service_id || idx}_${idx}`} className="border-t border-border hover:bg-muted/10">
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground">
{sType?.name || rs.service_type_id}
</td>
<td className="px-5 py-3.5 text-[13px] text-foreground font-medium">
{model?.name || rs.model_id || '—'}
</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
{brand?.name || '—'}
</td>
<td className="px-5 py-3.5 text-muted-foreground font-mono">
<span className="crm-cell-clip max-w-[220px]" title={rs.full_path || ''}>{rs.full_path || '—'}</span>
</td>
<td className="px-5 py-3.5 text-muted-foreground font-mono">
<span className="crm-cell-clip" title={rs.repair_service_id}>{rs.repair_service_id}</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
) : (
<div className="overflow-x-auto">
<table className="crm-data-table min-w-[900px]">
<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">Variant</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Device model</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Category</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Selling price</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Cost</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Duration</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Warranty</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Status</th>
</tr>
</thead>
<tbody className="bg-card">
{modelFilteredVariants.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-12 text-[13px] text-muted-foreground">
No repair variants found for the selected filter.
</td>
</tr>
) : (
variantsPager.items.map((rv) => {
const rs = repairServicesMap.get(rv.repair_service_id);
const sType = rs ? serviceTypesMap.get(rs.service_type_id) : null;
const model = rs ? modelsMap.get(rs.model_id) : null;
return (
<tr key={rv.variant_id} className="border-t border-border hover:bg-muted/10">
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground">{rv.name}</td>
<td className="px-5 py-3.5 text-[13px] text-foreground font-medium">{model?.name || '—'}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{sType?.name || '—'}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{formatCurrency(rv.price)}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
{rv.cost ? formatCurrency(rv.cost) : '—'}
</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{rv.duration_minutes} min</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{rv.warranty_days} days</td>
<td className="px-5 py-3.5">{renderActiveBadge(rv.status || 'Active')}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
{activeTab === 'types' && <TablePagination {...typesPager} />}
{activeTab === 'mappings' && <TablePagination {...mappingsPager} />}
{activeTab === 'variants' && <TablePagination {...variantsPager} />}
</div>
<SlideOver
open={showTypeModal}
onClose={() => {
if (isSubmitting) return;
setShowTypeModal(false);
}}
title="Add service category"
icon={<Tag className="w-4 h-4 text-primary shrink-0" />}
>
<form onSubmit={handleCreateServiceType} 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={labelClass}>
Category name <span className="text-primary">*</span>
</label>
<input
type="text"
placeholder="e.g. Screen Replacement"
value={typeName}
onChange={(e) => setTypeName(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Description</label>
<textarea
placeholder="e.g. Display glass replacement and OLED panel repairs"
value={typeDesc}
onChange={(e) => setTypeDesc(e.target.value)}
className={`${inputClass} h-20 py-2 resize-none`}
rows={3}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-2 font-semibold uppercase tracking-wider">SUPPORTED DEVICES</label>
<div className="flex items-center gap-6">
{CANONICAL_DEVICE_TYPES.map((dt) => (
<label key={dt.id} className="flex items-center gap-2 text-[13px] text-foreground cursor-pointer">
<input
type="checkbox"
checked={selectedDeviceTypes.includes(dt.id)}
onChange={(e) => {
if (e.target.checked) setSelectedDeviceTypes((prev) => [...prev, dt.id]);
else setSelectedDeviceTypes((prev) => prev.filter((x) => x !== dt.id));
}}
className="w-4 h-4 rounded border-border text-primary focus:ring-primary accent-primary cursor-pointer"
/>
<span className="font-medium">{dt.name}</span>
</label>
))}
</div>
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
<button type="button" onClick={() => setShowTypeModal(false)} disabled={isSubmitting} className={secondaryButton}>
Cancel
</button>
<button type="submit" disabled={isSubmitting} className={primaryButton}>
{isSubmitting ? 'Saving...' : 'Create category'}
</button>
</div>
</form>
</SlideOver>
<SlideOver
open={showMappingModal}
onClose={() => {
if (isSubmitting) return;
setShowMappingModal(false);
}}
title="Map model to category"
icon={<Smartphone className="w-4 h-4 text-primary shrink-0" />}
>
<form onSubmit={handleCreateMapping} 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={labelClass}>
Service category <span className="text-primary">*</span>
</label>
<CustomSelect
value={step2CategoryId}
onChange={setStep2CategoryId}
placeholder="Select category"
options={[
{ value: '', label: 'Select category' },
...serviceTypes.map((st) => ({ value: st.service_type_id, label: st.name })),
]}
/>
</div>
<div>
<label className={labelClass}>
Brand <span className="text-primary">*</span>
</label>
<CustomSelect
value={step2BrandId}
onChange={(value) => {
setStep2BrandId(value);
setStep2SeriesId('');
setStep2ModelId('');
}}
placeholder="Select brand"
options={[
{ value: '', label: 'Select brand' },
...brands.map((b) => ({ value: b.brand_id, label: b.name })),
]}
/>
</div>
<div>
<label className={labelClass}>Device series</label>
<CustomSelect
value={step2SeriesId}
disabled={!step2BrandId}
onChange={(value) => {
setStep2SeriesId(value);
setStep2ModelId('');
}}
placeholder={!step2BrandId ? 'Select brand first' : 'All series'}
options={[
{ value: '', label: !step2BrandId ? 'Select brand first' : 'All series' },
...step2SeriesList.map((s) => ({ value: s.series_id, label: s.name })),
]}
/>
</div>
<div>
<label className={labelClass}>
Device model <span className="text-primary">*</span>
</label>
<CustomSelect
value={step2ModelId}
disabled={!step2BrandId}
onChange={setStep2ModelId}
placeholder={!step2BrandId ? 'Select brand first' : 'Select model'}
options={[
{ value: '', label: !step2BrandId ? 'Select brand first' : 'Select model' },
...step2ModelsList.map((m) => ({ value: m.model_id, label: m.name })),
]}
/>
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
<button type="button" onClick={() => setShowMappingModal(false)} disabled={isSubmitting} className={secondaryButton}>
Cancel
</button>
<button type="submit" disabled={isSubmitting} className={primaryButton}>
{isSubmitting ? 'Saving...' : 'Save mapping'}
</button>
</div>
</form>
</SlideOver>
<SlideOver
open={showVariantModal}
onClose={() => {
if (isSubmitting) return;
setShowVariantModal(false);
}}
title="Add variants & pricing"
icon={<Wrench className="w-4 h-4 text-primary shrink-0" />}
>
<form onSubmit={handleCreateBatchVariants} 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={labelClass}>
Service category <span className="text-primary">*</span>
</label>
<CustomSelect
value={step3CategoryId}
onChange={(value) => {
setStep3CategoryId(value);
setStep3BrandId('');
setStep3ModelId('');
}}
placeholder="Select category"
options={[
{ value: '', label: 'Select category' },
...serviceTypes.map((st) => ({ value: st.service_type_id, label: st.name })),
]}
/>
</div>
<div>
<label className={labelClass}>
Brand <span className="text-primary">*</span>
</label>
<CustomSelect
value={step3BrandId}
disabled={!step3CategoryId}
onChange={(value) => {
setStep3BrandId(value);
setStep3ModelId('');
}}
placeholder={!step3CategoryId ? 'Select category first' : 'Select brand'}
options={[
{ value: '', label: !step3CategoryId ? 'Select category first' : 'Select brand' },
...step3MappedBrandsList.map((b) => ({ value: b.brand_id, label: b.name })),
]}
/>
</div>
<div>
<label className={labelClass}>
Device model <span className="text-primary">*</span>
</label>
<CustomSelect
value={step3ModelId}
disabled={!step3BrandId || !step3CategoryId}
onChange={setStep3ModelId}
placeholder={!step3BrandId ? 'Select brand first' : 'Select model'}
options={[
{ value: '', label: !step3BrandId ? 'Select brand first' : 'Select model' },
...step3MappedModelsList.map((m) => ({ value: m.model_id, label: m.name })),
]}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase">
Variants ({batchRows.length})
</label>
<button type="button" onClick={handleAddBatchRow} className="inline-flex items-center gap-1 text-[12px] font-semibold text-primary cursor-pointer">
<Plus className="w-3.5 h-3.5" />
Add row
</button>
</div>
<div className="space-y-2">
{batchRows.map((row, idx) => (
<div key={row.id} className="p-3 border border-border crm-radius-card bg-muted/10 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[12px] font-semibold text-muted-foreground">Row {idx + 1}</span>
{batchRows.length > 1 && (
<button
type="button"
onClick={() => handleRemoveBatchRow(row.id)}
className="w-8 h-8 crm-radius-control border border-border text-muted-foreground hover:text-destructive hover:bg-muted cursor-pointer flex items-center justify-center"
aria-label="Remove row"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<div>
<label className={labelClass}>Variant name</label>
<input
type="text"
placeholder="e.g. Original OLED Grade A+"
value={row.name}
onChange={(e) => handleBatchRowChange(row.id, 'name', e.target.value)}
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Selling price ()</label>
<input
type="number"
step="0.01"
placeholder="7500"
value={row.price}
onChange={(e) => handleBatchRowChange(row.id, 'price', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Cost ()</label>
<input
type="number"
step="0.01"
placeholder="4200"
value={row.cost}
onChange={(e) => handleBatchRowChange(row.id, 'cost', e.target.value)}
className={inputClass}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Duration (min)</label>
<input
type="number"
placeholder="45"
value={row.duration}
onChange={(e) => handleBatchRowChange(row.id, 'duration', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Warranty (days)</label>
<input
type="number"
placeholder="180"
value={row.warranty}
onChange={(e) => handleBatchRowChange(row.id, 'warranty', e.target.value)}
className={inputClass}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
<button type="button" onClick={() => setShowVariantModal(false)} disabled={isSubmitting} className={secondaryButton}>
Cancel
</button>
<button type="submit" disabled={isSubmitting} className={primaryButton}>
{isSubmitting ? 'Saving...' : `Save variants (${batchRows.length})`}
</button>
</div>
</form>
</SlideOver>
</div>
);
}