Resolve merge conflicts

This commit is contained in:
unknown 2025-10-10 15:40:05 +07:00
parent 549e926493
commit f470c96f5f
3 changed files with 672 additions and 0 deletions

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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')
}
}