962 lines
41 KiB
TypeScript
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>
|
|
);
|
|
}
|