Resolve merge conflicts
This commit is contained in:
parent
549e926493
commit
f470c96f5f
|
|
@ -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<File | null>(null)
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
const [validationResult, setValidationResult] = useState<ValidationResult | null>(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<ParsedDevice[]> => {
|
||||||
|
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 (
|
||||||
|
<Dialog open={isBulkCreateDialogOpen} onOpenChange={setIsBulkCreateDialogOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Create Devices</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a CSV file to create multiple devices at once.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Template Download */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">Download Template</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Download the CSV template to see the required format
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={downloadTemplate}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Upload */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Upload CSV File</h4>
|
||||||
|
<FileUpload
|
||||||
|
value={csvFile ? [csvFile] : []}
|
||||||
|
onValueChange={handleFileChange}
|
||||||
|
accept=".csv"
|
||||||
|
maxFiles={1}
|
||||||
|
onFileValidate={onFileValidate}
|
||||||
|
>
|
||||||
|
<FileUploadDropzone>
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Upload className="w-8 h-8 mb-2 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Drop your CSV file here or click to browse
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FileUploadDropzone>
|
||||||
|
<FileUploadTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
Select CSV File
|
||||||
|
</Button>
|
||||||
|
</FileUploadTrigger>
|
||||||
|
<FileUploadList>
|
||||||
|
{csvFile && (
|
||||||
|
<FileUploadItem value={csvFile}>
|
||||||
|
<FileUploadItemPreview />
|
||||||
|
<FileUploadItemMetadata />
|
||||||
|
<FileUploadItemDelete />
|
||||||
|
</FileUploadItem>
|
||||||
|
)}
|
||||||
|
</FileUploadList>
|
||||||
|
</FileUpload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Button */}
|
||||||
|
{csvFile && !validationResult && (
|
||||||
|
<Button
|
||||||
|
onClick={validateDevices}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Validating...' : 'Validate CSV Data'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation Results */}
|
||||||
|
{validationResult && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||||
|
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
||||||
|
<p className="font-medium text-green-900">{validationResult.valid.length}</p>
|
||||||
|
<p className="text-sm text-green-700">Valid Rows</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-red-50 rounded-lg">
|
||||||
|
<AlertCircle className="w-8 h-8 mx-auto mb-2 text-red-600" />
|
||||||
|
<p className="font-medium text-red-900">{validationResult.invalid.length}</p>
|
||||||
|
<p className="text-sm text-red-700">Invalid Rows</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<p className="font-medium text-blue-900">{validationResult.totalRows}</p>
|
||||||
|
<p className="text-sm text-blue-700">Total Rows</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invalid Rows Details */}
|
||||||
|
{validationResult.invalid.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2 text-red-900">Validation Errors</h4>
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-2">
|
||||||
|
{validationResult.invalid.map((device, index) => (
|
||||||
|
<Alert key={index} variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Row {device.row}:</strong> {device.errors.join(', ')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload Progress */}
|
||||||
|
{isProcessing && uploadProgress > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Creating devices...</span>
|
||||||
|
<span>{Math.round(uploadProgress)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={uploadProgress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleClose} disabled={isProcessing}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{validationResult && validationResult.valid.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleBulkCreate}
|
||||||
|
disabled={isProcessing || validationResult.invalid.length > 0}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Creating...' : `Create ${validationResult.valid.length} Devices`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{/* Province Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Provinsi</Label>
|
||||||
|
<InputSelect
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Semua Provinsi' },
|
||||||
|
...provinceOptions,
|
||||||
|
]}
|
||||||
|
value={selectedProvince || ''}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
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) => (
|
||||||
|
<InputSelectTrigger
|
||||||
|
{...provided}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</InputSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Kota/Kabupaten</Label>
|
||||||
|
<InputSelect
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Semua Kota/Kabupaten' },
|
||||||
|
...cityOptions,
|
||||||
|
]}
|
||||||
|
disabled={!selectedProvince || isLoadingRegencies}
|
||||||
|
value={selectedCity || ''}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
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) => (
|
||||||
|
<InputSelectTrigger
|
||||||
|
{...provided}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</InputSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* District Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Kecamatan</Label>
|
||||||
|
<InputSelect
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Semua Kecamatan' },
|
||||||
|
...districtOptions,
|
||||||
|
]}
|
||||||
|
disabled={!selectedCity || isLoadingDistricts}
|
||||||
|
value={selectedDistrict || ''}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
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) => (
|
||||||
|
<InputSelectTrigger
|
||||||
|
{...provided}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</InputSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<string>()
|
||||||
|
const duplicates = new Set<string>()
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue