diff --git a/src/features/devices/components/device-bulk-create-dialog.tsx b/src/features/devices/components/device-bulk-create-dialog.tsx new file mode 100644 index 0000000..ca2a469 --- /dev/null +++ b/src/features/devices/components/device-bulk-create-dialog.tsx @@ -0,0 +1,293 @@ +import { useState, useCallback } from 'react' +import { Upload, Download, AlertCircle, CheckCircle2 } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { + FileUpload, + FileUploadDropzone, + FileUploadTrigger, + FileUploadList, + FileUploadItem, + FileUploadItemPreview, + FileUploadItemMetadata, + FileUploadItemDelete, +} from '@/components/ui/file-upload' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Progress } from '@/components/ui/progress' + +import { useDevicesContext } from '../context/devices-context' +import { useBulkCreateDevices } from '../hooks/use-devices' +import { CSVParser, ParsedDevice, ValidationResult } from '../utils/csv-parser' + +export function DeviceBulkCreateDialog() { + const { isBulkCreateDialogOpen, setIsBulkCreateDialogOpen } = useDevicesContext() + const bulkCreateMutation = useBulkCreateDevices() + const [csvFile, setCsvFile] = useState(null) + const [isProcessing, setIsProcessing] = useState(false) + const [validationResult, setValidationResult] = useState(null) + const [uploadProgress, setUploadProgress] = useState(0) + + const handleFileChange = (files: File[]) => { + if (files.length > 0) { + const file = files[0] + if (file.type === 'text/csv' || file.name.endsWith('.csv')) { + setCsvFile(file) + setValidationResult(null) + } else { + toast.error('Please select a CSV file') + } + } + } + + const onFileValidate = useCallback( + (file: File) => { + if (!file.type.includes('csv') && !file.name.endsWith('.csv')) { + return 'Only CSV files are allowed' + } + if (file.size > 5 * 1024 * 1024) { // 5MB limit + return 'File size must be less than 5MB' + } + return null + }, + [] + ) + + const parseCSV = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => { + try { + const csv = e.target?.result as string + const devices = CSVParser.parseCSV(csv) + resolve(devices) + } catch (error) { + reject(error) + } + } + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsText(file) + }) + } + + const validateDevices = async () => { + if (!csvFile) return + + setIsProcessing(true) + try { + const devices = await parseCSV(csvFile) + const validationResult = CSVParser.validateDevices(devices) + + setValidationResult(validationResult) + + if (validationResult.invalid.length > 0) { + toast.warning(`${validationResult.invalid.length} rows have validation errors`) + } else { + toast.success('All rows are valid!') + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to parse CSV file') + } finally { + setIsProcessing(false) + } + } + + const handleBulkCreate = async () => { + if (!validationResult?.valid.length) return + + setIsProcessing(true) + setUploadProgress(0) + + try { + const devices = validationResult.valid.map(device => ({ + device_code: device.device_code, + device_type: device.device_type, + longitude: device.longitude, + latitude: device.latitude, + port_amount: device.port_amount, + status: device.status, + province: device.province, + city: device.city, + district: device.district, + tower_id: device.tower_id, + olt: device.olt + })) + + await bulkCreateMutation.mutateAsync(devices) + setUploadProgress(100) + handleClose() + } catch (_error) { + // Error is handled by the mutation + } finally { + setIsProcessing(false) + setUploadProgress(0) + } + } + + const handleClose = () => { + setCsvFile(null) + setValidationResult(null) + setUploadProgress(0) + setIsProcessing(false) + setIsBulkCreateDialogOpen(false) + } + + const downloadTemplate = () => { + const csvContent = CSVParser.generateTemplate() + const blob = new Blob([csvContent], { type: 'text/csv' }) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'device-template.csv' + a.click() + window.URL.revokeObjectURL(url) + } + + return ( + + + + Bulk Create Devices + + Upload a CSV file to create multiple devices at once. + + + +
+ {/* Template Download */} +
+
+

Download Template

+

+ Download the CSV template to see the required format +

+
+ +
+ + {/* File Upload */} +
+

Upload CSV File

+ + +
+ +

+ Drop your CSV file here or click to browse +

+
+
+ + + + + {csvFile && ( + + + + + + )} + +
+
+ + {/* Validation Button */} + {csvFile && !validationResult && ( + + )} + + {/* Validation Results */} + {validationResult && ( +
+
+
+ +

{validationResult.valid.length}

+

Valid Rows

+
+
+ +

{validationResult.invalid.length}

+

Invalid Rows

+
+
+

{validationResult.totalRows}

+

Total Rows

+
+
+ + {/* Invalid Rows Details */} + {validationResult.invalid.length > 0 && ( +
+

Validation Errors

+
+ {validationResult.invalid.map((device, index) => ( + + + + Row {device.row}: {device.errors.join(', ')} + + + ))} +
+
+ )} +
+ )} + + {/* Upload Progress */} + {isProcessing && uploadProgress > 0 && ( +
+
+ Creating devices... + {Math.round(uploadProgress)}% +
+ +
+ )} +
+ + + + {validationResult && validationResult.valid.length > 0 && ( + + )} + +
+
+ ) +} \ No newline at end of file diff --git a/src/features/devices/components/devices-region-filter.tsx b/src/features/devices/components/devices-region-filter.tsx new file mode 100644 index 0000000..344d26b --- /dev/null +++ b/src/features/devices/components/devices-region-filter.tsx @@ -0,0 +1,138 @@ +import { useDevicesContext } from '../context/devices-context' +import { useProvinces, useRegencies, useDistricts } from '@/services/indonesia-regions' +import { InputSelect, InputSelectTrigger } from '@/components/ui/input-select' +import { Label } from '@/components/ui/label' + +export function DevicesRegionFilter() { + const { + filters, + selectedProvince, + selectedCity, + selectedDistrict, + handleProvinceChange, + handleCityChange, + handleDistrictChange, + } = useDevicesContext() + + const { data: provinces } = useProvinces() + const { data: regencies, isLoading: isLoadingRegencies } = useRegencies( + selectedProvince + ) + const { data: districts, isLoading: isLoadingDistricts } = useDistricts( + selectedCity + ) + + const provinceOptions = provinces?.map((province) => ({ + value: province.id, + label: province.name, + })) ?? [] + + + + const cityOptions = regencies?.map((city) => ({ + value: city.id, + label: city.name, + })) ?? [] + + const districtOptions = districts?.map((district) => ({ + value: district.id, + label: district.name, + })) ?? [] + + return ( +
+ {/* Province Filter */} +
+ + { + const selectedOption = provinceOptions.find(opt => opt.value === value); + const provinceName = selectedOption?.label || value; + const provinceId = selectedOption?.value || value; + // Send both ID (for API) and name (for filtering) + handleProvinceChange(provinceId, provinceName); + }} + placeholder="Pilih provinsi" + > + {(provided) => ( + + )} + +
+ + {/* City Filter */} +
+ + { + const selectedOption = cityOptions.find(opt => opt.value === value); + const cityName = selectedOption?.label || value; + const cityId = selectedOption?.value || value; + handleCityChange(cityId, cityName); + }} + placeholder={ + !filters.province + ? 'Pilih provinsi dulu' + : isLoadingRegencies + ? 'Loading...' + : 'Pilih kota/kabupaten' + } + > + {(provided) => ( + + )} + +
+ + {/* District Filter */} +
+ + { + const selectedOption = districtOptions.find(opt => opt.value === value); + const districtName = selectedOption?.label || value; + const districtId = selectedOption?.value || value; + handleDistrictChange(districtId, districtName); + }} + placeholder={ + !filters.city + ? 'Pilih kota/kabupaten dulu' + : isLoadingDistricts + ? 'Loading...' + : 'Pilih kecamatan' + } + > + {(provided) => ( + + )} + +
+
+ ) +} \ No newline at end of file diff --git a/src/features/devices/utils/csv-parser.ts b/src/features/devices/utils/csv-parser.ts new file mode 100644 index 0000000..c890bad --- /dev/null +++ b/src/features/devices/utils/csv-parser.ts @@ -0,0 +1,241 @@ +export interface ParsedDevice { + device_code: string + device_type: string + longitude: number + latitude: number + port_amount?: number + status: 'active' | 'inactive' | 'maintenance' + province?: string + city?: string + district?: string + tower_id?: string + olt?: string + row: number + errors: string[] +} + +export interface ValidationResult { + valid: ParsedDevice[] + invalid: ParsedDevice[] + totalRows: number +} + +const REQUIRED_HEADERS = ['device_code', 'device_type', 'longitude', 'latitude'] +const VALID_DEVICE_TYPES = ['OTB', 'CLOSURE', 'ODP'] +const VALID_STATUSES = ['active', 'inactive', 'maintenance'] + +export class CSVParser { + static parseCSV(csvContent: string): ParsedDevice[] { + const lines = csvContent.split('\n').filter(line => line.trim()) + + if (lines.length < 2) { + throw new Error('CSV file must contain at least a header row and one data row') + } + + const headers = lines[0].split(',').map(h => h.trim().toLowerCase()) + + // Validate required headers + const missingHeaders = REQUIRED_HEADERS.filter(required => + !headers.includes(required.toLowerCase()) + ) + + if (missingHeaders.length > 0) { + throw new Error(`Missing required headers: ${missingHeaders.join(', ')}`) + } + + const devices: ParsedDevice[] = [] + + for (let i = 1; i < lines.length; i++) { + const values = CSVParser.parseCSVLine(lines[i]) + const device = CSVParser.parseDeviceRow(headers, values, i + 1) + devices.push(device) + } + + return devices + } + + private static parseCSVLine(line: string): string[] { + const result: string[] = [] + let current = '' + let inQuotes = false + + for (let i = 0; i < line.length; i++) { + const char = line[i] + + if (char === '"') { + inQuotes = !inQuotes + } else if (char === ',' && !inQuotes) { + result.push(current.trim()) + current = '' + } else { + current += char + } + } + + result.push(current.trim()) + return result + } + + private static parseDeviceRow(headers: string[], values: string[], rowNumber: number): ParsedDevice { + const device: ParsedDevice = { + device_code: '', + device_type: '', + longitude: 0, + latitude: 0, + status: 'active', + row: rowNumber, + errors: [] + } + + headers.forEach((header, index) => { + const value = values[index]?.replace(/^"|"$/g, '') || '' // Remove surrounding quotes + + switch (header) { + case 'device_code': + device.device_code = value + if (!value) { + device.errors.push('Device code is required') + } else if (value.length < 3) { + device.errors.push('Device code must be at least 3 characters') + } + break + + case 'device_type': + device.device_type = value.toUpperCase() + if (!value) { + device.errors.push('Device type is required') + } else if (!VALID_DEVICE_TYPES.includes(value.toUpperCase())) { + device.errors.push(`Device type must be one of: ${VALID_DEVICE_TYPES.join(', ')}`) + } + break + + case 'longitude': + const longitude = parseFloat(value) + if (!value || isNaN(longitude)) { + device.errors.push('Longitude is required and must be a valid number') + } else if (longitude < -180 || longitude > 180) { + device.errors.push('Longitude must be between -180 and 180') + } else { + device.longitude = longitude + } + break + + case 'latitude': + const latitude = parseFloat(value) + if (!value || isNaN(latitude)) { + device.errors.push('Latitude is required and must be a valid number') + } else if (latitude < -90 || latitude > 90) { + device.errors.push('Latitude must be between -90 and 90') + } else { + device.latitude = latitude + } + break + + case 'port_amount': + if (value) { + const portAmount = parseInt(value) + if (isNaN(portAmount) || portAmount < 0) { + device.errors.push('Port amount must be a non-negative number') + } else { + device.port_amount = portAmount + } + } + break + + case 'status': + if (value) { + const status = value.toLowerCase() + if (VALID_STATUSES.includes(status)) { + device.status = status as 'active' | 'inactive' | 'maintenance' + } else { + device.errors.push(`Status must be one of: ${VALID_STATUSES.join(', ')}`) + } + } + break + + case 'province': + device.province = value + break + + case 'city': + device.city = value + break + + case 'district': + device.district = value + break + + case 'tower_id': + device.tower_id = value + break + + case 'olt': + case 'olt_id': + device.olt = value + break + } + }) + + // Additional validation rules + if (device.device_type === 'CLOSURE' && device.port_amount && device.port_amount > 0) { + device.errors.push('CLOSURE devices should not have ports (port_amount should be 0 or empty)') + } + + return device + } + + static validateDevices(devices: ParsedDevice[]): ValidationResult { + // Check for duplicate device codes + const deviceCodes = new Set() + const duplicates = new Set() + + devices.forEach(device => { + if (device.device_code) { + if (deviceCodes.has(device.device_code)) { + duplicates.add(device.device_code) + } else { + deviceCodes.add(device.device_code) + } + } + }) + + // Mark devices with duplicate codes as invalid + if (duplicates.size > 0) { + devices.forEach(device => { + if (duplicates.has(device.device_code)) { + device.errors.push(`Duplicate device code: ${device.device_code}`) + } + }) + } + + return { + valid: devices.filter(d => d.errors.length === 0), + invalid: devices.filter(d => d.errors.length > 0), + totalRows: devices.length + } + } + + static generateTemplate(): string { + const headers = [ + 'device_code', + 'device_type', + 'longitude', + 'latitude', + 'port_amount', + 'status', + 'province', + 'city', + 'district', + 'tower_id', + 'olt' + ] + + const sampleData = [ + 'DEV001,OTB,106.8456,-6.2088,8,active,DKI Jakarta,Jakarta Selatan,Kebayoran Baru,,', + 'DEV002,CLOSURE,106.8500,-6.2100,,active,DKI Jakarta,Jakarta Selatan,Kebayoran Baru,,', + 'DEV003,ODP,106.8600,-6.2200,4,active,DKI Jakarta,Jakarta Selatan,Kebayoran Baru,,' + ] + + return [headers.join(','), ...sampleData].join('\n') + } +} \ No newline at end of file