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